From 39d2903841fa1e2e510a1011b4d0a65713270062 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:05:33 +0800 Subject: [PATCH 01/42] fix(ui): separate navigation pins from selection and show menu icons (#6883) * fix(ui): separate navigation pins from selection and show menu icons * fix(ui): simplify More menus and fold conversation views on resize * test(ui): fix navigation CI regressions and refresh catalogs * refactor(ui): render sidebar panel icons directly * fix(ui): keep Chat visible and stop persisting navigation pins * refactor(ui): drop unreachable pin validation from in-memory stores --- .../tests/profiles/sdk/keyless-smoke.e2e.ts | 32 ++++-- apps/web/tests/entry-customization.e2e.ts | 46 ++++---- .../trajectory-image-display.expected.e2e.ts | 1 + docs/subsystems/slots.i18n.yaml | 4 +- docs/subsystems/slots.md | 2 +- docs/subsystems/slots.zh.md | 2 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/contract/slots.ts | 16 +-- .../ui-conversation/src/client/locales.ts | 2 + .../skeleton/ConversationRoot.module.css | 7 ++ .../client/skeleton/ConversationSession.tsx | 49 +++++++-- .../ui-conversation/src/client/view-pins.ts | 26 ++--- .../tests/apply-wiring.client.spec.tsx | 6 +- .../tests/skeleton.client.spec.tsx | 104 +++++++++++++++++- .../tests/view-pins.client.spec.ts | 20 ++-- .../ui-entry-customization/README.i18n.yaml | 20 ++-- .../client/ui-entry-customization/README.md | 20 ++-- .../ui-entry-customization/README.zh.md | 20 ++-- .../src/client/EntryMenu.module.css | 13 +-- .../src/client/EntryMenuContent.tsx | 31 +++--- .../src/client/EntryPickerRow.tsx | 42 ------- .../src/client/index.ts | 2 +- .../src/client/locales.ts | 16 +-- .../tests/menu.client.spec.tsx | 97 +++++++--------- .../tests/pin-styles.client.spec.ts | 20 ---- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- packages/client/ui-primitives/src/Menu.tsx | 6 +- .../ui-primitives/tests/atoms.client.spec.tsx | 15 +++ .../client/ui-sidebar-right/README.i18n.yaml | 4 +- packages/client/ui-sidebar-right/README.md | 2 +- packages/client/ui-sidebar-right/README.zh.md | 2 +- .../src/client/tabs/guide/GuideBody.tsx | 8 +- .../src/client/tabs/guide/GuideMoreIcon.tsx | 15 +++ .../src/client/tabs/guide/guide-pins.ts | 25 ++--- .../tests/guide-body.client.spec.tsx | 6 + .../tests/guide-pins.client.spec.ts | 20 ++-- packages/client/ui-sidebar/README.i18n.yaml | 12 +- packages/client/ui-sidebar/README.md | 6 +- packages/client/ui-sidebar/README.zh.md | 6 +- .../ui-sidebar/src/client/SidebarRoot.tsx | 4 +- .../ui-sidebar/src/client/contract/slots.ts | 4 +- .../ui-sidebar/src/client/panel-pins.ts | 25 ++--- .../ui-sidebar/tests/apply.client.spec.tsx | 12 +- .../tests/panel-list.client.spec.tsx | 29 ++++- .../tests/panel-pins.client.spec.ts | 22 ++-- .../src/client/slot-catalog.ts | 2 +- 50 files changed, 454 insertions(+), 385 deletions(-) delete mode 100644 packages/client/ui-entry-customization/src/client/EntryPickerRow.tsx delete mode 100644 packages/client/ui-entry-customization/tests/pin-styles.client.spec.ts create mode 100644 packages/client/ui-sidebar-right/src/client/tabs/guide/GuideMoreIcon.tsx diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index 5c51bd36ae..7296e781df 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -3,9 +3,9 @@ import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/pr import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { promisify } from 'node:util' -import { zstdDecompress } from 'node:zlib' +import { zstdDecompressSync } from 'node:zlib' import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' +import { scanZstdFrames } from '@deepseek-ai/dsh-session-persistence-jsonl/src/zstd.js' import { execa } from 'execa' import { describe, expect, it, onTestFinished } from 'vitest' import { workspaceDependencyPaths, type PrimaryRuntimeManifest } from '@deepseek-ai/dsh-tool-workspace-dependencies' @@ -15,7 +15,6 @@ const launch = resolveExampleLaunch({ srcBin: fileURLToPath(new URL('../../../src/bin.ts', import.meta.url)), mode: 'lib', }) -const decompress = promisify(zstdDecompress) /** Frame one text or tool response from the local Messages endpoint. */ function messagesResponse(content: Record, stopReason: 'end_turn' | 'max_tokens' | 'tool_use'): string { @@ -187,11 +186,30 @@ describe('Python SDK dsh profile keyless smoke', () => { expect(exit.exitCode, `signal=${String(exit.signal)}; stderr=${stderr}`).toBe(0) const sessionsRoot = join(root, '.dsh', 'sessions') const files = await readdir(sessionsRoot, { recursive: true }) - const log = files.find(file => file.endsWith('.jsonl.zstd')) + // Session logs are JSONL across one zstd frame per batch, header frame + // first. Decompress frames explicitly: a single call over concatenated + // frames returned only the first frame on Node 24.19 and returns every + // frame on 24.22, so neither behavior may back the assertion. + const logs: { compressed: Buffer; header: Record }[] = [] + for (const file of files.filter(file => file.endsWith('.jsonl.zstd'))) { + const compressed = await readFile(join(sessionsRoot, file)) + expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd') + const first = scanZstdFrames(compressed).frames[0] + if (first === undefined) continue + const headerLine = zstdDecompressSync(compressed.subarray(first.start, first.end)).toString().trim().split('\n')[0] + if (headerLine === undefined) continue + logs.push({ compressed, header: JSON.parse(headerLine) as Record }) + } + const log = logs.find(entry => entry.header.type === 'session' && entry.header.id === 'main') expect(log).toBeDefined() - const compressed = await readFile(join(sessionsRoot, log!)) - expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd') - expect(JSON.parse((await decompress(compressed)).toString())).toMatchObject({ type: 'session', id: 'main' }) + const { frames, tornStart } = scanZstdFrames(log!.compressed) + expect(tornStart).toBeUndefined() + const records = frames + .flatMap(({ start, end }) => zstdDecompressSync(log!.compressed.subarray(start, end)).toString().trim().split('\n')) + .map(line => JSON.parse(line) as Record) + expect(records[0]).toMatchObject({ type: 'session', id: 'main' }) + expect(records.length).toBeGreaterThan(1) + expect(records.some(record => record.type === 'turn/end')).toBe(true) } finally { // No-op after exit; reject: false settles on every outcome, so cleanup never races teardown. child.kill('SIGKILL') diff --git a/apps/web/tests/entry-customization.e2e.ts b/apps/web/tests/entry-customization.e2e.ts index 4ea12f0238..8161012d28 100644 --- a/apps/web/tests/entry-customization.e2e.ts +++ b/apps/web/tests/entry-customization.e2e.ts @@ -25,37 +25,33 @@ describe('default navigation entry customization', () => { try { await browser?.close() } finally { await scaffold?.close() } }) - it('unpins a sidebar panel across reloads while its panel stays reachable from More', async () => { + it('keeps navigation outside More and offers only browsing and creation after reload', async () => { onTestFailed(() => saveFailureShot(page, 'entry-customization')) const nav = page.getByRole('navigation', { name: 'Global panels' }) - const plugins = nav.getByRole('button', { name: 'Plugins', exact: true }) const tasks = nav.getByRole('button', { name: 'Automation tasks', exact: true }) const more = nav.getByRole('button', { name: 'More', exact: true }) - await plugins.waitFor() await tasks.waitFor() - await more.waitFor() - const menu = page.getByRole('menu') - - await more.click() - expect(await menu.getByRole('menuitem', { name: 'Plugins', exact: true }).count()).toBe(0) - await menu.getByRole('menuitem', { name: 'Unpin from left sidebar: Automation tasks', exact: true }).click() - await tasks.waitFor({ state: 'detached' }) - await menu.getByRole('menuitem', { name: 'Automation tasks', exact: true }).click() + await tasks.click() await page.getByRole('heading', { name: 'Automation tasks', exact: true }).waitFor() - // The selected panel keeps its row until another panel is selected. - await tasks.waitFor() - expect(await tasks.getAttribute('aria-current')).toBe('page') - await plugins.click() - await tasks.waitFor({ state: 'detached' }) - expect(await plugins.getAttribute('aria-current')).toBe('page') - - await page.reload({ waitUntil: 'load' }) - await plugins.waitFor() - await more.waitFor() - expect(await tasks.count()).toBe(0) - await more.click() - await menu.getByRole('menuitem', { name: 'Pin to left sidebar: Automation tasks', exact: true }).click() - await tasks.waitFor() + for (const reload of [false, true]) { + if (reload) { + await page.reload({ waitUntil: 'load' }) + await tasks.click() + await page.getByRole('heading', { name: 'Automation tasks', exact: true }).waitFor() + } + await tasks.waitFor() + await more.click() + const menu = page.getByRole('menu') + await menu.getByRole('menuitem', { name: 'Browse other plugins', exact: true }).waitFor() + expect(await menu.getByRole('menuitem').allTextContents()).toEqual(['Browse other plugins', 'Create your plugin']) + expect(await menu.getByRole('separator').count()).toBe(0) + expect(await tasks.getAttribute('aria-current')).toBe('page') + await menu.getByRole('menuitem', { name: 'Browse other plugins', exact: true }).click() + await menu.waitFor({ state: 'detached' }) + expect(await nav.getByRole('button', { name: 'Plugins', exact: true }).getAttribute('aria-current')).toBe('page') + await tasks.click() + await page.getByRole('heading', { name: 'Automation tasks', exact: true }).waitFor() + } expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }) diff --git a/apps/web/tests/trajectory-image-display.expected.e2e.ts b/apps/web/tests/trajectory-image-display.expected.e2e.ts index 2a3ffc9aca..b8da2ed2c0 100644 --- a/apps/web/tests/trajectory-image-display.expected.e2e.ts +++ b/apps/web/tests/trajectory-image-display.expected.e2e.ts @@ -77,6 +77,7 @@ it('renders durable record images in the Trajectory details panel from the share // The virtual ledger needs a measurable viewport; jsdom reports zero // heights, so pin one and neutralize the imperative tail scroll. vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600) + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(1000) Object.defineProperty(HTMLElement.prototype, 'scrollTo', { configurable: true, value: () => {}, diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index 4d7615f4a9..6cc744d5b6 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.i18n.yaml @@ -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: 38962bc8b7d03c3a - zh: 99c48ac27abe8816 + en: 353a4e6b1bbc973b + zh: b46f8520b8c2240a /web-client-slots/declaration-and-lifecycle: en: f2d2c0d3b8f14ec7 zh: 441ed13ff3f81d2a diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index 6e73c55666..fb8ba31f8a 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -10,7 +10,7 @@ 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. -The root-scoped lists `sidebar.panellist.actions`, `conversation.session.header.views.actions`, and `sidebar.right.tab.guide.actions` supply the rows of the More menus after the panel rows, the View tabs, and the right-sidebar guide cards. A region draws its More button and menu only while its list has registrations, and passes the customizable entries with their pin state, `onOpen`, `onPinnedChange`, and `onDismiss`. Each region persists its own unpinned entry ids; [`ui-entry-customization`](../../packages/client/ui-entry-customization/README.md) registers the shipped rows. +The root-scoped lists `sidebar.panellist.actions`, `conversation.session.header.views.actions`, and `sidebar.right.tab.guide.actions` supply the rows of the More menus after the panel rows, the View tabs, and the right-sidebar guide cards. A region draws its More button and menu only while its list has registrations, and passes the customizable entries with their pin state, `onOpen`, `onPinnedChange`, and `onDismiss`. Panel and View entries also carry optional `active` state for the current selection, independently of pin state. Each region keeps its own unpinned entry ids in memory; [`ui-entry-customization`](../../packages/client/ui-entry-customization/README.md) registers the shipped rows. ## Declaration and lifecycle diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index 5b2e91ea24..1ced4db4fa 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -10,7 +10,7 @@ Slots 是 Web Client 的类型化 React 组合系统。[`dsh-client-ui-slots`](. 插件目录声明 `plugins.add.actions`,它是 root 作用域的 list,在添加插件菜单的安装项之后提供 `MenuItemButton` 行。owner 提供 `onDismiss()`,用于开始动作前关闭菜单。该 slot 遵循所属插件页面声明的生命周期。 -root 作用域的 list `sidebar.panellist.actions`、`conversation.session.header.views.actions` 和 `sidebar.right.tab.guide.actions` 提供面板行、View 标签和右侧栏引导卡片之后“更多”菜单的菜单行。区域只在其 list 有注册项时画“更多”按钮与菜单,并传入可自定义的入口及其 Pin 状态、`onOpen`、`onPinnedChange` 与 `onDismiss`。各区域自行持久化已收起的入口 id;[`ui-entry-customization`](../../packages/client/ui-entry-customization/README.zh.md) 注册随产品提供的菜单行。 +root 作用域的 list `sidebar.panellist.actions`、`conversation.session.header.views.actions` 和 `sidebar.right.tab.guide.actions` 提供面板行、View 标签和右侧栏引导卡片之后“更多”菜单的菜单行。区域只在其 list 有注册项时画“更多”按钮与菜单,并传入可自定义的入口及其 Pin 状态、`onOpen`、`onPinnedChange` 与 `onDismiss`。面板和 View 入口还通过可选的 `active` 状态标识当前选中项,该状态独立于 Pin 状态。各区域在内存中维护已收起的入口 id;[`ui-entry-customization`](../../packages/client/ui-entry-customization/README.zh.md) 注册随产品提供的菜单行。 ## 声明与生命周期 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 1606974f49..dcf29e6ee2 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -21,8 +21,8 @@ en: 9912b286384c41d7 zh: 3e994887c1948677 /deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props: - en: a7a0b235e1c5f2b0 - zh: ca24e6bf77ad7e1b + en: 626f5095ffbd8018 + zh: 672a7762384f4caf /deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries: en: bb1244310f5c0382 zh: 824b84077432b559 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 84049c6848..3abab20faf 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -72,7 +72,7 @@ The context-occupancy button shows a ring and percentage below the input card, a With Coding Tools off, the shell hides only Trajectory; other plugin-contributed Views remain available. The View tab bar is hidden when fewer than two Views are available. Turning Coding Tools off while Trajectory is active returns to Chat; stored View preferences and recorded Session data remain intact. Turning it on makes Trajectory available again. View owners receive the available roster so their navigation actions can follow the same visibility. -While the root-scoped `conversation.session.header.views.actions` list has registrations, a **More conversation views** button with a tooltip follows the View tabs in the tab row. It opens a menu whose rows are that list's registrations. Each receives `entries` (available Views in registration order with `id`, `label`, and `pinned`; Chat is never listed), `onOpen(id)`, which selects the View in this Session, `onPinnedChange(id, pinned)`, and `onDismiss()`, which closes the menu. The header keeps one root list of unpinned View ids in browser `localStorage` under `dsh.slot.manual-order.conversation.view.v1`, stored as `{ "unpinned": string[] }` and shared by every Session; the tab bar omits unpinned Views, and Chat always shows. The tab bar's visibility counts available Views, not pinned ones, so the bar and More stay while every other View is unpinned. Unpinning the active View leaves its content selected and keeps its tab, with `aria-selected`, until another View is selected. Ids of Views that are not registered stay stored; stored content other than a string array shows every tab. +While the root-scoped `conversation.session.header.views.actions` list has registrations, a **More conversation views** button with a tooltip follows the View tabs in the tab row. It opens a menu whose rows are that list's registrations. Each receives `entries` (Views outside the header in registration order with `id`, `label`, `pinned`, and `active`; Chat is never listed), `onOpen(id)`, which selects the View in this Session, `onPinnedChange(id, pinned)`, and `onDismiss()`, which closes the menu. The header keeps an in-memory list of unpinned View ids shared by every Session; the tab bar omits unpinned Views and places registered Chat first so it always remains outside More. Plugin reload resets pin choices, and legacy localStorage values are ignored. The tab bar's visibility counts available Views, not pinned ones, so the bar and More stay while every other View is unpinned. Unpinning the active View hides its tab immediately without changing the selected content. More lists only Views outside the header and identifies the current View through `active`. The header reads its available row width, reserves 48px for More and padding, and estimates 120px per tab. Remaining Views keep registration order in More. A single ResizeObserver watches the tab row so window resizing and sidebar expansion update the split; active selection does not change the order. The observer disconnects when the row is removed. If ResizeObserver is unavailable, the initial width calculation still applies without subsequent observation. The More button highlights and names the active View in its tooltip when that View is folded. Individual tabs are not measured. At least one preferred tab remains outside, and long labels truncate with a full-name tooltip. Unregistered View ids remain in the in-memory pin list until plugin reload. The composer registers the File command action and owns its label, availability, and native file-dialog callback. Menu availability and invocation both consult the mounted composer's current attachment-intake policy. Unmounting or locking the composer disables that action; disposing the plugin removes its registration. The callback binding stays inside the input module. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 0884a9cd39..4b9510e326 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -72,7 +72,7 @@ Queue 预览启用空白折叠和文档边缘 trim,保留 emoji,并使用 CS 关闭代码工作工具时,外壳仅隐藏 Trajectory;其他插件贡献的 View 仍然可用。可用 View 少于两个时隐藏 View 标签栏。在 Trajectory 激活时关闭代码工作工具会返回 Chat;已保存的 View 偏好和 Session 记录保持不变。开启后,Trajectory 恢复可用。View 所有者接收可用列表,使导航操作跟随相同的可见性。 -root 作用域的 `conversation.session.header.views.actions` list 有注册项时,标签行中的 View 标签之后显示带提示框的**更多会话视图**按钮。它打开的菜单以该 list 的注册项为菜单行。每个注册项接收 `entries`(按注册顺序的可用 View,含 `id`、`label` 与 `pinned`;不含 Chat)、在本 Session 选择 View 的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。头部在浏览器 `localStorage` 的 `dsh.slot.manual-order.conversation.view.v1` 下保存一份 root 级的已收起 View id 列表,格式为 `{ "unpinned": string[] }`,所有 Session 共用;标签栏不显示已收起的 View,Chat 始终显示。标签栏是否显示按可用 View 计算,不按已固定的 View 计算,因此其他 View 全部收起时,标签栏和“更多”仍然显示。收起当前激活的 View 不会切走其内容,其标签及 `aria-selected` 保留到选中其他 View 为止。未注册 View 的 id 保留在列表中;保存内容不是字符串数组时,所有标签都显示。 +root 作用域的 `conversation.session.header.views.actions` list 有注册项时,标签行中的 View 标签之后显示带提示框的**更多会话视图**按钮。它打开的菜单以该 list 的注册项为菜单行。每个注册项接收 `entries`(按注册顺序的头部之外的 View,含 `id`、`label`、`pinned` 与 `active`;不含 Chat)、在本 Session 选择 View 的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。头部在内存中保存所有 Session 共用的已收起 View id 列表;标签栏不显示已收起的 View,并将已注册的 Chat 放在首位,确保它始终留在更多菜单外。插件重新加载时 Pin 选择重置,不读取旧 localStorage 值。标签栏是否显示按可用 View 计算,不按已固定的 View 计算,因此其他 View 全部收起时,标签栏和“更多”仍然显示。收起当前激活的 View 会立即隐藏其标签,但不会切走其内容。更多菜单仅包含头部之外的 View,通过 `active` 标识当前 View。头部读取当前行可用宽度,为更多按钮与内边距预留 48px,每个标签估算 120px,其余 View 按注册顺序进入更多。单个 ResizeObserver 观察标签行,使窗口缩放和侧栏展开能够更新分组;当前选中项变化不改变顺序。标签行移除时断开观察。如果环境没有 ResizeObserver,仍按初始宽度计算,但不继续观察。当前 View 收起时,更多按钮高亮并在提示框中显示其名称。不逐项测量标签。至少保留一个优先展示的外部标签,长标题截断并通过提示框提供全名。未注册 View 的 id 保留在内存 Pin 列表中,直到插件重新加载。 输入框注册「文件」命令动作,负责其标题、可用性和原生文件选择器回调。菜单可用性与实际调用都读取已挂载输入框当前的附件接收策略。输入框卸载或锁定后该动作不可用,插件 dispose(资源释放)时移除注册。回调绑定留在输入模块内部。 diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index d93a848198..4a0bd3a739 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -430,13 +430,15 @@ export interface ConversationViewActionEntry { readonly id: string /** Resolved View label. */ readonly label: string - /** False while the tab is unpinned from the header. */ + /** False while the View is manually unpinned. A pinned View still enters More when the header's width budget cannot hold it. */ readonly pinned: boolean + /** Whether this View is selected in this Session, independently of its pin state. */ + readonly active?: boolean } /** Owner share of the View tab row's More menu rows. */ export interface ConversationViewActionsOwnerProps { - /** Available Views in registration order; Chat is never listed. */ + /** Views outside the header in registration order. */ readonly entries: readonly ConversationViewActionEntry[] /** * Select a View in this Session without changing its pin state. @@ -444,9 +446,9 @@ export interface ConversationViewActionsOwnerProps { */ readonly onOpen: (id: string) => void /** - * Show (true) or unpin (false) a View tab in every Session. + * Pin or unpin a View in every Session. * @param id - Listed View id. - * @param pinned - Whether the tab shows in the header. + * @param pinned - Whether the View is manually pinned; the width budget can still fold it into More. */ readonly onPinnedChange: (id: string, pinned: boolean) => void /** Close the More menu. */ @@ -458,15 +460,15 @@ export interface ConversationSessionHeaderInjected { /** Package-owned View roster source bound only for the Conversation header. */ readonly hooks: { readonly conversationViews: ObservableSnapshot - /** Ids of unpinned View tabs, persisted across reloads and shared by every Session. */ + /** Ids of unpinned View tabs held in memory and shared by every Session until plugin reload. */ readonly entryPins: ObservableSnapshot /** Whether `conversation.session.header.views.actions` has registrations. */ readonly entryActions: ObservableSnapshot } /** - * Show or unpin one View tab in every Session; Chat is ignored. + * Pin or unpin one View tab in every Session; Chat is ignored. * @param id - View id. - * @param pinned - Whether the tab shows in the header. + * @param pinned - Whether the View is manually pinned; the width budget can still fold it into More. */ setPinned: (id: string, pinned: boolean) => void /** Select a Session through the Session Controller. */ diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 5a221bc9a1..938326df75 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -79,6 +79,7 @@ export const zh = { 'hero.chooseWorkspace': '选择工作区', 'session.hierarchy': '会话层级', 'session.moreViews': '更多会话视图', + 'session.moreViewsCurrent': '更多会话视图 · {name}', 'todo.title': '任务', 'todo.progress.done': '{done} 已完成', 'todo.progress.active': '{active} 进行中', @@ -460,6 +461,7 @@ export const en = { 'hero.chooseWorkspace': 'Choose workspace', 'session.hierarchy': 'Session hierarchy', 'session.moreViews': 'More conversation views', + 'session.moreViewsCurrent': 'More conversation views · {name}', 'todo.title': 'To-dos', 'todo.progress.done': '{done} completed', 'todo.progress.active': '{active} in progress', diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 61ea5d58b2..29a4d5e74e 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -196,6 +196,9 @@ button.crumb:hover { z-index: 1; display: flex; gap: 16px 20px; + width: 100%; + min-width: 0; + box-sizing: border-box; align-items: flex-start; flex-wrap: wrap; margin-top: 10px; @@ -260,6 +263,10 @@ button.crumb:hover { color: var(--dsw-alias-label-secondary); } +.viewsMoreActive { color: var(--dsw-alias-state-business-primary); } + +.tabLabel { display: block; max-width: 88px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .viewsMore[aria-expanded='true'] { background: var(--dsw-alias-interactive-bg-hover); } diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index f4ef1f342a..3089b083df 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -1,6 +1,6 @@ /** Strict per-session header/body content inserted into the resident conversation layout. */ -import { useState } from 'react' +import { useLayoutEffect, useMemo, useRef, useState } from 'react' import clsx from 'clsx' import { Button, IconEllipsisOutlineRegular, Menu, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client' @@ -22,17 +22,17 @@ export type ConversationSessionHeaderProps = ConversationSessionHeaderSlotProps type ViewsMoreMenuProps = Omit & Pick - & { readonly label: string } + & { readonly label: string; readonly current: boolean } /** The View tab row's More launcher; its menu rows come from `conversation.session.header.views.actions`. */ -function ViewsMoreMenu({ label, entries, onOpen, onPinnedChange, renderSlot }: ViewsMoreMenuProps) { +function ViewsMoreMenu({ label, current, entries, onOpen, onPinnedChange, renderSlot }: ViewsMoreMenuProps) { const [open, setOpen] = useState(false) const onDismiss = (): void => { setOpen(false) } return ( - @@ -93,6 +93,31 @@ export function ConversationSessionHeader({ const unpinned = useEntryPins(ids => ids) const hasEntryActions = useEntryActions(registered => registered) const showTabs = !hideChrome && tabs.length > 1 + const tabRow = useRef(null) + const [visibleViewLimit, setVisibleViewLimit] = useState(1) + // Observe only the available row width; individual tabs are not measured. + useLayoutEffect(() => { + const row = tabRow.current + if (row === null || !hasEntryActions) return + const updateLimit = (): void => { + setVisibleViewLimit(Math.max(1, Math.floor((row.clientWidth - 48) / 120))) + } + updateLimit() + if (typeof ResizeObserver === 'undefined') return + const observer = new ResizeObserver(updateLimit) + observer.observe(row) + return () => { observer.disconnect() } + }, [showTabs, hasEntryActions]) + const { visibleTabs, moreTabs } = useMemo(() => { + const preferred = [ + ...tabs.filter(tab => tab.id === FIXED_VIEW_ID), + ...tabs.filter(tab => tab.id !== FIXED_VIEW_ID && !unpinned.includes(tab.id)), + ] + const visibleTabs = hasEntryActions ? preferred.slice(0, visibleViewLimit) : preferred + const visibleIds = new Set(visibleTabs.map(tab => tab.id)) + return { visibleTabs, moreTabs: tabs.filter(tab => !visibleIds.has(tab.id)) } + }, [tabs, unpinned, hasEntryActions, visibleViewLimit]) + const currentInMore = moreTabs.some(tab => tab.id === active?.id) return ( <>
@@ -169,10 +194,9 @@ export function ConversationSessionHeader({ {showTabs && ( // data-conversation-tabs: marks the tab strip, which the window-chrome // geometry and the browser coverage lane anchor on. -
+
- {tabs.filter(viewTab => viewTab.id === FIXED_VIEW_ID || viewTab.id === active?.id - || !unpinned.includes(viewTab.id)).map(viewTab => ( + {visibleTabs.map(viewTab => ( ))}
{hasEntryActions && ( viewTab.id !== FIXED_VIEW_ID) - .map(viewTab => ({ id: viewTab.id, label: viewTab.label, pinned: !unpinned.includes(viewTab.id) }))} + label={currentInMore && active !== undefined ? t('session.moreViewsCurrent', { name: active.label }) : t('session.moreViews')} + current={currentInMore} + entries={moreTabs.map(viewTab => ({ + id: viewTab.id, label: viewTab.label, + pinned: !unpinned.includes(viewTab.id), active: viewTab.id === active?.id, + }))} onOpen={selectView} onPinnedChange={setPinned} renderSlot={renderSlot} diff --git a/packages/client/ui-conversation/src/client/view-pins.ts b/packages/client/ui-conversation/src/client/view-pins.ts index aebf97470a..d3d4e1f408 100644 --- a/packages/client/ui-conversation/src/client/view-pins.ts +++ b/packages/client/ui-conversation/src/client/view-pins.ts @@ -1,11 +1,9 @@ -/** Persisted pin choices for the Conversation View tabs. */ +/** In-memory pin choices for the Conversation View tabs. */ import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store' /** View whose tab always shows and never enters the customization list. */ export const FIXED_VIEW_ID = 'chat' -/** Root-level key shared with the slot layer's future per-list preferences; Sessions share one list. */ -const STORAGE_KEY = 'dsh.slot.manual-order.conversation.view.v1' const NO_VIEWS: readonly string[] = [] /** Unpinned View ids and their only mutation. */ @@ -13,30 +11,24 @@ export interface ViewPins { /** Unpinned View ids; ids of Views that are not registered stay listed. */ readonly unpinned: ObservableSnapshot /** - * Show or hide one View tab; the fixed View is ignored. + * Pin or unpin one View tab; the fixed View is ignored. A pinned tab still + * enters More when the header's width budget cannot hold it. * @param id - View id. - * @param pinned - Whether the tab shows in the Conversation header. + * @param pinned - Whether the tab is manually pinned. */ readonly setPinned: (id: string, pinned: boolean) => void } -/** Stored content is unchecked; anything but a string array leaves every View pinned. */ -function unpinnedViews(value: unknown): readonly string[] { - if (typeof value !== 'object' || value === null || !('unpinned' in value)) return NO_VIEWS - const { unpinned } = value - return Array.isArray(unpinned) && unpinned.every((id): id is string => typeof id === 'string') ? unpinned : NO_VIEWS -} - /** - * Create the persisted View pin list; the snapshot keeps its reference until the list changes. - * @returns Validated unpinned View ids and the pin mutation. + * Create the in-memory View pin list; the snapshot keeps its reference until the list changes. + * @returns Unpinned View ids and the pin mutation. */ export function createViewPins(): ViewPins { - const store = createSnapshotStore({ unpinned: NO_VIEWS }, { persist: { name: STORAGE_KEY } }) + const store = createSnapshotStore<{ readonly unpinned: readonly string[] }>({ unpinned: NO_VIEWS }) return { - unpinned: { getSnapshot: () => unpinnedViews(store.getSnapshot()), subscribe: listener => store.subscribe(listener) }, + unpinned: { getSnapshot: () => store.getSnapshot().unpinned, subscribe: listener => store.subscribe(listener) }, setPinned: (id, pinned) => { - const current = unpinnedViews(store.getSnapshot()) + const current = store.getSnapshot().unpinned if (id === FIXED_VIEW_ID || pinned !== current.includes(id)) return store.set({ unpinned: pinned ? current.filter(entry => entry !== id) : [...current, id] }) }, diff --git a/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx b/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx index fa7f2fc4ef..a7b0bfdbdf 100644 --- a/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx @@ -44,7 +44,7 @@ function entry( } describe('target-neutral Conversation apply wiring', () => { - it('hands every Session header the same persisted View pins', async () => { + it('hands every Session header the same in-memory View pins without restoring saved choices', async () => { localStorage.setItem('dsh.slot.manual-order.conversation.view.v1', JSON.stringify({ unpinned: ['trajectory'] })) try { const b = await bench() @@ -56,10 +56,10 @@ describe('target-neutral Conversation apply wiring', () => { const inject = header.inject as (sessionId: SessionId) => PinHooks const injected = inject(SID) const first = injected.hooks.entryPins - expect(first.getSnapshot()).toEqual(['trajectory']) + expect(first.getSnapshot()).toEqual([]) expect(inject('session-2' as SessionId).hooks.entryPins).toBe(first) injected.setPinned('timeline', false) - expect(first.getSnapshot()).toEqual(['trajectory', 'timeline']) + expect(first.getSnapshot()).toEqual(['timeline']) const actions = injected.hooks.entryActions const changed = vi.fn() actions.subscribe(changed) diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index b278797d5d..bda16c8a08 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -85,6 +85,7 @@ function fireResize(el: Element): void { afterEach(() => { cleanup() + vi.restoreAllMocks() vi.unstubAllGlobals() resizeObservers.length = 0 }) @@ -144,6 +145,8 @@ function mount( unpinnedViews?: readonly string[] /** Whether the View actions list has registrations. */ entryActions?: boolean + /** Isolate header capability checks from the composer's required layout observers. */ + headerOnly?: boolean } = {}, ) { const sessionId = 'sessionId' in options ? options.sessionId : SID @@ -340,6 +343,7 @@ function mount( const renderFactorySlot = ((_name: string, input: ConversationContentInputProps, factoryOptions?: { slots?: Record ReactNode> }) => { + if (options.headerOnly) return null const common: ConversationViewsProps = { sessionId: SID, SessionProvider, @@ -681,7 +685,30 @@ describe('ConversationRoot resident composer', () => { expect(b.view.getByRole('tablist').hasAttribute('data-conversation-tabs')).toBe(true) }) - it('keeps the selected View tab while it is unpinned and hides it once another View is selected', () => { + it('keeps Chat outside at the narrowest width even when it registers after other Views', () => { + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(180) + const viewTabs = ['trajectory', 'timeline', 'chat', 'notes'].map(id => ({ id, label: id })) + const b = mount(sessionSnapshotOf(), undefined, undefined, { viewTabs, entryActions: true }) + expect(within(b.view.getByRole('tablist')).getAllByRole('tab').map(tab => tab.textContent)).toEqual(['chat']) + fireEvent.click(b.view.getByRole('button', { name: zh['session.moreViews'] })) + expect(b.actionsOwner().entries.map(entry => entry.id)).toEqual(['trajectory', 'timeline', 'notes']) + act(() => { b.actionsOwner().onOpen('trajectory') }) + expect(b.view.getByRole('tab', { name: 'chat' })).toBeTruthy() + expect(b.view.queryByRole('tab', { name: 'trajectory' })).toBeNull() + expect(b.actionsOwner().entries.find(entry => entry.id === 'trajectory')?.active).toBe(true) + }) + + it('calculates the initial tab budget when ResizeObserver is unavailable', () => { + vi.stubGlobal('ResizeObserver', undefined) + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(300) + const viewTabs = ['chat', 'trajectory', 'timeline'].map(id => ({ id, label: id })) + const b = mount(sessionSnapshotOf(), undefined, undefined, { viewTabs, entryActions: true, headerOnly: true }) + expect(within(b.view.getByRole('tablist')).getAllByRole('tab').map(tab => tab.textContent)).toEqual(['chat', 'trajectory']) + fireEvent.click(b.view.getByRole('button', { name: zh['session.moreViews'] })) + expect(b.actionsOwner().entries.map(entry => entry.id)).toEqual(['timeline']) + }) + + it('keeps an unpinned View selected without exposing its tab', () => { const viewTabs: ViewTab[] = [ { id: 'chat', label: 'Chat' }, { id: 'trajectory', label: 'Trajectory' }, @@ -691,7 +718,7 @@ describe('ConversationRoot resident composer', () => { act(() => { b.store.actions.setView('trajectory') }) const tabs = (): HTMLElement[] => within(b.view.getByRole('tablist')).getAllByRole('tab') expect(tabs().map(tab => [tab.textContent, tab.getAttribute('aria-selected')])).toEqual([ - ['Chat', 'false'], ['Trajectory', 'true'], ['Timeline', 'false'], + ['Chat', 'false'], ['Timeline', 'false'], ]) expect(b.view.getByTestId('view-trajectory')).toBeTruthy() act(() => { b.store.actions.setView('chat') }) @@ -708,6 +735,75 @@ describe('ConversationRoot resident composer', () => { expect(b.view.queryByRole('menu')).toBeNull() }) + it('reuses the width budget on View roster and selection changes without a window resize listener', () => { + let width = 480 + const readWidth = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => width) + const viewTabs: ViewTab[] = [ + { id: 'chat', label: 'Chat' }, + { id: 'trajectory', label: 'Trajectory' }, + { id: 'timeline', label: 'Timeline' }, + { id: 'inspector', label: 'Inspector' }, + ] + const b = mount(sessionSnapshotOf(), undefined, undefined, { viewTabs, entryActions: true }) + const external = () => within(b.view.getByRole('tablist')).getAllByRole('tab').map(tab => tab.textContent) + expect(external()).toEqual(['Chat', 'Trajectory', 'Timeline']) + fireEvent.click(b.view.getByRole('button', { name: zh['session.moreViews'] })) + expect(b.actionsOwner().entries.map(entry => entry.id)).toEqual(['inspector']) + act(() => { b.actionsOwner().onOpen('inspector') }) + expect(external()).toEqual(['Chat', 'Trajectory', 'Timeline']) + expect(b.actionsOwner().entries.find(entry => entry.id === 'inspector')?.active).toBe(true) + expect(b.view.getByRole('button', { name: '更多会话视图 · Inspector' })).toBeTruthy() + const reads = readWidth.mock.calls.length + width = 1000 + fireEvent(window, new Event('resize')) + act(() => { b.store.actions.setView('timeline') }) + expect(readWidth).toHaveBeenCalledTimes(reads) + expect(external()).toEqual(['Chat', 'Trajectory', 'Timeline']) + viewTabs.splice(1, 1) + act(() => { b.store.actions.setView('chat') }) + expect(external()).toEqual(['Chat', 'Timeline', 'Inspector']) + expect(readWidth).toHaveBeenCalledTimes(reads) + expect(b.actionsOwner().entries).toEqual([]) + expect(b.setPinned).not.toHaveBeenCalled() + }) + + it('folds and restores Views on container resize without changing selection, and disconnects on unmount', () => { + let width = 1000 + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => width) + const viewTabs = ['chat', 'trajectory', 'timeline', 'inspector', 'notes'].map(id => ({ id, label: id })) + const b = mount(sessionSnapshotOf(), undefined, undefined, { viewTabs, entryActions: true }) + const row = b.view.getByRole('tablist').parentElement! + const external = () => within(b.view.getByRole('tablist')).getAllByRole('tab').map(tab => tab.textContent) + expect(external()).toEqual(viewTabs.map(tab => tab.label)) + act(() => { b.store.actions.setView('notes') }) + width = 300 + act(() => { fireResize(row) }) + expect(external()).toEqual(['chat', 'trajectory']) + fireEvent.click(b.view.getByRole('button', { name: '更多会话视图 · notes' })) + expect(b.actionsOwner().entries.map(entry => entry.id)).toEqual(['timeline', 'inspector', 'notes']) + expect(b.actionsOwner().entries.find(entry => entry.id === 'notes')?.active).toBe(true) + width = 1000 + act(() => { fireResize(row) }) + expect(external()).toEqual(viewTabs.map(tab => tab.label)) + expect(b.view.getByRole('tab', { name: 'notes' }).getAttribute('aria-selected')).toBe('true') + expect(b.actionsOwner().entries).toEqual([]) + expect(b.setPinned).not.toHaveBeenCalled() + const observers = resizeObservers.filter(observer => observer.targets.includes(row)) + expect(observers).toHaveLength(1) + b.view.unmount() + expect(observers[0]!.targets).toEqual([]) + }) + + it('keeps all five Views outside when the sampled width budget is sufficient', () => { + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(1000) + const viewTabs = ['chat', 'trajectory', 'timeline', 'inspector', 'notes'].map(id => ({ id, label: id })) + const b = mount(sessionSnapshotOf(), undefined, undefined, { viewTabs, entryActions: true }) + expect(within(b.view.getByRole('tablist')).getAllByRole('tab').map(tab => tab.textContent)) + .toEqual(viewTabs.map(tab => tab.label)) + fireEvent.click(b.view.getByRole('button', { name: zh['session.moreViews'] })) + expect(b.actionsOwner().entries).toEqual([]) + }) + it('omits More while the View actions list has no registrations', () => { const b = mount(sessionSnapshotOf()) expect(b.view.getByRole('tablist')).toBeTruthy() @@ -726,11 +822,13 @@ describe('ConversationRoot resident composer', () => { fireEvent.click(more) expect(more.getAttribute('aria-expanded')).toBe('true') expect(within(b.view.getByRole('menu')).getByRole('menuitem', { name: 'Header view action' })).toBeTruthy() - expect(b.actionsOwner().entries).toEqual([{ id: 'trajectory', label: 'Trajectory', pinned: false }]) + expect(b.actionsOwner().entries).toEqual([{ id: 'trajectory', label: 'Trajectory', pinned: false, active: false }]) b.actionsOwner().onPinnedChange('trajectory', true) expect(b.setPinned).toHaveBeenCalledExactlyOnceWith('trajectory', true) act(() => { b.actionsOwner().onOpen('trajectory') }) expect(b.view.getByTestId('view-trajectory')).toBeTruthy() + expect(b.view.queryByRole('tab', { name: 'Trajectory' })).toBeNull() + expect(b.actionsOwner().entries).toEqual([{ id: 'trajectory', label: 'Trajectory', pinned: false, active: true }]) fireEvent.click(b.view.getByRole('menuitem', { name: 'Header view action' })) expect(b.view.queryByRole('menu')).toBeNull() expect(more.getAttribute('aria-expanded')).toBe('false') diff --git a/packages/client/ui-conversation/tests/view-pins.client.spec.ts b/packages/client/ui-conversation/tests/view-pins.client.spec.ts index b42e6171ee..fa4b42d53d 100644 --- a/packages/client/ui-conversation/tests/view-pins.client.spec.ts +++ b/packages/client/ui-conversation/tests/view-pins.client.spec.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -/** Conversation View pins persist one root list for every Session and ignore Chat. */ +/** Conversation View pins share one in-memory root list for every Session and ignore Chat. */ import { afterEach, describe, expect, it, vi } from 'vitest' import { createViewPins } from '../src/client/view-pins.ts' @@ -7,15 +7,15 @@ const KEY = 'dsh.slot.manual-order.conversation.view.v1' afterEach(() => { localStorage.clear() }) describe('Conversation View pins', () => { - it('persists unpinned View ids and restores them in a new instance', () => { + it('keeps unpinned View ids in memory without restoring them in a new instance', () => { const pins = createViewPins() const initial = pins.unpinned.getSnapshot() expect(initial).toEqual([]) expect(pins.unpinned.getSnapshot()).toBe(initial) pins.setPinned('trajectory', false) pins.setPinned('timeline', false) - expect(JSON.parse(localStorage.getItem(KEY) ?? 'null')).toEqual({ unpinned: ['trajectory', 'timeline'] }) - expect(createViewPins().unpinned.getSnapshot()).toEqual(['trajectory', 'timeline']) + expect(localStorage.getItem(KEY)).toBeNull() + expect(createViewPins().unpinned.getSnapshot()).toEqual([]) pins.setPinned('trajectory', true) expect(pins.unpinned.getSnapshot()).toEqual(['timeline']) }) @@ -34,20 +34,14 @@ describe('Conversation View pins', () => { }) it('keeps ids of Views that are not registered', () => { - localStorage.setItem(KEY, JSON.stringify({ unpinned: ['removed-view'] })) const pins = createViewPins() + pins.setPinned('removed-view', false) pins.setPinned('timeline', false) expect(pins.unpinned.getSnapshot()).toEqual(['removed-view', 'timeline']) }) - it.each([ - ['a non-array list', { unpinned: { timeline: true } }], - ['non-string ids', { unpinned: [null] }], - ['a missing list', { pinned: [] }], - ['null', null], - ['an array root', ['timeline']], - ])('reads %s as no unpinned Views with a stable snapshot', (_name, stored) => { - localStorage.setItem(KEY, JSON.stringify(stored)) + it('ignores a legacy stored list instead of restoring it', () => { + localStorage.setItem(KEY, JSON.stringify({ unpinned: ['trajectory'] })) const pins = createViewPins() const snapshot = pins.unpinned.getSnapshot() expect(snapshot).toEqual([]) diff --git a/packages/client/ui-entry-customization/README.i18n.yaml b/packages/client/ui-entry-customization/README.i18n.yaml index 3dc39a2b7d..2be9f86201 100644 --- a/packages/client/ui-entry-customization/README.i18n.yaml +++ b/packages/client/ui-entry-customization/README.i18n.yaml @@ -3,23 +3,23 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-entry-customization/README.md /: - en: 200adf0dd8c9757c - zh: 3d10d49a71289702 + en: 57416280d387ea2b + zh: f9b97e610116bf59 /deepseek-ai-dsh-client-ui-entry-customization: en: ab35194f4a9dafce zh: 7e9e57fde518c3d7 /deepseek-ai-dsh-client-ui-entry-customization/summary: - en: d53e7d71da9fd4d6 - zh: a32b635ec1e572f3 + en: 589199344fe3dd70 + zh: e5e3003c5c4d9479 /deepseek-ai-dsh-client-ui-entry-customization/table-of-contents: en: 76368b6b1da79c80 zh: 52384aee991ac542 /deepseek-ai-dsh-client-ui-entry-customization/use-this-package: - en: 7cc90968634e3f7b - zh: 54f58e17b14f7404 + en: e5ebba75f83c699c + zh: ff0e1229d7b771e0 /deepseek-ai-dsh-client-ui-entry-customization/understand-the-implementation: - en: 3dcb1f29d03d5afe - zh: 5ee39637636cd062 + en: eb2cd6781ce5cc49 + zh: 1ba64cf481e2889a /deepseek-ai-dsh-client-ui-entry-customization/model-experience: en: b891f311c09d536f zh: 68c0f41fbfed0d73 @@ -27,8 +27,8 @@ en: dc50ebf94183efa2 zh: 617803c558367430 /deepseek-ai-dsh-client-ui-entry-customization/known-limitations-and-deferred-work: - en: 2e582c76ffe9d663 - zh: 7e5e49ad3d6f0f83 + en: 5b4914a3ef05fa9c + zh: 9cbe8a17a88ee4d6 /deepseek-ai-dsh-client-ui-entry-customization/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/client/ui-entry-customization/README.md b/packages/client/ui-entry-customization/README.md index e8ec30ae03..d3dc65d58b 100644 --- a/packages/client/ui-entry-customization/README.md +++ b/packages/client/ui-entry-customization/README.md @@ -1,5 +1,5 @@ --- -description: "Fill the More menus of three navigation regions with pin controls, plugin browsing, and a Creator draft." +description: "Offer plugin browsing and a Creator draft in three navigation regions." kind: "package-reference" --- @@ -11,7 +11,7 @@ Desktop product events use the optional [product analytics service](../product-a ## Summary -Fill the More menus of the left sidebar, Conversation View tabs, and right-sidebar guide with each region's customizable entries, pin buttons, plugin browsing, and a reviewable Creator draft. The Web app loads it by default. Each region draws its own More button and stores its own pin choices; this package stores nothing. +Browse plugins and prepare a reviewable Creator draft from the More menus of the left sidebar, Conversation View tabs, and right-sidebar guide. The Web app loads this package by default. Each region draws its own More button; this package stores nothing. ## Table of Contents @@ -28,11 +28,11 @@ Fill the More menus of the left sidebar, Conversation View tabs, and right-sideb The `@deepseek-ai/dsh-web-app` bundle loads this package by default; it needs no Plugins switch or profile selection. A region shows its More button only while its actions slot has a registration, so unloading this package removes all three buttons. -Open **More** beneath the left navigation entries, **More conversation views** after the Conversation View tabs, or **More tools** after the right-sidebar guide cards. Each menu lists the region's customizable entries in registration order; Plugins, Chat, and Workspace files always show and are not listed. Selecting an entry's name closes the menu and opens the entry without changing its pin state. The separate pin button pins or unpins the region's launcher and keeps the menu open; a filled pin marks a pinned entry. Unpinned entries stay reachable through the menu, and pin choices persist across reloads. Long entry names wrap, except in the More tools menu, where they truncate to one line. +Open **More** beneath the left navigation entries, **More conversation views** after the Conversation View tabs, or **More tools** after the right-sidebar guide cards. Left-sidebar and guide menus offer only **Browse other plugins** and **Create your plugin**. The Conversation menu also lists folded Views before those actions and marks the active View. None of these menus provide pin controls or sorting. -**Browse other plugins** opens the Plugins main list without opening an installation dialog. **Create your plugin** opens a new or reusable blank Session with the Creator preset and adds a short localized request for a plugin in that region to its draft: an empty draft takes the request as its text, and an existing draft keeps its text, references, and attachments with the request appended as a new paragraph. It never sends a message. Review and send the draft yourself. Both actions remain available when a region has no customizable entries; an empty list adds no separator. +**Browse other plugins** opens the Plugins main list without opening an installation dialog. **Create your plugin** opens a new or reusable blank Session with the Creator preset and adds a short localized request for a plugin in that region to its draft: an empty draft takes the request as its text, and an existing draft keeps its text, references, and attachments with the request appended as a new paragraph. It never sends a message. Review and send the draft yourself. Both actions remain available regardless of the region’s entries; a separator appears above browsing only when folded Views precede it. -On Desktop, **Browse other plugins** and **Create your plugin** report `more_menu_item_click` before they act, with `action=browse_plugins` or `action=create_plugin` and the menu's `region`: `sidebar` for **More**, `conversation` for **More conversation views**, and `rightbar` for **More tools**. Opening an entry and changing a pin report nothing, and the menus report no exposure events. Without the analytics service, both actions behave the same and report nothing. +On Desktop, **Browse other plugins** and **Create your plugin** report `more_menu_item_click` before they act, with `action=browse_plugins` or `action=create_plugin` and the menu's `region`: `sidebar` for **More**, `conversation` for **More conversation views**, and `rightbar` for **More tools**. The menus report no exposure events. Without the analytics service, both actions behave the same and report nothing. ----- @@ -42,7 +42,7 @@ On Desktop, **Browse other plugins** and **Create your plugin** report `more_men
Implementation internals — click to expand -[`apply`](src/client/index.ts) registers [`EntryMenuContent`](src/client/EntryMenuContent.tsx) under id `entry-customization` into `sidebar.panellist.actions`, `conversation.session.header.views.actions`, and `sidebar.right.tab.guide.actions` through `ctx.slots.inject`, so each registration follows its region's declaration lifetime. The regions render these slots inside their own `Menu` and supply the entries, their pin state, `onOpen`, `onPinnedChange`, and `onDismiss` as owner props. The rows call `onDismiss` before opening an entry or running a footer action; pin changes leave the menu open. +[`apply`](src/client/index.ts) registers [`EntryMenuContent`](src/client/EntryMenuContent.tsx) under id `entry-customization` into `sidebar.panellist.actions`, `conversation.session.header.views.actions`, and `sidebar.right.tab.guide.actions` through `ctx.slots.inject`, so each registration follows its region's declaration lifetime. The regions render these slots inside their own `Menu`; the rows call `onDismiss` before browsing or creating. Only the Conversation menu consumes its owner’s folded entry list and navigation callback; pin callbacks remain unused. Each registration's `inject` supplies its region and two callbacks. Each callback first calls `ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action })`. Browsing then calls `pluginNavigation.openList()`. Creation then calls `uiWorkspace.startSession(undefined, { agentPreset: 'cordis', prompt, whenDraftExists: 'append' })` with the region's localized prompt; it does not go through `ui-agent-preset`, so the preset chip plays no introduction. The [`dsh-web-app` bundle patch](../../bundle/web-app/cordis.patch.yml) mounts the browser plugin. @@ -63,12 +63,12 @@ None; the package neither assembles nor sends model requests. -- If this package is disabled or fails to load after entries were unpinned, those launchers stay hidden and no More menu can restore them. Re-enable the package or clear the region's `dsh.slot.manual-order.*.v1` localStorage key. -- Pin choices live in browser localStorage, which is isolated by protocol, host, and port: changing the Web port or switching between Web and Desktop does not carry them over, and they do not sync across devices. +- More menus provide no visibility controls. Region pin callbacks remain available to other registrations and change only in-memory choices; plugin reload restores defaults. +- Legacy pin values in localStorage are neither read nor written. - **Create your plugin** does not check whether the Creator preset exists. Without it, Session creation fails, the Workspace shows its creation-failure notice, and no draft is prepared. Draft preparation also requires a resolvable Workspace; without one, the existing flow reports that a Workspace is required. -- Entries keep registration order; the menus offer no reordering, and entries cannot move between regions. +- Conversation owns the estimated tab split; these menus provide no sorting. - Conversation controls follow the existing nonblank-Session header and Coding Tools eligibility. Right-sidebar controls require a selected Session and appear on its guide page. -- Only active registered launchers appear; the menus are not an inventory of disabled or uninstalled plugins. +- The menus do not list plugins; use the browsing action to open the Plugins page. ### Dev Note diff --git a/packages/client/ui-entry-customization/README.zh.md b/packages/client/ui-entry-customization/README.zh.md index 4b0169538c..0a468598bd 100644 --- a/packages/client/ui-entry-customization/README.zh.md +++ b/packages/client/ui-entry-customization/README.zh.md @@ -1,5 +1,5 @@ --- -description: "为三个导航区域的“更多”菜单提供 Pin 控件、插件浏览与创造模式草稿。" +description: "为三个导航区域的更多菜单提供插件浏览与创造模式草稿。" kind: "package-reference" --- @@ -11,7 +11,7 @@ kind: "package-reference" ## 概述 -为左侧栏、会话 View 标签和右侧栏起始页的“更多”菜单提供内容:各区域可自定义的入口、Pin 按钮、插件浏览与可审阅的创造模式草稿。Web 应用默认加载本包。各区域自己画“更多”按钮并保存自己的 Pin 状态;本包不保存状态。 +为左侧栏、会话 View 标签和右侧栏起始页的“更多”菜单提供插件浏览与可审阅的创造模式草稿。Web 应用默认加载本包。各区域自己画“更多”按钮;本包不保存状态。 ## 目录 @@ -28,11 +28,11 @@ kind: "package-reference" `@deepseek-ai/dsh-web-app` 组合包默认加载本包,无需在插件页开启或由 profile 选择。区域只在其 actions slot 有注册项时显示“更多”按钮,因此卸载本包会移除三个按钮。 -打开左侧导航入口下方的**更多**、会话 View 标签之后的**更多会话视图**,或右侧栏起始页卡片之后的**更多工具**。每个菜单按注册顺序列出该区域可自定义的入口;插件管理、Chat 和工作区文件始终显示,不列入菜单。点击入口名称会关闭菜单并打开该入口,不改变 Pin 状态。独立的 Pin 按钮在区域中固定或收起该入口,并保持菜单打开;实心 Pin 表示已固定。已收起的入口仍可从菜单打开,Pin 状态在刷新后保留。过长的入口名称会换行,更多工具菜单中则截断为单行。 +打开左侧导航入口下方的**更多**、会话 View 标签之后的**更多会话视图**,或右侧栏起始页卡片之后的**更多工具**。左侧栏和起始页菜单仅提供**浏览更多插件**与**创建你的插件**。会话菜单在这两个操作之前列出收起的 View,并标记当前 View。所有菜单都不提供 Pin 或排序操作。 -**浏览更多插件**进入插件页主列表,不打开安装弹窗。**创建你的插件**用 Creator 预设打开新 Session 或可复用的空白 Session,并把对应区域的简短本地化插件创建请求加入草稿:空草稿直接以该请求为文字,已有草稿保留其文字、引用和附件,请求另起一段追加在后面。它绝不发送消息。请自行审阅并发送草稿。当前区域没有可自定义入口时,两项操作仍然可用;空列表不添加分隔线。 +**浏览更多插件**进入插件页主列表,不打开安装弹窗。**创建你的插件**用 Creator 预设打开新 Session 或可复用的空白 Session,并把对应区域的简短本地化插件创建请求加入草稿:空草稿直接以该请求为文字,已有草稿保留其文字、引用和附件,请求另起一段追加在后面。它绝不发送消息。请自行审阅并发送草稿。两项操作始终可用;仅当前面有收起的 View 时,在浏览操作之前显示分隔线。 -在桌面端,**浏览更多插件**与**创建你的插件**在执行前上报 `more_menu_item_click`,`action` 为 `browse_plugins` 或 `create_plugin`,`region` 为菜单所在区域:**更多**为 `sidebar`,**更多会话视图**为 `conversation`,**更多工具**为 `rightbar`。打开入口和更改 Pin 状态不上报,菜单也不上报曝光事件。没有埋点服务时,两项操作行为不变且不上报。 +在桌面端,**浏览更多插件**与**创建你的插件**在执行前上报 `more_menu_item_click`,`action` 为 `browse_plugins` 或 `create_plugin`,`region` 为菜单所在区域:**更多**为 `sidebar`,**更多会话视图**为 `conversation`,**更多工具**为 `rightbar`。菜单不上报曝光事件。没有埋点服务时,两项操作行为不变且不上报。 ----- @@ -42,7 +42,7 @@ kind: "package-reference"
实现细节——点击展开 -[`apply`](src/client/index.ts) 通过 `ctx.slots.inject` 以 id `entry-customization` 把 [`EntryMenuContent`](src/client/EntryMenuContent.tsx) 注册到 `sidebar.panellist.actions`、`conversation.session.header.views.actions` 和 `sidebar.right.tab.guide.actions`,每个注册项跟随所在区域声明的生命周期。各区域在自己的 `Menu` 中渲染这些 slot,并以 owner props 提供入口、Pin 状态、`onOpen`、`onPinnedChange` 与 `onDismiss`。菜单行先调用 `onDismiss`,再打开入口或执行底部操作;Pin 变化不关闭菜单。 +[`apply`](src/client/index.ts) 通过 `ctx.slots.inject` 以 id `entry-customization` 把 [`EntryMenuContent`](src/client/EntryMenuContent.tsx) 注册到 `sidebar.panellist.actions`、`conversation.session.header.views.actions` 和 `sidebar.right.tab.guide.actions`,每个注册项跟随所在区域声明的生命周期。各区域在自己的 `Menu` 中渲染这些 slot;菜单行先调用 `onDismiss`,再执行浏览或创建操作。仅会话菜单消费 owner 提供的收起入口列表及导航回调;Pin 回调不被消费。 每个注册项的 `inject` 提供所属区域与两个回调。每个回调先调用 `ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action })`。浏览随后调用 `pluginNavigation.openList()`。创建随后按区域取本地化提示语,调用 `uiWorkspace.startSession(undefined, { agentPreset: 'cordis', prompt, whenDraftExists: 'append' })`;该调用不经过 `ui-agent-preset`,预设标签不播放介绍动画。[`dsh-web-app` bundle patch](../../bundle/web-app/cordis.patch.yml) 挂载浏览器插件。 @@ -63,12 +63,12 @@ kind: "package-reference" -- 用户收起入口后,若本包被停用或加载失败,被收起的入口保持隐藏,且没有“更多”菜单可以恢复。重新启用本包或清除对应区域的 `dsh.slot.manual-order.*.v1` localStorage 键即可恢复。 -- Pin 状态保存在浏览器 localStorage,按协议、主机和端口隔离:更换 Web 端口或在 Web 与 Desktop 之间切换时不共享,也不跨设备同步。 +- 更多菜单不提供入口显隐管理。其他注册项仍可调用区域 Pin 回调,仅修改内存中的选择;插件重新加载后恢复默认值。 +- 不读取或写入 localStorage 中的旧 Pin 值。 - **创建你的插件**不检查 Creator 预设是否存在。预设缺失时 Session 创建失败,Workspace 显示创建失败提示,不准备草稿。草稿准备还要求可确定目标 Workspace;没有目标时,现有流程提示需要 Workspace。 -- 入口保持注册顺序;菜单不提供排序,入口也不能跨区域移动。 +- 会话区域负责按估算值分组;菜单不提供排序。 - 会话控件遵循现有非空 Session 头部与代码工作工具的可用性规则。右侧栏控件要求已选择 Session,并显示在其起始页。 -- 仅显示已激活、已注册的入口;菜单不是已停用或未安装插件的清单。 +- 菜单不列出插件清单;使用浏览操作进入插件页。 ### 开发备注 diff --git a/packages/client/ui-entry-customization/src/client/EntryMenu.module.css b/packages/client/ui-entry-customization/src/client/EntryMenu.module.css index 82ce8f3f88..d7d1c3a298 100644 --- a/packages/client/ui-entry-customization/src/client/EntryMenu.module.css +++ b/packages/client/ui-entry-customization/src/client/EntryMenu.module.css @@ -1,12 +1,3 @@ -.entryRow { position: relative; } -.entryName { display: flex; align-items: center; gap: 8px; padding-right: 28px; } +.entryName { display: flex; align-items: center; gap: 8px; } .entryTitle { min-width: 0; overflow-wrap: anywhere; white-space: normal; } -.singleLine .entryTitle { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.pinAnchor { position: absolute; right: 6px; top: 0; bottom: 0; display: flex; align-items: center; } -.pinButton { width: 22px; height: 22px; padding: 0; color: var(--dsw-alias-label-tertiary); } - -@media (hover: hover) { - .pinButton:hover { color: var(--dsw-alias-label-primary); } - .pinAnchor[data-pinned='false'] { opacity: 0; } - .entryRow:hover .pinAnchor, .entryRow:focus-within .pinAnchor { opacity: 1; } -} +.activeMark { flex: none; margin-left: auto; } diff --git a/packages/client/ui-entry-customization/src/client/EntryMenuContent.tsx b/packages/client/ui-entry-customization/src/client/EntryMenuContent.tsx index 0370d547ee..d8844281ab 100644 --- a/packages/client/ui-entry-customization/src/client/EntryMenuContent.tsx +++ b/packages/client/ui-entry-customization/src/client/EntryMenuContent.tsx @@ -1,17 +1,17 @@ -/** Rows of a navigation region's More menu: customizable entries, then plugin discovery and creation. */ +/** Folded Conversation Views and plugin discovery and creation actions. */ import { - IconPluginPinwheelOutlineRegular, IconPlusOutlineRegular, MenuItemButton, + IconCheckOutlineRegular, IconPluginPinwheelOutlineRegular, IconPlusOutlineRegular, MenuItemButton, } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' -import { EntryPickerRow } from './EntryPickerRow.tsx' +import css from './EntryMenu.module.css' /** Navigation region whose More menu hosts one registration. */ export type EntryRegion = 'sidebar' | 'conversation' | 'rightbar' /** Region identity and footer actions injected into each registration. */ export interface EntryMenuInjected { - /** Region of this registration; selects pin copy and the Creator prompt. */ + /** Region of this registration; selects analytics attributes and the Creator prompt. */ readonly region: EntryRegion /** Open the Plugins main list. */ readonly browsePlugins: () => void @@ -24,22 +24,23 @@ type EntryMenuContentProps = PropsRuntime<'sidebar.panellist.actions'> & PropsLocale<'entryCustomization'> & InjectFace /** - * Render the region's entries with pin actions, then the discovery and Creator rows. - * @param props - Owner entries and callbacks, injected region actions, and localized copy. + * Render folded Conversation Views followed by discovery and Creator actions, without pin controls. + * @param props - Owner dismissal callback, injected actions, and localized copy. * @returns Menu rows for the region's More menu. */ export function EntryMenuContent({ - entries, onOpen, onPinnedChange, onDismiss, region, browsePlugins, createPlugin, t, + entries, onOpen, region, onDismiss, browsePlugins, createPlugin, t, }: EntryMenuContentProps) { + const moreEntries = region === 'conversation' ? entries : [] return <> - {entries.map((entry) => { - const pinLabel = t(`${entry.pinned ? 'unpin' : 'pin'}.${region}`) - return { onDismiss(); onOpen(entry.id) }} - onPin={() => { onPinnedChange(entry.id, !entry.pinned) }} /> - })} - } separatorBefore={entries.length > 0} + {moreEntries.map(entry => { onDismiss(); onOpen(entry.id) }}> + + {entry.label} + {entry.active === true && } + + )} + 0} icon={} onSelect={() => { onDismiss(); browsePlugins() }}> {t('install')} diff --git a/packages/client/ui-entry-customization/src/client/EntryPickerRow.tsx b/packages/client/ui-entry-customization/src/client/EntryPickerRow.tsx deleted file mode 100644 index 4148de08f9..0000000000 --- a/packages/client/ui-entry-customization/src/client/EntryPickerRow.tsx +++ /dev/null @@ -1,42 +0,0 @@ -/** A launcher row separates navigation from its independently focusable pin action. */ -import clsx from 'clsx' -import { - Button, IconPinFillRegular, IconPinOutlineRegular, MenuItemButton, Tooltip, -} from '@deepseek-ai/dsh-client-ui-primitives' -import css from './EntryMenu.module.css' - -interface EntryPickerRowProps { - readonly label: string - readonly pinned: boolean - readonly pinLabel: string - readonly pinAccessibleLabel: string - /** Truncate the label to one line instead of wrapping it. */ - readonly singleLine: boolean - readonly onOpen: () => void - readonly onPin: () => void -} - -/** - * Render two sibling controls so pinning never opens the entry. - * @param props - Entry label, pin state, localized action names and independent callbacks. - * @returns A shared menu row with a trailing pin button. - */ -export function EntryPickerRow({ - label, pinned, pinLabel, pinAccessibleLabel, singleLine, onOpen, onPin, -}: EntryPickerRowProps) { - return
- - - {label} - - - - - - - -
-} diff --git a/packages/client/ui-entry-customization/src/client/index.ts b/packages/client/ui-entry-customization/src/client/index.ts index 0becd3802f..bde0b46701 100644 --- a/packages/client/ui-entry-customization/src/client/index.ts +++ b/packages/client/ui-entry-customization/src/client/index.ts @@ -13,7 +13,7 @@ import { en, zh } from './locales.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { - /** More menu rows: pin actions, footer actions, and Creator draft prompts. */ + /** More menu rows: navigation actions, and Creator draft prompts. */ entryCustomization: keyof typeof en } } diff --git a/packages/client/ui-entry-customization/src/client/locales.ts b/packages/client/ui-entry-customization/src/client/locales.ts index 5b7692411a..f35a54248f 100644 --- a/packages/client/ui-entry-customization/src/client/locales.ts +++ b/packages/client/ui-entry-customization/src/client/locales.ts @@ -1,12 +1,5 @@ -/** Copy for the More menu rows: pin actions, footer actions, and Creator draft prompts per region. */ +/** Copy for the More menu rows: navigation actions, and Creator draft prompts per region. */ export const en = { - 'pin.sidebar': 'Pin to left sidebar', - 'unpin.sidebar': 'Unpin from left sidebar', - 'pin.conversation': 'Pin to conversation tabs', - 'unpin.conversation': 'Unpin from conversation tabs', - 'pin.rightbar': 'Pin to right sidebar', - 'unpin.rightbar': 'Unpin from right sidebar', - pinLabel: '{action}: {name}', install: 'Browse other plugins', create: 'Create your plugin', 'createPrompt.sidebar': 'Create a DSH plugin with a left sidebar entry.', @@ -16,13 +9,6 @@ export const en = { /** Chinese counterpart of the entry-customization dictionary. */ export const zh: Record = { - 'pin.sidebar': '固定到左侧栏', - 'unpin.sidebar': '从左侧栏取消固定', - 'pin.conversation': '固定到会话标签栏', - 'unpin.conversation': '从会话标签栏取消固定', - 'pin.rightbar': '固定到右侧栏', - 'unpin.rightbar': '从右侧栏取消固定', - pinLabel: '{action}:{name}', install: '浏览更多插件', create: '创建你的插件', 'createPrompt.sidebar': '创建一个带左侧栏入口的 DSH 插件。', diff --git a/packages/client/ui-entry-customization/tests/menu.client.spec.tsx b/packages/client/ui-entry-customization/tests/menu.client.spec.tsx index 7171146a8e..4e1dd41cde 100644 --- a/packages/client/ui-entry-customization/tests/menu.client.spec.tsx +++ b/packages/client/ui-entry-customization/tests/menu.client.spec.tsx @@ -1,8 +1,8 @@ // @vitest-environment jsdom -/** The plugin fills each region's More menu with entry rows and the discovery and Creator actions. */ +/** The plugin fills each region's More menu with the discovery and Creator actions. */ import type { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' -import { act, cleanup, fireEvent, render, within } from '@testing-library/react' +import { cleanup, fireEvent, render, within } from '@testing-library/react' import type { ComponentProps } from 'react' import { useState } from 'react' import { makeTranslate, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' @@ -30,10 +30,9 @@ function content(overrides: Partial = {}) { onDismiss: vi.fn(), browsePlugins: vi.fn(), createPlugin: vi.fn(), } - // Framework hooks stay unused stubs; this isolated row set reads only its owner, inject, and locale shares. const props = { entries: [{ id: 'notes', label: 'Notes', pinned: true }, { id: 'tasks', label: 'Tasks', pinned: false }], - region: 'sidebar', t: makeTranslate(en), renderFactorySlot: unused, + region: 'sidebar', t: makeTranslate(en), usePanelInfo: unused, useSessions: unused, useSessionStatus: unused, useSessionRetainInfo: unused, useResource: unused, useWorkspaces: unused, ...callbacks, @@ -44,53 +43,45 @@ function content(overrides: Partial = {}) { } describe('More menu rows', () => { - it('separates opening from pinning and reports the inverted pin state', () => { - const b = content() - expect(b.view.getAllByRole('menuitem').map(row => row.getAttribute('aria-label') ?? row.textContent)).toEqual([ - 'Notes', 'Unpin from left sidebar: Notes', 'Tasks', 'Pin to left sidebar: Tasks', 'Browse other plugins', 'Create your plugin', - ]) - expect(b.view.getAllByRole('separator')).toHaveLength(1) - fireEvent.click(b.view.getByRole('menuitem', { name: 'Unpin from left sidebar: Notes' })) - fireEvent.click(b.view.getByRole('menuitem', { name: 'Pin to left sidebar: Tasks' })) - expect(b.onPinnedChange).toHaveBeenNthCalledWith(1, 'notes', false) - expect(b.onPinnedChange).toHaveBeenNthCalledWith(2, 'tasks', true) - expect(b.onDismiss).not.toHaveBeenCalled() - expect(b.onOpen).not.toHaveBeenCalled() - fireEvent.click(b.view.getByRole('menuitem', { name: 'Tasks' })) - expect(b.onDismiss).toHaveBeenCalledOnce() - expect(b.onOpen).toHaveBeenCalledExactlyOnceWith('tasks') - expect(b.onDismiss.mock.invocationCallOrder[0]).toBeLessThan(b.onOpen.mock.invocationCallOrder[0]!) - }) - - it.each([ - ['conversation', 'Pin to conversation tabs: Tasks'], - ['rightbar', 'Pin to right sidebar: Tasks'], - ] as const)('names the %s pin destination', (region, name) => { - const { view } = content({ region }) - expect(view.getByRole('menuitem', { name })).toBeTruthy() - }) - - it('keeps only the discovery and Creator actions, without a separator, for an empty roster', () => { - const b = content({ entries: [], region: 'rightbar' }) + it.each(['sidebar', 'rightbar'] as const)('shows only discovery and creation in %s regardless of entries', (region) => { + const b = content({ region, entries: [ + { id: 'notes', label: 'Notes', pinned: true, active: true }, + { id: 'tasks', label: 'Tasks', pinned: false }, + ] }) expect(b.view.getAllByRole('menuitem').map(row => row.textContent)).toEqual(['Browse other plugins', 'Create your plugin']) expect(b.view.queryByRole('separator')).toBeNull() fireEvent.click(b.view.getByRole('menuitem', { name: 'Browse other plugins' })) expect(b.browsePlugins).toHaveBeenCalledExactlyOnceWith() + expect(b.onDismiss.mock.invocationCallOrder[0]).toBeLessThan(b.browsePlugins.mock.invocationCallOrder[0]!) fireEvent.click(b.view.getByRole('menuitem', { name: 'Create your plugin' })) expect(b.createPlugin).toHaveBeenCalledExactlyOnceWith() - expect(b.onDismiss).toHaveBeenCalledTimes(2) + expect(b.onDismiss.mock.invocationCallOrder[1]).toBeLessThan(b.createPlugin.mock.invocationCallOrder[0]!) + expect(b.onOpen).not.toHaveBeenCalled() + expect(b.onPinnedChange).not.toHaveBeenCalled() }) - it('shows the pin hint after the shared tooltip delay outside the menu card', () => { - const { view } = content() - const unpin = view.getByRole('menuitem', { name: 'Unpin from left sidebar: Notes' }) - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - fireEvent.mouseEnter(unpin) - act(() => { vi.advanceTimersByTime(499) }) - expect(view.queryByRole('tooltip', { hidden: true })).toBeNull() - act(() => { vi.advanceTimersByTime(1) }) - expect(view.getByRole('tooltip', { hidden: true }).closest('[role="menu"]')).toBeNull() - fireEvent.mouseLeave(unpin) + it('opens folded Conversation Views and marks the active View without pin controls', () => { + const b = content({ region: 'conversation', entries: [ + { id: 'notes', label: 'Notes', pinned: true, active: true }, + { id: 'tasks', label: 'Tasks', pinned: true, active: false }, + ] }) + expect(b.view.getAllByRole('menuitem').map(row => row.textContent)) + .toEqual(['Notes', 'Tasks', 'Browse other plugins', 'Create your plugin']) + const currentRow = b.view.getByRole('menuitem', { name: 'Notes' }) + expect(currentRow.getAttribute('aria-current')).toBe('page') + expect(currentRow.querySelector('svg')?.previousElementSibling?.textContent).toBe('Notes') + expect(b.view.getByRole('menuitem', { name: 'Tasks' }).hasAttribute('aria-current')).toBe(false) + expect(b.view.getAllByRole('separator')).toHaveLength(1) + fireEvent.click(b.view.getByRole('menuitem', { name: 'Tasks' })) + expect(b.onOpen).toHaveBeenCalledExactlyOnceWith('tasks') + expect(b.onDismiss.mock.invocationCallOrder[0]).toBeLessThan(b.onOpen.mock.invocationCallOrder[0]!) + expect(b.onPinnedChange).not.toHaveBeenCalled() + }) + + it('keeps both actions for an empty roster', () => { + const b = content({ entries: [] }) + expect(b.view.getAllByRole('menuitem').map(row => row.textContent)).toEqual(['Browse other plugins', 'Create your plugin']) + expect(b.view.queryByRole('separator')).toBeNull() }) }) @@ -101,7 +92,7 @@ function Frame({ renderSlot }: PropsRenderSlots) { return <> {SEATS.map(seat =>
{renderSlot(seat, { - entries, + entries: seat === 'conversation.session.header.views.actions' ? [] : entries, onOpen: (id) => { setOpened(`${seat}:${id}`) }, onPinnedChange: (_id, next) => { setPinned(next) }, onDismiss: () => {}, @@ -142,15 +133,10 @@ describe('More menu registrations', () => { const b = await bench() for (const seat of SEATS) { expect(b.runtime.slots.entries(seat).map(entry => entry.options.id)).toEqual(['entry-customization']) - expect(within(b.view.getByRole('menu', { name: seat })).getByRole('menuitem', { name: 'Notes' })).toBeTruthy() + expect(within(b.view.getByRole('menu', { name: seat })).getAllByRole('menuitem').map(row => row.textContent)) + .toEqual(['Browse other plugins', 'Create your plugin']) } const sidebar = within(b.view.getByRole('menu', { name: 'sidebar.panellist.actions' })) - fireEvent.click(sidebar.getByRole('menuitem', { name: 'Unpin from left sidebar: Notes' })) - expect(within(b.view.getByRole('menu', { name: 'sidebar.right.tab.guide.actions' })) - .getByRole('menuitem', { name: 'Pin to right sidebar: Notes' })).toBeTruthy() - fireEvent.click(within(b.view.getByRole('menu', { name: 'conversation.session.header.views.actions' })) - .getByRole('menuitem', { name: 'Notes' })) - expect(b.view.getByTestId('opened').textContent).toBe('conversation.session.header.views.actions:notes') fireEvent.click(sidebar.getByRole('menuitem', { name: 'Browse other plugins' })) expect(b.openList).toHaveBeenCalledExactlyOnceWith() await b.plugin.dispose() @@ -192,15 +178,6 @@ describe('More menu registrations', () => { expect(b.track).toHaveBeenCalledTimes(2) }) - it('reports neither entry opening nor pin changes', async () => { - const b = await bench() - const menu = within(b.view.getByRole('menu', { name: 'sidebar.panellist.actions' })) - fireEvent.click(menu.getByRole('menuitem', { name: 'Unpin from left sidebar: Notes' })) - fireEvent.click(menu.getByRole('menuitem', { name: 'Notes' })) - expect(b.view.getByTestId('opened').textContent).toBe('sidebar.panellist.actions:notes') - expect(b.track).not.toHaveBeenCalled() - }) - it('keeps both footer actions working without the analytics service', async () => { const b = await bench('en', { analytics: false }) const menu = within(b.view.getByRole('menu', { name: 'sidebar.right.tab.guide.actions' })) diff --git a/packages/client/ui-entry-customization/tests/pin-styles.client.spec.ts b/packages/client/ui-entry-customization/tests/pin-styles.client.spec.ts deleted file mode 100644 index c30d3ee705..0000000000 --- a/packages/client/ui-entry-customization/tests/pin-styles.client.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** Pin controls use Session-action ink without changing the navigation control's dimensions. */ -import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' - -const css = readFileSync(new URL('../src/client/EntryMenu.module.css', import.meta.url), 'utf8') - -describe('entry pin styling', () => { - it('uses tertiary ink for both pin states and primary ink on pointer hover', () => { - expect(css).toMatch(/\.pinButton\s*\{[^}]*color:\s*var\(--dsw-alias-label-tertiary\)/) - expect(css).toMatch(/\.pinButton:hover\s*\{[^}]*color:\s*var\(--dsw-alias-label-primary\)/) - expect(css).not.toMatch(/\[data-pinned=['"]true['"]\]\s+\.pinButton\s*\{[^}]*color:/) - }) - - it('centers the pin without creating a transformed tooltip container', () => { - const pinAnchor = css.match(/\.pinAnchor\s*\{([^}]*)\}/)?.[1] - expect(pinAnchor).toBeDefined() - expect(pinAnchor).toContain('align-items: center') - expect(pinAnchor).not.toContain('transform:') - }) -}) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 3399a8e620..e12048274a 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -18,8 +18,8 @@ en: 7bae72c97d0449f5 zh: 4589ecc2b216edc5 /deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog: - en: 75315591e4aa1202 - zh: 2b1920468ccb715d + en: c804a44a782227cb + zh: 14b53da9bf298c5f /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: en: 09c61f61c560f8a2 zh: 895ac73add1199b8 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 48496db16d..76449c4f59 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -46,7 +46,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `Checkbox` | Labeled native checkbox with controlled state, keyboard interaction, and disabled styling; the caller supplies localized `label` text. | | `Input` | Single-line text entry for search boxes and inline forms. Its ref targets the native input for focus and is cleared on unmount. | | `InlineEditor` | Autofocused multi-line inline editor for short drafts such as queued messages and goals. It grows with its text and its width up to six lines, then scrolls; Enter saves, Shift+Enter breaks the line, Enter during IME composition does nothing, and Escape cancels. The caller supplies the localized `label` and rebinds the scrollbar on an elevated surface. | -| `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. | +| `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. `MenuItemButton.current` exposes the current page or view through `aria-current` without changing selection or appearance. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. | | `MenuGroup`, `observeStickyMenuGroups` | Localized, accessible groups for custom menus and listboxes, with shared sticky-heading styling and native intersection/size observation; the caller owns the observer's lifetime. | | `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. | | `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. | diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index b9e678b6b1..0ad5b2297b 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -46,7 +46,7 @@ kind: "package-library" | `Checkbox` | 带标签的原生复选框,支持受控状态、键盘交互和禁用样式;调用方提供本地化的 `label` 文本。 | | `Input` | 单行文本输入,用于搜索框与行内表单。ref 指向原生输入框,供焦点控制使用,并在卸载时清空。 | | `InlineEditor` | 自动聚焦的多行行内编辑器,用于排队消息、goal 等短草稿。它随文本和宽度增高,最多六行,之后滚动;Enter 保存,Shift+Enter 换行,输入法组合中的 Enter 不生效,Escape 取消。调用方提供本地化 `label`,并在 elevated 表面上重绑滚动条。 | -| `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 | +| `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。`MenuItemButton.current` 通过 `aria-current` 标识当前页面或视图,不改变选中状态或外观。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 | | `MenuGroup`、`observeStickyMenuGroups` | 为自定义菜单与列表框提供本地化、可访问的分组,共用吸顶标题样式与原生交叉/尺寸观察;调用方负责观察器的生命周期。 | | `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 | | `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。 | diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index 0f1225fc42..87e76287c7 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -51,6 +51,8 @@ export interface MenuItemButtonProps { shortcut?: MenuItem['shortcut'] /** Leading icon (figma .Menu_cell gap 8). */ icon?: ReactNode + /** Whether this row identifies the currently displayed page or view. */ + current?: boolean /** Whether the row cannot be activated. */ disabled?: boolean /** Destructive row: error-colored text/icon and danger hover fill. */ @@ -75,6 +77,7 @@ export interface MenuItemButtonProps { * @param props.children - visible row label. * @param props.shortcut - effective key labels and accessible combination. * @param props.icon - optional leading icon. + * @param props.current - whether the row identifies the currently displayed page or view. * @param props.disabled - whether the row cannot be activated. * @param props.danger - whether to use the destructive row colors. * @param props.separatorBefore - whether this row starts a new group (hairline above it). @@ -82,7 +85,7 @@ export interface MenuItemButtonProps { * @returns one menu-item row. */ export function MenuItemButton({ - children, shortcut, icon, disabled = false, danger = false, separatorBefore = false, onSelect, + children, shortcut, icon, current, disabled = false, danger = false, separatorBefore = false, onSelect, }: MenuItemButtonProps) { return (
@@ -93,6 +96,7 @@ export function MenuItemButton({ className={clsx(css.item, danger && css.danger)} disabled={disabled} aria-keyshortcuts={shortcut?.aria} + aria-current={current === true ? 'page' : undefined} onClick={onSelect} > {icon !== undefined && {icon}} diff --git a/packages/client/ui-primitives/tests/atoms.client.spec.tsx b/packages/client/ui-primitives/tests/atoms.client.spec.tsx index bc48ea0d38..d63a757110 100644 --- a/packages/client/ui-primitives/tests/atoms.client.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.client.spec.tsx @@ -80,6 +80,21 @@ describe('Input', () => { }) }) +describe('MenuItemButton', () => { + it('exposes the current page without preventing activation and clears it when selection moves', () => { + const onSelect = vi.fn() + const { rerender } = render(Notes) + const row = screen.getByRole('menuitem', { name: 'Notes' }) + expect(row.getAttribute('aria-current')).toBe('page') + fireEvent.click(row) + expect(onSelect).toHaveBeenCalledOnce() + rerender(Notes) + expect(row.hasAttribute('aria-current')).toBe(false) + rerender(Notes) + expect(row.hasAttribute('aria-current')).toBe(false) + }) +}) + describe('Menu', () => { const items = [ { id: 'a', label: 'Alpha' }, diff --git a/packages/client/ui-sidebar-right/README.i18n.yaml b/packages/client/ui-sidebar-right/README.i18n.yaml index 4c93ea2e93..6f7b7c2eb9 100644 --- a/packages/client/ui-sidebar-right/README.i18n.yaml +++ b/packages/client/ui-sidebar-right/README.i18n.yaml @@ -36,8 +36,8 @@ en: 717b86e05b44ebdd zh: 0f4f694d0ea1d7f8 /deepseek-ai-dsh-client-ui-sidebar-right/the-guide: - en: dfb9e0911e4278f1 - zh: e395e10f61ad040d + en: 0a77be5c16ef83c6 + zh: 532545fef09c3e66 /deepseek-ai-dsh-client-ui-sidebar-right/copy: en: 4315a1c03ca5fef3 zh: c09a09f1d9b99107 diff --git a/packages/client/ui-sidebar-right/README.md b/packages/client/ui-sidebar-right/README.md index ea0269b716..833d08b317 100644 --- a/packages/client/ui-sidebar-right/README.md +++ b/packages/client/ui-sidebar-right/README.md @@ -114,7 +114,7 @@ Close and refresh commands resolve the focused pane and tab occurrence. Page bod Each `guide` entry has an `id` unique within its provider. The guide renders `sidebar.right.tab.guide.entry` by the active definition id, forwarding the entry id, resolved title and optional description, plus the framework `useTabInfo` hook. A provider can register a custom card under its definition id; otherwise the standard icon/title button renders. The sidebar owns width and placement, while custom components own their markup and interaction. Guide cards use the shared R20 radius, including hover fills and provider-owned split buttons. Entry identity remains stable across sorting and unrelated registrations. Unboxed shortcut text aligns at the right. The terminal shell menu sits beside its entry title. -While the root-scoped `sidebar.right.tab.guide.actions` list has registrations, a **More tools** card follows the guide cards. Its menu end-aligns to the card, is at least as wide as the card, and grows with its rows up to 480px or the viewport width minus 24px. The menu rows are that list's registrations. Each receives `entries` (cards in registry order with `id`, `label`, and `pinned`; kind `files` is never listed), `onOpen(id)`, which opens the card's tab type in the guide's place, `onPinnedChange(id, pinned)`, and `onDismiss()`, which closes the menu. The guide keeps one root list of unpinned card ids, each `kind:entry id`, in browser `localStorage` under `dsh.slot.manual-order.sidebar.right.tab.guide.list.v1`, stored as `{ "unpinned": string[] }`; the key reserves the name of a future guide list slot. The kind identifies a card instead of the provider id, so a pin choice survives an extension taking over that kind with the same entry id. Cards of kind `files` always show. Unpinning hides only the card; it closes no tab and changes neither tab-type registration nor the default page. Description density follows the visible card count. Ids of cards that are not registered stay stored; stored content other than a string array shows every card. +While the root-scoped `sidebar.right.tab.guide.actions` list has registrations, a **More tools** card follows the guide cards. Its menu end-aligns to the card, is at least as wide as the card, and grows with its rows up to 480px or the viewport width minus 24px. The menu rows are that list's registrations. Each receives `entries` (cards in registry order with `id`, `label`, and `pinned`; kind `files` is never listed), `onOpen(id)`, which opens the card's tab type in the guide's place, `onPinnedChange(id, pinned)`, and `onDismiss()`, which closes the menu. The guide keeps an in-memory list of unpinned card ids, each `kind:entry id`. Plugin reload resets pin choices; legacy localStorage values are ignored. The default More menu only offers plugin browsing and creation; pin callbacks remain available to other registrations. The kind identifies a card instead of the provider id, so a pin choice survives an extension taking over that kind with the same entry id. Cards of kind `files` always show. Unpinning hides only the card; it closes no tab and changes neither tab-type registration nor the default page. Description density follows the visible card count. Unregistered card ids remain in memory until plugin reload. A tab type can declare `multiple: true` for independent instances such as terminals. Each open receives a distinct content address, so placement and docking preserve those instances; ordinary pages retain per-pane uniqueness. diff --git a/packages/client/ui-sidebar-right/README.zh.md b/packages/client/ui-sidebar-right/README.zh.md index 052b38e2c8..d16b18f161 100644 --- a/packages/client/ui-sidebar-right/README.zh.md +++ b/packages/client/ui-sidebar-right/README.zh.md @@ -114,7 +114,7 @@ Tab 域按(Session,Tab id)保留品牌化 occurrence id、导航、中止 每个 `guide` 入口具有 provider 内唯一的 `id`。开始页按当前生效的定义 id 渲染 `sidebar.right.tab.guide.entry`,传入入口 id、解析后的标题、可选描述,以及框架提供的 `useTabInfo` hook。provider 可按自己的定义 id 注册自定义卡片;未注册时使用默认图标与标题按钮。侧栏负责宽度和排列,自定义组件负责内部结构和交互。引导卡片统一使用 R20,包含悬停底色与提供方自有的分体按钮。排序或其他注册变化不会改变入口身份。无背景的快捷键文字右对齐。终端 Shell 菜单位于入口标题旁。 -root 作用域的 `sidebar.right.tab.guide.actions` list 有注册项时,引导卡片之后显示**更多工具**卡片。它的菜单与卡片末端对齐,不窄于卡片,并随菜单行增宽,最大为 480px 与视口宽度减 24px 中的较小值。菜单行为该 list 的注册项。每个注册项接收 `entries`(按注册表顺序的卡片,含 `id`、`label` 与 `pinned`;不含 kind 为 `files` 的卡片)、在引导页位置打开卡片标签类型的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。引导页在浏览器 `localStorage` 的 `dsh.slot.manual-order.sidebar.right.tab.guide.list.v1` 下保存一份 root 级的已收起卡片 id 列表,每个 id 为 `kind:入口 id`,格式为 `{ "unpinned": string[] }`;该键名为以后的引导入口 list slot 预留。卡片由 kind 而不是提供方 id 标识,因此扩展接管某个 kind 且保持入口 id 时,Pin 选择仍然有效。kind 为 `files` 的卡片始终显示。收起只隐藏卡片,不关闭标签,也不改变标签类型注册或默认页。描述的显示密度按可见卡片数计算。未注册卡片的 id 保留在列表中;保存内容不是字符串数组时,所有卡片都显示。 +root 作用域的 `sidebar.right.tab.guide.actions` list 有注册项时,引导卡片之后显示**更多工具**卡片。它的菜单与卡片末端对齐,不窄于卡片,并随菜单行增宽,最大为 480px 与视口宽度减 24px 中的较小值。菜单行为该 list 的注册项。每个注册项接收 `entries`(按注册表顺序的卡片,含 `id`、`label` 与 `pinned`;不含 kind 为 `files` 的卡片)、在引导页位置打开卡片标签类型的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。引导页在内存中保存已收起卡片 id 列表,每个 id 为 `kind:入口 id`。插件重新加载时 Pin 选择重置,不读取旧 localStorage 值。默认更多菜单只提供插件浏览和创建;其他注册项仍可使用 Pin 回调。卡片由 kind 而不是提供方 id 标识,因此扩展接管某个 kind 且保持入口 id 时,Pin 选择仍然有效。kind 为 `files` 的卡片始终显示。收起只隐藏卡片,不关闭标签,也不改变标签类型注册或默认页。描述的显示密度按可见卡片数计算。未注册卡片的 id 保留在内存中,直到插件重新加载。 终端等需要独立实例的 tab 类型可声明 `multiple: true`。每次打开都会获得独立内容地址,因此移动和停靠会保留这些实例;普通页面仍在每个格内去重。 diff --git a/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx b/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx index 4d40f86e01..febe9f1954 100644 --- a/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx +++ b/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx @@ -19,8 +19,7 @@ * open. */ import { useState } from 'react' -import clsx from 'clsx' -import { IconAppsAddOutlineRegular, Menu, ShortcutKeys } from '@deepseek-ai/dsh-client-ui-primitives' +import { Menu, ShortcutKeys } from '@deepseek-ai/dsh-client-ui-primitives' import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client' import type { ReactNode } from 'react' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' @@ -30,6 +29,7 @@ import type { import type { SidebarRightGuideActionsOwnerProps } from '../../contract/slots.ts' import type { SidebarRightGuideBox } from '../../tab-registry.ts' import { CompassGlyph, CubeGlyph } from './GuideTitle.tsx' +import { GuideMoreIcon } from './GuideMoreIcon.tsx' import { FIXED_GUIDE_KIND, guideEntryId } from './guide-pins.ts' import css from './GuideBody.module.css' @@ -39,7 +39,7 @@ export interface GuideInjected { readonly hooks: { readonly shortcuts: ObservableSnapshot readonly guideEntries: ObservableSnapshot - /** Unpinned `kind:id` card ids, persisted across reloads. */ + /** Unpinned `kind:id` card ids held in memory until plugin reload. */ readonly entryPins: ObservableSnapshot /** Whether `sidebar.right.tab.guide.actions` has registrations. */ readonly entryActions: ObservableSnapshot @@ -106,7 +106,7 @@ function GuideMoreMenu({ label, entries, onOpen, onPinnedChange, renderSlot }: G anchor={( )}> diff --git a/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideMoreIcon.tsx b/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideMoreIcon.tsx new file mode 100644 index 0000000000..61f7913aa3 --- /dev/null +++ b/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideMoreIcon.tsx @@ -0,0 +1,15 @@ +/** Designer-supplied artwork used only by the right-sidebar More tools card. */ + +/** + * Render the More tools glyph at its authored size and ink. + * @returns Decorative 22px More tools artwork. + */ +export function GuideMoreIcon() { + return +} diff --git a/packages/client/ui-sidebar-right/src/client/tabs/guide/guide-pins.ts b/packages/client/ui-sidebar-right/src/client/tabs/guide/guide-pins.ts index 3614a6778b..f43ee7503d 100644 --- a/packages/client/ui-sidebar-right/src/client/tabs/guide/guide-pins.ts +++ b/packages/client/ui-sidebar-right/src/client/tabs/guide/guide-pins.ts @@ -1,12 +1,10 @@ -/** Persisted pin choices for the guide's tool cards. */ +/** In-memory pin choices for the guide's tool cards. */ import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { SidebarRightGuideBox } from '../../tab-registry.ts' /** Tab kind whose card always shows and never enters the customization list. */ export const FIXED_GUIDE_KIND = 'files' -/** Root-level key reserved for the future guide list slot's per-list preferences. */ -const STORAGE_KEY = 'dsh.slot.manual-order.sidebar.right.tab.guide.list.v1' const NO_CARDS: readonly string[] = [] /** @@ -23,30 +21,23 @@ export interface GuidePins { /** Unpinned `kind:id` card ids; ids of cards that are not registered stay listed. */ readonly unpinned: ObservableSnapshot /** - * Show or hide one guide card; cards of the fixed kind are ignored. + * Pin or unpin one guide card; cards of the fixed kind are ignored. * @param id - `kind:id` card id. - * @param pinned - Whether the card shows on the guide. + * @param pinned - Whether the card is manually pinned. */ readonly setPinned: (id: string, pinned: boolean) => void } -/** Stored content is unchecked; anything but a string array leaves every card pinned. */ -function unpinnedCards(value: unknown): readonly string[] { - if (typeof value !== 'object' || value === null || !('unpinned' in value)) return NO_CARDS - const { unpinned } = value - return Array.isArray(unpinned) && unpinned.every((id): id is string => typeof id === 'string') ? unpinned : NO_CARDS -} - /** - * Create the persisted guide pin list; the snapshot keeps its reference until the list changes. - * @returns Validated unpinned card ids and the pin mutation. + * Create the in-memory guide pin list; the snapshot keeps its reference until the list changes. + * @returns Unpinned card ids and the pin mutation. */ export function createGuidePins(): GuidePins { - const store = createSnapshotStore({ unpinned: NO_CARDS }, { persist: { name: STORAGE_KEY } }) + const store = createSnapshotStore<{ readonly unpinned: readonly string[] }>({ unpinned: NO_CARDS }) return { - unpinned: { getSnapshot: () => unpinnedCards(store.getSnapshot()), subscribe: listener => store.subscribe(listener) }, + unpinned: { getSnapshot: () => store.getSnapshot().unpinned, subscribe: listener => store.subscribe(listener) }, setPinned: (id, pinned) => { - const current = unpinnedCards(store.getSnapshot()) + const current = store.getSnapshot().unpinned if (id.startsWith(`${FIXED_GUIDE_KIND}:`) || pinned !== current.includes(id)) return store.set({ unpinned: pinned ? current.filter(entry => entry !== id) : [...current, id] }) }, diff --git a/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx b/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx index c4dd7131f6..fb6cf422c3 100644 --- a/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx @@ -119,6 +119,12 @@ describe('GuideBody', () => { const guide = b.view.container.querySelector('[data-sidebar-right-guide]') expect(guide?.lastElementChild?.contains(more)).toBe(true) expect(more.getAttribute('aria-haspopup')).toBe('menu') + const glyph = more.querySelector('svg') + expect(glyph?.getAttribute('width')).toBe('22') + expect(glyph?.getAttribute('height')).toBe('22') + expect(glyph?.getAttribute('stroke')).toBe('#A6B2C7') + expect(glyph?.getAttribute('aria-hidden')).toBe('true') + expect(glyph?.querySelectorAll('path')).toHaveLength(4) fireEvent.click(more) expect(more.getAttribute('aria-expanded')).toBe('true') expect(b.actionsOwner().entries).toEqual([ diff --git a/packages/client/ui-sidebar-right/tests/guide-pins.client.spec.ts b/packages/client/ui-sidebar-right/tests/guide-pins.client.spec.ts index 1bc8994b94..5c775816fd 100644 --- a/packages/client/ui-sidebar-right/tests/guide-pins.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/guide-pins.client.spec.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -/** Guide card pins persist per `kind:id` and ignore every Files card. */ +/** Guide card pins stay in memory per `kind:id` and ignore every Files card. */ import { afterEach, describe, expect, it, vi } from 'vitest' import { createGuidePins, guideEntryId } from '../src/client/tabs/guide/guide-pins.ts' @@ -11,15 +11,15 @@ describe('guide card pins', () => { expect(guideEntryId({ kind: 'terminal', id: 'shell' })).toBe('terminal:shell') }) - it('persists unpinned card ids and restores them in a new instance', () => { + it('keeps unpinned card ids in memory without restoring them in a new instance', () => { const pins = createGuidePins() const initial = pins.unpinned.getSnapshot() expect(initial).toEqual([]) expect(pins.unpinned.getSnapshot()).toBe(initial) pins.setPinned('terminal:shell', false) pins.setPinned('browser:page', false) - expect(JSON.parse(localStorage.getItem(KEY) ?? 'null')).toEqual({ unpinned: ['terminal:shell', 'browser:page'] }) - expect(createGuidePins().unpinned.getSnapshot()).toEqual(['terminal:shell', 'browser:page']) + expect(localStorage.getItem(KEY)).toBeNull() + expect(createGuidePins().unpinned.getSnapshot()).toEqual([]) pins.setPinned('terminal:shell', true) expect(pins.unpinned.getSnapshot()).toEqual(['browser:page']) }) @@ -38,20 +38,14 @@ describe('guide card pins', () => { }) it('keeps ids of cards that are not registered', () => { - localStorage.setItem(KEY, JSON.stringify({ unpinned: ['removed:card'] })) const pins = createGuidePins() + pins.setPinned('removed:card', false) pins.setPinned('terminal:shell', false) expect(pins.unpinned.getSnapshot()).toEqual(['removed:card', 'terminal:shell']) }) - it.each([ - ['a non-array list', { unpinned: 'terminal:shell' }], - ['non-string ids', { unpinned: [false] }], - ['a missing list', {}], - ['null', null], - ['a number', 7], - ])('reads %s as no unpinned cards with a stable snapshot', (_name, stored) => { - localStorage.setItem(KEY, JSON.stringify(stored)) + it('ignores a legacy stored list instead of restoring it', () => { + localStorage.setItem(KEY, JSON.stringify({ unpinned: ['terminal:shell'] })) const pins = createGuidePins() const snapshot = pins.unpinned.getSnapshot() expect(snapshot).toEqual([]) diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index c1df759615..d10c5fa113 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/README.i18n.yaml @@ -21,8 +21,8 @@ en: 098f85b9287d3147 zh: b921c128ac37d2cd /deepseek-ai-dsh-client-ui-sidebar/use-this-package/global-panel-entries: - en: b109498d88367117 - zh: 6d407f265bacdb0d + en: e7f316023445511b + zh: 6271c8c011533877 /deepseek-ai-dsh-client-ui-sidebar/use-this-package/collapse-behavior: en: 20e744360b4be2cc zh: a90be7e8526bbb21 @@ -33,11 +33,11 @@ en: 9e7b31acee759f2b zh: 3a6beb63b23d26ae /deepseek-ai-dsh-client-ui-sidebar/use-this-package/click-analytics: - en: bb3f4a199ed49039 - zh: cc29413fd31fe2e7 + en: 8855ad013285a27e + zh: f19b3e75c38c5b0a /deepseek-ai-dsh-client-ui-sidebar/understand-the-implementation: - en: bc51c446d8b8e098 - zh: c0c7bbfb50541d33 + en: 39b5e3ca8ce8c5c2 + zh: 1d652b7554772dad /deepseek-ai-dsh-client-ui-sidebar/understand-the-implementation/slot-discipline: en: 6eefc5c45abe8a8f zh: 20b651e48a0ea903 diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 27f22137f7..ce59c01d45 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -37,7 +37,7 @@ The expanded brand row renders `sidebar.brand.mark` and `sidebar.brand.name` as Plugins add an icon component to the root-scoped `sidebar.panellist` list with an `id`, optional `order`, and a string or locale-aware `label`. The same id addresses the component registered in the layout's root-scoped `main` keyed slot; selecting a missing main entry throws without changing the current selection. The label supplies plain visible text, the accessible name, and the collapsed tooltip. Expanded navigation rows have a 2px gap. Each row reads its own selected state through `usePanelInfo`; moving DOM focus to search or a directory picker does not change the displayed panel or its selected row. With no panel registrations, neither the list nor spacing for it is rendered. The shipped composition registers no example panel. -While the root-scoped `sidebar.panellist.actions` list has registrations, a **More** button styled as a panel row follows the rows inside the navigation landmark; the collapsed rail shows its label as a tooltip. It opens a menu whose rows are that list's registrations. Each receives `entries` (customizable panels in registration order with `id`, `label`, and `pinned`; Plugins is never listed), `onOpen(id)`, which selects the panel, `onPinnedChange(id, pinned)`, and `onDismiss()`, which closes the menu. The sidebar keeps one root list of unpinned panel ids in browser `localStorage` under `dsh.slot.manual-order.sidebar.panellist.v1`, stored as `{ "unpinned": string[] }` and shared by every Session; the rows omit unpinned panels except the selected one, which keeps its row and `aria-current` until another panel is selected, and Plugins always shows. Unpinning hides only the row; the main panel, its registration, and the current selection stay unchanged. Ids of panels that are not registered stay stored, so a panel that registers again stays unpinned; stored content other than a string array shows every row. +While the root-scoped `sidebar.panellist.actions` list has registrations, a **More** button styled as a panel row follows the rows inside the navigation landmark; the collapsed rail shows its label as a tooltip. It opens a menu whose rows are that list's registrations. Each receives `entries` (customizable panels in registration order with `id`, `label`, `pinned`, and `active`; Plugins is never listed), `onOpen(id)`, which selects the panel, `onPinnedChange(id, pinned)`, and `onDismiss()`, which closes the menu. The sidebar keeps one in-memory list of unpinned panel ids shared by every Session; Plugins always shows. The default More menu only offers plugin browsing and creation, without panel rows or pin controls. Pin callbacks remain available to other registrations, but choices reset when the plugin reloads and legacy localStorage values are ignored. Unpinning hides only the row; the main panel, its registration, and the current selection stay unchanged. Unregistered panel ids remain in the in-memory list until the plugin reloads. ### Collapse behavior @@ -64,7 +64,7 @@ On Desktop, the official navigation controls report `sidebar_menu_click` with `m - The expanded brand and the New Session button report `menu_name=new_session` with `position=sidebar`. While the Windows caption hosts the collapsed New Session button, that button reports `position=titlebar`; the macOS New Session control beside the traffic lights also reports `position=titlebar`. - The **Plugins** and **Automation tasks** rows report `menu_name=plugin` and `menu_name=cron` with `position=sidebar`. Other panel rows report nothing. - Each **More** button click reports `menu_name=more` with `position=sidebar`, whether it opens or closes the menu. -- A More menu row that opens Plugins or Automation tasks through `onOpen(id)` reports `menu_name=plugin` or `menu_name=cron` with `position=sidebar_more`. The report precedes the registration check, so a request for a panel that has unregistered still counts although nothing is selected. +- A third-party More menu row that calls `onOpen(id)` for Plugins or Automation tasks reports `menu_name=plugin` or `menu_name=cron` with `position=sidebar_more`. The shipped More menu only browses and creates plugins, so it never calls `onOpen`. The report precedes the registration check, so a request for a panel that has unregistered still counts although nothing is selected. ----- @@ -74,7 +74,7 @@ On Desktop, the official navigation controls report `sidebar_menu_click` with `m
Implementation internals — click to expand -The shell is pure composition: `SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared brand, the `sidebar.workspaces` and `sidebar.settings` child slots, and injected navigation callbacks. Panel entries and their optional titles use the same composition path. Panel metadata is derived from list registrations and locale changes; selection belongs to the layout store. The injected `trackEntryClick(attributes)` forwards to `ctx.get('productAnalytics')?.track('sidebar_menu_click', attributes)`, and `selectPanel` only selects, so each control supplies its own entry and position. +The shell is pure composition: `SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared brand, the `sidebar.workspaces` and `sidebar.settings` child slots, and injected navigation callbacks. The sidebar owns `sidebar.panellist` and renders each panel's registered icon directly. Panel metadata is derived from list registrations and locale changes; selection belongs to the layout store. The injected `trackEntryClick(attributes)` forwards to `ctx.get('productAnalytics')?.track('sidebar_menu_click', attributes)`, and `selectPanel` only selects, so each control supplies its own entry and position. ### Slot discipline diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index b4d636ad10..a5d6610fb9 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -37,7 +37,7 @@ dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将 插件在 root 作用域的 `sidebar.panellist` list 中注册图标组件,提供 `id`、可选 `order`,以及字符串或 locale-aware 的 `label`。同一个 id 寻址布局中 root 作用域 `main` keyed slot 的组件;选择不存在的主面板条目会抛错,并保留当前选中态。标签提供普通可见文字、无障碍名称和折叠提示。展开态导航行的间距为 2px。每一行通过 `usePanelInfo` 读取自己的选中态;DOM 焦点移到搜索框或目录选择器时,显示的面板及其列表项选中态不变。没有面板注册项时,列表及其间距均不渲染。产品随附的组合不注册示例面板。 -root 作用域的 `sidebar.panellist.actions` list 有注册项时,导航区域的面板行之后显示面板行样式的**更多**按钮;收起的窄栏以提示框显示其名称。它打开的菜单以该 list 的注册项为菜单行。每个注册项接收 `entries`(按注册顺序的可自定义面板,含 `id`、`label` 与 `pinned`;不含插件管理)、选中面板的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。侧边栏在浏览器 `localStorage` 的 `dsh.slot.manual-order.sidebar.panellist.v1` 下保存一份 root 级的已收起面板 id 列表,格式为 `{ "unpinned": string[] }`,所有会话共用;面板行不显示已收起的面板,但当前选中的面板保留其行和 `aria-current`,直到选中其他面板;插件管理入口始终显示。收起只隐藏导航行,主面板、其注册和当前选中态不变。未注册面板的 id 保留在列表中,因此重新注册的面板保持收起;保存内容不是字符串数组时,所有行都显示。 +root 作用域的 `sidebar.panellist.actions` list 有注册项时,导航区域的面板行之后显示面板行样式的**更多**按钮;收起的窄栏以提示框显示其名称。它打开的菜单以该 list 的注册项为菜单行。每个注册项接收 `entries`(按注册顺序的可自定义面板,含 `id`、`label`、`pinned` 与 `active`;不含插件管理)、选中面板的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。侧边栏在内存中保存所有会话共用的已收起面板 id 列表;插件管理入口始终显示。默认更多菜单只提供插件浏览和创建,不列出面板或 Pin 控件。其他注册项仍可调用 Pin 回调,但插件重新加载时选择重置,不读取旧 localStorage 值。收起只隐藏导航行,主面板、其注册和当前选中态不变。未注册面板的 id 保留在内存列表中,直到插件重新加载。 ### 折叠行为 @@ -64,7 +64,7 @@ root 作用域的 `sidebar.panellist.actions` list 有注册项时,导航区 - 展开态品牌与新建会话按钮上报 `menu_name=new_session`、`position=sidebar`。Windows 顶栏承载收起态新建会话按钮时,该按钮上报 `position=titlebar`;macOS 红绿灯旁的新建会话控件同样上报 `position=titlebar`。 - **插件**与**自动化任务**面板行分别上报 `menu_name=plugin` 与 `menu_name=cron`,`position=sidebar`。其他面板行不上报。 - 每次点击**更多**按钮都上报 `menu_name=more`、`position=sidebar`,无论打开还是关闭菜单。 -- 更多菜单中的行通过 `onOpen(id)` 打开插件或自动化任务时,上报 `menu_name=plugin` 或 `menu_name=cron`,`position=sidebar_more`。上报先于注册检查,因此所请求的面板已注销时仍会计数,但不会选中任何面板。 +- 第三方注册的更多菜单行通过 `onOpen(id)` 打开插件或自动化任务时,上报 `menu_name=plugin` 或 `menu_name=cron`,`position=sidebar_more`。随产品提供的更多菜单只浏览和创建插件,不会调用 `onOpen`。上报先于注册检查,因此所请求的面板已注销时仍会计数,但不会选中任何面板。 ----- @@ -74,7 +74,7 @@ root 作用域的 `sidebar.panellist.actions` list 有注册项时,导航区
实现细节——点击展开 -外壳是纯组合:`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 与 `useWorkspaces` 钩子、已声明的品牌、`sidebar.workspaces` 与 `sidebar.settings` 子 slot,以及注入的导航回调。面板入口及其可选标题使用相同的组合方式。面板元数据由列表注册和 locale 变化派生;选中态属于布局存储。注入的 `trackEntryClick(attributes)` 转发到 `ctx.get('productAnalytics')?.track('sidebar_menu_click', attributes)`;`selectPanel` 只负责选中,因此由各控件自行提供入口与位置。 +外壳是纯组合:`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 与 `useWorkspaces` 钩子、已声明的品牌、`sidebar.workspaces` 与 `sidebar.settings` 子 slot,以及注入的导航回调。侧边栏声明 `sidebar.panellist`,并直接渲染各面板注册的图标。面板元数据由列表注册和 locale 变化派生;选中态属于布局存储。注入的 `trackEntryClick(attributes)` 转发到 `ctx.get('productAnalytics')?.track('sidebar_menu_click', attributes)`;`selectPanel` 只负责选中,因此由各控件自行提供入口与位置。 ### slot 纪律 diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index 15c0cfa5b2..3f13691345 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -332,7 +332,7 @@ export function SidebarRoot({ {panels.length > 0 && (
+ +## Write actions + +The Turn Tail branch action reads the Session-scope `useAccess('session/fork')` Hook and disappears while the calling Peer's access snapshot excludes that endpoint; the view always receives the `forkAt` handler. A branch names its child only while the snapshot includes `session/rename` for the source Session; otherwise the child keeps the Host's title and opens once the Session list carries it. A branch the Host refuses states why, on the Session it was asked of, through the composer's notice channel, as the Host's own message with the stable code beside it. The Session tree's branch verb shares the refusal notice and the rule for naming and opening a branch. These answers never replace Host authorization. + ----- The Chat view detects selection and context-menu gestures on business nodes marked with `data-text-interaction-source`. Both selection endpoints must resolve to the same source container. Displayed user content and settled Assistant text or reasoning participate; tools, injected context, streaming or interrupted Assistant text, and Markdown controls do not. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 06d1d924b2..2a54199c2b 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -24,6 +24,7 @@ kind: "package-reference" - [轮次过程折叠](#turn-process-folding) - [分组渲染](#grouped-rendering) - [滚动归属](#scroll-ownership) +- [写操作](#write-actions) - [模型体验](#model-experience) - [已知限制与暂缓事项](#known-limitations-and-deferred-work) - [开发备注](#dev-note) @@ -41,13 +42,13 @@ kind: "package-reference" 原生消息图片使用独立的用户与输出位置。`conversation.chat.user.content` Factory 声明 `conversation.chat.user.images`,由 `UserContent` 渲染;Assistant 节点注册声明 `conversation.chat.message.images`,由 `AssistantMarkdown` 渲染。两者通过显式 props 接收图片来源、授权 `loadImage` 和布局参数。官方 `MessageImages` 条目调用共享的 root 作用域 `conversation.images.content` Factory。每个位置均可独立替换,无需复制画廊实现。 -Session 作用域的 `conversation.chat.user.content` Factory 只渲染用户内容区域:文本、额外内容块、按输入顺序排列的图片/文件附件及引用摘要。[`UserContentInput`](src/client/contract/slots.ts) 携带预处理的 `content`、变换 `facts`、显示 props 和显式 `loadImage`;引用动作由节点自己的 Injection 提供。正文调用共享 `conversation.user.text` Factory,图片调用自身拥有的图片 Slot。直接调用需要 Session 绑定,行包装层由调用方提供。 +Session 作用域的 `conversation.chat.user.content` Factory 只渲染用户内容区域:文本、额外内容块、按输入顺序排列的图片/文件附件及引用摘要。[`UserContentInput`](src/client/contract/slots.ts) 携带预处理的 `content`、变换 `facts`、显示 props 和显式 `loadImage`;引用动作由节点自己的 Injection 提供。正文调用共享 `conversation.user.text` Factory,图片调用自身拥有的图片 Slot。直接调用需要 Session 绑定,行包装层由调用方提供。引用渲染独立于 Session 的访问答案;撤回 `session/fork` 答案只移除分支操作,不替换内容或重置轮次开合状态。 `UserStyleBubble` 和[各来源包装层]() 保留用户行、key、pending/echo 标记、时间、复制控件及其他消息操作。内容 Factory 不读取来源专属模型或私有 Context,也不是全局用户内容替换入口。 独立的 Markdown 图片以内嵌预览显示,点击打开共享图片浮层;本地路径在消息落定后基于当前查看的工作区解析。图片文件链接保持点击打开侧栏,鼠标停留或键盘聚焦时显示缩略图,Esc 关闭缩略图。图片加载失败时保留本地化状态与图片说明;不执行重复图片过滤。 -设置 → 通用设置 → 网页链接默认打开方式控制普通点击 Chat HTTP(S) 链接时的目标:「应用内侧边栏」(默认)打开新的右侧 Sidebar Browser tab,「默认浏览器」打开外部标签页。该设置项位于快捷键之后,仅在 Sidebar Browser 可用时显示。若 Sidebar Browser 未注册,两种选择均使用外部浏览器;带修饰键的点击保留原生行为。已认证浏览器的 `ui-chat.linkOpening` 偏好在 Host 上持久化,设置无法持久化写入时仅在当前进程内生效。已发送的文件引用及消息日志确认调用的 skill 也可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。 +设置 → 通用设置 → 网页链接默认打开方式控制普通点击 Chat HTTP(S) 链接时的目标:「应用内侧边栏」(默认)打开新的右侧 Sidebar Browser tab,「默认浏览器」打开外部标签页。该设置项位于快捷键之后,仅在 Sidebar Browser 可用时显示。若 Sidebar Browser 未注册,两种选择均使用外部浏览器;带修饰键的点击保留原生行为。`ui-chat.linkOpening` 偏好在已认证浏览器的 Host 上持久化,设置无法持久化写入时仅在当前进程内生效。已发送的文件引用及消息日志确认调用的 skill 也可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。页面的标签类型注册表决定如何打开文件地址。只列出工作区路径而不提供文件内容的页面提供自己的标签反馈;Chat 只委派导航,不推断文件读取权限。 ## Chat 隐藏的行 @@ -174,6 +175,11 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且
+ +## 写操作 + +轮次末尾的分支操作读取会话作用域的 `useAccess('session/fork')` 钩子,调用方 Peer 的访问快照不含该端点时不显示;视图始终收到 `forkAt` 处理函数。只有当快照对源 Session 含 `session/rename` 时,分支才为子会话命名;否则子会话保留 Host 给的标题,并在会话列表出现它之后打开。Host 拒绝的一次分支会在它被请求的那个 Session 上说明原因(走 composer 的 notice 通道),内容为 Host 自己的消息并附上稳定错误码。会话树的分支动词共用这一拒绝提示,以及同一套命名与打开分支的规则。这些界面判断不替代 Host 授权。 + ----- Chat view 在标记了 `data-text-interaction-source` 的业务节点上检测选区和右键手势。选区两端必须定位到同一个来源容器。已展示的用户内容和已完成的 Assistant 文本或 reasoning 参与交互;Tool、注入上下文、流式或中断的 Assistant 文本以及 Markdown 控件不参与。 diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index c5ffa1e87c..a726fa8a32 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -161,6 +161,7 @@ export const zh = { 'message.stopped': '已停止', 'message.branch': '在新对话中分支', 'message.branchUnavailable': '仅可从已完成轮次的最后一条消息分支', + 'message.branchRefused': '未能完成分支:{reason}', 'message.retry.active': '正在重试模型请求', 'message.retry.cancelled': '模型请求重试已取消', 'message.retry.started': '已重试模型请求', @@ -363,6 +364,7 @@ export const en = { 'message.stopped': 'Stopped', 'message.branch': 'Branch into a new conversation', 'message.branchUnavailable': 'Available only on the last message of a completed turn', + 'message.branchRefused': 'Could not complete the branch: {reason}', 'message.retry.active': 'Retrying model request', 'message.retry.cancelled': 'Model request retry cancelled', 'message.retry.started': 'Retried model request', diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index 557b9e2a04..7bf84793b1 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -69,9 +69,12 @@ async function bench(initialSettings?: ChatSettings, withBrowserRegistry = true, openTab: vi.fn<(kind: string, options?: unknown) => void>(), } runtime.ctx.provide('sidebarRight', sidebarRight as never) + // Pages open files through the right Sidebar's tab-type registry, whose own claim decides whether + // an address opens at all; the spy records whether this call site ever asks it. const browserAvailable = createSnapshotStore(true) const sidebarRightTabs = { get: vi.fn<(kind: string) => object | undefined>(() => browserAvailable.getSnapshot() ? {} : undefined), + candidates: vi.fn<(address: string) => readonly object[]>(() => []), subscribe: (listener: () => void) => browserAvailable.subscribe(listener), register: vi.fn(() => () => {}), } @@ -82,13 +85,15 @@ async function bench(initialSettings?: ChatSettings, withBrowserRegistry = true, ) runtime.remote.provideNamespaces({ session: { openWorkspacePath } }) const openSession = vi.fn<(id: SessionId) => void>() - runtime.ctx.provide('uiWorkspace', { + const uiWorkspace = { openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { beforeOpen(ROOT) openSession(ROOT) }), openSession, - } as never) + openListedBranch: vi.fn((_childId: SessionId) => {}), + } + runtime.ctx.provide('uiWorkspace', uiWorkspace as never) const session = sessionFakeFor() await runtime.sessions.add({ id: ROOT, @@ -135,6 +140,7 @@ async function bench(initialSettings?: ChatSettings, withBrowserRegistry = true, }, runtime, chat, chatSettings, browserAvailable, layout, openWorkspacePath, sidebarRight, sidebarRightTabs, session, chatViewApi, chatFlowApi, rootReference, openSession, + uiWorkspace, } } @@ -288,6 +294,59 @@ describe('Chat inject API', () => { await b.runtime.dispose() }) + it('states why a refused branch failed, on the Session it was asked of', async () => { + /** The notice the addressed Session's composer currently carries, as the page would show it. */ + const noticeOf = (sessionId: SessionId): string | undefined => { + const scope = b.runtime.sessions.scope(sessionId) + if (scope === undefined) return undefined + const input = scope.get('conversation')?.input.for(scope) + return input?.notices.getSnapshot()?.text + } + const b = await bench() + const { injected } = b.chatViewApi(b.rootReference) + // The Host's own message reaches the reader with the stable code beside it, and the Session it was + // asked of is the one that shows it. + vi.spyOn(b.runtime.sessions, 'fork').mockRejectedValueOnce({ + rpcError: { code: 'boundary-not-committed', message: 'boundary is not committed' }, + }) + injected.forkAt(18) + await vi.waitFor(() => { + expect(noticeOf(ROOT)).toBe( + '未能完成分支:boundary is not committed (boundary-not-committed)', + ) + }) + // A rejection that named no code keeps its own message and gains no placeholder. + vi.spyOn(b.runtime.sessions, 'fork').mockRejectedValueOnce(new Error('fork child rename failed')) + injected.forkAt(19) + await vi.waitFor(() => { + expect(noticeOf(ROOT)).toBe('未能完成分支:fork child rename failed') + }) + await b.runtime.dispose() + }) + + it('leaves a branch unnamed and opens it once listed while access excludes renaming the Session', async () => { + const b = await bench() + const source = vi.spyOn(b.runtime.ctx.remoteAccess, 'source') + source.mockImplementation((_kind, _id, endpoint) => createSnapshotStore(endpoint !== 'session/rename')) + const { injected } = b.chatViewApi(b.rootReference) + injected.forkAt(17) + // Naming a branch renames it, which this page may not do: asking for the rename would fail the + // fork after the Host already made the child, so the page forks an unnamed branch. + await vi.waitFor(() => { + expect(b.runtime.sessions.calls).toContainEqual({ + method: 'fork', args: [{ sessionId: ROOT, atSeq: 17, increaseTitle: false, onCreated: expect.any(Function) as (childId: SessionId) => void }], + }) + }) + // The child's own list item is what says the branch is this page's own, so the page asks the + // Workspace navigation to open it then — that service waits for the item rather than assembling + // a view of a child the page cannot yet describe. + expect(b.uiWorkspace.openListedBranch).toHaveBeenCalledWith(ROOT) + expect(b.runtime.sessions.calls).not.toContainEqual({ method: 'open', args: [ROOT] }) + expect(source).toHaveBeenCalledWith('session', ROOT, 'session/rename') + source.mockRestore() + await b.runtime.dispose() + }) + it('addresses file paths under the Session\'s scope and opens them in the right Sidebar', async () => { const b = await bench() const { injected } = b.chatViewApi(b.rootReference) @@ -308,6 +367,10 @@ describe('Chat inject API', () => { // A line travels as the `file` type's navigation parameter, not in the address. await injected.openFile('src/a.ts', { line: 7 }) expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/root-1/src/a.ts', { params: { line: 7 } }) + // Which type claims the address is the Sidebar registry's decision: the call site hands the + // address over without asking whether this page carries a type that would open it. A page with + // none — a reader's share — is answered by its own registered type, or by the Sidebar's refusal. + expect(b.sidebarRightTabs.candidates).not.toHaveBeenCalled() await b.runtime.dispose() }) @@ -509,6 +572,7 @@ describe('Chat inject API', () => { const flow = b.chatFlowApi(b.rootReference) expect(flow.instance).toBe(b.chatViewApi(b.rootReference).instance) + expect(flow.injected.hooks.presentation).toBe(injected.hooks.presentation) expect(flow.injected.keyedHooks.chatNode('missing')).toBe(injected.keyedHooks.chatNode('missing')) expect(flow.injected.keyedHooks.chatNodeProcess('missing')).toBe(injected.keyedHooks.chatNodeProcess('missing')) expect(flow.injected.keyedHooks.chatNodeBottom('missing')).toBe(injected.keyedHooks.chatNodeBottom('missing')) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index cf2516d005..8c62bccf63 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -1926,6 +1926,43 @@ describe('ChatView', () => { expect(h.forkAt).toHaveBeenCalledWith(3) })) + it('keeps next-input folding independent from a live session/fork answer in the same flow', () => { + const h = makeCompletionHarness('detailed', 'next-input') + const fork = createSnapshotStore(true) + const useFork = bindSnapshotSelector(fork) + // Chat Node renderers read the harness's standard props object, so the override goes there. + Object.assign(h.props, { + useAccess: (endpoint: string) => { + const allowed = useFork(value => value) + return endpoint === 'session/fork' ? allowed : true + }, + }) + const view = render() + const root = view.container.querySelector('[data-chat-group-key][data-chat-turn="2"]')! + act(() => { h.complete() }) + const control = view.container.querySelector('[data-turn-process="2"]')! + expect(control.getAttribute('aria-expanded')).toBe('true') + expect(root.closest('[hidden]')).toBeNull() + expect(view.getAllByRole('button', { name: '在新对话中分支' })).toHaveLength(2) + + act(() => { fork.set(false) }) + expect(view.queryAllByRole('button', { name: '在新对话中分支' })).toHaveLength(0) + expect(view.container.querySelector('[data-chat-group-key][data-chat-turn="2"]')).toBe(root) + expect(control.getAttribute('aria-expanded')).toBe('true') + expect(root.closest('[hidden]')).toBeNull() + + act(() => { h.sendNext() }) + expect(view.getByText('next question').closest('[data-submission-echo]')).not.toBeNull() + expect(control.getAttribute('aria-expanded')).toBe('false') + expect(root.hasAttribute('hidden')).toBe(true) + act(() => { fork.set(true) }) + expect(view.getAllByRole('button', { name: '在新对话中分支' })).toHaveLength(2) + expect(view.container.querySelector('[data-chat-group-key][data-chat-turn="2"]')).toBe(root) + expect(control.getAttribute('aria-expanded')).toBe('false') + expect(root.hasAttribute('hidden')).toBe(true) + expect(view.getByText('current answer').closest('[hidden]')).toBeNull() + }) + it('keeps pending file mentions non-interactive until admission enables the reference injector', () => { const pending: InboxState['next-step'][number] = { id: MessageId('pending-file-mention'), role: 'user', source: { kind: 'user' }, diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 4bcc57e1cd..b75dc1dc03 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -86,6 +86,8 @@ While the root-scoped `conversation.session.header.views.actions` list has regis The composer registers the File command action and owns its label, availability, and native file-dialog callback. Menu availability and invocation both consult the mounted composer's current attachment-intake policy. Unmounting or locking the composer disables that action; disposing the plugin removes its registration. The callback binding stays inside the input module. +The composer reads its Session's access answers through the Session-scope `useAccess` Hook: Send disappears while the access snapshot excludes `session/prompt`, Stop while it excludes `session/cancel`, and file intake follows `fileUploads/upload`. A composer without a Session has no answer and keeps its ordinary controls. Stop and file intake recheck the live answer when invoked. These UI answers never replace Host authorization. + `SessionInputShell` owns one Lexical editor per Session through its private [DraftEditorRuntime](src/client/input/editor/runtime.ts), while retaining submission, attachment selection, and recovery decisions. [DraftEditor](src/client/input/editor/DraftEditor.tsx) renders the borrowed editor; InputBar retains its Hooks and refs and installs DOM behavior through [view-binding](src/client/input/editor/view-binding.ts). Editor-facing types live in [draft-editor.ts](src/client/contract/draft-editor.ts), with shared input and submission types in [input.ts](src/client/contract/input.ts). Background editor updates retain the draft selection without changing the document selection or reclaiming focus from another control; explicit Composer focus restores its selection. This separation does not support simultaneous editable roots for one Session; [the two-stage isolation proposal](../../../.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.md) defines the remaining work. Drafts retain plain text and file, folder, and Session reference chips across Session switching and reload. Public input methods accept text; internal restoration and Workspace transfer preserve structured references. The input model imports saved content before its first published read; React only mounts that editor and binds the existing Conversation store writer. The original storage key accepts legacy strings and writes semantic snapshots without runtime node IDs. A pending skill catalog does not block editing: slash names remain ordinary text until a matching catalog entry arrives. Arrival rematches the current text without changing chips, selection, or Undo history. Browser-owned attachment File objects are not persisted by this mechanism. The [draft initialization decision](../../../.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.md) defines ownership and restoration. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 57c499aaf8..afc088144f 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -86,6 +86,8 @@ root 作用域的 `conversation.session.header.views.actions` list 有注册项 输入框注册「文件」命令动作,负责其标题、可用性和原生文件选择器回调。菜单可用性与实际调用都读取已挂载输入框当前的附件接收策略。输入框卸载或锁定后该动作不可用,插件 dispose(资源释放)时移除注册。回调绑定留在输入模块内部。 +输入框通过会话作用域的 `useAccess` 钩子读取所在 Session 的访问答案:访问快照不含 `session/prompt` 时隐藏发送,不含 `session/cancel` 时隐藏停止,附件接收跟随 `fileUploads/upload`。没有 Session 的输入框尚无答案,保留普通控件。停止与附件接收在调用时重新读取当前答案。这些界面判断不替代 Host 授权。 + `SessionInputShell` 通过私有 [DraftEditorRuntime](src/client/input/editor/runtime.ts) 为每个 Session 持有一个 Lexical editor,同时保留提交、附件选择和恢复决策。[DraftEditor](src/client/input/editor/DraftEditor.tsx) 呈现借用的 editor;InputBar 保留钩子与 refs,并通过 [view-binding](src/client/input/editor/view-binding.ts) 安装 DOM 行为。编辑器类型位于 [draft-editor.ts](src/client/contract/draft-editor.ts),共享输入和提交类型位于 [input.ts](src/client/contract/input.ts)。编辑器后台更新会保留草稿选区,不修改文档选区或从其他控件夺回焦点;显式聚焦 Composer 时恢复其选区。这一拆分不支持同一 Session 同时挂载多个可编辑 root;[两阶段隔离提案](../../../.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.zh.md) 定义剩余工作。 草稿中的普通文字及文件、目录、Session 引用胶囊在切换 Session 和刷新后保留。公开输入方法接受文本;内部恢复和工作区携带保留结构化引用。输入模型在首次对外读取前导入保存内容;React 只挂载同一编辑器并绑定现有 Conversation store 写入回调。原存储 key 兼容旧字符串,新写入保存不含运行时节点 ID 的语义快照。等待 skill 目录不会阻塞编辑:斜杠名称保持普通文字,直到匹配的目录项到达。目录到达只重匹配当前文字,不改变胶囊、选区或 Undo 历史。浏览器持有的附件 File 对象不由这一机制持久化。[草稿初始化决策](../../../.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.zh.md)规定所有权与恢复流程。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 6a004c8740..bf038fc239 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -58,7 +58,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Services required by the Conversation plugin. */ export const inject = [ - 'slots', 'sessions', 'fileUpload', 'uiSession', 'uiWorkspace', 'locale', 'configForms', + 'slots', 'sessions', 'fileUpload', 'uiSession', 'uiWorkspace', 'locale', 'configForms', 'remoteAccess', ] /** Conversation runtime configuration. */ @@ -96,6 +96,10 @@ const ABSENT_FILE_UPLOADS = { getSnapshot: () => EMPTY_FILE_UPLOADS, subscribe: () => () => {}, } +const ABSENT_FILE_INTAKE = { + getSnapshot: () => false, + subscribe: () => () => {}, +} /** * Browser-shell bridge reporting the harness-host path of a picked file. The @@ -158,8 +162,10 @@ export function apply(ctx: Context, config: Config = Config({})): void { const slots = ctx.slots // Schemastery's field default is materialized before Cordis calls apply. const maxConcurrentFileUploads = config.maxConcurrentFileUploads as number - const workspaceNavigation = ctx.get('uiWorkspace') as unknown as WorkspaceNavigation + const workspaceNavigation = ctx.get('uiWorkspace') as WorkspaceNavigation const uiConversation = new UiConversation(ctx, sessions) + // File intake is its own access question: a page may prompt a Session whose files it may not upload. + const fileIntakeOf = (id: SessionId) => ctx.remoteAccess.source('session', id, 'fileUploads/upload') uiConversation.userText.register(sessionReferenceRule) uiConversation.userText.register(collapseWhitespaceRule) uiConversation.userText.register(trimWhitespaceRule) @@ -237,7 +243,16 @@ export function apply(ctx: Context, config: Config = Config({})): void { if (trackedBindings.has(binding)) return trackedBindings.add(binding) bindings.add(binding) - binding.ctx.effect(() => () => { bindings.delete(binding) }, 'ui-conversation: active Provider binding') + ctx.effect(() => binding.ctx.effect(() => { + // Controller withdrawal precedes asynchronous Session-scope disposal. + const disposeRetention = sessions.retainInfo(binding.sessionId).subscribe(() => { + if (sessions.binding(binding.sessionId) !== binding) bindings.delete(binding) + }) + return () => { + disposeRetention() + bindings.delete(binding) + } + }, 'ui-conversation: active Provider binding'), 'ui-conversation: View binding ownership') } const refreshViews = (): void => { const current = conversationViews.getSnapshot() @@ -258,6 +273,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { disposeDeveloperTools() disposeLocale() disposeViews() + bindings.clear() } }, 'ui-conversation: View selection') @@ -484,6 +500,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { stopShortcut, busyEnter: submissionPolicy.busyEnter, fileUploads: ABSENT_FILE_UPLOADS, + fileIntake: ABSENT_FILE_INTAKE, notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON, menuLauncher: ABSENT_MENU_LAUNCHER, @@ -494,9 +511,12 @@ export function apply(ctx: Context, config: Config = Config({})): void { const shell = inputHub.shell(sessionId) const inputTriggers = inputHub.inputTriggers(sessionId) const bridge = hostPathBridge() + const fileIntake = fileIntakeOf(sessionId) + const cancelAccess = ctx.remoteAccess.source('session', sessionId, 'session/cancel') return { keyboard: shell, addFiles: (files, directories = new Set()) => { + if (!fileIntake.getSnapshot()) return t('attachment.dropBlocked') if (sessions.binding(sessionId) === undefined) return t('file.sessionUnavailable') if (shell.snapshot.phase === 'adjudicating' || shell.snapshot.phase === 'submitting') { return t('attachment.dropBlocked') @@ -557,10 +577,16 @@ export function apply(ctx: Context, config: Config = Config({})): void { span: { ...selection, draftRev: snapshot.draftRev }, }) }, - stop: () => { stop(sessionId) }, + // The bar hides Stop while the access snapshot excludes `session/cancel`; the handler re-reads + // the live answer, so a click the page had already rendered never reaches a withdrawn endpoint. + stop: () => { + if (!cancelAccess.getSnapshot()) return + stop(sessionId) + }, hooks: { stopShortcut, busyEnter: submissionPolicy.busyEnter, + fileIntake, fileUploads: conversation.fileUploads, notices: shell.notices, lexicon: shell.lexicon, diff --git a/packages/client/ui-conversation/src/client/branch.ts b/packages/client/ui-conversation/src/client/branch.ts new file mode 100644 index 0000000000..24c000553c --- /dev/null +++ b/packages/client/ui-conversation/src/client/branch.ts @@ -0,0 +1,72 @@ +/** + * How this package states one refused branch. + * + * The branch action on the conversation and the verb on the sidebar's Session row both reach this + * through the `uiConversation` service, so a reader's branch states the same reason whichever surface + * asked for it. + * + * @module @deepseek-ai/dsh-client-ui-conversation/src/client/branch + */ + +import type { Context } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +/** Stable code and message one fork rejection carries. */ +export interface ForkRefusal { + /** Code the Host refused with, or {@link UNNAMED_REFUSAL} when the rejection named none. */ + readonly code: string + /** The Host's own message, empty when the rejection carried none. */ + readonly message: string +} + +/** Code stand-in for a rejection that named none of its own. */ +const UNNAMED_REFUSAL = 'unknown' + +/** + * Read the stable code and message one fork rejection carries. + * @param error - Rejection the fork reported. + * @returns The code the Host refused with, and its own message when it named one. + */ +export function readForkRefusal(error: unknown): ForkRefusal { + const wrapped = error as { + readonly code?: unknown + readonly message?: unknown + readonly rpcError?: { readonly code?: unknown; readonly message?: unknown } + } + const code = typeof wrapped.rpcError?.code === 'string' && wrapped.rpcError.code !== '' + ? wrapped.rpcError.code + : typeof wrapped.code === 'string' && wrapped.code !== '' ? wrapped.code : UNNAMED_REFUSAL + const message = typeof wrapped.rpcError?.message === 'string' && wrapped.rpcError.message !== '' + ? wrapped.rpcError.message + : typeof wrapped.message === 'string' ? wrapped.message : '' + return { code, message } +} + +/** + * State why one branch was refused, on the Session it was asked of. + * + * The reason is the Host's own message with the stable code beside it. A failed branch leaves the Session + * unchanged; this notice is the only trace it leaves. + * @param ctx - Client root Context. + * @param sessionId - Session the branch was asked of. + * @param error - Rejection the fork reported. + * @param message - The caller's copy for one stated reason. + */ +export function noteBranchRefusal( + ctx: Context, + sessionId: SessionId, + error: unknown, + message: (reason: string) => string, +): void { + const refusal = readForkRefusal(error) + const scope = ctx.sessions.scope(sessionId) + if (scope === undefined) return + const conversation = scope.get('conversation') + if (conversation === undefined) return + // The Host's own words, with the stable code beside them when it named one; a rejection that named + // neither stays its message alone rather than gaining a placeholder code. + const reason = refusal.message === '' + ? refusal.code + : refusal.code === UNNAMED_REFUSAL ? refusal.message : `${refusal.message} (${refusal.code})` + conversation.input.for(scope).notify('error', message(reason)) +} diff --git a/packages/client/ui-conversation/src/client/contract/input.ts b/packages/client/ui-conversation/src/client/contract/input.ts index a47e044b6c..947054f75e 100644 --- a/packages/client/ui-conversation/src/client/contract/input.ts +++ b/packages/client/ui-conversation/src/client/contract/input.ts @@ -216,6 +216,8 @@ export interface SessionInput extends InputTarget { focus(): void /** Input state store (InputZone currency + decorations read here). */ readonly state: SnapshotStore + /** Latest notice this composer carries (null after clear), which the addressed bar renders. */ + readonly notices: SnapshotStore } /** Session-addressed access to the per-session input facade. */ diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index f8d86187d3..4d9df4900d 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -528,6 +528,8 @@ export interface ComposerBarInjected { toggleCommandMenu: ((selection: EditSelection) => void) | undefined stop: (() => void) | undefined hooks: { + /** Live permission for file intake, independently of text prompting. */ + fileIntake: ObservableSnapshot /** Readable sequence while the fixed Stop command is registered. */ stopShortcut: ObservableSnapshot /** diff --git a/packages/client/ui-conversation/src/client/conversation/assembly.ts b/packages/client/ui-conversation/src/client/conversation/assembly.ts index 9b3ed5f5b8..64ea68b0c0 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembly.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembly.ts @@ -23,6 +23,7 @@ import { ConversationNodeAssembler } from './assembler.ts' import { ConversationEventRegistry } from './event-registry.ts' import { HistoricalImageCache } from './historical-images.ts' import { ConversationViewRegistry } from './view-registry.ts' +import { noteBranchRefusal } from '../branch.ts' import { ConversationGroupRegistry } from './group-registry.ts' import { ConversationInteractionRegistry } from './interaction-registry.ts' import { UserTextRegistry } from './user-text-registry.ts' @@ -236,6 +237,20 @@ export class UiConversation extends Service { }, 'ui-conversation assembly') } + /** + * State why one branch was refused, on the Session it was asked of. + * + * The reason comes from the page's own vocabulary for the code when it has one, and otherwise from + * the Host's own message with the stable code beside it. A failed branch leaves the Session + * unchanged; this notice is the only trace it leaves. + * @param sessionId - Session the branch was asked of. + * @param error - Rejection the fork reported. + * @param message - The caller's copy for one stated reason. + */ + noticeBranchRefusal(sessionId: SessionId, error: unknown, message: (reason: string) => string): void { + noteBranchRefusal(this.ctx, sessionId, error, message) + } + /** * Resolve the Conversation binding for one Controller binding or Session id. * @param source - Session binding or identity. diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index d4f06accc5..844acb801b 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -104,6 +104,7 @@ export type { } from './contract/input.ts' export type { ArbitrateKey, ArbitrateOutcome, DraftInput, DraftQuote, DraftReference, DraftSnapshot, ReferenceInsert, TokenSpan } from './contract/draft-editor.ts' export type { ComposerBlock, ComposerBlocks } from './contract/composer-blocks.ts' +export type { ForkRefusal } from './branch.ts' declare module '@deepseek-ai/cordis' { interface Context { diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 6ab4c5b4a9..32758bc6f8 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -213,7 +213,8 @@ export class InputHub implements SessionInputResolver { */ canPickFiles(id: SessionId): boolean { const binding = this.sessions().binding(id) - return binding !== undefined && this.shells.get(binding)?.canPickFiles() === true + return this.rootCtx.remoteAccess.source('session', id, 'fileUploads/upload').getSnapshot() + && binding !== undefined && this.shells.get(binding)?.canPickFiles() === true } /** @@ -222,7 +223,7 @@ export class InputHub implements SessionInputResolver { */ pickFiles(id: SessionId): void { const binding = this.sessions().binding(id) - if (binding !== undefined) this.shells.get(binding)?.pickFiles() + if (this.canPickFiles(id) && binding !== undefined) this.shells.get(binding)?.pickFiles() } /** diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 80d04e9b80..27d305c0e5 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -48,14 +48,21 @@ export const InputBar = memo(function InputBar({ retryFileUpload, toggleCommandMenu, stop, t, renderSlot, useBusyEnter, useFileUploads, useNotices, useLexicon, useMenuLauncher, useStopShortcut, + useFileIntake, useAccess, useProjection, sessionId, variant, disabled: inert = false, blocked, workspacePickerOpen = false, onRequestWorkspace, placeholder, accessory, }: InputBarProps) { const input = useInput(s => s) const notice = useNotices(s => s) + const acceptsFiles = useFileIntake(allowed => allowed) const busyEnter = useBusyEnter(s => s) const stopKeys = useStopShortcut(keys => keys) + // A page whose access snapshot excludes prompting or stopping this Session is offered no Send or + // no Stop rather than a disabled one; without a Session neither question has an answer yet. + const promptDenied = useAccess('session/prompt') === false + const cancelDenied = useAccess('session/cancel') === false + const stopAction = cancelDenied ? undefined : stop void useLexicon // hook seat stays bound by the inject compartment; text-ref decoration rides the shell's editor transforms const commandMenuOpen = useMenuLauncher(source => source === 'command') const [activity, setActivity] = useState(false) @@ -214,7 +221,8 @@ export const InputBar = memo(function InputBar({ // The host enforces the same image limits at submit for callers that bypass // this composer. const intakeFiles = useCallback((files: readonly File[], directories?: ReadonlySet): void => { - if (locked || machineBusy || (subagent !== null && !continuable) || addFiles === undefined || files.length === 0) return + if (!acceptsFiles || locked || machineBusy || (subagent !== null && !continuable) + || addFiles === undefined || files.length === 0) return const rejected = ((): string | null => { // Child prompts support images, but not generic file receipts or the // Desktop path-reference fallback. Refuse mixed batches before intake. @@ -240,9 +248,9 @@ export const InputBar = memo(function InputBar({ return addFiles(files, directories) })() if (rejected !== null) showToast(rejected) - }, [locked, machineBusy, subagent, continuable, addFiles, attachments, imageLimits, showToast, t]) + }, [acceptsFiles, locked, machineBusy, subagent, continuable, addFiles, attachments, imageLimits, showToast, t]) - const canAcceptDrop = (subagent === null || continuable) && !locked && !machineBusy && addFiles !== undefined + const canAcceptDrop = acceptsFiles && (subagent === null || continuable) && !locked && !machineBusy && addFiles !== undefined const fileInputRef = useRef(null) const onPickFiles = (e: ChangeEvent): void => { @@ -331,7 +339,7 @@ export const InputBar = memo(function InputBar({ // exposes Stop independently. const primaryStops = running && subagent === null && (empty || blocked !== undefined) // Disabled native buttons may omit mouseleave; their tooltip must close from state. - const primaryDisabled = primaryStops ? stop === undefined : empty || disabled || machineBusy || uploadsPending + const primaryDisabled = primaryStops ? stopAction === undefined : empty || disabled || machineBusy || uploadsPending const interruptible = running && continuable const primarySubmitMode = resolveSubmitMode(busyEnter, running, 'enter', steeringAvailable) const plainMessageDraft = !empty && input?.phase === 'plain' && !draft.trimStart().startsWith('/') @@ -342,7 +350,7 @@ export const InputBar = memo(function InputBar({ : t('input.send') const onPrimary = (): void => { if (primaryStops) { - stop?.() + stopAction?.() return } if (keyboard === undefined) return // absent machine: the button is disabled @@ -467,7 +475,7 @@ export const InputBar = memo(function InputBar({ ref={fileInputRef} type="file" multiple - disabled={subagent !== null} + disabled={!canAcceptDrop || subagent !== null} hidden onChange={onPickFiles} /> @@ -489,15 +497,15 @@ export const InputBar = memo(function InputBar({ {input === undefined || sessionId === undefined ? null :
{renderSlot('conversation.input.activity', { locked, onActiveChange: setActivity })}
} - {interruptible && ( - + {interruptible && !cancelDenied && ( + )} - - - + {(primaryStops ? cancelDenied : promptDenied) ? null : ( + + + + )}
diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index 16caa7db32..6a3ce79d82 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -167,6 +167,38 @@ describe('Conversation inject API', () => { await vi.waitFor(() => { expect(document.querySelector('[data-quote-preview-text]')?.textContent).toBe('@literal /review') }) expect(document.querySelector('[data-slot-error]')).toBeNull() expect(errors).not.toHaveBeenCalled() + it('refuses every File intake while the access snapshot excludes uploads but allows prompting', async () => { + const b = await bench() + onTestFinished(() => b.runtime.dispose()) + const denied = createSnapshotStore(false) + const source = vi.spyOn(b.runtime.ctx.remoteAccess, 'source') + onTestFinished(() => { source.mockRestore() }) + source.mockImplementation((kind, id, endpoint) => { + expect([kind, id]).toEqual(['session', ROOT]) + return endpoint === 'fileUploads/upload' ? denied : createSnapshotStore(true) + }) + const composer = b.composerApi(ROOT) + expect(composer.hooks.fileIntake).toBe(denied) + for (const file of [new File(['note'], 'note.txt'), new File(['image'], 'shot.png', { type: 'image/png' })]) { + expect(composer.addFiles?.([file])).toBe('当前无法添加文件或图片') + } + expect(b.rootUpload).not.toHaveBeenCalled() + // The File command reads the same answer: a bound picker stays closed. + const contributions = new Map() + b.runtime.ctx.provide('commandUi', { + register: (contribution: CommandContribution) => { + contributions.set(contribution.name, contribution) + return () => { contributions.delete(contribution.name) } + }, + } satisfies Pick) + await vi.waitFor(() => { expect(contributions.has('file')).toBe(true) }) + const open = vi.fn() + composer.keyboard!.bindFilePicker({ open, available: () => true }) + const file = contributions.get('file')! + expect(file.available({ sessionId: ROOT })).toBe(false) + if (file.ui.kind !== 'action') throw new Error('File must be an action') + file.ui.run({ sessionId: ROOT }) + expect(open).not.toHaveBeenCalled() }) it('owns the File action, reads its mounted composer availability, and unregisters on disposal', async () => { @@ -403,6 +435,23 @@ describe('Conversation inject API', () => { await b.runtime.dispose() }) + it('never stops a Session after its access snapshot withdraws session/cancel', async () => { + const b = await bench() + const cancel = createSnapshotStore(true) + const source = vi.spyOn(b.runtime.ctx.remoteAccess, 'source') + onTestFinished(() => { source.mockRestore() }) + source.mockImplementation((_kind, _id, endpoint) => endpoint === 'session/cancel' ? cancel : createSnapshotStore(true)) + const composer = b.composerApi(ROOT) + composer.stop!() + await vi.waitFor(() => { expect(b.sessionFake.cancel).toHaveBeenCalledOnce() }) + // Withdrawn while the view is open: the same handler re-reads the live answer and the cancel + // never reaches the Session. + cancel.set(false) + composer.stop!() + expect(b.sessionFake.cancel).toHaveBeenCalledOnce() + await b.runtime.dispose() + }) + it('releases a draft attachment only after the input shell accepts its removal', async () => { const b = await bench() const composer = b.composerApi(ROOT) @@ -604,6 +653,56 @@ describe('Conversation inject API', () => { await b.runtime.dispose() }) + it('releases View refresh ownership before a withdrawn Session or plugin can receive locale changes', async () => { + const b = await bench() + onTestFinished(() => b.runtime.dispose()) + const removeView = b.slots.register( + { name: 'conversation.view', id: 'chat', label: 'Chat' }, + (() => null) as never, + ) + onTestFinished(removeView) + const binding = b.rootReference.binding + b.runtime.ctx.uiSession.bindingSource(b.rootReference) + const resolve = vi.spyOn(b.runtime.ctx.uiConversation, 'binding') + const errors = vi.spyOn(console, 'error') + onTestFinished(() => { resolve.mockRestore(); errors.mockRestore() }) + resolve.mockClear() + + b.rootReference.release() + expect(b.runtime.sessions.binding(ROOT)).toBeUndefined() + b.runtime.ctx.locale.setLocale('en') + expect(resolve).not.toHaveBeenCalled() + expect(errors).not.toHaveBeenCalled() + await binding.ctx.fiber.dispose() + await b.feature.dispose() + b.runtime.ctx.locale.setLocale('zh') + expect(resolve).not.toHaveBeenCalled() + expect(errors).not.toHaveBeenCalled() + }) + + it('detaches View refresh when Conversation unloads before its retained Session', async () => { + const b = await bench() + onTestFinished(() => b.runtime.dispose()) + const removeView = b.slots.register( + { name: 'conversation.view', id: 'chat', label: 'Chat' }, + (() => null) as never, + ) + onTestFinished(removeView) + b.runtime.ctx.uiSession.bindingSource(b.rootReference) + const resolve = vi.spyOn(b.runtime.ctx.uiConversation, 'binding') + const errors = vi.spyOn(console, 'error') + onTestFinished(() => { resolve.mockRestore(); errors.mockRestore() }) + + await b.feature.dispose() + expect(b.runtime.sessions.binding(ROOT)).toBe(b.rootReference.binding) + resolve.mockClear() + b.runtime.ctx.locale.setLocale('en') + b.rootReference.release() + b.runtime.ctx.locale.setLocale('zh') + expect(resolve).not.toHaveBeenCalled() + expect(errors).not.toHaveBeenCalled() + }) + it('projects the dynamic View registration ledger', async () => { const b = await bench() const source = b.viewSource(ROOT) diff --git a/packages/client/ui-conversation/tests/branch.client.spec.ts b/packages/client/ui-conversation/tests/branch.client.spec.ts new file mode 100644 index 0000000000..5802852942 --- /dev/null +++ b/packages/client/ui-conversation/tests/branch.client.spec.ts @@ -0,0 +1,73 @@ +/** What one refused branch states, on the Session it was asked of. */ + +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, vi } from 'vitest' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { noteBranchRefusal, readForkRefusal } from '../src/client/branch.ts' + +const CHILD = 'child' as SessionId + +/** + * Build a root context whose Session face is the reduced one these helpers use. + * @param options - What the addressed Session's scope carries. + * @returns The context and the notice spy. + */ +function bench(options: { + readonly scope?: 'composer' | 'no-composer' | 'absent' +}) { + const ctx = new Context() + const notify = vi.fn() + const scope = options.scope ?? 'composer' + ctx.provide('sessions', { + scope: () => (scope === 'absent' ? undefined : { + get: (name: string) => (scope === 'composer' && name === 'conversation' + ? { input: { for: () => ({ notify }) } } + : undefined), + }), + } as never) + return { ctx, notify } +} + +describe('readForkRefusal', () => { + it('reads the code and message a rejection carries, wherever it carries them', () => { + expect(readForkRefusal({ rpcError: { code: 'fork-not-permitted', message: 'not allowed' } })) + .toEqual({ code: 'fork-not-permitted', message: 'not allowed' }) + expect(readForkRefusal(Object.assign(new Error('refused'), { code: 'share-stopped' }))) + .toEqual({ code: 'share-stopped', message: 'refused' }) + }) + + it('names the placeholder code only when the rejection named none', () => { + expect(readForkRefusal(new Error('fork failed'))).toEqual({ code: 'unknown', message: 'fork failed' }) + expect(readForkRefusal('a rejection that is not an object')).toEqual({ code: 'unknown', message: '' }) + // An empty code or message is the same as an absent one: the helper never states a blank. + expect(readForkRefusal({ code: '', rpcError: { code: '', message: '' } })) + .toEqual({ code: 'unknown', message: '' }) + }) +}) + +describe('noteBranchRefusal', () => { + it('states the Host\'s message with its stable code', () => { + const b = bench({}) + noteBranchRefusal( + b.ctx, CHILD, { code: 'boundary-not-committed', message: 'boundary is not committed' }, + reason => `未能完成分支:${reason}`, + ) + expect(b.notify).toHaveBeenLastCalledWith('error', '未能完成分支:boundary is not committed (boundary-not-committed)') + }) + + it('states the code alone when the rejection carried no message of its own', () => { + const b = bench({}) + noteBranchRefusal(b.ctx, CHILD, { code: 'share-expired' }, reason => `未能完成分支:${reason}`) + expect(b.notify).toHaveBeenLastCalledWith('error', '未能完成分支:share-expired') + }) + + it('says nothing when the Session it was asked of has no composer to state it on', () => { + const absent = bench({ scope: 'absent' }) + noteBranchRefusal(absent.ctx, CHILD, { code: 'share-stopped' }, reason => reason) + expect(absent.notify).not.toHaveBeenCalled() + // A scope without the Conversation face is the same silence, reached without a throw. + const bare = bench({ scope: 'no-composer' }) + noteBranchRefusal(bare.ctx, CHILD, { code: 'share-stopped' }, reason => reason) + expect(bare.notify).not.toHaveBeenCalled() + }) +}) diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index c84953db02..a5e0ceaeb8 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -106,6 +106,9 @@ interface BenchOptions { attachments?: readonly ComposerAttachment[] /** Upload states served for file-kind drafts (absent = every file is ready). */ fileUploads?: DraftFileUploads + acceptsFiles?: boolean + /** Endpoints the Session's access snapshot excludes (absent = every endpoint is allowed). */ + deniedEndpoints?: readonly string[] addFiles?: (files: readonly File[], directories?: ReadonlySet) => string | null commandMenuOpen?: boolean busyEnter?: 'queue' | 'steer' @@ -193,6 +196,7 @@ function bench(over?: BenchOptions) { items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, })), ...allowAllAccess, + useAccess: endpoint => !(over?.deniedEndpoints ?? []).includes(endpoint), useProjection: ((key: string, selector?: (v: unknown) => unknown) => (selector ?? (v => v))(key === 'plan' ? over?.plan @@ -203,6 +207,7 @@ function bench(over?: BenchOptions) { inputActions: shell.actions, keyboard: shell, addFiles: over?.addFiles ?? (() => null), + useFileIntake: bindSnapshotSelector(createSnapshotStore(over?.acceptsFiles ?? true)), useFileUploads: bindSnapshotSelector(createSnapshotStore(over?.fileUploads ?? {})), retryFileUpload: undefined, removeAttachment, @@ -708,6 +713,23 @@ describe('image draft rail', () => { } }) + it('keeps text editable but refuses attachment-slot, picker and paste intake without permission', () => { + const added = vi.fn(() => null) + const result = bench({ acceptsFiles: false, addFiles: added, draft: 'text stays editable' }) + const attachment = attachmentOwner(result.slotCalls) + expect(attachment.canAcceptDrop).toBe(false) + const file = new File(['note'], 'note.txt') + attachment.onAddFiles([file]) + const picker = result.view.container.querySelector('input[type=file]')! + expect(picker.disabled).toBe(true) + fireEvent.change(picker, { target: { files: [file] } }) + fireEvent.paste(result.textarea, { clipboardData: { + items: [{ kind: 'file', type: 'text/plain', getAsFile: () => file }], getData: () => '', + } }) + expect(added).not.toHaveBeenCalled() + expect(result.textarea.getAttribute('contenteditable')).toBe('true') + }) + it('announces a rejected attachment-slot intake through the same toast', () => { const addFiles = vi.fn(() => '图片读取服务不可用') const result = bench({ addFiles }) @@ -1197,6 +1219,41 @@ describe('running and lock semantics', () => { expect(sink).toHaveBeenCalledWith('直接插话', [], 'steer', expect.any(AbortSignal)) }) + it('a running composer whose access excludes prompting and stopping offers neither Stop nor Send', () => { + const { view, stop } = bench({ + running: true, + // The bar is handed a live Stop handler all the same, so what withholds the action is the access + // answer and not a missing face. + deniedEndpoints: ['session/prompt', 'session/cancel'], + blocked: { reason: '只读查看:本页由 owner 分享,无法发送消息' }, + placeholder: '只读查看:本页由 owner 分享,无法发送消息', + }) + expect(view.container.querySelector('button[aria-label="停止生成"]')).toBeNull() + expect(view.container.querySelector('button[aria-label="发送消息"]')).toBeNull() + expect(stop).not.toHaveBeenCalled() + }) + + it('a running composer whose access excludes only prompting keeps Stop but offers no busy Send', () => { + const empty = bench({ running: true, deniedEndpoints: ['session/prompt'] }) + expect(empty.view.getByLabelText('停止生成')).toBeTruthy() + cleanup() + // A draft would make the primary action the busy Send, which the snapshot excludes. + const drafted = bench({ running: true, deniedEndpoints: ['session/prompt'], draft: '排到下一轮' }) + expect(drafted.view.container.querySelector('button[aria-label="排队发送"]')).toBeNull() + }) + + it('an idle composer whose access excludes prompting offers no Send', () => { + const { view } = bench({ deniedEndpoints: ['session/prompt'], draft: 'hello' }) + expect(view.container.querySelector('button[aria-label="发送消息"]')).toBeNull() + }) + + it('a composer whose access has no answer yet keeps its ordinary Send', () => { + const { view, props } = bench({ draft: 'hello' }) + // Without a bound Session the Session-scope Hook answers undefined, which denies nothing. + view.rerender( undefined} />) + expect(view.getByLabelText('发送消息')).toBeTruthy() + }) + it('running Cmd/Ctrl+Enter uses the opposite of the busy-state Enter preference', () => { const meta = bench({ running: true, busyEnter: 'steer', draft: '排到下一轮' }) fireEvent.keyDown(meta.textarea, { key: 'Enter', metaKey: true }) @@ -1959,6 +2016,21 @@ describe('command launcher chrome and control seats', () => { expect(launcher.getAttribute('aria-expanded')).toBe('true') }) + it('follows the access answers over a running Session, and restores the actions when they return', () => { + const { view, props } = bench({ running: true }) + // While the page may stop the running Session, the primary action is its Stop. + expect(view.getByLabelText('停止生成')).toBeTruthy() + + // The access snapshot now excludes the Session's writes: the bar offers neither Stop nor Send. + view.rerender( false} />) + expect(view.queryByLabelText('停止生成')).toBeNull() + expect(view.queryByLabelText('发送消息')).toBeNull() + + // The answers return: the bar offers the action the page holds again. + view.rerender() + expect(view.getByLabelText('停止生成')).toBeTruthy() + }) + it('opening the command menu from the button puts the keyboard in the editor first', () => { const toggleCommandMenu = vi.fn() const { view, textarea } = bench({ toggleCommandMenu }) diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index 16e468dccb..b8345b2605 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -73,6 +73,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled inputActions: shell.actions, keyboard: shell, addFiles: () => null, + useFileIntake: bindSnapshotSelector(createSnapshotStore(true)), useFileUploads: bindSnapshotSelector(createSnapshotStore({})), retryFileUpload: undefined, removeAttachment: () => {}, diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index 5a0f2d6fba..b627ed99b7 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -175,6 +175,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo inputActions: shell.actions, keyboard: shell, addFiles: () => null, + useFileIntake: bindSnapshotSelector(createSnapshotStore(true)), useFileUploads: bindSnapshotSelector(createSnapshotStore({})), retryFileUpload: undefined, removeAttachment: () => {}, diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index b278797d5d..b21eddf60e 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -300,6 +300,7 @@ function mount( inputActions={inputActions} keyboard={wiring} addFiles={() => null} + useFileIntake={bindSnapshotSelector(createSnapshotStore(true))} useFileUploads={bindSnapshotSelector(createSnapshotStore({}))} retryFileUpload={undefined} removeAttachment={() => {}} diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index a82b593ea8..b3f65169e0 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -36,7 +36,7 @@ The `present` tool's preparing stage is one non-expandable document-icon row. It The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../deliverables/deliverable-file-references/README.md) owns file-count limits and Session declarations. A single delivery fills the row; multiple deliveries form a two-column grid, collapsed after four files. Cards show the basename, description or file type, and a shared native opening control. Delivery and changed-file icon tiles use white at 50% opacity in the light theme and 5% in the dark theme. Icon tiles and single-file changed cards use the same border as delivery cards. Clicking the card previews the file in the right Sidebar. The control opens the default application and lists associated applications, with file reveal last. Matching inline-code references also open Sidebar previews. Delivery cards appear in the turn tail only after the turn ends, so a turn waiting on a blocking question shows none yet; the `present` tool description directs the model to link files in its message for earlier review. Cards include every declaration in the turn, including declarations recorded after the closing reply; duplicate declarations use the last description. -The `present` tool row retains its document icon and recorded result across progress, success, failure, and interruption. Native actions share pending state and publish progress or retryable errors on the card. Successful open or reveal feedback stays visible for five seconds, fades over 200ms, then restores the file description; failures remain visible until another attempt. Desktop metadata is loaded when cards mount and invalidated on connection replacement. Without a Host desktop, cards retain Sidebar preview but omit native controls; failed metadata reads offer Retry. Delivery cards and change review expose `deliverables.file.actions` and `deliverables.review.file.actions`, supplied by [ui-open-in-app](../ui-open-in-app/README.md). Association GET requests and native POST actions both validate the recorded file coordinates and Session filesystem mapping before using the Host desktop. +The `present` tool row retains its document icon and recorded result across progress, success, failure, and interruption. Native actions share pending state and publish progress or retryable errors on the card. Successful open or reveal feedback stays visible for five seconds, fades over 200ms, then restores the file description; failures remain visible until another attempt. Desktop metadata is loaded when cards mount and invalidated on connection replacement; a page whose injected access baseline is not the owner's issues no such read, so its cards keep the Sidebar preview and their unavailable fallback, and the native controls and the owner-only reads behind them stay off. Without a Host desktop, cards retain Sidebar preview but omit native controls; failed metadata reads offer Retry. Delivery cards and change review expose `deliverables.file.actions` and `deliverables.review.file.actions`, supplied by [ui-open-in-app](../ui-open-in-app/README.md). Association GET requests and native POST actions both validate the recorded file coordinates and Session filesystem mapping before using the Host desktop. ### The changed-files card diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 52169d6b34..6196e43fde 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -36,7 +36,7 @@ kind: "package-reference" Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present`,用于声明会话文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建后以 `files: [{ path, description? }]` 调用。[present 工具](../../deliverables/deliverable-file-references/README.zh.md)拥有文件数量限制和会话声明。单个交付占满一行,多个交付使用双列网格,超过四个时默认收起。卡片显示文件名、说明或文件类型,以及共享的原生打开控件。交付和改动文件卡片的图标底块在浅色主题下使用 50% 不透明度的白色,深色主题下为 5%。图标底块与单文件改动卡片的外框使用与交付卡片相同的描边。点击卡片会在右侧 Sidebar 预览文件。控件打开默认应用,并列出关联应用,文件定位始终放在最后。匹配的行内代码引用也打开 Sidebar 预览。交付卡片只在本轮结束后出现在 turn tail 中,因此等待阻塞提问的轮次暂时没有卡片;`present` 工具描述会引导模型在需要提前审阅时于消息中链接文件。卡片包含本轮的所有声明,包括收尾回复之后记录的声明;重复声明使用最近一次的说明。 -`present` 工具行在执行、成功、失败和中断状态下保留文档图标及记录的结果。原生操作共享等待状态,并在卡片显示进度或可重试错误。打开或定位成功的反馈显示五秒后,在 200ms 内淡出并恢复文件说明;失败信息保留到下次尝试。卡片挂载时读取桌面信息,连接更换时清除缓存。Host 没有桌面时,卡片保留 Sidebar 预览并隐藏原生控件;元数据读取失败时提供重试。交付卡片和变更对比页通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 使用 [ui-open-in-app](../ui-open-in-app/README.zh.md) 提供的控件。关联应用 GET 查询与原生 POST 操作都会先校验记录中的文件坐标及会话文件系统映射,再调用 Host 桌面。 +`present` 工具行在执行、成功、失败和中断状态下保留文档图标及记录的结果。原生操作共享等待状态,并在卡片显示进度或可重试错误。打开或定位成功的反馈显示五秒后,在 200ms 内淡出并恢复文件说明;失败信息保留到下次尝试。卡片挂载时读取桌面信息,连接更换时清除缓存;注入的访问基线不属于 owner 的页面不会发起该读取,其卡片保留 Sidebar 预览与不可用回退,原生控件及其背后的 owner-only 读取都保持关闭。Host 没有桌面时,卡片保留 Sidebar 预览并隐藏原生控件;元数据读取失败时提供重试。交付卡片和变更对比页通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 使用 [ui-open-in-app](../ui-open-in-app/README.zh.md) 提供的控件。关联应用 GET 查询与原生 POST 操作都会先校验记录中的文件坐标及会话文件系统映射,再调用 Host 桌面。 ### 改动文件卡片 diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index ef5bc4b06d..1cf8d7a816 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -39,15 +39,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } } -/** Required services for the tail-slot and tab-type registrations and their dictionaries. */ -export const inject = ['slots', 'locale', 'uiConversation', 'remote', 'remote.session', 'sidebarRightTabs', 'sidebarRight', 'configForms'] +/** Required services for the tail-slot and tab-type registrations, their dictionaries, and page access. */ +export const inject = ['slots', 'locale', 'uiConversation', 'remote', 'remote.session', 'sidebarRightTabs', 'sidebarRight', 'configForms', 'remoteAccess'] /** * Client plugin body: register the dictionaries, the turn-tail entry, and the comparison tab type. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - const opener = new PresentedOpenController() + // A page a business package re-labeled for another Peer must not read the Owner's host routes. + const opener = new PresentedOpenController(ctx.remoteAccess.owner) const summaries = new ChangesSummaryStore() const diffs = new ChangesDiffStore() ctx.effect(() => () => Promise.all([opener.dispose(), summaries.dispose(), diffs.dispose()])) diff --git a/packages/client/ui-deliverables/src/client/present-open.ts b/packages/client/ui-deliverables/src/client/present-open.ts index c116de3a44..847581897f 100644 --- a/packages/client/ui-deliverables/src/client/present-open.ts +++ b/packages/client/ui-deliverables/src/client/present-open.ts @@ -27,6 +27,14 @@ export class PresentedOpenController { private readonly lifetime = new AbortController() private readonly pending = new Set>() + /** + * @param ownerHostRoutes - whether the Owner's own HTTP host routes are reachable from this page. + * False on a page a business package re-labeled for another Peer: `present.host` and the open, + * reveal, and changes routes behind it belong to the Owner's origin, so leaving `host` null keeps + * the cards' unavailable fallback and stops the dependent owner-only reads. + */ + constructor(private readonly ownerHostRoutes = true) {} + /** * Open a declared file once while a request for the same coordinates is pending. * Failures remain visible on the card and a later gesture retries them. @@ -77,7 +85,7 @@ export class PresentedOpenController { * @returns after metadata or a retryable error is published. */ async loadHost(): Promise { - if (this.lifetime.signal.aborted) return + if (!this.ownerHostRoutes || this.lifetime.signal.aborted) return if (this.loading !== undefined) return this.loading this.host.set(null) const task = this.readHost(AbortSignal.any([this.lifetime.signal, this.metadata.signal])) diff --git a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx index d58d4fd6e5..3be6e57d9b 100644 --- a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx @@ -864,6 +864,7 @@ describe('plugin registration', () => { session, } as never) ctx.provide('remote.session', session as never) + ctx.provide('remoteAccess', { owner: true } as never) ctx.provide('configForms', { developerTools: { enabled: createSnapshotStore(true) }, get: () => stubConfigForm().scope } as never) await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() diff --git a/packages/client/ui-deliverables/tests/present-open.client.spec.ts b/packages/client/ui-deliverables/tests/present-open.client.spec.ts index f6910df3f9..69b2fd6043 100644 --- a/packages/client/ui-deliverables/tests/present-open.client.spec.ts +++ b/packages/client/ui-deliverables/tests/present-open.client.spec.ts @@ -131,6 +131,19 @@ it('coalesces metadata reads and suppresses their publication after disposal', a }) +it('issues no owner-only host read on a page a business package re-labeled for another Peer', async () => { + const fetcher = vi.fn().mockResolvedValue(Response.json({ name: 'owner-host', available: true, fileManager: 'finder' })) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController(false) + await controller.loadHost() + controller.resetHost() + await controller.loadHost() + // Unread metadata keeps the card's unavailable fallback, and the reads that hang off it stay off too. + expect(fetcher).not.toHaveBeenCalled() + expect(controller.host.getSnapshot()).toBeNull() + await controller.dispose() +}) + it('invalidates cached desktop metadata without eagerly fetching an unused Host', async () => { const fetcher = vi.fn().mockResolvedValue(Response.json({ name: 'old', available: false, fileManager: null })) vi.stubGlobal('fetch', fetcher) diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index 938b893b40..46fab00ee1 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -17,6 +17,7 @@ export const zh = { 'shortcut.directoryBusy': '正在选择或添加工作区', 'shortcut.noCompletedTurn': '当前会话没有已结束的轮次', 'shortcut.forkFailed': '无法分叉会话,请重试', + 'shortcut.notPermitted': '当前页面不允许此操作', 'section.workspaces': '工作区', 'section.sessions': '会话', 'viewOptions.label': '视图选项', @@ -143,6 +144,7 @@ export const en = { 'shortcut.directoryBusy': 'Selecting or adding a workspace', 'shortcut.noCompletedTurn': 'This session has no completed turn', 'shortcut.forkFailed': 'Could not fork the session. Try again.', + 'shortcut.notPermitted': 'This page may not perform that action', 'section.workspaces': 'Workspaces', 'section.sessions': 'Sessions', 'viewOptions.label': 'View options', diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index 6b4221ae19..fc540b78fa 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -59,11 +59,12 @@ export interface UiWorkspace { options?: Pick, ): Promise /** - * Fork a Session without changing the current selection. + * Fork a Session. Pages whose access snapshot includes renaming it increment the child title without + * selecting it; other pages leave the title unchanged and open the child once it is listed. * @param sessionId - source Session. * @param onCreated - observer before the optional child-title update. * @param options - optional permission to start source migration; omission preserves the Host default. - * @returns the child SessionId after creation and inherited-title increment. + * @returns the child SessionId after creation and any permitted inherited-title increment. */ forkSession( sessionId: SessionId, @@ -71,6 +72,14 @@ export interface UiWorkspace { options?: Pick[0], 'allowMigration'>, ): Promise /** + * Open a created Session once its id is known to the list. Later navigation or plugin disposal + * cancels the wait; a completed refresh without that id or a failed refresh leaves selection intact. + * @param childId - Session the Host created. + */ + openListedBranch(childId: SessionId): void + /** + * Resolve the Session for a new conversation in a Workspace: the Host reuses + * the first listed blank member while it is reusable and otherwise creates one, * Resolve the Session for a new conversation in a Workspace: navigation names the first * reusable blank member, skipping blanks that list a preset other than a requested one, and * the Host reuses it under the requested preset or its default, or creates another Session. @@ -325,12 +334,65 @@ class UiWorkspaceService extends Service implements UiWorkspace { onCreated?: (childId: SessionId) => void, options?: Pick[0], 'allowMigration'>, ): Promise { - return this.sessions.fork({ + // Naming a branch renames the child. A page whose access snapshot excludes renaming the source + // Session leaves the name to the Host — asking for it would fail the fork it just made. + const namesBranch = this.ctx.remoteAccess.source('session', sessionId, 'session/rename').getSnapshot() + const childId = await this.sessions.fork({ sessionId, - increaseTitle: true, + increaseTitle: namesBranch, ...onCreated === undefined ? {} : { onCreated }, ...options?.allowMigration === undefined ? {} : { allowMigration: options.allowMigration }, }) + if (!namesBranch) { + // A page that may not rename the Session it branched presents the child only once the Host's own + // summary of it is on the list; opening sooner assembles a view of a branch it cannot describe. + this.openListedBranch(childId) + } + return childId + } + + /** @inheritdoc */ + openListedBranch(childId: SessionId): void { + const navigation = AbortSignal.any([this.ctx.layout.beginNavigation(), this.lifetime.signal]) + if (navigation.aborted) return + let stop = (): void => {} + let settled = false + const finish = (): void => { + settled = true + stop() + navigation.removeEventListener('abort', finish) + } + const open = (): void => { + if (settled || navigation.aborted || this.sessions.list.getSnapshot().byId[childId] === undefined) return + finish() + try { + this.replaceMain(childId, navigation, 'reveal') + } catch (error: unknown) { + // oxlint-disable-next-line typescript/no-unnecessary-condition -- retain may synchronously abort navigation before throwing. + if (!navigation.aborted) this.notify({ kind: 'createFailed', message: creationFailureMessage(error) }) + } + } + stop = this.sessions.list.subscribe(open) + navigation.addEventListener('abort', finish, { once: true }) + // Registration may synchronously publish the row; always recheck after the disposer is owned. + // oxlint-disable-next-line typescript/no-unnecessary-condition -- subscribe may synchronously call open and finish. + if (settled) { + stop() + navigation.removeEventListener('abort', finish) + return + } + open() + // oxlint-disable-next-line typescript/no-unnecessary-condition -- open synchronously settles a listed child. + if (settled) return + void this.sessions.refresh().then(() => { + open() + finish() + }, (error: unknown) => { + if (!settled && !navigation.aborted) { + this.notify({ kind: 'createFailed', message: creationFailureMessage(error) }) + } + finish() + }) } startSession(workspaceId?: WorkspaceId, options?: StartSessionOptions): void { diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index fd708fbebe..f273864029 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -253,6 +253,7 @@ type SessionTreeProps = Pick< WorkspaceBrowserProps, 'useSessionStatus' | 'startSession' | 'open' | 'insertWorkspaceBefore' | 't' | 'usePanelInfo' + | 'useRemoteAccess' | 'useSessionAccess' | 'useWorkspaceAccess' > & PropsRenderSlots< | 'sidebar.workspaces.session.menu.item' | 'sidebar.workspaces.session.row.action' @@ -317,7 +318,7 @@ function EmptySessions({ rowState, onLeaveArchivedOnly, t }: Pick row.id === 'session.new')} group={group} + useRemoteAccess={useRemoteAccess} + useWorkspaceAccess={useWorkspaceAccess} containsCurrentDescendant={currentAncestors.has(group.key)} home={home} t={t} @@ -634,6 +637,7 @@ function SessionTree({ : undefined} drag={dragProps} home={home} + useSessionAccess={useSessionAccess} t={t} /> ) @@ -689,12 +693,13 @@ function SessionTree({ /** The flat "In one list" body: every session is one draggable top-level row. */ function FlatList({ - list, sessionIds, rowState, onLeaveArchivedOnly, useSessionStatus, open, onSessionRenameRequest, + list, sessionIds, rowState, onLeaveArchivedOnly, useSessionStatus, useSessionAccess, open, onSessionRenameRequest, usePanelInfo, setSessionOrder, workspaceReady, animationResetKey, revealSessionId, onSessionRevealed, renderSlot, home, t, }: Pick< SessionTreeProps, | 'useSessionStatus' + | 'useSessionAccess' | 'open' | 'onSessionRenameRequest' | 'renderSlot' @@ -761,6 +766,7 @@ function FlatList({ onReveal={node.id === revealSessionId ? () => { onSessionRevealed(node.id) } : undefined} + useSessionAccess={useSessionAccess} drag={{ start: () => { dropCommitted.current = false @@ -904,6 +910,9 @@ export function WorkspaceBrowser({ expandSidebar, useSessions, useSessionStatus, + useRemoteAccess, + useSessionAccess, + useWorkspaceAccess, useWorkspaces, useStore, actions, @@ -1425,6 +1434,7 @@ export function WorkspaceBrowser({ : groupBy === 'flat' ? ( ) { const [, setMenuOpen] = useMenuOpenState() const shortcut = useShortcuts(rows => rows.find(row => row.id === 'session.archive')) const archived = useArchived(set => set.has(sessionId)) + const allowed = useSessionAccess(sessionId, archived ? 'workspace/unarchiveSession' : 'workspace/archiveSession') + if (!allowed) return null return ( ) { const archived = useArchived(set => set.has(sessionId)) + const allowed = useSessionAccess(sessionId, archived ? 'workspace/unarchiveSession' : 'workspace/archiveSession') + if (!allowed) return null return ( - - - + {canStartSession && ( + + + + )}
) } diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index cf8bcc1167..efba9af48f 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -135,6 +135,7 @@ export function SidebarRoot({ selectPanel, setPinned, trackEntryClick, + useRemoteAccess, usePanels, useEntryPins, useEntryActions, @@ -149,6 +150,8 @@ export function SidebarRoot({ const hasEntryActions = useEntryActions(registered => registered) const shortcut = useShortcuts(rows => rows.find(row => row.id === 'sidebar.left.toggle')) const newShortcut = useShortcuts(rows => rows.find(row => row.id === 'session.new')) + // A page whose access snapshot excludes starting a Session offers no New Session affordance. + const canStartSession = useRemoteAccess('session/create') const toggleLabel = collapsed ? t('toggle.open') : t('toggle.collapse') // Wide content stays mounted while the collapse animates (fading via // .collapsed .wide), unmounts at settle, and remounts right away on expand. @@ -278,7 +281,8 @@ export function SidebarRoot({ traffic lights and keeps the toggle at the sidebar's top-right. */} {darwinDesktop &&
{toggle}
}
- {/* Expanded, the brand doubles as a New Session shortcut — except on + {/* Expanded, the brand doubles as a New Session shortcut while the + access snapshot allows `session/create` — except on macOS, where it stays part of the logo row's window-drag surface (a button would subtract itself through the global no-drag rule); the collapsed rail's logo is the expand toggle below instead. @@ -307,7 +311,7 @@ export function SidebarRoot({ ) - return darwinDesktop + return darwinDesktop || !canStartSession ? {identity} : ( - + {canStartSession && ( + + + + )} {panels.length > 0 && (