From 3515c48a37cadae13e49a518fc9b28b474416bc3 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Sun, 20 Sep 2026 15:37:38 +0800 Subject: [PATCH 001/192] fix(desktop): resolve the development runtime directory from the build target The unpackaged shell derived the primary-runtime directory from process.arch, while preparePrimaryRuntime() resolves the target through resolveDesktopBuildTarget(), which fixes Windows to x64. On a Windows ARM64 host the shell looked under targets/win-arm64 while the payload was under targets/win-x64, and the missing office-skills/scripts/check_office.py became a fatal startup dialog. The launcher now resolves that directory through the same resolver and passes it to the shell as DSH_DESKTOP_PRIMARY_RUNTIME_DIR. --- apps/desktop/README.i18n.yaml | 4 ++-- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/scripts/desktop-build-paths.d.mts | 13 +++++++++++++ apps/desktop/scripts/desktop-build-paths.mjs | 17 +++++++++++++++++ apps/desktop/scripts/dev.ts | 2 ++ apps/desktop/scripts/test-workspace-updates.ts | 2 +- apps/desktop/src/main.ts | 10 +++++++++- apps/desktop/tests/desktop-build-paths.spec.ts | 10 ++++++++++ apps/desktop/tests/main-startup.spec.ts | 1 + 10 files changed, 57 insertions(+), 6 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 455501862e..02b287a60c 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: 6c9a6a8784732e6fa1369df5632391b789786b22 -README.zh.md: a0f2184d911eeab46902670cf992b6847e2bca31 +README.md: 860867eef3ae1da8c89d8292f00afa9ad2a20872 +README.zh.md: ac9a4ddbfff1ad96950fbda6e14ea2632480ea03 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 6c9a6a8784..860867eef3 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -342,7 +342,7 @@ The downloaded bytes are inert, and the installation call is recorded rather tha ## Low-level development overrides -An unpackaged Electron process uses `.desktop-build/development/project` under its application directory as its development project. `DSH_DESKTOP_PNPM_ENTRY` and `DSH_DESKTOP_DSH_DIR` select explicit runtime resources. Packaged applications ignore these variables, resolve signed resources from `process.resourcesPath`, and use the managed Desktop profile. +An unpackaged Electron process uses `.desktop-build/development/project` under its application directory as its development project. `DSH_DESKTOP_PNPM_ENTRY`, `DSH_DESKTOP_DSH_DIR`, and `DSH_DESKTOP_PRIMARY_RUNTIME_DIR` select explicit runtime resources. The development launcher sets `DSH_DESKTOP_PRIMARY_RUNTIME_DIR` to the primary-runtime directory of the target it prepared, because the Windows target is always x64. Packaged applications ignore these variables, resolve signed resources from `process.resourcesPath`, and use the managed Desktop profile. ## Known limitations diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index a0f2184d91..ac9a4ddbff 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -342,7 +342,7 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd ## 底层开发覆盖项 -未打包的 Electron 进程使用应用目录下的 `.desktop-build/development/project` 作为开发项目。`DSH_DESKTOP_PNPM_ENTRY` 和 `DSH_DESKTOP_DSH_DIR` 用于选择明确的运行时资源。打包应用会忽略这些变量,从 `process.resourcesPath` 解析签名资源,并使用受管 Desktop profile。 +未打包的 Electron 进程使用应用目录下的 `.desktop-build/development/project` 作为开发项目。`DSH_DESKTOP_PNPM_ENTRY`、`DSH_DESKTOP_DSH_DIR` 和 `DSH_DESKTOP_PRIMARY_RUNTIME_DIR` 用于选择明确的运行时资源。开发启动器将 `DSH_DESKTOP_PRIMARY_RUNTIME_DIR` 设置为其已准备目标的 primary-runtime 目录,因为 Windows 目标固定为 x64。打包应用会忽略这些变量,从 `process.resourcesPath` 解析签名资源,并使用受管 Desktop profile。 ## 已知限制 diff --git a/apps/desktop/scripts/desktop-build-paths.d.mts b/apps/desktop/scripts/desktop-build-paths.d.mts index f20d60da48..2939a5b2e6 100644 --- a/apps/desktop/scripts/desktop-build-paths.d.mts +++ b/apps/desktop/scripts/desktop-build-paths.d.mts @@ -47,3 +47,16 @@ export function resolveDesktopTargetBuildPaths( hostPlatform?: NodeJS.Platform, hostArch?: string, ): DesktopTargetBuildPaths + +/** + * Resolve the primary-runtime directory an unpackaged development launch uses. + * @param env - Packaging environment. + * @param hostPlatform - Build-host platform used when no target override exists. + * @param hostArch - Build-host architecture used when no target override exists. + * @returns Primary-runtime directory prepared for the selected target. + */ +export function developmentRuntimeDirectory( + env?: NodeJS.ProcessEnv, + hostPlatform?: NodeJS.Platform, + hostArch?: string, +): string diff --git a/apps/desktop/scripts/desktop-build-paths.mjs b/apps/desktop/scripts/desktop-build-paths.mjs index 375e61b542..0d1abaefb4 100644 --- a/apps/desktop/scripts/desktop-build-paths.mjs +++ b/apps/desktop/scripts/desktop-build-paths.mjs @@ -69,3 +69,20 @@ export function resolveDesktopTargetBuildPaths( ) { return desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch)) } + +/** + * Resolve the primary-runtime directory an unpackaged development launch uses. + * The build target fixes Windows to x64, so the shell cannot derive this directory from + * the architecture of the process that launched it. + * @param {NodeJS.ProcessEnv} env - Packaging environment. + * @param {NodeJS.Platform} hostPlatform - Build-host platform used when no target override exists. + * @param {string} hostArch - Build-host architecture used when no target override exists. + * @returns {string} Primary-runtime directory prepared for the selected target. + */ +export function developmentRuntimeDirectory( + env = process.env, + hostPlatform = process.platform, + hostArch = process.arch, +) { + return join(resolveDesktopTargetBuildPaths(env, hostPlatform, hostArch).runtime, 'primary-runtime') +} diff --git a/apps/desktop/scripts/dev.ts b/apps/desktop/scripts/dev.ts index 05e1bdbf0e..481ce59f4b 100644 --- a/apps/desktop/scripts/dev.ts +++ b/apps/desktop/scripts/dev.ts @@ -7,6 +7,7 @@ import { join, resolve } from 'node:path' import { parseArgs } from 'node:util' import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' import type { DesktopRelease } from '../src/release.ts' +import { developmentRuntimeDirectory } from './desktop-build-paths.mjs' import { prepareDevelopmentProject } from './development-project.ts' import { preparePrimaryRuntime } from './prepare-primary-runtime.ts' @@ -66,6 +67,7 @@ async function launchElectron(): Promise { const environment: NodeJS.ProcessEnv = { ...process.env, DSH_HOME: home, + DSH_DESKTOP_PRIMARY_RUNTIME_DIR: developmentRuntimeDirectory(), DSH_DESKTOP_HOST_INSPECT_PORT: String(hostPort), DSH_DESKTOP_OPEN_DEVTOOLS: process.env.DSH_DESKTOP_OPEN_DEVTOOLS ?? '1', ELECTRON_ENABLE_LOGGING: process.env.ELECTRON_ENABLE_LOGGING ?? '1', diff --git a/apps/desktop/scripts/test-workspace-updates.ts b/apps/desktop/scripts/test-workspace-updates.ts index b768de5544..81c6c96921 100644 --- a/apps/desktop/scripts/test-workspace-updates.ts +++ b/apps/desktop/scripts/test-workspace-updates.ts @@ -56,7 +56,7 @@ try { ...(interactive ? ['--interactive'] : [])], { cwd: root, env: { ...environment, DSH_HOME: join(root, 'home'), USERPROFILE: root, HOME: root, TEMP: root, TMP: root, TMPDIR: root, DSH_WORKSPACE_UPDATE_ROOT: root, DSH_WORKSPACE_UPDATE_TOKEN: randomUUID(), - DSH_DESKTOP_OPEN_DEVTOOLS: '0' }, + DSH_DESKTOP_PRIMARY_RUNTIME_DIR: join(application, 'runtime', 'primary-runtime'), DSH_DESKTOP_OPEN_DEVTOOLS: '0' }, // Hiding the GUI process suppresses its first window and can suspend renderer frame callbacks. stdio: 'inherit', windowsHide: false, }) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index e5aa563c6e..925458c4d0 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -98,6 +98,14 @@ function runtimeResources(): RuntimeResources { return { node, nodeBin, pnpm, dsh } } +function developmentPrimaryRuntime(): string { + const directory = process.env.DSH_DESKTOP_PRIMARY_RUNTIME_DIR + if (directory === undefined || directory === '') { + throw new Error('dsh desktop: DSH_DESKTOP_PRIMARY_RUNTIME_DIR is required for an unpackaged launch') + } + return directory +} + function developmentHostInspectPort(enabled: boolean): number | undefined { const configured = process.env.DSH_DESKTOP_HOST_INSPECT_PORT if (!enabled || configured === undefined || configured === '') return undefined @@ -261,7 +269,7 @@ async function main(): Promise { const hostInspectPort = developmentHostInspectPort(development) const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject, hostInspectPort, process.env, onFailure, - development ? join(app.getAppPath(), '.desktop-build', 'targets', `${process.platform === 'darwin' ? 'mac' : 'win'}-${process.arch}`, 'runtime', 'primary-runtime') + development ? developmentPrimaryRuntime() : join(process.resourcesPath, 'runtime', 'primary-runtime'), resources) return { diff --git a/apps/desktop/tests/desktop-build-paths.spec.ts b/apps/desktop/tests/desktop-build-paths.spec.ts index fdbab81b60..53767b270a 100644 --- a/apps/desktop/tests/desktop-build-paths.spec.ts +++ b/apps/desktop/tests/desktop-build-paths.spec.ts @@ -2,6 +2,7 @@ import { join, sep } from 'node:path' import { describe, expect, it } from 'vitest' import { desktopTargetBuildPaths, + developmentRuntimeDirectory, resolveDesktopBuildTarget, } from '../scripts/desktop-build-paths.mjs' @@ -38,6 +39,15 @@ describe('desktop build paths', () => { expect(arm64.downloads).not.toContain(`${sep}targets${sep}`) }) + it('resolves the development primary runtime from the build target rather than the host architecture', () => { + expect(developmentRuntimeDirectory({}, 'darwin', 'arm64')) + .toContain(join('targets', 'mac-arm64', 'runtime', 'primary-runtime')) + expect(developmentRuntimeDirectory({}, 'darwin', 'x64')) + .toContain(join('targets', 'mac-x64', 'runtime', 'primary-runtime')) + expect(developmentRuntimeDirectory({}, 'win32', 'arm64')) + .toContain(join('targets', 'win-x64', 'runtime', 'primary-runtime')) + }) + it('resolves environment overrides and rejects unsupported targets', () => { expect(resolveDesktopBuildTarget({ DSH_DESKTOP_TARGET_PLATFORM: 'darwin', diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 171e3855f1..da680befcf 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -291,6 +291,7 @@ beforeEach(() => { vi.spyOn(console, 'info').mockImplementation(() => {}) vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', 'test-pnpm') vi.stubEnv('DSH_DESKTOP_DSH_DIR', 'test-runtime') + vi.stubEnv('DSH_DESKTOP_PRIMARY_RUNTIME_DIR', 'test-primary-runtime') vi.stubGlobal('process', { ...process, platform: 'win32', arch: 'x64', resourcesPath: 'desktop-test-resources' }) vi.stubEnv('DSH_DESKTOP_HOST_INSPECT_PORT', undefined) vi.stubEnv('DSH_DESKTOP_MANDATORY_UPDATE_CONFIG', undefined) From 31a83ebc4bb97217126430000d2ce8dd947d56f4 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Sun, 20 Sep 2026 19:01:15 +0800 Subject: [PATCH 002/192] refactor(client): let chrome rows own the macOS window drag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macOS window's draggable area was a hand-written band: a 52px strip, deepened to 76px by a marker the Conversation header had to keep in step with its own height. It drifted from the layout — blank runs inside a chrome row did not drag, and a control a few pixels away dragged the window instead. Chrome rows now mark their own element (`data-window-drag`) and the shell declares the surface once (`html[data-platform='darwin'] [data-window-drag]`), so a row's own box is its draggable geometry and no fixed band has to match any row's height. The frame band, its 52/76 figures, the marker, and the per-sheet drag rules are gone; interactive controls and overlays portal beside `#root` where one rule subtracts them. Recollection becomes one shell watcher (`window-drag/recall.ts`): Electron rebuilds the window's drag rects only when a computed app-region value changes (electron#32341), so the watcher measures every marked row each frame while the surface moves, sets `data-window-drag-recall` for the frames whose geometry changed, and clears it on the frame that finds the geometry settled. The right panel's hand-written pulse is deleted with it. Coverage is a contract with executable parts. `window-drag/regions.ts` publishes the composition rule and the interactive selector that both the sheet and its spec compare against; the ui-theme gate asserts the package tree declares drag in exactly two places and pairs every row's mark with its sheet, selector, and authored height; and the browser lane boots the shipped composition in Chromium with the darwin marker to assert per-row geometry, a content body under a marked head row, a covering overlay, and the watcher's pulse. The plugin manager's detail views mark the head row they share rather than the pane they render in, so their text and form labels stay content. Both real machines agree: the macOS matrix ran on a real `hiddenInset` window with HID-level pointer input and window-frame readback, and the Windows matrix on a Windows 11 ARM64 VM — chrome rows drag, content and the covering overlay do not, and the controls keep their clicks. Refs #4619 --- ...19-window-drag-coverage-contract.i18n.yaml | 6 + ...026-09-19-window-drag-coverage-contract.md | 63 +++ ...-09-19-window-drag-coverage-contract.zh.md | 63 +++ ...3-macos-hidden-titlebar-vibrancy.i18n.yaml | 4 +- ...26-09-13-macos-hidden-titlebar-vibrancy.md | 8 +- ...09-13-macos-hidden-titlebar-vibrancy.zh.md | 8 +- apps/web/tests/window-drag-coverage.e2e.ts | 492 ++++++++++++++++++ apps/web/tsconfig.json | 1 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../client/skeleton/ConversationHeader.tsx | 2 +- .../skeleton/ConversationRoot.module.css | 14 +- .../client/skeleton/ConversationSession.tsx | 6 +- .../tests/skeleton.client.spec.tsx | 2 +- .../ui-dockkit/src/components/TabPanel.tsx | 2 +- .../src/components/dockkit.module.css | 21 +- .../tests/app-region-styles.client.spec.ts | 50 +- packages/client/ui-layout/README.i18n.yaml | 4 +- packages/client/ui-layout/README.md | 2 +- packages/client/ui-layout/README.zh.md | 2 +- .../ui-layout/src/client/AppFrame.module.css | 40 +- .../client/ui-layout/src/client/AppFrame.tsx | 23 - .../ui-layout/tests/app-frame.client.spec.tsx | 8 +- .../src/client/PluginManagerPage.module.css | 19 +- .../src/client/PluginManagerPage.tsx | 6 +- .../client/ui-settings-general/package.json | 4 +- .../src/client/SettingsRoot.module.css | 9 +- .../src/client/SettingsRoot.tsx | 9 +- .../src/client/shell/SidebarRight.module.css | 28 +- .../src/client/shell/SidebarRight.tsx | 33 +- .../tests/region-nudge-styles.client.spec.ts | 46 -- .../tests/seat.client.spec.tsx | 41 -- packages/client/ui-sidebar/README.i18n.yaml | 4 +- packages/client/ui-sidebar/README.md | 2 +- packages/client/ui-sidebar/README.zh.md | 2 +- .../src/client/SidebarRoot.module.css | 25 +- .../ui-sidebar/src/client/SidebarRoot.tsx | 4 +- .../sidebar-snapshot.client.spec.tsx.snap | 5 + .../tests/app-region-styles.client.spec.ts | 272 ++++++++-- .../client/ui-theme/tests/stylesheet-scan.ts | 21 +- packages/client/web/src/base.css | 44 +- packages/client/web/src/boot.ts | 9 + packages/client/web/src/window-drag/recall.ts | 171 ++++++ .../client/web/src/window-drag/regions.ts | 106 ++++ .../web/tests/base-styles.client.spec.ts | 36 +- .../tests/window-drag-recall.client.spec.ts | 375 +++++++++++++ .../tests/window-drag-regions.client.spec.ts | 69 +++ pnpm-lock.yaml | 170 +++--- tsconfig.host.json | 4 +- 50 files changed, 1931 insertions(+), 412 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.md create mode 100644 .agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.zh.md create mode 100644 apps/web/tests/window-drag-coverage.e2e.ts delete mode 100644 packages/client/ui-sidebar-right/tests/region-nudge-styles.client.spec.ts create mode 100644 packages/client/web/src/window-drag/recall.ts create mode 100644 packages/client/web/src/window-drag/regions.ts create mode 100644 packages/client/web/tests/window-drag-recall.client.spec.ts create mode 100644 packages/client/web/tests/window-drag-regions.client.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.i18n.yaml new file mode 100644 index 0000000000..1138c29772 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.md +2026-09-19-window-drag-coverage-contract.md: 71d9b192de7b10e10462c3f9360b1e2f56644b77 +2026-09-19-window-drag-coverage-contract.zh.md: edbcc010e69e1b6b9f7e02761efaa8798d338801 diff --git a/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.md b/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.md new file mode 100644 index 0000000000..71d9b192de --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.md @@ -0,0 +1,63 @@ +# Agent Note: Window drag coverage as a contract + +Status: implemented + +English | [中文](2026-09-19-window-drag-coverage-contract.zh.md) + +## Problem + +The macOS desktop window's draggable area is authored twice. The layout decides where chrome rows are — their heights and what they contain — while hand-written app-region declarations decide what drags: one row declaration per chrome row (the sidebar's top strip and logo row, ui-dockkit's strip row, the Conversation header, the plugin manager's page heads) plus per-box subtractions spread across ui-web, ui-settings-general and ui-sidebar-right. The two descriptions are kept in step by hand, so they drift: a row whose box does not match the band leaves chrome outside the drag surface (the reported "the header drags, but some blank runs and controls do not") or puts content inside it (a press that misses a control by a few pixels drags the window, and a double click there runs the system's title-bar action). + +## Decision + +Coverage becomes a contract with four executed parts: + +1. ui-web's `window-drag/regions.ts` publishes the composition rule as an executable model — a point is draggable when the last collected app-region box containing it declares `drag`, the geometry-in-DOM-order composition the native window applies — together with `INTERACTIVE_SELECTOR`, the single source for the interactive subtraction ui-web base.css declares. The base-styles spec asserts the sheet declares exactly that list, so the two cannot drift. +2. The shell declares the darwin drag surface exactly once: `html[data-platform='darwin'] [data-window-drag]` in ui-web base.css. Every chrome row marks its own element in markup, so the row's box is the window's draggable geometry and no fixed band has to match any row's height. The ui-theme app-region gate holds a manifest that pairs each row's markup mark with its sheet, its class selector and its authored height, and refuses a mark on any element the manifest does not name — so a row that stops marking itself, an unmarked row added to the composition, and a mark on a content container all fail the same gate. +3. The browser lane gains `window-drag-coverage`, which boots the shipped composition in Chromium with `data-platform='darwin'` and asserts, through the model, that no interactive box lies inside the drag surface, plus per-row probes for the chrome rows and a page whose head row marks itself while its scrollable body stays content. +4. The shell owns the one recollection watcher — ui-web's `window-drag/recall.ts`, installed by the boot kernel. It measures every marked row once per frame while the surface can be moving, sets `data-window-drag-recall` on the body for each frame whose geometry changed, and clears it on the frame that finds the geometry settled. No chrome row carries a pulse of its own. + +The native half — whether a press really drags or reaches the page — stays a checklist, because no keyless CI lane reaches the window server: the state matrix below is run for any change to this layer. + +## Alternatives considered + +**Publishing measured rectangles from JavaScript as synthetic drag boxes.** Geometry would have to be re-derived at pointer cadence, and one stale frame is exactly the swallow class this work removes; CSS-derived regions cannot be stale by construction. + +**A global background drag (the `movableByWindowBackground` reading).** Dragging anywhere that is not a control would break text selection and blank-area clicks. + +**One drag declaration per row in each row's own sheet.** That was this work's first shape, with a manifest pinning each row's height and the band over it. Every sheet then restates the window's geometry, and a row whose box moves in layout alone still has to be re-declared in CSS; one shell rule over a markup mark keeps the declaration count at one, and the manifest pairs the marks with the geometry that the gate can still check. + +**Pinning current coverage as one golden snapshot.** That would freeze the two failure classes next to the correct behaviour; the manifest separates the invariant (no swallowed control) from the gaps (a budget that only shrinks). + +## Consequences + +- A new control needs no drag declaration: the interactive selector subtracts it. A new chrome row marks its element `data-window-drag` and adds a manifest entry pinning its height, so no band arithmetic and no other sheet changes. +- A row's own box is the whole draggable geometry, and a content container is never one. The plugin manager's detail views mark the head row they share, not the detail container they are rendered in: marking the container would drag the window over its text and form labels, and the lane samples the body below that head to keep it so. +- No coverage gap remains: every chrome row owns its run, and the gate asserts the whole package tree declares the drag surface in exactly two places — the shell's mark rule and the Windows caption row. The plugin manager's page head and its detail views' shared head row include the 48px clearance inside the row, so the entry-page gap closed with them. +- The settings overlay portals beside `#root` (like the Modal primitive) and dropped its per-sheet `no-drag` rule: a covering surface inside the root precedes the columns' chrome, so a drag row declared later would override its subtraction, and ui-web base.css's `body > :not(#root)` rule subtracts a portalled one wherever it overlaps a drag row. The browser lane asserts that placement with the overlay open. +- A row that slides across a hidden/visible edge cannot rely on the layout change alone: Electron recollects the window's drag rects only when a computed app-region value changes (electron#32341), and Blink skips hidden boxes when it collects them. The watcher closes that gap for every row at once — it re-arms on a DOM change that touches a marked row or on a marked row's box resizing, then pulses each frame in which a box moved — so the right panel carries no pulse of its own. + +## Testing + +- `packages/client/web/tests/window-drag-regions.client.spec.ts` — the composition rule, including order sensitivity and box edges. +- `packages/client/web/tests/base-styles.client.spec.ts` — base.css declares the model's interactive selector, the one darwin drag rule, and the recall mark's subtraction. +- `packages/client/ui-theme/tests/app-region-styles.client.spec.ts` — drag ownership, the row manifest, and each row's mark; a mark outside the manifest or a changed row height fails. +- `packages/client/ui-dockkit/tests/app-region-styles.client.spec.ts` — the strip row's own subtraction; its drag is the markup mark's, so the sheet declares none. +- `packages/client/web/tests/window-drag-recall.client.spec.ts` — the watcher through its two seams: what re-arms it, what it ignores, the settle loop, the shell defaults, and disposal. +- `apps/web/tests/window-drag-coverage.e2e.ts` — real Chromium: no swallowed control, per-row probes, a detail page whose head row drags while its body does not, and the recall pulse the shell fires while the right panel slides. +- Real-machine verification (2026-09-20, both platforms, HID-level pointer input plus window-frame readback): the macOS matrix above ran on a real `hiddenInset` window and the Windows cells ran on a Windows 11 ARM64 VM — chrome rows drag, content and the covering overlay do not, controls keep their clicks, and the right panel's strip row drags after its slide. + +## Manual state matrix + +Run on the packaged macOS app for any change to this layer. Each cell means both halves: drag the row's blank run (the window must move) and click the row's controls (they must act rather than drag). + +| State | Rows to walk | +| --- | --- | +| Sidebar expanded, Conversation selected, view tabs shown | sidebar top strip, sidebar logo row, conversation title row, conversation tab-strip row, every control in those rows | +| Sidebar expanded, Conversation selected, single view (no tabs) | same rows, plus the transcript's first rows below the header | +| Sidebar collapsed | Conversation header over the centre, `shell.leading` seat controls, reopen and New session | +| Right sidebar pushed | panel strip row and its controls, the pane body run below the strip, the pane divider's top run | +| Right sidebar fullscreen | panel strip row at the traffic lights, first pane's strip inset, its controls | +| Entry page (guide, plugin manager, settings) | page head with its 48px clearance, its first control row, and a detail page's head row over its body | +| Window fullscreen on/off | every row above with the traffic lights hidden, in both sidebar states | +| Double click in a chrome row's blank run | on Windows the caption row takes the system title-bar action (maximize/restore); on macOS the darwin rows are app-region areas rather than a title bar, so no system action is expected there — only the controls keep their own double click | diff --git a/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.zh.md b/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.zh.md new file mode 100644 index 0000000000..edbcc010e6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.zh.md @@ -0,0 +1,63 @@ +# Agent Note: 把窗口拖拽覆盖变成契约 + +Status: implemented + +[English](2026-09-19-window-drag-coverage-contract.md) | 中文 + +## Problem + +macOS 桌面窗口的可拖区域被写了两遍。布局决定 chrome 行在哪——它们多高、装了什么;手写的 app-region 声明决定什么能拖:每个 chrome 行一条行声明(侧栏顶条与 logo 行、ui-dockkit 的 strip 行、会话 header、插件管理的页头),加上散落在 ui-web、ui-settings-general、ui-sidebar-right 的逐盒减除。两份描述靠人肉保持一致,于是会漂:盒子与 band 不匹配的行,要么有 chrome 落在拖拽面之外(就是反馈里的"header 能拖,但有些空白与控制不能"),要么有内容落进拖拽面(点偏几个像素就变成拖窗口,双击那里还会触发系统标题栏行为)。 + +## Decision + +覆盖变成由四部分组成的、可执行的契约: + +1. ui-web 的 `window-drag/regions.ts` 把合成规则发布成可执行模型——当包含某点的最后一个 app-region 盒声明 `drag` 时该点可拖,这正是原生窗口施加的"按 DOM 顺序的几何合成"——并发布 `INTERACTIVE_SELECTOR`,作为 ui-web base.css 交互减除的唯一来源。base-styles spec 断言样式表声明的正是该列表,两者无法漂移。 +2. 外壳只声明一次 darwin 拖拽面:ui-web base.css 里的 `html[data-platform='darwin'] [data-window-drag]`。每个 chrome 行在 markup 里给自己的元素打标,于是该行自己的盒子就是窗口的可拖几何,不需要任何固定 band 去匹配行高。ui-theme 的 app-region 门禁持有一份清单,把每行的 markup 标记与其样式表、类选择器和写死的高度配成一条,并拒绝清单未点名的元素上的标记——行不再打标、新增未打标的行、把标记打在内容容器上,都会在同一条门禁上失败。 +3. 浏览器车道增加 `window-drag-coverage`:以 `data-platform='darwin'` 在 Chromium 里启动真实组合,用模型断言没有任何交互盒落在拖拽面内,并对各 chrome 行逐一探测,另加一个"页头行打标、其可滚动正文保持内容"的页面。 +4. 外壳持有唯一那个重收集 watcher——ui-web 的 `window-drag/recall.ts`,由引导内核安装。在表面可能移动期间,它每帧测量每个被打标的行,凡该帧几何发生变化就在 body 上打 `data-window-drag-recall`,并在第一次发现几何稳定的那一帧清掉它。任何 chrome 行都不再自带脉冲。 + +原生那一半——一次按下到底是拖窗口还是到达页面——仍是清单,因为没有无密钥 CI 车道能到达窗口服务器:对本层的任何改动都要走下面的状态矩阵。 + +## Alternatives considered + +**用 JavaScript 测量矩形并发布合成 drag 盒。** 几何得按指针节拍重新推导,而一帧陈旧正是这项工作要消除的吞点击类别;CSS 推导的区域在构造上不可能陈旧。 + +**全局背景拖拽(`movableByWindowBackground` 的读法)。** 只要不是控件就能拖,会破坏文本选择与空白处点击。 + +**每个行在自己的样式表里各声明一条 drag。** 这是本项工作最初的样子,并配一份清单钉住每行高度与覆盖它的 band。那样每个样式表都要复述窗口几何,而且只在布局里移动的盒子仍得在 CSS 里重新声明;改成"markup 打标 + 外壳一条规则"后声明只有一处,清单则继续把标记与门禁能查的几何配起来。 + +**把当前覆盖钉成一张金标准快照。** 那会把两类故障和正确行为一起冻住;清单把不变量(不得吞掉控件)与缺口(只许缩小的预算)分开。 + +## Consequences + +- 新控件不需要任何 drag 声明:交互选择器会减除它。新 chrome 行只需给元素打上 `data-window-drag` 并在清单里加一条钉住高度,无需 band 算术,也不牵动其它样式表。 +- 行自己的盒子就是完整的可拖几何,内容容器永远不是。插件管理的详情视图给它们共用的页头行打标,而不是给它所在的详情容器打标:标记容器会让窗口在它的正文与表单 label 上拖动;车道会采样该页头下方的正文来守住这一点。 +- 不再有任何缺口:每个 chrome 行自持其拖拽段,门禁断言整个包树只在两处声明拖拽面——外壳的标记规则与 Windows 标题栏行。插件管理页头与其详情视图共用的页头行都把 48px 避让量包含在行内,入口页那条缺口也随之关闭。 +- settings 浮层现在与 Modal primitive 一样 portal 到 `#root` 之外,并删掉了它自己那条 no-drag:挂在 root 内的覆盖层先于各列 chrome,后声明的 drag 行会盖掉它的减除;portal 之后由 ui-web base.css 的 `body > :not(#root)` 规则在它与 drag 行重叠处减除。车道在浮层打开时会断言这个归属。 +- 跨越隐藏/显示边界滑动的行不能只靠布局变化:只有当计算出的 app-region 值变化时,Electron 才会重新收集窗口的拖拽矩形(electron#32341),而 Blink 收集时会跳过隐藏的盒子。watcher 一次性把这个缺口对所有行补上——它在"触及被打标行的 DOM 变化"或"被打标行的盒子尺寸变化"时重新启动,随后在盒子仍在移动的每一帧发脉冲——因此右栏不再自带任何脉冲。 + +## Testing + +- `packages/client/web/tests/window-drag-regions.client.spec.ts` —— 合成规则,含顺序敏感与盒子边界。 +- `packages/client/web/tests/base-styles.client.spec.ts` —— base.css 声明的正是模型的交互选择器、唯一那条 darwin drag 规则,以及 recall 标记的减除。 +- `packages/client/ui-theme/tests/app-region-styles.client.spec.ts` —— 拖拽所有权、行清单与每行的标记;清单之外的标记或行高变化即失败。 +- `packages/client/ui-dockkit/tests/app-region-styles.client.spec.ts` —— strip 行自己的减除;它的 drag 属于 markup 标记,因此样式表一条都不声明。 +- `packages/client/web/tests/window-drag-recall.client.spec.ts` —— 通过两个 seam 驱动 watcher:什么会重新启动它、什么被它忽略、收敛循环、外壳默认值,以及销毁。 +- `apps/web/tests/window-drag-coverage.e2e.ts` —— 真 Chromium:无控件被吞、逐行探测、一个页头行可拖而正文不可拖的详情页,以及右栏滑动期间外壳发出的 recall 脉冲。 +- 真机验收(2026-09-20,双平台,HID 级指针输入 + 窗口矩形读数):上面的 macOS 矩阵在真实 `hiddenInset` 窗口上跑过,Windows 各格在 Windows 11 ARM64 虚拟机上跑过——chrome 行可拖、正文与覆盖浮层不可拖、控件保留点击、右栏 strip 行在滑入后可拖。 + +## Manual state matrix + +对本层的任何改动都要在打包 macOS 应用上跑。每个格子包含两半:拖该行的空白段(窗口必须移动),点该行的控件(必须生效而不是拖动)。 + +| 状态 | 要走的行 | +| --- | --- | +| 侧栏展开、选中会话、显示视图 tab | 侧栏顶条、侧栏 logo 行、会话标题行、会话 tab 条行,以及这些行里的每个控件 | +| 侧栏展开、选中会话、单视图(无 tab) | 同上,外加 header 之下的转录区首行 | +| 侧栏收起 | 覆盖中列的 会话 header、`shell.leading` 座的控件、重新打开与新会话 | +| 右栏 push | 面板 strip 行及其控件、strip 下方的 pane body 段、分栏分隔条的顶部一段 | +| 右栏 fullscreen | 红绿灯处的面板 strip 行、首格 strip 内边距、其控件 | +| 入口页(开始页、插件管理、设置) | 含 48px 避让量的页头、它的首个控件行,以及详情页页头行与其正文 | +| 窗口全屏开关 | 以上各行在红绿灯隐藏时、两种侧栏状态各一遍 | +| 在 chrome 行空白段双击 | Windows 上 caption 行走系统标题栏行为(最大化/还原);macOS 上 darwin 行只是 app-region 区域而非标题栏,因此那里没有系统动作——只有控件保留自己的双击 | diff --git a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.i18n.yaml b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.i18n.yaml index d15870537e..99ecd869cb 100644 --- a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md -2026-09-13-macos-hidden-titlebar-vibrancy.md: 807c79d06c468e01b8b9df3f18a32e5fbaba1e90 -2026-09-13-macos-hidden-titlebar-vibrancy.zh.md: a521aff7624b1f9dbab18e7d0f02f61af0bdc24a +2026-09-13-macos-hidden-titlebar-vibrancy.md: 005bf783bf7396446313b598b25255bff3a8ad7a +2026-09-13-macos-hidden-titlebar-vibrancy.zh.md: 17b746962d5ffd02f23769f17c7b71170cb9689e diff --git a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md index 807c79d06c..005bf783bf 100644 --- a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md +++ b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md @@ -18,11 +18,11 @@ Every macOS web-side adjustment keys off `html[data-platform='darwin']`, which o **Native theme sync.** The vibrancy material follows `nativeTheme.themeSource`, which otherwise tracks the OS appearance and diverges from the app's own theme preference. The ui-theme boot script and ui-layout `ThemePresenter` publish `html[data-ds-theme-source]` (`light`, `dark`, or `system`; a fixed preference, including registered theme ids, publishes its resolved scheme). The app preload observes the attribute and forwards it over `dsh-desktop:native-theme-set`; main validates the value and the sender (the main window's WebContents, including its local static Web document) and assigns `nativeTheme.themeSource`. Publishing the preference rather than the resolved scheme preserves OS-follow while the preference is `system`. -**Sidebar top strip and full hide.** On darwin the sidebar opens with a 52px top strip that clears the traffic lights and carries the collapse toggle; the frame's `.leadingBand` drag band covers the same 52px geometry, so the strip declares no app-region of its own and the toggle subtracts itself through the base.css interactive rule. Collapsing the sidebar hides the column entirely — `computeColumns` takes an explicit `collapsedWidth` and the AppFrame passes 0 on darwin desktop — instead of the 56px rail the other platforms keep. The reopen affordances live in the frame's root-scoped `shell.leading` window-chrome seat, which the AppFrame mounts only while the column is fully hidden; ui-sidebar registers `HeaderLeadingControls` (open sidebar + New Session, reusing the shell's inject face and locale) into it. The seat placement, the `--dsh-frame-leading-clearance` variable panels pad by, and the removal of the earlier conversation-header seat are owned by the [shell.leading Agent Note](../architecture/2026-09-17-frame-shell-leading-window-chrome-seat.md). +**Sidebar top strip and full hide.** On darwin the sidebar opens with a 52px top strip that clears the traffic lights and carries the collapse toggle; the strip marks itself `data-window-drag`, so its own 52px box is the window's drag region (ui-web base.css declares the one darwin drag rule) and the toggle subtracts itself through the base.css interactive rule. Collapsing the sidebar hides the column entirely — `computeColumns` takes an explicit `collapsedWidth` and the AppFrame passes 0 on darwin desktop — instead of the 56px rail the other platforms keep. The reopen affordances live in the frame's root-scoped `shell.leading` window-chrome seat, which the AppFrame mounts only while the column is fully hidden; ui-sidebar registers `HeaderLeadingControls` (open sidebar + New Session, reusing the shell's inject face and locale) into it. The seat placement, the `--dsh-frame-leading-clearance` variable panels pad by, and the removal of the earlier conversation-header seat are owned by the [shell.leading Agent Note](../architecture/2026-09-17-frame-shell-leading-window-chrome-seat.md). **Window fullscreen marker.** macOS fullscreen hides the traffic lights, so layouts built around them must relax. The main process relays `isFullScreen()` over `dsh-desktop:window-fullscreen` on `enter-full-screen`, `leave-full-screen`, and each `did-finish-load`; the app preload's `syncWindowFullscreen()` mirrors it onto `html[data-fullscreen]`. Consumers: the AppFrame drops `--dsh-frame-leading-clearance` to 84px and moves the seat to `left: 12px`, the sidebar's `.topStrip` moves the toggle to the strip's left edge, and the right sidebar's fullscreen panel resets its first pane's dockkit strip inset to the ordinary 10px. -**Drag regions.** Electron computes drag regions from window geometry in DOM order, not stacking, so `-webkit-app-region: drag` is declared only inside the two allowlisted stylesheets: ui-layout `AppFrame.module.css` (the darwin `.leadingBand`, a 52px full-width `pointer-events: none` strip rendered as the frame's first child, and the Windows caption `.frame::before`) and ui-sidebar `SidebarRoot.module.css` (`.logoRow`, which extends the band below 52px). Everything interactive subtracts itself through two global ui-web base.css rules: one `:is(...)` selector opting out every interactive element (buttons, links, inputs, roles, `[tabindex]`), and one `body > :not(#root)` rule covering every overlay portaled beside `#root`. Because the drag bands mount before all content, any later no-drag box wins; content containers never declare drag, which would override earlier-mounted overlays. +**Drag regions.** Electron computes drag regions from window geometry in DOM order, not stacking, so the shell declares `-webkit-app-region: drag` once, over the mark a chrome row puts on its own box, each row's box being its draggable geometry: the sidebar's top strip and logo row (ui-sidebar), the right sidebar's dockkit strip (ui-dockkit), the Conversation header (ui-conversation), and the plugin manager's page head and shared detail head (ui-plugin-manager). The frame declares none — the Windows caption row is that platform's own chrome — so no fixed band has to match any row's height. Everything interactive subtracts itself through two global ui-web base.css rules: one `:is(...)` selector opting out every interactive element (buttons, links, inputs, roles, `[tabindex]`), and one `body > :not(#root)` rule covering every overlay portaled beside `#root`. The ordering rule those two rest on: a surface that covers the window and must stay clickable portals beside `#root` (the settings overlay does), which puts it after every drag row in document order; content containers never declare drag, which would override earlier-mounted overlays. The [coverage contract note](../architecture/2026-09-19-window-drag-coverage-contract.md) owns the manifest, the browser lane, and the manual state matrix. **Fullscreen traffic-light clearance.** ui-dockkit publishes the tab strip's start inset as `--dsh-dockkit-strip-inline-start` (fallback the design's own 10px). The right sidebar's fullscreen presentation on darwin sets 88px on the panel body and resets 10px for every non-first split cell's subtree, so exactly the pane touching the window's top-left corner clears the lights at any split depth. @@ -43,9 +43,9 @@ Every macOS web-side adjustment keys off `html[data-platform='darwin']`, which o - The macOS window gains a translucent sidebar and hidden titlebar at zero cost to other platforms: every rule is scoped to `[data-platform='darwin']`, which only the Electron preload sets. - The vibrancy material follows the app theme, including third-party registered themes (their resolved scheme). Screenshots and screen recordings differ from the flat web rendering. - The reopen controls occupy the frame's `shell.leading` seat, a public slot in the client catalog; the earlier `conversation.session.header.leading` seat is removed ([shell.leading Agent Note](../architecture/2026-09-17-frame-shell-leading-window-chrome-seat.md)). -- Drag-region ownership is a window-global invariant: `-webkit-app-region: drag` appears only inside the two allowlisted stylesheets (ui-layout `AppFrame.module.css`: darwin `.leadingBand` and Windows caption `.frame::before`; ui-sidebar `SidebarRoot.module.css`: `.logoRow`); other packages rely on the base.css opt-outs or add a scoped `no-drag`, never a new drag surface. The ui-theme app-region stylesheet gate enforces the allowlist over every package stylesheet. +- Drag-region ownership is a window-global invariant: `-webkit-app-region: drag` appears only in ui-web `base.css` (the one darwin rule over `data-window-drag`) and in ui-layout `AppFrame.module.css` (the Windows caption `.frame::before`); other packages rely on the base.css opt-outs or add a scoped `no-drag`, never a new drag surface. The ui-theme app-region gate pairs each chrome row's markup mark with its sheet, selector, and authored height, and refuses a mark anywhere else. - The transparent window plus vibrancy is accepted to look different in screen sharing and may flash on startup; the transparent `backgroundColor` mitigates the flash. ## Testing -ui-theme boot and ui-layout presenter specs pin the `data-ds-theme-source` publication and disposal. The ui-sidebar apply spec pins the `shell.leading` registration (component, locale, shared inject face) and its removal on teardown; the ui-layout app-frame spec pins the seat's darwin mount. The desktop main-startup spec pins the fullscreen relay (transitions, reloads, destroyed-window guard, darwin-only registration) and the preload-platform spec pins the `html[data-fullscreen]` mirror. The ui-theme corner-shape and full-round style gates cover the new stylesheet, and its app-region style gate pins drag-region ownership to the two allowlisted stylesheets across every package stylesheet. +ui-theme boot and ui-layout presenter specs pin the `data-ds-theme-source` publication and disposal. The ui-sidebar apply spec pins the `shell.leading` registration (component, locale, shared inject face) and its removal on teardown; the ui-layout app-frame spec pins the seat's darwin mount. The desktop main-startup spec pins the fullscreen relay (transitions, reloads, destroyed-window guard, darwin-only registration) and the preload-platform spec pins the `html[data-fullscreen]` mirror. The ui-theme corner-shape and full-round style gates cover the new stylesheet, and its app-region gate pins the one darwin drag rule and every chrome row's markup mark. diff --git a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md index a521aff762..17b746962d 100644 --- a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md +++ b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md @@ -18,11 +18,11 @@ Electron 主进程在 darwin 上以 `titleBarStyle: 'hiddenInset'`、`trafficLig **原生主题同步。** 毛玻璃材质跟随 `nativeTheme.themeSource`,后者默认跟踪系统外观,会与应用自身的主题偏好背离。ui-theme 引导脚本与 ui-layout 的 `ThemePresenter` 发布 `html[data-ds-theme-source]`(`light`、`dark` 或 `system`;固定偏好——包括注册主题 id——发布其解析后的配色)。应用 preload 观察该属性并经 `dsh-desktop:native-theme-set` 转发;主进程校验取值与发送者(主窗口的 WebContents,包括其本地静态 Web 文档)后赋给 `nativeTheme.themeSource`。发布偏好而非解析值,可在偏好为 `system` 时保留跟随系统。 -**侧边栏顶部条与完全隐藏。** darwin 上侧边栏展开时有一条 52px 的顶部条,避开红绿灯并承载收起按钮;框架的 `.leadingBand` 拖拽带覆盖同一 52px 几何,顶部条自身不声明 app-region,按钮经 base.css 交互规则自行减除。收起侧边栏时整列隐藏——`computeColumns` 接受显式 `collapsedWidth`,AppFrame 在 darwin 桌面传 0——而非其他平台保留的 56px rail。重新打开的入口位于框架 root 作用域的 `shell.leading` 窗口 chrome 座,由 AppFrame 仅在整列隐藏时挂载;ui-sidebar 向其注册 `HeaderLeadingControls`(打开侧边栏 + 新会话,复用 shell 的 inject face 与 locale)。座的位置、面板用于避让的 `--dsh-frame-leading-clearance` 变量,以及早先会话头部座的移除,归 [shell.leading Agent Note](../architecture/2026-09-17-frame-shell-leading-window-chrome-seat.zh.md) 所有。 +**侧边栏顶部条与完全隐藏。** darwin 上侧边栏展开时有一条 52px 的顶部条,避开红绿灯并承载收起按钮;顶部条自己打上 `data-window-drag`,于是它自身那 52px 盒子就是窗口的拖拽区(唯一的 darwin drag 规则由 ui-web base.css 声明),按钮经 base.css 交互规则自行减除。收起侧边栏时整列隐藏——`computeColumns` 接受显式 `collapsedWidth`,AppFrame 在 darwin 桌面传 0——而非其他平台保留的 56px rail。重新打开的入口位于框架 root 作用域的 `shell.leading` 窗口 chrome 座,由 AppFrame 仅在整列隐藏时挂载;ui-sidebar 向其注册 `HeaderLeadingControls`(打开侧边栏 + 新会话,复用 shell 的 inject face 与 locale)。座的位置、面板用于避让的 `--dsh-frame-leading-clearance` 变量,以及早先会话头部座的移除,归 [shell.leading Agent Note](../architecture/2026-09-17-frame-shell-leading-window-chrome-seat.zh.md) 所有。 **窗口全屏标记。** macOS 全屏隐藏红绿灯,围绕它们构建的布局必须放松。主进程在 `enter-full-screen`、`leave-full-screen` 与每次 `did-finish-load` 时经 `dsh-desktop:window-fullscreen` 转发 `isFullScreen()`;应用 preload 的 `syncWindowFullscreen()` 把它镜像到 `html[data-fullscreen]`。消费者:AppFrame 把 `--dsh-frame-leading-clearance` 降到 84px 并把座移到 `left: 12px`,侧边栏 `.topStrip` 把收起按钮移到条带左端,右侧边栏全屏面板把首格 pane 的 dockkit strip 内边距重置回常规 10px。 -**拖拽区。** Electron 按 DOM 顺序以窗口几何计算拖拽区,不看层叠,因此 `-webkit-app-region: drag` 只在两个允许清单样式表内声明:ui-layout `AppFrame.module.css`(darwin 的 `.leadingBand`——52px 全宽、`pointer-events: none`、作为框架第一个子元素渲染的条带——与 Windows 顶栏 `.frame::before`)和 ui-sidebar `SidebarRoot.module.css`(`.logoRow`,把拖拽带向 52px 以下延伸)。所有可交互内容通过 ui-web base.css 的两条全局规则自行减除:一条 `:is(...)` 选择器让每个交互元素(按钮、链接、输入框、role、`[tabindex]`)退出拖拽,一条 `body > :not(#root)` 规则覆盖所有 portal 到 `#root` 之外的浮层。拖拽带先于全部内容挂载,任何靠后的 no-drag 盒子必然生效;内容容器永不声明 drag——那会覆盖更早挂载的浮层。 +**拖拽区。** Electron 按 DOM 顺序以窗口几何计算拖拽区,不看层叠,因此外壳只声明一次 `-webkit-app-region: drag`,作用于 chrome 行打在自身盒子上的标记,行自身的盒子就是它的可拖几何:侧栏顶条与 logo 行(ui-sidebar)、右侧边栏的 dockkit strip(ui-dockkit)、会话 header(ui-conversation)、插件管理的页头与共用的详情页头(ui-plugin-manager)。框架不再声明任何 drag——Windows 标题栏行是那个平台自己的 chrome——因此没有一条固定带子需要匹配某一行的高度。所有可交互内容通过 ui-web base.css 的两条全局规则自行减除:一条 `:is(...)` 选择器让每个交互元素(按钮、链接、输入框、role、`[tabindex]`)退出拖拽,一条 `body > :not(#root)` 规则覆盖所有 portal 到 `#root` 之外的浮层。这两条规则依赖的顺序约定:覆盖窗口且必须可点的表面 portal 到 `#root` 之外(settings 浮层即如此),从而在文档顺序上晚于所有 drag 行;内容容器永不声明 drag——那会覆盖更早挂载的浮层。清单、浏览器车道与人工状态矩阵归 [覆盖契约 note](../architecture/2026-09-19-window-drag-coverage-contract.zh.md)。 **全屏避开红绿灯。** ui-dockkit 把 tab 条起始内边距发布为 `--dsh-dockkit-strip-inline-start`(回退为设计自身的 10px)。右侧边栏全屏形态在 darwin 上对面板主体设 88px,并对每个非首格 split 单元的子树重置为 10px,使得任意分屏深度下恰好只有触及窗口左上角的 pane 避开红绿灯。 @@ -43,9 +43,9 @@ Electron 主进程在 darwin 上以 `titleBarStyle: 'hiddenInset'`、`trafficLig - macOS 窗口获得半透明侧边栏与隐藏标题栏,对其他平台零成本:所有规则限定在 `[data-platform='darwin']` 下,该属性仅由 Electron preload 设置。 - 毛玻璃材质跟随应用主题,含第三方注册主题(取其解析配色)。截图与录屏与纯 Web 的平面渲染不同。 - 重新打开控件占据框架的 `shell.leading` 座,是客户端 catalog 中的公开 slot;早先的 `conversation.session.header.leading` 座已移除([shell.leading Agent Note](../architecture/2026-09-17-frame-shell-leading-window-chrome-seat.zh.md))。 -- 拖拽区归属是窗口级全局不变量:`-webkit-app-region: drag` 只出现在两个允许清单样式表内(ui-layout `AppFrame.module.css`:darwin `.leadingBand` 与 Windows 顶栏 `.frame::before`;ui-sidebar `SidebarRoot.module.css`:`.logoRow`);其他包依赖 base.css 的退出规则或添加限定范围的 `no-drag`,永不新增拖拽面。ui-theme 的 app-region 样式门禁对全部包内样式表强制该允许清单。 +- 拖拽区归属是窗口级全局不变量:`-webkit-app-region: drag` 只出现在 ui-web `base.css`(作用于 `data-window-drag` 的唯一 darwin 规则)与 ui-layout `AppFrame.module.css`(Windows 顶栏 `.frame::before`)内;其他包依赖 base.css 的退出规则或添加限定范围的 `no-drag`,永不新增拖拽面。ui-theme 的 app-region 门禁把每个 chrome 行的 markup 标记与它的样式表、选择器和写死高度配成一条,并拒绝别处的标记。 - 接受透明窗口 + 毛玻璃在屏幕共享中呈现不同、启动可能闪烁;透明 `backgroundColor` 缓解闪烁。 ## Testing -ui-theme 引导与 ui-layout presenter 测试钉住 `data-ds-theme-source` 的发布与清除。ui-sidebar apply 测试钉住 `shell.leading` 注册(组件、locale、共享 inject face)及 teardown 移除;ui-layout 的 app-frame 测试钉住 darwin 下座的挂载。desktop main-startup 测试钉住全屏转发(状态切换、重载、已销毁窗口守卫、仅 darwin 注册),preload-platform 测试钉住 `html[data-fullscreen]` 镜像。ui-theme 的 corner-shape 与 full-round 样式门覆盖新样式表,其 app-region 样式门在全部包内样式表上把拖拽区归属钉在两个允许清单样式表内。 +ui-theme 引导与 ui-layout presenter 测试钉住 `data-ds-theme-source` 的发布与清除。ui-sidebar apply 测试钉住 `shell.leading` 注册(组件、locale、共享 inject face)及 teardown 移除;ui-layout 的 app-frame 测试钉住 darwin 下座的挂载。desktop main-startup 测试钉住全屏转发(状态切换、重载、已销毁窗口守卫、仅 darwin 注册),preload-platform 测试钉住 `html[data-fullscreen]` 镜像。ui-theme 的 corner-shape 与 full-round 样式门覆盖新样式表,其 app-region 门禁钉住唯一那条 darwin drag 规则与每个 chrome 行的 markup 标记。 diff --git a/apps/web/tests/window-drag-coverage.e2e.ts b/apps/web/tests/window-drag-coverage.e2e.ts new file mode 100644 index 0000000000..80df040862 --- /dev/null +++ b/apps/web/tests/window-drag-coverage.e2e.ts @@ -0,0 +1,492 @@ +// Browser coverage for the macOS window drag surface: which chrome rows drag, +// which pixels stay content, and that no control is swallowed by the band. +// +// The lane boots the real composition in Chromium and marks the document root +// darwin — the one platform where these rules exist (ui-web base.css and +// ui-layout AppFrame scope every app-region rule to it). CDP-injected clicks +// cannot decide this class: the swallow happens in Electron's native window, so +// the claims here are geometry claims decided by the shared composition model +// (ui-web window-drag/regions.ts). +// +// Probes are anchored to stable data hooks and derived from live rects, so a +// layout change moves them rather than invalidating them. The ui-theme app-region +// gate holds the other half of the claim: it pins each row's mark, its sheet, and +// its authored height, and refuses a `data-window-drag` mark anywhere else. A +// probe whose expectation is not yet met carries `gap` and fails loudly, so a +// recorded gap shrinks here first and the manifest follows. +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + INTERACTIVE_SELECTOR, RECALL_MARK, isDraggableAt, type RegionRect, +} from '@deepseek-ai/dsh-client-web/src/window-drag/regions.ts' +import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +/** One collected box: its app-region value, whether it is interactive, and where it is. */ +interface CollectedRegion extends RegionRect { + /** Whether the element itself matches the interactive selector. */ + readonly interactive: boolean + /** Sample points at which the browser's hit test reaches this element or its content. */ + readonly hits: readonly (readonly [number, number])[] +} + +/** A point the coverage table claims something about. */ +interface CoverageProbe { + /** Row the probe speaks for, for the failure message. */ + readonly row: string + /** What the point is, for the failure message. */ + readonly what: string + /** Resolves the point from the live rects captured for the state under test. */ + readonly at: () => readonly [number, number] + /** Whether the window would drag there. */ + readonly drag: boolean + /** Set when the expectation is a recorded gap the refactor must close. */ + readonly gap?: string +} + +/** A viewport rect. */ +interface Rect { + readonly x: number + readonly y: number + readonly width: number + readonly height: number +} + +const EMPTY: Rect = { x: 0, y: 0, width: 0, height: 0 } + +/** A browser-half bundle whose row registers configuration into the plugin page. */ +const FIXTURE_PLUGINS = fileURLToPath(new URL('./fixtures/plugins', import.meta.url)) + +/** Read one hook's viewport rect, or a zero rect while it is not rendered. */ +async function rectOf(page: Page, selector: string): Promise { + const locator = page.locator(selector).first() + if (await locator.count() === 0) return EMPTY + return locator.evaluate((element) => { + const rect = element.getBoundingClientRect() + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height } + }) +} + +/** + * Collect every visible app-region box in DOM order. + * @param page - the page under test. + * @returns the boxes, in document order, with the interactive flag of each element. + */ +async function collectedRegions(page: Page, selector: string): Promise { + return page.evaluate((interactiveSelector) => { + const collected: { + x: number + y: number + width: number + height: number + draggable: boolean + interactive: boolean + hits: [number, number][] + }[] = [] + for (const element of Array.from(document.querySelectorAll('*'))) { + const style = getComputedStyle(element) + const region = style.getPropertyValue('-webkit-app-region') + if (region !== 'drag' && region !== 'no-drag') continue + if (style.visibility === 'hidden' || style.display === 'none') continue + const rect = element.getBoundingClientRect() + if (rect.width === 0 || rect.height === 0) continue + const interactive = element.matches(interactiveSelector) + const insetX = Math.min(2, rect.width / 4) + const insetY = Math.min(2, rect.height / 4) + const samples: [number, number][] = [ + [rect.x + rect.width / 2, rect.y + rect.height / 2], + [rect.x + insetX, rect.y + insetY], + [rect.x + rect.width - insetX, rect.y + insetY], + [rect.x + insetX, rect.y + rect.height - insetY], + [rect.x + rect.width - insetX, rect.y + rect.height - insetY], + ] + const hits = interactive + ? samples.filter(([x, y]) => { + const top = document.elementFromPoint(x, y) + return top !== null && (top === element || element.contains(top)) + }) + : [] + collected.push({ + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + draggable: region === 'drag', + interactive, + hits, + }) + } + return collected + }, selector) +} + +/** + * Interactive boxes whose press cannot reach the page, as printable descriptions. + * A sample counts only where the browser's own hit test reaches the control: a + * box clipped away by its column (the collapsed sidebar's hidden controls sit at + * negative x) is not hittable anywhere it overlaps a drag row, and a press there + * belongs to whatever is painted. + * @param regions - collected boxes in DOM order. + * @returns one description per interactive box with a hittable sample inside the drag surface. + */ +function swallowedBoxes(regions: readonly CollectedRegion[]): string[] { + return regions + .filter(region => region.interactive) + .filter(region => region.hits.some(([x, y]) => isDraggableAt(regions, x, y))) + .map(region => `${Math.round(region.x)},${Math.round(region.y)} ${Math.round(region.width)}x${Math.round(region.height)}`) +} + +/** + * Settle when the shell first pulses the drag recall mark, or false when 15s pass + * without one. Start it before the gesture under test and await it after. + * @param page - the page under test. + * @returns the pending observation of the first pulse. + */ +function firstRecallPulse(page: Page): Promise { + return page.evaluate((mark: string) => new Promise((resolve) => { + const observer = new MutationObserver((records) => { + const pulsed = records.some(record => record.attributeName === mark + && (record.target as Element).hasAttribute(mark)) + if (!pulsed) return + observer.disconnect() + resolve(true) + }) + observer.observe(document.body, { attributes: true, attributeFilter: [mark] }) + setTimeout(() => { + observer.disconnect() + resolve(false) + }, 15_000) + }), RECALL_MARK) +} + +/** + * Wait until nothing under the page is animating, so a probe measures the settled + * layout instead of a frame of a column slide. + * @param page - the page under test. + */ +async function settled(page: Page): Promise { + await expect.poll(() => page.evaluate(() => document.getAnimations() + .every(animation => animation.playState === 'finished' || animation.playState === 'idle')), + { timeout: 20_000 }).toBe(true) +} + +describe('web e2e: macOS window drag coverage', () => { + let scaffold: WebScaffold + let browser: Browser + + beforeAll(async () => { + // A manageable profile: the plugin page draws its card list — and so its + // detail views — only when the deployment runs a profile it can manage. + scaffold = await launchWebScaffold({ + profile: { packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-live-client') }] }, + }) + browser = await chromium.launch() + }, 180_000) + + afterAll(async () => { + try { await browser?.close() } + finally { await scaffold?.close() } + }) + + /** + * Open a darwin-marked page in the shipped composition. + * @returns the page and its console tripwire. + */ + async function darwinPage(): Promise<{ page: Page; tripwire: ReturnType }> { + const page = await newEnglishPage(browser) + await page.addInitScript((value: string) => { + const mark = (): void => { document.documentElement.setAttribute('data-platform', value) } + if (document.documentElement === null) document.addEventListener('DOMContentLoaded', mark, { once: true }) + else mark() + }, 'darwin') + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + return { page, tripwire } + } + + it('leaves no interactive box inside the drag surface, and keeps the panel strip row draggable', async () => { + const { page, tripwire } = await darwinPage() + try { + await connectFreshWorkspace(page, scaffold.workspaceCwd) + const agent = scaffold.ctx.agents.list()[0] + if (agent === undefined) throw new Error('connected workspace did not create an Agent') + agent.session.append('turn/start', { turn: 1 }) + const pulsing = firstRecallPulse(page) + await page.locator('[data-sidebar-right-expand]').click() + await page.locator('[data-sidebar-right-panel][data-sidebar-right-open]').waitFor({ timeout: 15_000 }) + // The panel slides in from the frame's right edge; probing before the slide + // settles measures the hidden position, where the band does not reach. + await expect.poll(async () => page.locator('[data-sidebar-right-panel]').evaluate((element) => { + const rect = element.getBoundingClientRect() + return Math.round(innerWidth - rect.right) === 0 + && element.getAnimations({ subtree: true }) + .every(animation => animation.playState === 'finished' || animation.playState === 'idle') + }), { timeout: 15_000 }).toBe(true) + + // The shell's watcher, not the panel, is what keeps the native drag rects in + // step with the slide: it must have pulsed while the panel moved and must be + // settled now — a mark left set would subtract the whole surface. + expect(await pulsing, 'the shell pulses the drag recall mark while the panel slides').toBe(true) + // The watcher clears the mark on the frame after the geometry settles. + await expect.poll( + () => page.evaluate((mark: string) => document.body.hasAttribute(mark), RECALL_MARK), + { timeout: 5_000 }, + ).toBe(false) + + const regions = await collectedRegions(page, INTERACTIVE_SELECTOR) + const strip = await rectOf(page, '[data-dockkit-strip]') + const panel = await rectOf(page, '[data-sidebar-right-panel]') + const tabs = await rectOf(page, '[data-conversation-tabs]') + const header = await rectOf(page, '[data-slot="conversation.header"] > header') + const seat = await rectOf(page, '[data-shell-leading]') + // The sidebar column drags through its own rows (ui-sidebar), so its + // coverage is the rows' geometry rather than a frame band's height. + const toggle = await rectOf(page, 'button[aria-label="Collapse sidebar"]') + + const probes: CoverageProbe[] = [ + { + row: 'right sidebar panel strip', + what: 'the strip row top inset, above the controls', + at: () => [strip.x + strip.width / 2, strip.y + 4], + drag: true, + }, + { + row: 'right sidebar panel strip', + what: 'the run below the strip row, over the pane body', + at: () => [panel.x + panel.width / 2, strip.y + strip.height + 4], + drag: false, + }, + { + row: 'conversation header', + what: 'the blank run right of the view tabs, on the tab strip row', + at: () => [tabs.x + tabs.width + 8, tabs.y + tabs.height / 2], + drag: true, + }, + { + row: 'conversation header', + what: 'the title row blank run', + at: () => [header.x + header.width / 2, header.y + 4], + drag: true, + }, + { + row: 'conversation header', + what: 'the transcript run below the header block', + at: () => [header.x + header.width / 2, header.y + header.height + 4], + drag: false, + }, + ] + if (toggle.width > 0) { + probes.push( + { + row: 'sidebar top strip', + what: 'the blank run left of the collapse toggle', + at: () => [toggle.x - 40, toggle.y + toggle.height / 2], + drag: true, + }, + { + row: 'sidebar column', + what: 'the content run below the chrome rows', + at: () => [toggle.x - 40, toggle.y + toggle.height + 70], + drag: false, + }, + ) + } + if (seat.width > 0) { + probes.push({ + row: 'shell.leading seat', + what: 'the row of window-chrome controls', + at: () => [seat.x + 4, seat.y + seat.height / 2], + drag: false, + }) + } + + for (const probe of probes) { + const [x, y] = probe.at() + const expected = `${probe.row}: ${probe.what} at ${Math.round(x)},${Math.round(y)}` + + (probe.gap === undefined ? '' : ` (recorded gap — ${probe.gap})`) + expect(isDraggableAt(regions, x, y), expected).toBe(probe.drag) + } + + // The invariant that has no gap budget: a press inside any control box must + // reach the page instead of dragging the window. + expect(swallowedBoxes(regions), 'interactive boxes inside the drag surface').toEqual([]) + + expect(tripwire.pageErrors).toEqual([]) + } catch (error) { + await saveFailureShot(page, 'window-drag-coverage') + throw error + } finally { + await page.close() + } + }, 180_000) + + it('keeps the Conversation header draggable over the collapsed column, and the seat out of it', async () => { + const { page, tripwire } = await darwinPage() + try { + await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click() + await page.locator('[data-shell-leading]').waitFor({ state: 'visible', timeout: 15_000 }) + await settled(page) + const regions = await collectedRegions(page, INTERACTIVE_SELECTOR) + const header = await rectOf(page, '[data-slot="conversation.header"] > header') + const seat = await rectOf(page, '[data-shell-leading]') + + // With the column hidden the centre starts at the frame's left edge, where + // the Conversation header owns the run. + expect( + isDraggableAt(regions, header.x + 40, header.y + 4), + 'the Conversation header over the frame’s left edge', + ).toBe(true) + expect( + isDraggableAt(regions, seat.x + seat.width / 2, seat.y + seat.height / 2), + 'the seat holding the reopen controls', + ).toBe(false) + + expect(swallowedBoxes(regions), 'interactive boxes inside the drag surface').toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + } catch (error) { + await saveFailureShot(page, 'window-drag-coverage-collapsed') + throw error + } finally { + await page.close() + } + }, 120_000) + + it('keeps an entry page’s chrome row draggable', async () => { + const { page, tripwire } = await darwinPage() + try { + await page.getByRole('button', { name: 'Plugins', exact: true }).click() + const head = page.locator('[class*="pageHead"]').first() + await head.waitFor({ timeout: 15_000 }) + await settled(page) + const rect = await head.evaluate((element) => { + const box = element.getBoundingClientRect() + return { x: box.x, y: box.y, width: box.width, height: box.height } + }) + + const regions = await collectedRegions(page, INTERACTIVE_SELECTOR) + // The page head owns its run, clearance included: that is the entry page's + // window strip, with no frame band behind it. + expect( + isDraggableAt(regions, rect.x + 40, rect.y + 8), + 'the plugin manager page head', + ).toBe(true) + expect( + isDraggableAt(regions, rect.x + 40, rect.y + rect.height + 40), + 'the page body below the head', + ).toBe(false) + + expect(swallowedBoxes(regions), 'interactive boxes inside the drag surface').toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + } catch (error) { + await saveFailureShot(page, 'window-drag-coverage-entry-page') + throw error + } finally { + await page.close() + } + }, 120_000) + + it('keeps a detail page’s head row draggable and its body out of the drag surface', async () => { + const { page, tripwire } = await darwinPage() + try { + await page.getByRole('button', { name: 'Plugins', exact: true }).click() + const cards = page.locator('[data-plugin-package], [data-plugin-item]') + await cards.first().waitFor({ timeout: 30_000 }) + await settled(page) + // The card's head carries the one control that opens its page. + await cards.first().locator('button').first().click() + // The mark sits on the head row the detail views share, not on the detail + // container: marking the container would drag the window over its text and + // form labels wherever no control covers them. + const detail = page.locator('[data-plugin-item-detail], [data-plugin-detail], [data-plugin-row-detail]').first() + await detail.waitFor({ timeout: 15_000 }) + const geometry = await detail.evaluate((element) => { + const detailBox = element.getBoundingClientRect() + const headBox = element.firstElementChild?.getBoundingClientRect() + return { + detail: { x: detailBox.x, y: detailBox.y, width: detailBox.width, height: detailBox.height }, + head: headBox === undefined + ? null + : { x: headBox.x, y: headBox.y, width: headBox.width, height: headBox.height }, + viewport: { width: innerWidth, height: innerHeight }, + } + }) + if (geometry.head === null) throw new Error('the detail view rendered no head row') + const { detail: detailRect, head: headRect, viewport } = geometry + const regions = await collectedRegions(page, INTERACTIVE_SELECTOR) + + // The head's top inset is the window clearance above the crumb: blank, so + // it drags like the entry page's head. + expect( + isDraggableAt(regions, headRect.x + headRect.width / 2, headRect.y + 4), + 'the detail head row’s top inset', + ).toBe(true) + + // Below the head, a point that no control covers is content: pressing the + // description, a label, or a gap in the form must reach the page. + const interactive = regions.filter(region => region.interactive) + const bodyTop = headRect.y + headRect.height + const bodyBottom = Math.min(detailRect.y + detailRect.height, viewport.height - 4) + const dragging: string[] = [] + for (let row = 0; row < 8; row += 1) { + for (let column = 0; column < 5; column += 1) { + const x = detailRect.x + (detailRect.width * (column + 0.5)) / 5 + const y = bodyTop + ((bodyBottom - bodyTop) * (row + 0.5)) / 8 + if (y <= bodyTop || y > viewport.height - 4 || x > viewport.width - 4) continue + if (interactive.some(region => x >= region.x && x < region.x + region.width + && y >= region.y && y < region.y + region.height)) continue + if (isDraggableAt(regions, x, y)) dragging.push(`${Math.round(x)},${Math.round(y)}`) + } + } + expect(dragging, 'content points inside the detail body that drag the window').toEqual([]) + + expect(swallowedBoxes(regions), 'interactive boxes inside the drag surface').toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + } catch (error) { + await saveFailureShot(page, 'window-drag-coverage-detail-page') + throw error + } finally { + await page.close() + } + }, 120_000) + + it('keeps a covering overlay out of the drag surface', async () => { + const { page, tripwire } = await darwinPage() + try { + await page.locator('button[aria-haspopup="dialog"]').first().click() + const dialog = page.locator('[role="dialog"]').first() + await dialog.waitFor({ timeout: 15_000 }) + await settled(page) + const layer = await dialog.evaluate((element) => { + const overlay = element.parentElement + const rect = overlay?.getBoundingClientRect() + return { + parentIsBody: overlay?.parentElement === document.body, + coversWindow: rect !== undefined && rect.top <= 0 && rect.bottom >= innerHeight, + } + }) + // A covering surface inside #root precedes the columns' chrome, so a drag + // row declared later would override its subtraction; beside the root, + // ui-web base.css's body rule subtracts it instead. + expect(layer.parentIsBody, 'the covering overlay portals beside #root').toBe(true) + expect(layer.coversWindow).toBe(true) + + const regions = await collectedRegions(page, INTERACTIVE_SELECTOR) + const header = await rectOf(page, '[data-slot="conversation.header"] > header') + expect( + isDraggableAt(regions, header.x + header.width / 2, 8), + 'the window top behind an open covering overlay', + ).toBe(false) + expect(swallowedBoxes(regions), 'interactive boxes inside the drag surface').toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + } catch (error) { + await saveFailureShot(page, 'window-drag-coverage-overlay') + throw error + } finally { + await page.close() + } + }, 120_000) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 4508e03def..67463cc363 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -143,6 +143,7 @@ "tests/chat-long-interactions.e2e.ts", "tests/chat-continuous-conversation.e2e.ts", "tests/composer-tab-geometry.e2e.ts", + "tests/window-drag-coverage.e2e.ts", "tests/complex-history.perf.ts", "tests/pwsh-terminal.e2e.ts", "tests/workflow-run.e2e.ts" diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index b871029e8d..55087f1601 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 16e821ed4caabae491d552a823bd69a2e976e2bd -README.zh.md: 7316b00c69ca74284b81678082d93e870dee6bbe +README.md: 2ab123cc67854bbac9631f74517633dcb625384d +README.zh.md: c852cdbad545aee75497bde79a3a6f50c4fc168e diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 16e821ed4c..2ab123cc67 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -52,7 +52,7 @@ Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and c The package occupies the root-scoped `main` key `conversation`. Its `main.conversation` shell keeps the resident `conversation.header` outside the optional-Session `conversation.content` Component Factory. The header hosts root-scoped navigation without a selected Session; titles, actions, and View tabs remain in its strict Session child. The Factory owns the shared body and Composer, reads the current Session through its standard Hook, and exposes strict-Session `views` plus root-scoped `widthControls` local positions. Its default adapter renders the existing `conversation.session` entry, while the main occurrence selects the width handles; an embedded occurrence can replace `views` and omit those handles without rendering the main Header. The shared body and Composer register the queue and Todo docks. The Todo dock uses the shared panel elevation above the composer; its rows use the shared idle, ongoing, and done markers for pending, in-progress, and completed items. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop. -A blank Session retains the header's leading and corner controls, including the right-sidebar opener, while hiding its title, actions, utilities, and View tabs. Selecting a Workspace creates the Session needed by these controls; the first message is not required. Without a selected Session, the strict header is absent; the resident container reserves a 40px drag band on macOS desktop and no empty vertical space on Web, Windows, or Linux. Sidebar entries retain their own data and execution prerequisites. A started Session header uses one row when fewer than two Views are available; the tab row reserves space only while rendered. +A blank Session retains the header's leading and corner controls, including the right-sidebar opener, while hiding its title, actions, utilities, and View tabs. Selecting a Workspace creates the Session needed by these controls; the first message is not required. Without a selected Session, the strict header is absent; the resident header keeps a 40px window strip on macOS desktop that it owns as its drag row, and no empty vertical space on Web, Windows, or Linux. Sidebar entries retain their own data and execution prerequisites. A started Session header uses one row when fewer than two Views are available; the tab row reserves space only while rendered. View selection is deterministic: a registered persisted selection wins, otherwise registered `chat` wins, otherwise no View renders. It never chooses the first registered View. Shell phase combines Session lifecycle with the active-target set; no target-specific snapshot is read by the shell. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 7316b00c69..c852cdbad5 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -52,7 +52,7 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map, 本包占据 root 作用域 `main` 中的 `conversation` key。其 `main.conversation` 外壳将常驻的 `conversation.header` 放在可选 Session 的 `conversation.content` Component Factory 外。未选中 Session 时,头部仍承载根作用域导航;标题、操作和 View 标签保留在严格 Session 子组件中。Factory 拥有共享正文与 Composer,通过其标准 Hook 读取当前 Session,并公开 strict-Session `views` 与 root-scoped `widthControls` 两个局部位置。默认 adapter 渲染现有 `conversation.session` entry,主 occurrence 选择宽度拖拽条;嵌入式 occurrence 可以替换 `views`、省略拖拽条,且不渲染主 Header。共享正文与 Composer 注册 queue dock 和 Todo dock。Todo dock 在 composer 上方使用共享面板 elevation;其中的行分别以共享 idle、ongoing 与 done 标记表示待处理、进行中与已完成。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。 -blank Session 保留 header 的 leading 与 corner 控件,包括右侧栏展开入口,同时隐藏标题、actions、utilities 和 View tabs。选择 Workspace 会创建这些控件所需的 Session,无需先发送消息。没有选中 Session 时,strict header 不挂载;常驻容器在 macOS 桌面保留 40px 拖拽区域,在 Web、Windows 或 Linux 上不预留空白高度。侧栏各入口仍遵循自身的数据与执行环境要求。 已开始的 Session 在可用 View 少于两个时使用单行标题栏,仅在渲染标签行时保留其高度。 +blank Session 保留 header 的 leading 与 corner 控件,包括右侧栏展开入口,同时隐藏标题、actions、utilities 和 View tabs。选择 Workspace 会创建这些控件所需的 Session,无需先发送消息。没有选中 Session 时,strict header 不挂载;常驻 header 在 macOS 桌面保留 40px 的窗口顶条,由它自己作为拖拽行持有,在 Web、Windows 或 Linux 上不预留空白高度。侧栏各入口仍遵循自身的数据与执行环境要求。 已开始的 Session 在可用 View 少于两个时使用单行标题栏,仅在渲染标签行时保留其高度。 View 选择规则固定:有效且已注册的持久化选择优先,其次是已注册的 `chat`,否则不渲染 View;绝不选择第一个已注册 View。Shell phase 只组合 Session lifecycle 与 active-target set,不读取任何 target-specific 快照。 diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationHeader.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationHeader.tsx index a8f2e6b122..b3b07c2b6b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationHeader.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationHeader.tsx @@ -15,7 +15,7 @@ export function ConversationHeader({ sessionId, useSession, useConversation, ren const blank = session === undefined || conversation === undefined || (session.blank && conversationPhase(session, conversation) === 'blank') return ( -
+
{renderSlot('conversation.header.leading', {})}
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 25530eef99..ed85896510 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -47,13 +47,13 @@ min-height: 0; } -/* macOS hiddenInset titlebar: window dragging comes from ui-layout's - .leadingBand (the frame's first child), never from this header. Electron - composes app-regions in document order ignoring stacking, so a drag rule - here would keep eating clicks through overlays mounted earlier in the DOM - (the settings modal lives in the sidebar column). The title-row clusters - overlap the band and subtract their whole boxes; individual controls are - covered by the base.css interactive-element rule. */ +/* macOS hiddenInset titlebar: the header block owns the window drag over its + own box — title row plus the view tab strip, so the draggable geometry is the + header's geometry and no frame band has to match its height. The header marks + itself `data-window-drag` (ConversationHeader); below it the title-row clusters + subtract their whole boxes, and individual controls are covered by the base.css + interactive-element rule. A covering overlay lives beside #root + (ui-settings-general), which puts it after this row in document order. */ :global([data-platform='darwin']) .headerLeading, :global([data-platform='darwin']) .headerActions, :global([data-platform='darwin']) .headerUtilities, diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index d17f52e3f8..f1179cf32b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -75,7 +75,7 @@ export function ConversationSessionHeader({ const last = index === ancestry.length - 1 // The current crumb has no navigation, so it is plain text // rather than a disabled button: on darwin desktop a button - // would subtract itself from the window drag band (ui-web + // would subtract itself from the header's drag row (ui-web // base.css) and leave the title inert for dragging too. const title = last ? ( @@ -138,8 +138,8 @@ export function ConversationSessionHeader({ {showTabs && ( - // data-conversation-tabs: ui-layout's window drag band matches this - // marker (:has) to deepen only while the tab strip adds header height. + // data-conversation-tabs: marks the tab strip, which the window-chrome + // geometry and the browser coverage lane anchor on.
{tabs.map(viewTab => (
- + ) } @@ -1140,7 +1140,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
{showsCards ? ( -
+

{t('title')}

{t('intro')}

diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index b0d0e950b6..b5eaf35c06 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -64,8 +64,10 @@ "@deepseek-ai/dsh-remote-mock": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@types/react": "~18.3.1", + "@types/react-dom": "~18.3.0", "clsx": "^2.0.0", - "react": "^18.2.0" + "react": "^18.2.0", + "react-dom": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-settings-general/src/client/SettingsRoot.module.css b/packages/client/ui-settings-general/src/client/SettingsRoot.module.css index 8e7569b3ca..333f5fdafe 100644 --- a/packages/client/ui-settings-general/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings-general/src/client/SettingsRoot.module.css @@ -239,8 +239,7 @@ white-space: nowrap; } -/* macOS: overlays covering the window drag bands must subtract themselves - (Electron app-regions are geometry, not stacking). */ -:global([data-platform='darwin']) .overlay { - -webkit-app-region: no-drag; -} +/* No app-region rule here: the overlay portals beside #root (SettingsRoot), so + ui-web base.css's `body > :not(#root)` subtraction covers it wherever it + overlaps a chrome row's drag — including above every column, which an in-root + overlay could not promise once a chrome row declares drag itself. */ diff --git a/packages/client/ui-settings-general/src/client/SettingsRoot.tsx b/packages/client/ui-settings-general/src/client/SettingsRoot.tsx index 6e82913513..90a1eceb66 100644 --- a/packages/client/ui-settings-general/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings-general/src/client/SettingsRoot.tsx @@ -11,6 +11,7 @@ * to the step, so a mounted-but-deciding step paints nothing here. */ import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' import clsx from 'clsx' import { ConnectionIndicator, @@ -67,7 +68,11 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP const closeButton = useRef(null) useEffect(() => { closeButton.current?.focus() }, []) - return ( + // Portalled beside #root like the Modal primitive: a covering surface mounted + // inside the root would precede the columns' chrome in document order, so a + // chrome row that declares window drag after it would override its subtraction. + // Beside the root, base.css's `body > :not(#root)` rule subtracts it instead. + return createPortal((
- ) + ), document.body) } /** diff --git a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css index 890b897cc8..eaee49200a 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css +++ b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css @@ -46,19 +46,6 @@ transition: transform var(--ds-transition-duration-slow) var(--ds-ease-in-out); } -/* One-frame app-region pulse at each open/close edge (RightbarSeat): changing - this computed value is what makes Electron recollect the window's drag - rects, which the slide's transform/visibility flips alone never dirty - (electron#32341). The panel's steady state sets no app-region in either - presentation, so this write is a real change at both edges — including a - hidden panel opening straight into fullscreen (a narrow window's first - open), whose rects were skipped while hidden. While set, the whole panel is - out of the drag band for that frame; the removal frame recollects the - steady-state geometry below. */ -:global(html[data-platform='darwin']) .panel[data-sidebar-right-region-nudge] { - -webkit-app-region: no-drag; -} - .panel[data-sidebar-right-panel='fullscreen'] { position: fixed; inset: 0; @@ -66,16 +53,11 @@ border: none; } -/* No whole-box subtraction here: Electron drag regions are window geometry, - not stacking, so the frame's leading band does keep acting through this - fixed overlay — but the kit already subtracts every box that must stay - usable (tabs, strip-end chrome, pane bodies, dividers; ui-dockkit), and - ui-web base.css subtracts interactive elements and portalled overlays. - What remains of the band under the strips' blank runs is the window drag - affordance, the same native-tab-bar reading as the push presentation. A - panel-wide no-drag would trade it away and, worse, make no-drag the - steady state, turning the open/close pulse above into a computed-value - no-op exactly when a hidden panel opens straight into fullscreen. */ +/* No whole-box subtraction here: the strip rows inside mark themselves + `data-window-drag`, and ui-web base.css subtracts interactive elements and + portalled overlays, so a strip's blank runs stay draggable through this + fixed overlay while its tabs and strip-end chrome keep their clicks. A + panel-wide no-drag would trade that drag affordance away. */ /* Windows keeps caption controls above fullscreen file and terminal panels. */ :global([data-windows-titlebar]) .panel[data-sidebar-right-panel='fullscreen'] { diff --git a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx index 6b388b219f..b4853ca237 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx +++ b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx @@ -375,36 +375,9 @@ export function RightbarSeat({ if (shown && !fullscreen && !canShow) actions.setExpanded(sessionId, false) }, [actions, sessionId, shown, fullscreen, canShow]) - // Electron rebuilds the window's -webkit-app-region rects only when a style - // or layout pass dirties an app-region value (electron#32341), and Blink - // skips hidden boxes when it collects them. The open/close slide flips only - // transform and visibility — neither dirties the rects — so the strip's - // no-drag boxes (ui-dockkit) are missing right after the first open, leaving - // the tabs under the window drag band (ui-layout .leadingBand) swallowed by - // it, and they would linger after a close. Pulsing an app-region rule - // (SidebarRight.module.css) on the panel forces a recollection at both edges - // of the gesture: once at the flip, once after the slide settles and the - // stylesheet's delayed visibility flip (0.3s) has landed. - useEffect(() => { - if (document.documentElement.dataset.platform !== 'darwin') return - const panel = panelRef.current - if (panel === null) return - let raf: number | null = null - const nudge = (): void => { - panel.setAttribute('data-sidebar-right-region-nudge', '') - raf = requestAnimationFrame(() => { - raf = null - panel.removeAttribute('data-sidebar-right-region-nudge') - }) - } - nudge() - const settle = setTimeout(nudge, 400) - return () => { - clearTimeout(settle) - if (raf !== null) cancelAnimationFrame(raf) - panel.removeAttribute('data-sidebar-right-region-nudge') - } - }, [shown]) + // The open/close slide needs no pulse of its own: the shell's window drag + // watcher (ui-web) measures the marked rows every frame the surface moves and + // sets the recall mark itself (electron#32341). // Fullscreen leaves the previous column report in force until its own slide // completes. Normal presentation and zero-duration transitions report before paint. diff --git a/packages/client/ui-sidebar-right/tests/region-nudge-styles.client.spec.ts b/packages/client/ui-sidebar-right/tests/region-nudge-styles.client.spec.ts deleted file mode 100644 index e095849e1d..0000000000 --- a/packages/client/ui-sidebar-right/tests/region-nudge-styles.client.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * App-region nudge pulse, asserted against SidebarRight.module.css on disk: - * Electron recollects the window's drag rects only when a style pass changes - * a computed app-region value (electron#32341), so the panel's steady state - * must declare NO app-region in either presentation — the open/close no-drag - * pulse is then a real computed-value change at both edges, including a - * hidden panel opening straight into fullscreen (a narrow window's first - * open, whose rects were skipped while hidden). A steady-state value on the - * panel (the old fullscreen whole-box no-drag) makes the pulse a silent - * no-op and leaves the frame's drag band swallowing the strip's top-right - * controls; fine-grained subtraction belongs to ui-dockkit (tabs, strip-end - * chrome, pane bodies, dividers), leaving the strips' blank runs draggable. - */ -import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' - -const css = readFileSync(new URL('../src/client/shell/SidebarRight.module.css', import.meta.url), 'utf8') - -/** - * Every ` -> value` pair of rules declaring `-webkit-app-region`. - * @returns one entry per declaring rule, in source order. - */ -function appRegionRules(): [selector: string, value: string][] { - const rules: [string, string][] = [] - for (const match of css.matchAll(/(?[^{}]+)\{(?[^{}]*)\}/g)) { - const value = /-webkit-app-region:\s*([^;]+);/.exec(match.groups!['body']!)?.[1] - if (value !== undefined) rules.push([match.groups!['sel']!.trim(), value.trim()]) - } - return rules -} - -describe('app-region nudge pulse styles', () => { - it('pulses the panel to no-drag', () => { - const pulses = appRegionRules() - .filter(([selector]) => selector.includes('[data-sidebar-right-region-nudge]')) - expect(pulses).toHaveLength(1) - expect(pulses[0]![0]).toMatch(/\.panel\[data-sidebar-right-region-nudge\]\s*$/) - expect(pulses[0]![1]).toBe('no-drag') - }) - - it('declares no steady-state app-region, so the pulse is a real change', () => { - const steady = appRegionRules() - .filter(([selector]) => !selector.includes('[data-sidebar-right-region-nudge]')) - expect(steady).toEqual([]) - }) -}) diff --git a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx index 417e3742ca..b1b8866bf9 100644 --- a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx @@ -268,47 +268,6 @@ describe('RightbarSeat presentation', () => { expect(h.frame.closeRightbar).toHaveBeenCalled() }) - it('skips the nudge while the darwin seat has no surface to render', async () => { - // The seat's first render returns null (the open effect has not created - // the surface yet), so the nudge effect fires with an unattached panel ref. - document.documentElement.dataset.platform = 'darwin' - try { - const h = await mountSeat() - const panel = element(h.view.container, '[data-sidebar-right-panel]') - expect(panel.hasAttribute('data-sidebar-right-region-nudge')).toBe(false) - } finally { - delete document.documentElement.dataset.platform - } - }) - - it('pulses the app-region nudge at each open and close edge on macOS only', async () => { - const h = await mountSeat() - const panel = element(h.view.container, '[data-sidebar-right-panel]') - const marks = vi.spyOn(panel, 'setAttribute') - const nudges = () => marks.mock.calls.filter(([name]) => name === 'data-sidebar-right-region-nudge').length - // Web and Windows compose no app-regions: the slide needs no nudge. - act(() => { h.controller.toggleExpanded() }) - act(() => { h.controller.toggleExpanded() }) - expect(nudges()).toBe(0) - document.documentElement.dataset.platform = 'darwin' - try { - act(() => { h.controller.toggleExpanded() }) - expect(nudges()).toBe(1) - expect(panel.hasAttribute('data-sidebar-right-region-nudge')).toBe(true) - // A frame later the mark lifts (the pulse itself is the recollection - // trigger), and the settle pulse re-marks after the 0.3s slide. - await act(async () => { await new Promise(resolve => setTimeout(resolve, 450)) }) - expect(nudges()).toBe(2) - expect(panel.hasAttribute('data-sidebar-right-region-nudge')).toBe(false) - // The close edge pulses again so the hidden panel's stale rects drop. - act(() => { h.controller.toggleExpanded() }) - expect(nudges()).toBe(3) - expect(panel.hasAttribute('data-sidebar-right-region-nudge')).toBe(true) - } finally { - delete document.documentElement.dataset.platform - } - }) - it('fills the viewport without replacing the content tree or releasing the wide track', async () => { const h = await mountSeat() const tab = h.open() diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index 72b5b20666..7d64e76cb3 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar/README.md -README.md: 94bb3a70ca8c06b5838f41436bb0f2ba2751501e -README.zh.md: 86f54882ba1c96b9b50898993931757f7afef4e5 +README.md: 9ee8d9eb95d0991f1cdfcee3b5beee4d249d3f50 +README.zh.md: c858362b4b8a8c5e41b337253454d41001f926fa diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 94bb3a70ca..9ee8d9eb95 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -45,7 +45,7 @@ On Windows Electron, `html[data-windows-titlebar]` fixes the sidebar toggle in t ### macOS desktop -Under `html[data-platform='darwin']` (set only by the desktop preload) the expanded column opens with a 52px top strip that clears the hiddenInset traffic lights and carries the collapse toggle; the frame's own drag band (ui-layout `.leadingBand`) covers that strip, and the logo row below extends the drag surface, so the brand wordmark is not a New Session shortcut there — the dedicated New Session button keeps the action — and collapsing hides the column entirely instead of leaving the rail. The package registers `HeaderLeadingControls` into the frame's `shell.leading` window-chrome seat (ui-layout), which mounts it — the open-sidebar and New Session controls beside the traffic lights — only while the column is hidden, over every main panel. Rationale and the window-integration contract: the [macOS hidden-titlebar Agent Note](../../../.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md). +Under `html[data-platform='darwin']` (set only by the desktop preload) the expanded column opens with a 52px top strip that clears the hiddenInset traffic lights and carries the collapse toggle; the strip and the logo row below each mark themselves `data-window-drag`, so each row's own box is the window's drag region (ui-web base.css declares the one darwin drag rule), and the brand wordmark is not a New Session shortcut there — the dedicated New Session button keeps the action — and collapsing hides the column entirely instead of leaving the rail. The package registers `HeaderLeadingControls` into the frame's `shell.leading` window-chrome seat (ui-layout), which mounts it — the open-sidebar and New Session controls beside the traffic lights — only while the column is hidden, over every main panel. Rationale and the window-integration contract: the [macOS hidden-titlebar Agent Note](../../../.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md). ### Scrollbars diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index 86f54882ba..c858362b4b 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -45,7 +45,7 @@ dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将 ### macOS 桌面 -在 `html[data-platform='darwin']`(仅由桌面 preload 设置)下,展开的侧边栏列顶部有一条 52px 的顶部条:避开 hiddenInset 红绿灯并承载收起按钮;框架自身的拖拽带(ui-layout `.leadingBand`)覆盖这条顶部条,其下的 logo 行延伸这块拖拽面,因此品牌 wordmark 在 macOS 上不再是 New Session 快捷入口——专用的 New Session 按钮保留该操作;收起时整列隐藏而非保留轨道。本包向框架的 `shell.leading` 窗口 chrome 座(ui-layout)注册 `HeaderLeadingControls`——打开侧边栏与 New Session 两个控件,由框架仅在列隐藏时挂载于红绿灯旁,覆盖所有主面板。设计依据与窗口集成约定见 [macOS 隐藏标题栏 Agent Note](../../../.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md)。 +在 `html[data-platform='darwin']`(仅由桌面 preload 设置)下,展开的侧边栏列顶部有一条 52px 的顶部条:避开 hiddenInset 红绿灯并承载收起按钮;顶部条与其下的 logo 行各自打上 `data-window-drag`,于是每行自己的盒子就是窗口的拖拽区(唯一的 darwin drag 规则由 ui-web base.css 声明),因此品牌 wordmark 在 macOS 上不再是 New Session 快捷入口——专用的 New Session 按钮保留该操作;收起时整列隐藏而非保留轨道。本包向框架的 `shell.leading` 窗口 chrome 座(ui-layout)注册 `HeaderLeadingControls`——打开侧边栏与 New Session 两个控件,由框架仅在列隐藏时挂载于红绿灯旁,覆盖所有主面板。设计依据与窗口集成约定见 [macOS 隐藏标题栏 Agent Note](../../../.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md)。 ### 滚动条 diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 144070ba3f..972be46d74 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -176,8 +176,9 @@ /* macOS hiddenInset titlebar: the strip spans the column's top edge (negative margins cancel the root padding) so it clears the traffic lights at - (16, 18). Window dragging over this run comes from ui-layout's .leadingBand - (same 52px geometry); the toggle subtracts via the base.css rule. */ + (16, 18). The strip marks itself `data-window-drag` in markup (SidebarRoot), so + its own run is the column's draggable geometry and no frame band has to match + the strip's height; the toggle inside subtracts via the base.css rule. */ .topStrip { flex: none; display: flex; @@ -207,7 +208,15 @@ /* Logo row (figma pad (4,8,4,8)): expanded keeps the panel toggle at the right edge; the rail gives every control the same base left anchor before - the shared entry translation is applied. */ + the shared entry translation is applied. macOS hiddenInset titlebar: the row + is marked `data-window-drag` in markup (SidebarRoot), so ui-web base.css's + single darwin rule carries the drag over its own box — below the top strip + (40–100px: the strip's 52px foot with the row's -12px tuck). The brand rides + that box (SidebarRoot renders the wordmark as a plain span there, so the + global interactive no-drag rule never subtracts it); the dedicated New + Session button below keeps the pointer and keyboard paths. Safe as a drag + source: it precedes the sidebar's overlay slots in document order, so their + no-drag still wins. */ .logoRow { flex: none; display: flex; @@ -228,16 +237,6 @@ justify-content: flex-start; } -/* macOS hiddenInset titlebar: the logo row extends the frame band's drag - below 52px, and the brand rides it — SidebarRoot renders the wordmark as a - plain span there, so the global interactive no-drag rule never subtracts - it; the dedicated New Session button below keeps the pointer and keyboard - paths. Safe as a drag source: it precedes the sidebar's overlay slots in - document order, so their no-drag still wins. */ -:global([data-platform='darwin']) .logoRow { - -webkit-app-region: drag; -} - /* Brand group (figma I133:7632): the full wordmark rides the text ink (figma flows: the main-screen instance is black; blue is brand emphasis only). A button only in behavior (New Session shortcut): the diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index 12bcbe8ca8..90a978f03b 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -208,8 +208,8 @@ export function SidebarRoot({ > {/* macOS hiddenInset titlebar: the strip shares the row with the traffic lights and keeps the toggle at the sidebar's top-right. */} - {darwinDesktop &&
{toggle}
} -
+ {darwinDesktop &&
{toggle}
} +
{/* Expanded, the brand doubles as a New Session shortcut — 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); diff --git a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap index d3f6bbfc56..a467c3dbba 100644 --- a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap +++ b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap @@ -11,6 +11,7 @@ exports[`sidebar shell snapshots > renders Windows caption controls in expanded >
+
+

{t(running ? 'signOutRunningDescription' : 'signOutDescription')}

+ {failed &&

{t('failed')}

} +
+
+ + +
+ +} diff --git a/packages/client/ui-settings-account/src/client/index.ts b/packages/client/ui-settings-account/src/client/index.ts index a6a8b79272..a5b1c715ab 100644 --- a/packages/client/ui-settings-account/src/client/index.ts +++ b/packages/client/ui-settings-account/src/client/index.ts @@ -120,6 +120,11 @@ export function apply(ctx: Context): void { } }, async cancel(id) { const result = await ctx.remote.account.cancelSignIn(id); if (!result.ok) throw new Error('account cancel failed') }, + async hasRunningAccountTasks() { + const result = await ctx.remote.account.hasRunningAccountTasks() + if (!result.ok) throw new Error('account task query failed') + return result.value + }, async signOut() { const result = await ctx.remote.account.signOut(); if (!result.ok) throw new Error('account sign-out failed') }, } ctx.slots.inject('settings.models.sign-in', () => ctx.slots.register({ diff --git a/packages/client/ui-settings-account/src/client/locales.ts b/packages/client/ui-settings-account/src/client/locales.ts index e332296af7..b93c8e61c0 100644 --- a/packages/client/ui-settings-account/src/client/locales.ts +++ b/packages/client/ui-settings-account/src/client/locales.ts @@ -9,7 +9,9 @@ export const en = { loading: 'Loading…', backToHarness: 'Back to DeepSeek Harness', settings: 'Settings', contactUs: 'Contact us', menu: 'Account menu', nav: 'Account', signedIn: 'Signed in to DeepSeek', signedOut: 'Not signed in', - signIn: 'Sign in', signOut: 'Sign out', cancel: 'Cancel', open: 'Open browser', + signIn: 'Sign in', signOut: 'Sign out', + signOutDescription: 'Signing out will not delete any data. You can sign in to this account again.', + signOutRunningDescription: 'Tasks are currently running. Signing out will interrupt them. Sign out now?', cancel: 'Cancel', open: 'Open browser', initializing: 'Starting sign in…', waiting: 'Continue in your browser', completing: 'Completing sign in…', expired: 'Sign in expired. Try again.', failed: 'Could not complete the operation. Try again.', @@ -33,7 +35,9 @@ export const zh: Record = { loading: '加载中…', backToHarness: '返回 DeepSeek Harness', settings: '设置', contactUs: '联系我们', menu: '账号菜单', nav: '账号与余额', signedIn: '已登录 DeepSeek', signedOut: '尚未登录', - signIn: '登录', signOut: '退出登录', cancel: '取消', open: '打开浏览器', + signIn: '登录', signOut: '退出登录', + signOutDescription: '退出登录不会丢失任何数据,你仍可以登录此账号。', + signOutRunningDescription: '当前有进程正在执行中,点击“退出登录”,将会打断进程,是否即刻退出登录?', cancel: '取消', open: '打开浏览器', initializing: '正在发起登录…', waiting: '请在浏览器中继续', completing: '正在完成登录…', expired: '登录已过期,请重试。', failed: '操作未完成,请重试。', diff --git a/packages/client/ui-settings-account/tests/account.client.spec.tsx b/packages/client/ui-settings-account/tests/account.client.spec.tsx index 9dfa3dd269..3757579a79 100644 --- a/packages/client/ui-settings-account/tests/account.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/account.client.spec.tsx @@ -17,6 +17,7 @@ function mount(state: Omit, copy: typeof en | typeof zh = getSnapshot: () => ({ view: { ...state, links: { usageUrl: 'http://localhost:8081/usage', topUpUrl: 'http://localhost:8081/top_up' } }, details, failed: false }), subscribe: () => () => {}, } }, + hasRunningAccountTasks: vi.fn(async () => false), contactUs: vi.fn(), showLogin: vi.fn(), setOnboarding: vi.fn(), refresh: vi.fn(() => Promise.resolve()), start: vi.fn(() => Promise.resolve()), cancel: vi.fn(() => Promise.resolve()), signOut: vi.fn(() => Promise.resolve()), @@ -50,13 +51,14 @@ it('starts sign-in and disables cancellation during persistence', async () => { expect(screen.queryByRole('button', { name: en.signIn })).toBeNull() }) -it.each([en, zh])('opens settings and signs out from the sidebar account menu', async (copy) => { +it.each([en, zh].flatMap(copy => [false, true].map(running => ({ copy, running }))))('confirms sidebar sign-out with task impact $running', async ({ copy, running }) => { const signOut = vi.fn(() => Promise.resolve()) const openSettings = vi.fn() const operations = mount({ status: 'credential-stored', attempt: null }, copy) cleanup() const { AccountMenu } = await import('../src/client/AccountMenu.tsx') render( running} useAccount={selector => selector(operations.hooks.account.getSnapshot())} wide openOnboarding={() => {}} openSettings={openSettings} t={key => key in copy ? copy[key as AccountKey] : key} />) fireEvent.click(screen.getByRole('button', { name: copy.menu })) @@ -70,6 +72,9 @@ it.each([en, zh])('opens settings and signs out from the sidebar account menu', expect(screen.queryByRole('menu')).toBeNull() fireEvent.click(screen.getByRole('button', { name: copy.menu })) await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: copy.signOut })) }) + expect(signOut).not.toHaveBeenCalled() + expect(screen.getByText(running ? copy.signOutRunningDescription : copy.signOutDescription)).toBeTruthy() + await act(async () => { fireEvent.click(screen.getByRole('button', { name: copy.signOut })) }) expect(signOut).toHaveBeenCalledOnce() expect(screen.queryByRole('menu')).toBeNull() }) diff --git a/packages/client/ui-settings-account/tests/expected/sign-out-idle-en.txt b/packages/client/ui-settings-account/tests/expected/sign-out-idle-en.txt new file mode 100644 index 0000000000..9e7ca254e0 --- /dev/null +++ b/packages/client/ui-settings-account/tests/expected/sign-out-idle-en.txt @@ -0,0 +1 @@ +Sign outSigning out will not delete any data. You can sign in to this account again.CancelSign out diff --git a/packages/client/ui-settings-account/tests/expected/sign-out-idle-zh.txt b/packages/client/ui-settings-account/tests/expected/sign-out-idle-zh.txt new file mode 100644 index 0000000000..9ad80dfc63 --- /dev/null +++ b/packages/client/ui-settings-account/tests/expected/sign-out-idle-zh.txt @@ -0,0 +1 @@ +退出登录退出登录不会丢失任何数据,你仍可以登录此账号。取消退出登录 diff --git a/packages/client/ui-settings-account/tests/expected/sign-out-running-en.txt b/packages/client/ui-settings-account/tests/expected/sign-out-running-en.txt new file mode 100644 index 0000000000..005841b323 --- /dev/null +++ b/packages/client/ui-settings-account/tests/expected/sign-out-running-en.txt @@ -0,0 +1 @@ +Sign outTasks are currently running. Signing out will interrupt them. Sign out now?CancelSign out diff --git a/packages/client/ui-settings-account/tests/expected/sign-out-running-zh.txt b/packages/client/ui-settings-account/tests/expected/sign-out-running-zh.txt new file mode 100644 index 0000000000..b3effa3b49 --- /dev/null +++ b/packages/client/ui-settings-account/tests/expected/sign-out-running-zh.txt @@ -0,0 +1 @@ +退出登录当前有进程正在执行中,点击“退出登录”,将会打断进程,是否即刻退出登录?取消退出登录 diff --git a/packages/client/ui-settings-account/tests/sign-out-dialog.client.spec.tsx b/packages/client/ui-settings-account/tests/sign-out-dialog.client.spec.tsx new file mode 100644 index 0000000000..9ee95e897f --- /dev/null +++ b/packages/client/ui-settings-account/tests/sign-out-dialog.client.spec.tsx @@ -0,0 +1,45 @@ +// @vitest-environment jsdom +/** Sign-out impact copy and explicit confirmation. */ +import { cleanup, fireEvent, render, screen, act } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { SignOutDialog } from '../src/client/SignOutDialog.tsx' +import { en, zh } from '../src/client/locales.ts' + +afterEach(cleanup) +function mount(running: boolean, copy: typeof en | typeof zh = en, signOut = vi.fn(async () => {})) { + const close = vi.fn() + render( copy[key]} />) + return { signOut, close } +} +it.each([en, zh].flatMap(copy => [false, true].map(running => ({ copy, running }))))( + 'confirms sign-out with localized task impact: $running', async ({ copy, running }) => { + const props = mount(running, copy) + await expect(`${screen.getByRole('dialog').textContent}\n`) + .toMatchFileSnapshot(`./expected/sign-out-${running ? 'running' : 'idle'}-${copy === en ? 'en' : 'zh'}.txt`) + expect(props.signOut).not.toHaveBeenCalled() + await act(async () => { fireEvent.click(screen.getByRole('button', { name: copy.signOut })) }) + expect(props.signOut).toHaveBeenCalledOnce() + expect(props.close).toHaveBeenCalledOnce() + }, +) +it.each(['cancel', 'close', 'escape'] as const)('dismisses with %s without signing out', (action) => { + const props = mount(true) + if (action === 'escape') fireEvent.keyDown(document, { key: 'Escape' }) + else fireEvent.click(screen.getByRole('button', { name: en[action] })) + expect(props.close).toHaveBeenCalledOnce() + expect(props.signOut).not.toHaveBeenCalled() +}) +it('keeps failures open for retry and blocks dismissal during sign-out', async () => { + const pending = Promise.withResolvers() + const signOut = vi.fn(() => pending.promise).mockRejectedValueOnce(new Error('offline')) + const props = mount(true, en, signOut) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: en.signOut })) }) + expect(screen.getByRole('alert').textContent).toBe(en.failed) + expect(props.close).not.toHaveBeenCalled() + await act(async () => { fireEvent.click(screen.getByRole('button', { name: en.signOut })) }) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(props.close).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: en.signOut }).hasAttribute('disabled')).toBe(true) + await act(async () => { pending.resolve(undefined) }) + expect(props.close).toHaveBeenCalledOnce() +}) diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 454baf309b..5d0264c3ce 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -51,6 +51,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'running', ctx: new Context(), send: () => {}, diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index 28d7b1027d..09a7b5bbcc 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -100,6 +100,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'running', ctx: new Context(), send: () => {}, diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 8a503e803f..25942ade6f 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md -README.md: 7c3073aefb90fa2ec74d521d6c9d97aff5f74ace -README.zh.md: 44621a0b02eadfc574f6f6b924f64cfeb4141cd3 +README.md: e58f05e619fc5a39d26edd71ed92c79fe04dcc81 +README.zh.md: 490a4a1849daee182357f6eaadc44a5339b060ca diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 7c3073aefb..e58f05e619 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -25,6 +25,8 @@ English | [中文](README.zh.md) ## Use this package +The running phase retains the final prepared provider through tool execution and retry preparation, resets it before publishing `turn/end`, and exposes it through `Agent.activeProvider`. Plugins can cancel matching live agents without consulting mutable settings or historical request headers. + Mount `dsh-agent-loop` in any composition that should run agents. It supplies the driver behind `ctx.agents` and starts any agents you declare in its config; both [`dsh-base`](../../bundle/base/README.md) and [`dsh-sdk-minimal`](../../bundle/sdk-minimal/README.md) mount it as an explicit row. ### Configure declarative agents diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 44621a0b02..490a4a1849 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -25,6 +25,8 @@ kind: "package-reference" ## 使用本包 +运行阶段在工具执行及重试准备期间保留最终准备完成的提供方,在发布 `turn/end` 前重置,并通过 `Agent.activeProvider` 暴露。插件可以取消匹配的运行中代理,无须读取可变设置或历史请求头。 + 在任何应运行 agent 的组合中挂载 `dsh-agent-loop`。它提供 `ctx.agents` 背后的驱动器,并启动你在配置中声明的 agent;[`dsh-base`](../../bundle/base/README.zh.md) 与 [`dsh-sdk-minimal`](../../bundle/sdk-minimal/README.zh.md) 都将它作为显式配置行挂载。 ### 配置声明式 agent diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 4ea7593551..f44e1aa024 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -46,7 +46,7 @@ type Phase = lastTurn: number wakeRequested: boolean } - | { kind: 'running'; abort: AbortController; turn: number; step: number; wakeRequested: boolean } + | { kind: 'running'; abort: AbortController; turn: number; step: number; wakeRequested: boolean; activeProvider?: string } type StepEndReason = Extract @@ -115,6 +115,10 @@ export class ReactLoopAgent implements Agent { return this.phase.kind === 'idle' || this.phase.kind === 'maintenance' ? 'idle' : 'running' } + get activeProvider(): string | undefined { + return this.phase.kind === 'running' ? this.phase.activeProvider : undefined + } + /** Commit a phase and publish its externally visible status transition. */ private setPhase(next: Phase): void { const previousStatus = this.status @@ -336,6 +340,7 @@ export class ReactLoopAgent implements Agent { this.throwError(error) } finally { try { + delete phase.activeProvider // oxlint-disable-next-line typescript/no-non-null-assertion -- every exit assigns a turn ending this.session.append('turn/end', { turn, reason: turnEnds! }) } catch (error: unknown) { @@ -547,6 +552,7 @@ export class ReactLoopAgent implements Agent { config = proposedConfig } signal.throwIfAborted() + if (this.phase.kind === 'running') this.phase.activeProvider = config.provider return { config, ...preparedCall === undefined ? {} : { preparedCall } } } diff --git a/packages/core/agent-loop/tests/inbox.spec.ts b/packages/core/agent-loop/tests/inbox.spec.ts index 739d18b990..f4a9eeb387 100644 --- a/packages/core/agent-loop/tests/inbox.spec.ts +++ b/packages/core/agent-loop/tests/inbox.spec.ts @@ -26,6 +26,7 @@ function stubAgent(rawId: string, overrides: Partial = {}): Agent { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx, send: () => {}, diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index 59f4628536..26ee824e52 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent/README.md -README.md: 8ba3dc730790acb0f792173e9ee01d2dc954957e -README.zh.md: 0718ae5f3f73b1fd0bb5b600de3a08c50e67421e +README.md: a07145131186fddb30de62536f3001a36b32dc04 +README.zh.md: d21c010e418c997807b3eec36a585a8b84bea31d diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 8ba3dc7307..a071451311 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -25,6 +25,8 @@ Use `dsh-agent` to create or resume live agents, send follow-up or steering inpu ## Use this package +The read-only `Agent.activeProvider` identifies the current turn’s latest prepared request. It is absent before the first request is prepared and between turns; tools and retries retain the last provider until another request finishes preparation. Configuration changes alone do not reclassify in-flight work. + Mount `dsh-agent` wherever live agents exist: it provides `ctx.agents` and the `Agent` handle that plugins, UI, hooks, and orchestrators work against. The service is inert until a driver registers a factory — the shipped driver is `dsh-agent-loop`, so the smallest useful composition loads both. ### Create or resume an agent diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index 0718ae5f3f..d21c010e41 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -25,6 +25,8 @@ kind: "package-reference" ## 使用本包 +只读 `Agent.activeProvider` 标识当前轮次最近一次完成准备的请求提供方。首请求准备完成前及轮次之间没有该值;工具执行和重试期间保留上一提供方,直到另一请求完成准备。仅改变配置不会改变在途任务的归属。 + 在存在实时 agent 的任何地方挂载 `dsh-agent`:它提供 `ctx.agents` 以及插件、UI、钩子和编排器所面向编程的 `Agent` 句柄。在没有驱动器注册工厂之前,该服务保持惰性——随附驱动器是 `dsh-agent-loop`,因此最小的可用组合需要同时加载两者。 ### 创建或恢复 agent diff --git a/packages/core/agent/src/runtime-types.ts b/packages/core/agent/src/runtime-types.ts index 2118507b8c..bb87e7986e 100644 --- a/packages/core/agent/src/runtime-types.ts +++ b/packages/core/agent/src/runtime-types.ts @@ -170,6 +170,8 @@ declare module './types.ts' { readonly inbox: Inbox /** The current lifecycle state, mirrored on every `agent/status` transition. */ readonly status: AgentStatus + /** Latest prepared provider in this turn; retained through tools and retries, absent before preparation and between turns. */ + readonly activeProvider: string | undefined /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ readonly ctx: Context diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index c31acf5c66..99f20f2419 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -24,6 +24,7 @@ function stubAgent(rawId: string, overrides: Partial = {}): Agent { inbox: { nextTurn: [], nextStep: [], } as never, + activeProvider: undefined, status: 'idle', ctx, send: () => {}, diff --git a/packages/credentials/deepseek-account-platform/README.i18n.yaml b/packages/credentials/deepseek-account-platform/README.i18n.yaml index 7315a4b6c1..b896927357 100644 --- a/packages/credentials/deepseek-account-platform/README.i18n.yaml +++ b/packages/credentials/deepseek-account-platform/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/credentials/deepseek-account-platform/README.md -README.md: 248f679dd7b27af35eafa59579df7538db213b23 -README.zh.md: 36c3f861d60f2c02cca76feabed85862630ead98 +README.md: 31172b1749428decebed41fac99e3fc34a70cf6d +README.zh.md: bdff2428021b7452865cb10d6774fff618c3b111 diff --git a/packages/credentials/deepseek-account-platform/README.md b/packages/credentials/deepseek-account-platform/README.md index 248f679dd7..31172b1749 100644 --- a/packages/credentials/deepseek-account-platform/README.md +++ b/packages/credentials/deepseek-account-platform/README.md @@ -28,6 +28,8 @@ Sign in through the system browser and keep the account credential in the existi The profile projection maps `id_profile.picture` to `avatarUrl`, using null when no picture is configured. +Token resolution refuses requests while local sign-out is in progress. Successful local removal publishes `deepseek-account/signed-out` before returning the signed-out snapshot; remote revocation remains independent. + getProfile / getBalance sends the stored grant in the x-dsh-auth-token header to GET /auth-api/v0/users/current and GET /api/v0/users/get_user_summary on platformOrigin. The grant issuer must match that origin. Host projects only the account UID, profile name, avatar URL, phone or email exactly as masked by Platform, and normal_wallets / bonus_wallets currency/balance strings; response tokens and other fields are discarded. Bonus wallets remain separate from recharge balances. Credential changes and disposal invalidate in-flight queries. Configure platformOrigin, allowLoopbackHttp, requestTimeoutMs, and attemptTimeoutMs in the plugin row. HTTP is accepted only for explicitly enabled loopback development. The provider registers /oauth/callback on the existing Host webServer before auth_init, validates state, exchanges an S256 PKCE code once, and commits a grant before redirecting to auth_exchange.biz_data.authorized_url. Browser destinations default to the configured platform origin and always require the fixed /dsh/authorize or /dsh/authorized path. The completion URL sets `login_source` to the initiating client (`web` or `desktop`) and preserves other Platform-supplied query parameters. Device identity is a separate random UUID record shared by processes using the same credential store; device_model reports OS and architecture. diff --git a/packages/credentials/deepseek-account-platform/README.zh.md b/packages/credentials/deepseek-account-platform/README.zh.md index 36c3f861d6..bdff242802 100644 --- a/packages/credentials/deepseek-account-platform/README.zh.md +++ b/packages/credentials/deepseek-account-platform/README.zh.md @@ -26,6 +26,8 @@ getPlatformSession 仅在已存授权的 issuer 与 platformOrigin 一致时导 ## 使用此包 +本地退出登录期间拒绝解析 token。本地授权成功删除后,先发布 `deepseek-account/signed-out`,再返回已退出的状态快照;远程撤销独立执行。 + 账号资料投影将 `id_profile.picture` 映射为 `avatarUrl`,没有配置头像时返回 null。 getProfile / getBalance 将保存的授权 token 通过 x-dsh-auth-token 请求头,向 platformOrigin 上的 GET /auth-api/v0/users/current 和 GET /api/v0/users/get_user_summary 发起请求。授权签发来源必须与该来源一致。Host 只投影账号 UID、资料名称、头像 URL、由 Platform 脱敏的手机号或邮箱(原样保留),以及 normal_wallets / bonus_wallets 的币种和余额字符串,丢弃响应 token 与其他字段。赠送钱包不计入充值余额。凭证变化和销毁会使进行中的查询失效。 diff --git a/packages/credentials/deepseek-account-platform/src/index.ts b/packages/credentials/deepseek-account-platform/src/index.ts index bea62a3558..835b79d7da 100644 --- a/packages/credentials/deepseek-account-platform/src/index.ts +++ b/packages/credentials/deepseek-account-platform/src/index.ts @@ -222,6 +222,7 @@ export class PlatformAccount extends DeepSeekAccount { } override async resolveToken(url: string): Promise { + if (this.closed || this.removing !== undefined) return undefined const destination = new URL(url) if (destination.origin !== this.inferenceOrigin || destination.username || destination.password) return undefined const record = await this.ctx.credentials.readRecord(KEY) @@ -311,6 +312,7 @@ export class PlatformAccount extends DeepSeekAccount { this.revoke(parsed.data.token) } this.attempt = undefined + this.ctx.emit('deepseek-account/signed-out') this.changed() return this.getState() })().finally(() => { this.removing = undefined }) diff --git a/packages/credentials/deepseek-account-platform/tests/account.spec.ts b/packages/credentials/deepseek-account-platform/tests/account.spec.ts index 407a56f89b..d04dc498ce 100644 --- a/packages/credentials/deepseek-account-platform/tests/account.spec.ts +++ b/packages/credentials/deepseek-account-platform/tests/account.spec.ts @@ -329,6 +329,18 @@ it('retains profile data when balance fails instead of reporting a zero balance' expect(await readDetails(f.account)).toMatchObject({ profile: { status: 'ready' }, balance: { status: 'failed' } }) }) +it('publishes sign-out only after local grant removal and refuses token resolution while removing', async () => { + const f = await fixture() + await storeAccount(f) + const observed: string[] = [] + f.ctx.on('deepseek-account/signed-out', () => { observed.push('signed-out') }) + const pending = f.account.signOut() + expect(await f.account.resolveToken('https://api.deepseek.com')).toBeUndefined() + await pending + expect(observed).toEqual(['signed-out']) + expect((await f.account.getState()).status).toBe('signed-out') +}) + it('discards account details when sign-out races the Platform response', async () => { const f = await fixture() await storeAccount(f) diff --git a/packages/credentials/deepseek-account/README.i18n.yaml b/packages/credentials/deepseek-account/README.i18n.yaml index 7d8ddfe1f3..4841809a90 100644 --- a/packages/credentials/deepseek-account/README.i18n.yaml +++ b/packages/credentials/deepseek-account/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/credentials/deepseek-account/README.md -README.md: 72b42ee996b809992e48ea4c124f0793d756526a -README.zh.md: b30813e24adc2afc9e8245b839432dd195093c63 +README.md: 52bc8e15ee72282b79c0b8823b56476650cdf728 +README.zh.md: 427b4a22aaf1ed503d41e90d0db600d220ef931e diff --git a/packages/credentials/deepseek-account/README.md b/packages/credentials/deepseek-account/README.md index 72b42ee996..52bc8e15ee 100644 --- a/packages/credentials/deepseek-account/README.md +++ b/packages/credentials/deepseek-account/README.md @@ -26,6 +26,8 @@ Account consumers read stored login state, start or cancel a browser login, and `AccountProfile.avatarUrl` is an optional profile image URL; null or absence means no avatar. +Successful local sign-out emits `deepseek-account/signed-out`. Consumers own any cancellation policy; the account service does not track agents or model providers. + The service defines account operations and reconnectable state snapshots. The platform provider owns the protocol and stored grant. Credentials are Host-only; the API controller exports state and commands without resolveToken. diff --git a/packages/credentials/deepseek-account/README.zh.md b/packages/credentials/deepseek-account/README.zh.md index b30813e24a..427b4a22aa 100644 --- a/packages/credentials/deepseek-account/README.zh.md +++ b/packages/credentials/deepseek-account/README.zh.md @@ -24,6 +24,8 @@ getPlatformSession 为原生 Platform 内嵌提供仅限 Host 的 origin/token ## 使用此包 +本地成功退出登录后发出 `deepseek-account/signed-out`。消费方负责取消策略;账号服务不跟踪 agent 或模型提供方。 + `AccountProfile.avatarUrl` 是可选的账号头像 URL;为空或缺失时表示没有头像。 该服务定义账号操作和可重连的状态快照。平台提供者负责协议和授权记录。凭证仅限 Host;API 控制器只导出状态与操作,不导出 resolveToken。 diff --git a/packages/credentials/deepseek-account/src/index.ts b/packages/credentials/deepseek-account/src/index.ts index 465bbedf03..804dc512f6 100644 --- a/packages/credentials/deepseek-account/src/index.ts +++ b/packages/credentials/deepseek-account/src/index.ts @@ -4,6 +4,12 @@ import type { AccountDetails, AccountView, SignInAttemptId } from './types.ts' export type { AccountDetails, AccountProfile, AccountWallet, AccountLinks, AccountView, SignInAttemptId, SignInAttemptView, SignInErrorCode } from './types.ts' declare module '@deepseek-ai/cordis' { + interface Events { + /** Local grant removal has completed. + * @mode emit + */ + 'deepseek-account/signed-out'(): void + } interface Context { deepseekAccount: DeepSeekAccount } @@ -53,7 +59,7 @@ export abstract class DeepSeekAccount extends Service { */ abstract cancelSignIn(id: SignInAttemptId): Promise /** - * Remove the local grant while retaining API keys and tasks; the provider revokes it in the background. + * Remove the local grant while retaining API keys; the provider revokes it in the background. * @returns the signed-out state after local removal; remote failures never restore the grant. */ abstract signOut(): Promise diff --git a/packages/deliverables/tool-present/tests/present.spec.ts b/packages/deliverables/tool-present/tests/present.spec.ts index 6a603163bd..947d5acedc 100644 --- a/packages/deliverables/tool-present/tests/present.spec.ts +++ b/packages/deliverables/tool-present/tests/present.spec.ts @@ -35,6 +35,7 @@ async function agent(ctx: Context, cwd: string | undefined): Promise { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', get ctx() { return scope.ctx }, send: () => {}, diff --git a/packages/experimental/browser-use-runtime/tests/resources.spec.ts b/packages/experimental/browser-use-runtime/tests/resources.spec.ts index f838191d44..104489f420 100644 --- a/packages/experimental/browser-use-runtime/tests/resources.spec.ts +++ b/packages/experimental/browser-use-runtime/tests/resources.spec.ts @@ -16,7 +16,7 @@ async function fixture() { const fiber = ctx.plugin(() => {}) const session = Session.create(SessionId(id)) const agent: Agent = { - id: session.id, session, ctx: fiber.ctx, options: {}, status: 'idle', + id: session.id, session, ctx: fiber.ctx, options: {}, activeProvider: undefined, status: 'idle', inbox: unsupportedInbox(), send() {}, followup() {}, inject() {}, cancel() {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), runMaintenance: task => task(new AbortController().signal), diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index a56e94240d..9e8c680d18 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -895,7 +895,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'abstract signOut(): Promise', - description: 'Remove the local grant while retaining API keys and tasks; the provider revokes it in the background.', + description: 'Remove the local grant while retaining API keys; the provider revokes it in the background.', parameters: [], returns: 'the signed-out state after local removal; remote failures never restore the grant.', }, @@ -3744,6 +3744,14 @@ export const EVENT_API: readonly EventApiEntry[] = [ description: 'Committed change to a provider-managed credential source: a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Listener failures are contained and logged — a sync throw and an async rejection alike — without changing the committed operation\'s outcome, except `INVARIANT`-coded failures, which rethrow after every listener ran; that rethrow reaches the emitter only from synchronous listeners, so invariant checks on this event must not be async functions.', parameters: [{ name: 'ref', description: 'the reference whose stored value changed.' }], }, + { + name: 'deepseek-account/signed-out', + mode: 'emit', + signature: '\'deepseek-account/signed-out\'(): void', + summary: 'Local grant removal has completed.', + description: 'Local grant removal has completed.', + parameters: [], + }, { name: 'domain/changed', mode: 'emit', diff --git a/packages/feedback/command-feedback/tests/command-feedback.spec.ts b/packages/feedback/command-feedback/tests/command-feedback.spec.ts index 4e5401c09e..7db9617a08 100644 --- a/packages/feedback/command-feedback/tests/command-feedback.spec.ts +++ b/packages/feedback/command-feedback/tests/command-feedback.spec.ts @@ -38,6 +38,7 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } session, inbox: unsupportedInbox(), ctx: new Context(), + activeProvider: undefined, get status() { return status }, send: () => {}, followup: () => {}, diff --git a/packages/feedback/command-feedback/tests/loader-composition.spec.ts b/packages/feedback/command-feedback/tests/loader-composition.spec.ts index 273657e579..ee442e64f0 100644 --- a/packages/feedback/command-feedback/tests/loader-composition.spec.ts +++ b/packages/feedback/command-feedback/tests/loader-composition.spec.ts @@ -37,6 +37,7 @@ async function agent(ctx: Context): Promise { session, inbox: unsupportedInbox(), ctx: scope.ctx, + activeProvider: undefined, get status() { return status }, send: () => {}, followup: () => {}, diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 1f893f37fe..6abb17030f 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -38,6 +38,7 @@ async function agent(ctx: Context, cwd: string): Promise { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 1e4e65c7a1..3ad29e5b3b 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -30,6 +30,7 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } session, inbox, ctx: new Context(), + activeProvider: undefined, get status() { return status }, send: () => {}, followup: () => {}, diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 06e31d7196..68f71918ab 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -59,6 +59,7 @@ function stubAgentForSession(session: Session, suppliedCtx?: Context): StubAgent session, inbox, ctx: agentCtx, + activeProvider: undefined, status: 'idle', send: () => {}, followup: () => {}, diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts index 227da38ad8..9bc7ae6693 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -44,6 +44,7 @@ async function liveAgent(ctx: Context, session: Session): Promise { session, inbox: unsupportedInbox(), ctx, + activeProvider: undefined, get status() { return status }, send: () => {}, followup: () => {}, diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index ecc7822bce..9e606e4bb1 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -58,6 +58,7 @@ function stubAgent(rawId: string, supplied?: Session, suppliedCtx?: Context): St options: {}, session, inbox, + activeProvider: undefined, get status() { return status }, ctx: agentCtx, send: () => {}, diff --git a/packages/jobs/jobs-local/tests/jobs.spec.ts b/packages/jobs/jobs-local/tests/jobs.spec.ts index dafd91844f..1e0c300f8f 100644 --- a/packages/jobs/jobs-local/tests/jobs.spec.ts +++ b/packages/jobs/jobs-local/tests/jobs.spec.ts @@ -36,6 +36,7 @@ function stubAgent(ctx: Context, rawId: string, presetScope?: ScopeKey): Agent { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle' as const, ctx: agentCtx, send: () => {}, diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 9445acaf60..11c68612d8 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: 5a3a3a4ff1cf3c30e8c44f079dc90069654efeac -README.zh.md: d58364c0ac21bd67d21761fc732bf9d34dfac802 +README.md: abd29fbdc49a100fa3e5a92ad4db05fc487ef456 +README.zh.md: d955dc40190a2b00d9034859c99c004ba06a4fa3 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 5a3a3a4ff1..abd29fbdc4 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -234,6 +234,6 @@ This Dev Note is non-authoritative working context: undecided directions and not **Runtime invariant:** No companion is published. This package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. -When the account provider is mounted, its stored token takes priority only for https://api.deepseek.com. Other origins and signed-out accounts retain the configured API-key reference. Chat and Files requests reject redirects. Signing out removes only the account grant and preserves API keys. +`deepseek-official` uses only its configured API-key reference; `deepseek-account` uses only the stored DSH grant for the account provider’s allowed inference origin. Both routes share protocol, model, and file settings. Missing or ineligible account credentials reject the request with a sign-in prompt; neither route falls back to the other. Chat and Files requests reject redirects. Successful sign-out cancels live agents whose current prepared provider is `deepseek-account`, including tool execution, while preserving pending inbox messages and independent API-key tasks. DSH account tokens use x-dsh-auth-token without a Bearer prefix for both inference protocols and all Files operations. API keys retain each protocol’s existing authentication header. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index d58364c0ac..d955dc4019 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -234,6 +234,6 @@ loop 保留的响应块会追加到下一个请求,并保留其更早的可复 **运行时不变式:** 不发布伴生入口。本包没有独立事件序列或可变数据关系,相关约定在所属 seam 强制执行。 -挂载账号提供者时,其已保存 token 仅对 https://api.deepseek.com 优先生效。其他来源和已退出的账号继续使用配置的 API Key 引用。聊天和文件请求拒绝重定向。退出登录只删除账号授权记录,保留 API Key。 +`deepseek-official` 仅使用配置的 API Key 引用;`deepseek-account` 仅在账号提供方允许的推理来源使用已保存的 DSH 授权。两条路由共享协议、模型和文件设置。账号凭证缺失或不适用于目标时拒绝请求并提示登录;两条路由均不回退到另一凭证。Chat 和 Files 请求拒绝重定向。退登成功会取消当前已准备提供方为 `deepseek-account` 的运行代理,包括工具执行阶段,同时保留待处理消息和独立的 API Key 任务。 DSH 账号 token 在两种推理协议和所有 Files 操作中使用 x-dsh-auth-token,不加 Bearer 前缀。API Key 保持各协议现有的认证请求头。 diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 9f9d194442..5b031e3e5d 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -39,7 +39,8 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/dsh-deepseek-account": "workspace:^" + "@deepseek-ai/dsh-deepseek-account": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^" }, "dependencies": { "@deepseek-ai/dsh-brand": "workspace:^", @@ -79,6 +80,9 @@ "peerDependenciesMeta": { "@deepseek-ai/dsh-deepseek-account": { "optional": true + }, + "@deepseek-ai/dsh-agent": { + "optional": true } } } diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 722f69bc3f..c25125987e 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -23,7 +23,7 @@ export class DeepSeekAdapter extends LlmAdapter { return new DeepSeekMessagesAdapter({ connection: () => connection, apiKey: this.dependencies.resolveApiKey, - ...this.dependencies.resolveAccountToken === undefined ? {} : { accountToken: this.dependencies.resolveAccountToken }, + accountCredential: this.dependencies.accountCredential === true, userId: this.dependencies.resolveUserId, attachments: () => this.dependencies.resolveAttachments?.(), imageAccess: (ref) => { @@ -41,7 +41,7 @@ export class DeepSeekAdapter extends LlmAdapter { } } - override providerInfo(provider: string) { return this.implementation().providerInfo(provider) } + override providerInfo(provider: string) { return { id: provider, name: provider === 'deepseek-account' ? 'DeepSeek (Account)' : 'DeepSeek (API Key)' } } override providerRetryPolicy(provider: string) { return this.implementation().providerRetryPolicy(provider) } override listModels(provider: string) { return this.implementation().listModels(provider) } override resolveModel(provider: string, model: string, signal?: AbortSignal) { diff --git a/packages/llm/llm-deepseek/src/common/types.ts b/packages/llm/llm-deepseek/src/common/types.ts index 21fe8f37a7..07072e92e7 100644 --- a/packages/llm/llm-deepseek/src/common/types.ts +++ b/packages/llm/llm-deepseek/src/common/types.ts @@ -94,14 +94,14 @@ export interface DeepSeekAdapterOptions { /** Current validated connection facts; called once per operation. */ options: () => DeepSeekConnectionOptions /** - * Resolve the bearer token for the connection facts of one request. The + * Resolve this adapter’s credential for the connection facts of one request. The * snapshot is passed in — never re-read — so the key can only ever come * from the same resolution as the endpoint it is sent to. Throws `LlmError` - * `MISSING_CREDENTIAL` when no key is available anywhere. + * `MISSING_CREDENTIAL` for a missing API key; account routes reject unavailable grants. */ resolveApiKey: (connection: DeepSeekConnectionOptions) => Promise - /** Resolve a DSH account token only for an eligible official endpoint. */ - resolveAccountToken?: (connection: DeepSeekConnectionOptions) => Promise + /** Send the resolved credential as a bare DSH account token; never fall back to an API key. */ + accountCredential?: boolean /** Resolve the harness-home anonymous id shared with telemetry and feedback. */ resolveUserId: () => AnonymousUserId /** Resolve the current durable attachment service; absence rejects image input. */ diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 6cf7922f6c..c740a9e449 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -1,5 +1,6 @@ /** Register DeepSeek with protocol selection and request-local settings and credentials. */ import type {} from '@deepseek-ai/dsh-deepseek-account' +import type {} from '@deepseek-ai/dsh-agent' import type { Context } from '@deepseek-ai/cordis' import { assertUsableApiKey, LlmError, resolveImageAttachmentAccess } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-fs' @@ -107,13 +108,17 @@ export function apply(ctx: Context, config: Config): void { let userId: AnonymousUserId | undefined const resolveUserId = (): AnonymousUserId => userId ??= getOrCreateAnonymousUserId() - const adapter = new DeepSeekAdapter({ + const adapter = (accountCredential: boolean): DeepSeekAdapter => new DeepSeekAdapter({ + accountCredential, options, onReplayDegrade: ({ provider, model, reason }) => { ctx.logger.warn(`llm-deepseek: unusable Messages replay state on assistant history for route "${provider}/${model}"; sending provider-neutral content (${reason})`) }, - resolveApiKey, - resolveAccountToken: connection => ctx.get('deepseekAccount')?.resolveToken(connection.baseURL) ?? Promise.resolve(undefined), + resolveApiKey: accountCredential ? async (connection) => { + const token = await ctx.get('deepseekAccount')?.resolveToken(connection.baseURL) + if (token === undefined) throw new LlmError('Sign in to DeepSeek to use the account provider. The request destination must allow account authentication.', 'ACCOUNT_SIGN_IN_REQUIRED') + return token + } : resolveApiKey, resolveUserId, resolveAttachments: () => ctx.get('attachments'), resolveImageAccess: (attachments, ref) => resolveImageAttachmentAccess( @@ -128,11 +133,21 @@ export function apply(ctx: Context, config: Config): void { }, }) ctx.llm.registerConfigurableProviders([ - { provider: PROVIDER, displayName: 'DeepSeek', settingsNs: NS, settingsPath: [] }, + { provider: PROVIDER, displayName: 'DeepSeek (API Key)', settingsNs: NS, settingsPath: [] }, ]) // Route effects bind to this apply fiber via the stable `ctx` reference, // even when a swap runs inside the scoped settings callback below. - const registration = ctx.llm.registerAdapter([PROVIDER], adapter) + const registration = ctx.llm.registerAdapter([PROVIDER], adapter(false)) + const accountRegistration = ctx.llm.registerAdapter(['deepseek-account'], adapter(true)) + ctx.inject(['agents'], (agentCtx) => { + agentCtx.on('deepseek-account/signed-out', () => { + for (const agent of agentCtx.agents.list()) { + if (agent.activeProvider === 'deepseek-account') { + agent.cancel({ kind: 'hook', reason: 'deepseek-account/signed-out' }, { keepInbox: true }) + } + } + }) + }) let registeredPolicy = options().retryPolicy const ensureRegistrationFacts = (): void => { const policy = options().retryPolicy @@ -143,6 +158,7 @@ export function apply(ctx: Context, config: Config): void { // publish an empty route set between the two, and an observer that reacted // to it would see this provider disappear and come back. registration.replace([PROVIDER]) + accountRegistration.replace(['deepseek-account']) registeredPolicy = policy } diff --git a/packages/llm/llm-deepseek/src/protocols/chat-completions/adapter.ts b/packages/llm/llm-deepseek/src/protocols/chat-completions/adapter.ts index 6e32d4d703..78b7810dca 100644 --- a/packages/llm/llm-deepseek/src/protocols/chat-completions/adapter.ts +++ b/packages/llm/llm-deepseek/src/protocols/chat-completions/adapter.ts @@ -193,8 +193,7 @@ export class ChatCompletionsAdapter extends LlmAdapter { ) } } - const accountToken = await this.config.resolveAccountToken?.(connection) - const apiKey = accountToken ?? await this.config.resolveApiKey(connection) + const apiKey = await this.config.resolveApiKey(connection) const userId = this.config.resolveUserId() const consumer = new AbortController() const upstream = options.signal === undefined @@ -206,7 +205,7 @@ export class ChatCompletionsAdapter extends LlmAdapter { watchdog.signal, connection, apiKey, - accountToken !== undefined, + this.config.accountCredential === true, userId, attachments, () => { watchdog.pulse() }, diff --git a/packages/llm/llm-deepseek/src/protocols/messages/adapter.ts b/packages/llm/llm-deepseek/src/protocols/messages/adapter.ts index f669be7ed3..ade2e75d2f 100644 --- a/packages/llm/llm-deepseek/src/protocols/messages/adapter.ts +++ b/packages/llm/llm-deepseek/src/protocols/messages/adapter.ts @@ -24,7 +24,7 @@ export interface AdapterDependencies { /** Resolve the key named by that same generation. */ apiKey(connection: Connection): Promise /** DSH account token for an eligible official endpoint. */ - accountToken?(connection: Connection): Promise + accountCredential?: boolean /** Stable anonymous Harness identity. */ userId(): string /** Current attachment service; absence is valid for text requests. */ @@ -94,10 +94,9 @@ export class DeepSeekMessagesAdapter extends LlmAdapter { const { messages, versions } = await prepareImages( options.messages, connection, options.model, this.dependencies.attachments(), this.dependencies.imageAccess, signal, ) - const accountToken = await this.dependencies.accountToken?.(connection) - const key = accountToken ?? await this.dependencies.apiKey(connection) + const key = await this.dependencies.apiKey(connection) const files = new RequestFiles(this.dependencies.files(), { - baseURL: connection.baseURL, apiKey: key, accountCredential: accountToken !== undefined, protocol: 'messages', + baseURL: connection.baseURL, apiKey: key, accountCredential: this.dependencies.accountCredential === true, protocol: 'messages', }, connection.filePolicy, connection.filesApiTimeoutMs, signal, activity) let inline = false @@ -129,7 +128,7 @@ export class DeepSeekMessagesAdapter extends LlmAdapter { headers: { ...attributionHeaders(), 'content-type': 'application/json', 'accept': 'text/event-stream', - ...accountToken === undefined ? { 'x-api-key': key } : { 'x-dsh-auth-token': accountToken }, + ...this.dependencies.accountCredential === true ? { 'x-dsh-auth-token': key } : { 'x-api-key': key }, 'anthropic-version': '2023-06-01', ...fileIds === undefined || fileIds.size === 0 ? {} : { 'anthropic-beta': MESSAGES_FILES_BETA }, 'x-deepseek-harness-user-id': this.dependencies.userId(), diff --git a/packages/llm/llm-deepseek/tests/account-routing.spec.ts b/packages/llm/llm-deepseek/tests/account-routing.spec.ts new file mode 100644 index 0000000000..64b050fd02 --- /dev/null +++ b/packages/llm/llm-deepseek/tests/account-routing.spec.ts @@ -0,0 +1,216 @@ +/** Account route cancellation uses the real loop, registry, and tool lifecycle. */ +import { afterEach, expect, it, vi } from 'vitest' +import type { DeepSeekAccount } from '@deepseek-ai/dsh-deepseek-account' +import { Context } from '@deepseek-ai/cordis' +import LlmRuntime, { createUserMessage, LlmAdapter, type GenerateOptions, type StreamChunk, ToolCallId } from '@deepseek-ai/dsh-llm' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import * as DeepSeek from '../src/index.ts' +import { assemble } from './assemble.ts' + +const contexts: Context[] = [] +afterEach(async () => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() +}) + +async function harness() { + const ctx = new Context() + contexts.push(ctx) + for (const plugin of [LlmRuntime, SessionStore, SessionProjectionRegistry, SystemPrompt, ToolRuntime, AgentRegistry]) { + await ctx.plugin(plugin) + } + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(DeepSeek, { protocol: 'chat-completions' }) + return ctx +} + +function send(agent: Agent) { + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) +} + +class ScriptedAdapter extends LlmAdapter { + constructor(private run: (request: GenerateOptions) => AsyncIterable) { super() } + override stream(request: GenerateOptions) { return this.run(request) } +} + +it.each(['chat-completions', 'messages'] as const)('never falls back to an API key while signed out: %s', async (protocol) => { + vi.stubEnv('DEEPSEEK_API_KEY', 'fixture-api-key') + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(LlmRuntime) + await ctx.plugin(DeepSeek, { protocol }) + const fetch = vi.spyOn(globalThis, 'fetch') + const result = await assemble(ctx, { provider: 'deepseek-account', model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'ACCOUNT_SIGN_IN_REQUIRED' } }) + expect(fetch).not.toHaveBeenCalled() +}) + +it.each(['deepseek-account', 'deepseek-official'])('cancels only the active account route during tools: %s', async (provider) => { + const ctx = await harness() + const toolEntered = Promise.withResolvers() + const release = Promise.withResolvers() + const adapter = new ScriptedAdapter(async function* () { + yield { type: 'block-start', index: 0, blockType: 'tool-call' } + yield { type: 'block-end', index: 0, block: { type: 'tool-call', id: ToolCallId('fixture-call'), name: 'wait', arguments: '{}' } } + yield { type: 'finish', reason: { kind: 'tool-calls' } } + }) + ctx.on('llm/stream', request => adapter.stream(request)) + const agent = await ctx.agentLoop.create(SessionId(`tools-${provider}`), { provider, model: 'deepseek-v4-flash' }) + ctx.tools.register(defineContentToolFixture({ name: 'wait', description: '', parameters: {}, async execute() { + toolEntered.resolve(undefined) + await release.promise + return [{ type: 'text', text: 'done' }] + } })) + send(agent) + await toolEntered.promise + expect(agent.activeProvider).toBe(provider) + agent.inbox.splice('next-turn', Infinity, 0, [createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } })]) + ctx.emit('deepseek-account/signed-out') + // Changing settings cannot reclassify the already running tool call. + expect(agent.activeProvider).toBe(provider) + expect(agent.inbox.nextTurn).toHaveLength(1) + if (provider === 'deepseek-official') agent.cancel({ kind: 'user' }, { keepInbox: true }) + release.resolve(undefined) + await agent.whenIdle() + expect(agent.activeProvider).toBeUndefined() + expect(agent.inbox.nextTurn).toHaveLength(1) + expect(agent.session.snapshotEvents().at(-1)?.data).toMatchObject({ reason: { kind: 'aborted', reason: provider === 'deepseek-account' + ? { kind: 'hook', reason: 'deepseek-account/signed-out' } : { kind: 'user' } } }) +}) + +it.each(['tool', 'retry'])('replaces the active provider only after the next request binds: %s', async (transition) => { + const ctx = await harness() + const preparing = Promise.withResolvers() + const proceed = Promise.withResolvers() + const streaming = Promise.withResolvers() + let requests = 0 + ctx.on('agent/request', async (_event, next) => { + const config = await next() + if (++requests === 1) return config + preparing.resolve(undefined) + await proceed.promise + return { ...config, provider: 'deepseek-official' } + }) + ctx.on('agent/request-error', async (_event, next) => { await next(); return { kind: 'retry' } }) + ctx.on('llm/stream', async function* (request) { + if (request.provider === 'deepseek-account') { + if (transition === 'retry') { + yield { type: 'finish', reason: { kind: 'error', failure: { code: 'TRANSPORT', message: 'retry fixture' } } } + return + } + yield { type: 'block-start', index: 0, blockType: 'tool-call' } + yield { type: 'block-end', index: 0, block: { type: 'tool-call', id: ToolCallId('switch-call'), name: 'continue', arguments: '{}' } } + yield { type: 'finish', reason: { kind: 'tool-calls' } } + return + } + const signal = request.signal! + streaming.resolve(signal) + await new Promise((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) }) + signal.throwIfAborted() + }) + ctx.tools.register(defineContentToolFixture({ name: 'continue', description: '', parameters: {}, execute: async () => [{ type: 'text', text: 'done' }] })) + const agent = await ctx.agentLoop.create(SessionId('switch-provider'), { provider: 'deepseek-account', model: 'deepseek-v4-flash' }) + expect(agent.activeProvider).toBeUndefined() + send(agent) + await preparing.promise + expect(agent.activeProvider).toBe('deepseek-account') + proceed.resolve(undefined) + const signal = await streaming.promise + expect(agent.activeProvider).toBe('deepseek-official') + ctx.emit('deepseek-account/signed-out') + expect(signal.aborted).toBe(false) + agent.cancel({ kind: 'user' }) + await agent.whenIdle() + expect(agent.activeProvider).toBeUndefined() +}) + +it('does not infer a provider while the first request is still being prepared', async () => { + const ctx = await harness() + const preparing = Promise.withResolvers() + const proceed = Promise.withResolvers() + ctx.on('agent/request', async ({ signal }, next) => { + preparing.resolve(undefined) + await proceed.promise + expect(signal.aborted).toBe(false) + return next() + }) + const agent = await ctx.agentLoop.create(SessionId('first-preparation'), { provider: 'deepseek-account', model: 'deepseek-v4-flash' }) + send(agent) + await preparing.promise + expect(agent.activeProvider).toBeUndefined() + ctx.emit('deepseek-account/signed-out') + proceed.resolve(undefined) + await agent.whenIdle() + expect(agent.session.snapshotEvents().at(-1)?.data).toMatchObject({ reason: { kind: 'error', error: { code: 'ACCOUNT_SIGN_IN_REQUIRED' } } }) +}) + + +it.each(['chat-completions', 'messages'] as const)('never borrows an account token for a missing API key: %s', async (protocol) => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const ctx = new Context() + contexts.push(ctx) + const resolveToken = vi.fn(async () => 'fixture-account-token') + ctx.provide('deepseekAccount', { resolveToken } as unknown as DeepSeekAccount) + await ctx.plugin(LlmRuntime) + await ctx.plugin(DeepSeek, { protocol }) + const fetch = vi.spyOn(globalThis, 'fetch') + const result = await assemble(ctx, { provider: 'deepseek-official', model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) + expect(resolveToken).not.toHaveBeenCalled() + expect(fetch).not.toHaveBeenCalled() +}) + + +it('propagates account sign-out to the HTTP request signal', async () => { + const ctx = await harness() + ctx.provide('deepseekAccount', { resolveToken: async () => 'fixture-account-token' } as unknown as DeepSeekAccount) + const started = Promise.withResolvers() + vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => { + const signal = init!.signal! + expect(new Headers(init!.headers).get('x-dsh-auth-token')).toBe('fixture-account-token') + started.resolve(signal) + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { reject(new Error('request aborted', { cause: signal.reason })) }, { once: true }) + }) + }) + const agent = await ctx.agentLoop.create(SessionId('http-abort'), { provider: 'deepseek-account', model: 'deepseek-v4-flash' }) + send(agent) + const signal = await started.promise + ctx.emit('deepseek-account/signed-out') + await agent.whenIdle() + expect(signal.aborted).toBe(true) + expect(agent.session.snapshotEvents().at(-1)?.data).toMatchObject({ reason: { kind: 'aborted', reason: { kind: 'hook', reason: 'deepseek-account/signed-out' } } }) +}) + + +it('cancels an account child without stopping its API-key parent', async () => { + const ctx = await harness() + const parentStarted = Promise.withResolvers() + const childStarted = Promise.withResolvers() + ctx.on('llm/stream', async function* (request) { + const signal = request.signal! + if (request.provider === 'deepseek-account') childStarted.resolve(signal) + else parentStarted.resolve(signal) + yield { type: 'block-start', index: 0, blockType: 'text' } + await new Promise((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) }) + signal.throwIfAborted() + }) + const parent = await ctx.agentLoop.create(SessionId('parent'), { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + const childHandle = await ctx.agents.create({ sessionId: SessionId('child'), parentAgent: parent, + agentOptions: { provider: 'deepseek-account', model: 'deepseek-v4-flash' } }) + send(parent) + send(childHandle.agent) + const [parentSignal, childSignal] = await Promise.all([parentStarted.promise, childStarted.promise]) + ctx.emit('deepseek-account/signed-out') + await childHandle.agent.whenIdle() + expect(childSignal.aborted).toBe(true) + expect(parentSignal.aborted).toBe(false) + parent.cancel({ kind: 'user' }) + await parent.whenIdle() +}) diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 77b0beb741..2c95e5db00 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -1697,10 +1697,10 @@ describe('plugin registration and config', () => { protocol: 'chat-completions', baseURL: server.url, }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek (Account)' }]) expect(ctx.llm.listConfigurableProviders()).toEqual([{ provider: 'deepseek-official', - displayName: 'DeepSeek', + displayName: 'DeepSeek (API Key)', settingsNs: 'llm-deepseek', settingsPath: [], }]) @@ -1733,7 +1733,7 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: 'http://127.0.0.1:1' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek (Account)' }]) await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ { provider: 'deepseek-official', id: 'deepseek-flash', name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'] }, { @@ -2186,7 +2186,7 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek (Account)' }]) }) it('loads keyless, keeps the catalog browsable, and fails the request actionably', async () => { @@ -2196,7 +2196,7 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: 'http://127.0.0.1:1' }) // First-boot onboarding: the route registers so models stay discoverable; // only the request itself needs a key. - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek (Account)' }]) await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2) const first = await assemble(ctx, { model: 'deepseek-flash', messages: [] }) expect(first.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) @@ -2277,7 +2277,7 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmRuntime) // Registration succeeds; no call is made (would hit api.deepseek.com). await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek (Account)' }]) }) it('adapter is constructible directly for embedding over the shared resolver', async () => { @@ -2360,11 +2360,11 @@ describe('plugin registration and config', () => { it.each([ - ['chat-completions', 'https://api.deepseek.com', 'account-token'], - ['messages', 'https://api.deepseek.com', 'account-token'], - ['chat-completions', 'https://custom.example.test', 'ambient-key'], - ['messages', 'https://custom.example.test', 'ambient-key'], -] as const)('selects account or API-key credentials from the actual endpoint %s', async (protocol, baseURL, expected) => { + ['chat-completions', 'deepseek-account', 'account-token'], + ['messages', 'deepseek-account', 'account-token'], + ['chat-completions', 'deepseek-official', 'ambient-key'], + ['messages', 'deepseek-official', 'ambient-key'], +] as const)('keeps provider credentials independent for %s %s', async (protocol, provider, expected) => { vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key') const ctx = new Context() // This consumer uses only resolveToken; the real provider owns origin validation in its own suite. @@ -2372,7 +2372,7 @@ it.each([ resolveToken: (url: string) => Promise.resolve(url === 'https://api.deepseek.com' ? 'account-token' : undefined), } as DeepSeekAccount) await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { baseURL, protocol }) + await ctx.plugin(LlmDeepSeek, { baseURL: 'https://api.deepseek.com', protocol }) const request = vi.spyOn(globalThis, 'fetch').mockImplementation((_input, init) => { const headers = new Headers(init?.headers) expect(headers.get('authorization')).toBe(expected === 'account-token' || protocol === 'messages' ? null : `Bearer ${expected}`) @@ -2382,7 +2382,7 @@ it.each([ return Promise.resolve(new Response('fixture rejection', { status: 400 })) }) try { - await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + await assemble(ctx, { provider, model: 'deepseek-v4-flash', messages: [] }) expect(request).toHaveBeenCalledOnce() - } finally { request.mockRestore() } + } finally { request.mockRestore(); await ctx.fiber.dispose() } }) diff --git a/packages/llm/llm-deepseek/tsconfig.json b/packages/llm/llm-deepseek/tsconfig.json index b1bae1c4bc..e366466ab9 100644 --- a/packages/llm/llm-deepseek/tsconfig.json +++ b/packages/llm/llm-deepseek/tsconfig.json @@ -58,6 +58,9 @@ }, { "path": "../../credentials/deepseek-account" + }, + { + "path": "../../core/agent" } ] } diff --git a/packages/schedule/schedule/tests/runtime.spec.ts b/packages/schedule/schedule/tests/runtime.spec.ts index d86859ac50..22eba5d3be 100644 --- a/packages/schedule/schedule/tests/runtime.spec.ts +++ b/packages/schedule/schedule/tests/runtime.spec.ts @@ -63,6 +63,7 @@ async function harness(): Promise { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: new Context(), send(_message: UserMessage, _target: InboxTarget, _wakeup: boolean) {}, diff --git a/packages/schedule/schedule/tests/tools.spec.ts b/packages/schedule/schedule/tests/tools.spec.ts index a34281c1b5..c7775725ba 100644 --- a/packages/schedule/schedule/tests/tools.spec.ts +++ b/packages/schedule/schedule/tests/tools.spec.ts @@ -30,6 +30,7 @@ function stubAgent(ctx: Context, id: string): Agent { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: new Context(), send(_message: UserMessage, _target: InboxTarget, _wakeup: boolean) {}, diff --git a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts index ea3688fefb..a520878b48 100644 --- a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts @@ -49,6 +49,7 @@ async function agent(ctx: Context, cwd: string): Promise { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/shell/tool-bash-persistent/tests/tools.spec.ts b/packages/shell/tool-bash-persistent/tests/tools.spec.ts index a79d116556..5c114c1c81 100644 --- a/packages/shell/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/tools.spec.ts @@ -43,6 +43,7 @@ async function agent(ctx: Context, cwd: string | undefined): Promise { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts index eacd51d586..22f9951599 100644 --- a/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts @@ -56,6 +56,7 @@ async function agent(ctx: Context, cwd: string): Promise { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts index c260f818eb..b7cb58ccac 100644 --- a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts @@ -43,6 +43,7 @@ async function agent(ctx: Context, cwd: string | undefined): Promise { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 7d9fb0d2f0..15528c1074 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -73,6 +73,7 @@ function agentForCwd(cwd: string): Agent { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', send: () => {}, followup: () => {}, @@ -90,6 +91,7 @@ function sessionAgent(session: Session, id = 'tool-skill-agent'): Agent { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'running', ctx: new Context(), send: () => {}, diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index 16b7f9ffce..9904d11c8e 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -56,6 +56,7 @@ function agent(ctx: Context, cwd?: string): Agent { }) return { id, options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx, send: () => {}, @@ -658,6 +659,7 @@ describe('terminal-bash plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: ownerFiber.ctx, send: () => {}, @@ -708,6 +710,7 @@ describe('terminal-bash plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: ownerFiber.ctx, send: () => {}, diff --git a/packages/terminal/terminal-bash/tests/local.spec.ts b/packages/terminal/terminal-bash/tests/local.spec.ts index 7ef28c6929..31a870becd 100644 --- a/packages/terminal/terminal-bash/tests/local.spec.ts +++ b/packages/terminal/terminal-bash/tests/local.spec.ts @@ -41,6 +41,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { const session = Session.create(id) const agent: Agent = { id, options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/terminal/terminal/tests/service.spec.ts b/packages/terminal/terminal/tests/service.spec.ts index 11da341945..d43654b997 100644 --- a/packages/terminal/terminal/tests/service.spec.ts +++ b/packages/terminal/terminal/tests/service.spec.ts @@ -28,6 +28,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: scopeFiber.ctx, send: () => {}, diff --git a/packages/terminal/tool-terminal/tests/loader-composition.spec.ts b/packages/terminal/tool-terminal/tests/loader-composition.spec.ts index b983872934..8279699981 100644 --- a/packages/terminal/tool-terminal/tests/loader-composition.spec.ts +++ b/packages/terminal/tool-terminal/tests/loader-composition.spec.ts @@ -44,6 +44,7 @@ async function agent(ctx: Context): Promise { const session = Session.create(id) const value: Agent = { id, options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/terminal/tool-terminal/tests/tools.spec.ts b/packages/terminal/tool-terminal/tests/tools.spec.ts index 934b4c6d60..e106a8c50a 100644 --- a/packages/terminal/tool-terminal/tests/tools.spec.ts +++ b/packages/terminal/tool-terminal/tests/tools.spec.ts @@ -20,6 +20,7 @@ async function fakeAgent(ctx: Context, rawId: string): Promise { const session = Session.create(id) const agent: Agent = { id, options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/todo/tool-todo/tests/loader-composition.spec.ts b/packages/todo/tool-todo/tests/loader-composition.spec.ts index a1e76b01b3..5ec74013fe 100644 --- a/packages/todo/tool-todo/tests/loader-composition.spec.ts +++ b/packages/todo/tool-todo/tests/loader-composition.spec.ts @@ -35,6 +35,7 @@ async function agent(ctx: Context): Promise { const session = Session.create(id) const value: Agent = { id, options: {}, session, inbox: unsupportedInbox(), + activeProvider: undefined, status: 'idle', ctx: scope.ctx, followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, runMaintenance: task => task(new AbortController().signal), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81dfd23e8a..7778620938 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -948,6 +948,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent '@deepseek-ai/dsh-deepseek-account': specifier: workspace:^ version: link:../../credentials/deepseek-account diff --git a/python/sdk/tests/expected/account-provider-signout.json b/python/sdk/tests/expected/account-provider-signout.json new file mode 100644 index 0000000000..64969395aa --- /dev/null +++ b/python/sdk/tests/expected/account-provider-signout.json @@ -0,0 +1,11 @@ +{ + "final_response": "Before sign-out", + "finish_reason": "aborted", + "reason": { + "kind": "aborted", + "reason": { + "kind": "hook", + "reason": "deepseek-account/signed-out" + } + } +} diff --git a/python/sdk/tests/test_account_provider_snapshot.py b/python/sdk/tests/test_account_provider_snapshot.py new file mode 100644 index 0000000000..5d27eb5522 --- /dev/null +++ b/python/sdk/tests/test_account_provider_snapshot.py @@ -0,0 +1,35 @@ +"""Project the recorded account cancellation through the real Python SDK.""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from deepseek_harness import DeepSeekHarness + +ROOT = Path(__file__).resolve().parents[3] +CLI = ROOT / 'apps' / 'cli' / 'lib' / 'bin.js' + + +@pytest.mark.skipif(not CLI.exists(), reason='requires the built dsh profile runtime') +def test_account_provider_signout_snapshot(tmp_path: Path) -> None: + with DeepSeekHarness( + dsh_bin=str(CLI), + dsh_home=str(tmp_path / 'home'), + cwd=str(tmp_path), + profile='sdk', + patches=(str(ROOT / 'snapshots' / 'sdk' / 'account-provider-signout' / 'cordis.yml'),), + provider='deepseek-account', + model='deepseek-v4-flash', + env={'DSH_TELEMETRY_DISABLED': '1', 'DSH_SNAPSHOT': 'replay'}, + ) as harness: + result = harness.run('Reply with exactly: SDK snapshot') + ending = next(event for event in reversed(result.events) if event['type'] == 'turn/end') + actual = { + 'final_response': result.final_response, + 'finish_reason': result.finish_reason, + 'reason': ending['data']['reason'], + } + expected = Path(__file__).with_name('expected') / 'account-provider-signout.json' + assert actual == json.loads(expected.read_text()) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index e2adb7f7e0..838cf6d998 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -216,6 +216,7 @@ export const EVENT_SCOPE_PAGE: Record = { 'cordis': 'extensions.md', 'authorization': 'credentials.md', 'credentials': 'credentials.md', + 'deepseek-account': 'credentials.md', 'domain': 'storage.md', 'fs': 'filesystem.md', 'goal': 'goal.md', diff --git a/snapshots/sdk/account-provider-signout/account-signout.mjs b/snapshots/sdk/account-provider-signout/account-signout.mjs new file mode 100644 index 0000000000..c468f6022b --- /dev/null +++ b/snapshots/sdk/account-provider-signout/account-signout.mjs @@ -0,0 +1,11 @@ +/** Deterministic transport boundary; cancellation belongs to the shipped DeepSeek plugin. */ +export const inject = ['llm', 'agents'] +export function apply(ctx) { + ctx.on('llm/stream', async function* (request, next) { + if (request.provider !== 'deepseek-account') { yield* next(); return } + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: 'Before sign-out' } + ctx.emit('deepseek-account/signed-out') + request.signal.throwIfAborted() + }) +} diff --git a/snapshots/sdk/account-provider-signout/cordis.snapshot.yml b/snapshots/sdk/account-provider-signout/cordis.snapshot.yml new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/snapshots/sdk/account-provider-signout/cordis.snapshot.yml @@ -0,0 +1 @@ +[] diff --git a/snapshots/sdk/account-provider-signout/cordis.yml b/snapshots/sdk/account-provider-signout/cordis.yml new file mode 100644 index 0000000000..dc92a814c0 --- /dev/null +++ b/snapshots/sdk/account-provider-signout/cordis.yml @@ -0,0 +1,33 @@ +# TypeScript SDK snapshot-record patch over `dsh --profile sdk`. + +# The fixture corpus must not depend on the developer's personal skill roots. +# Ordinary product launches retain the sdk profile's default skill discovery. +- id: skill-filesystem + name: '@deepseek-ai/dsh-skill-filesystem' + config: + includeDefaultRoots: false + +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + thinking: enabled + reasoningEffort: max + +- id: session-persistence-jsonl + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js dshHomePath('sessions') + compression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + backgroundMode: one-shot + enableRunInBackground: false + maxDepth: 1 + +- insert: + - id: account-signout-fixture + name: './account-signout.mjs' diff --git a/snapshots/sdk/account-provider-signout/notifications.expected.jsonl b/snapshots/sdk/account-provider-signout/notifications.expected.jsonl new file mode 100644 index 0000000000..58848035b2 --- /dev/null +++ b/snapshots/sdk/account-provider-signout/notifications.expected.jsonl @@ -0,0 +1,15 @@ +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":3,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly: SDK snapshot OK"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}}} +{"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"running"}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":4,"time":0,"data":{"turn":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly: SDK snapshot OK"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-account","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"max"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":"{{tools}}"},"reason":"initial"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-account","model":"deepseek-v4-flash","contextWindow":1000000}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"Reply with exactly: SDK snapshot","messageSeqs":[8],"source":{"kind":"fallback"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"Before sign-out"}],"source":{"kind":"model","provider":"deepseek-account","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"interrupted":true,"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["Before sign-out"]}]},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":14,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":15,"time":0,"data":{"turn":1,"reason":{"kind":"aborted","reason":{"kind":"hook","reason":"deepseek-account/signed-out"}}}}}} +{"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"idle"}} diff --git a/snapshots/sdk/account-provider-signout/result.expected.json b/snapshots/sdk/account-provider-signout/result.expected.json new file mode 100644 index 0000000000..126859b4d3 --- /dev/null +++ b/snapshots/sdk/account-provider-signout/result.expected.json @@ -0,0 +1 @@ +{"sessionId":"{{sessionId}}","finalResponse":"Before sign-out"} diff --git a/snapshots/sdk/account-provider-signout/session.v3.jsonl b/snapshots/sdk/account-provider-signout/session.v3.jsonl new file mode 100644 index 0000000000..c4914849ec --- /dev/null +++ b/snapshots/sdk/account-provider-signout/session.v3.jsonl @@ -0,0 +1,17 @@ +{"type":"session","version":3,"id":"{{session:1}}","createdAt":1785097381464,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"permission/preset","data":{"preset":"workspace-write"}} +{"type":"sandbox/mode","data":{"mode":"workspace-write"}} +{"type":"approval/policy","data":{"policy":"ask"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly: SDK snapshot OK"}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly: SDK snapshot OK"}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-account","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"max"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-account","model":"deepseek-v4-flash","contextWindow":1000000}} +{"type":"session/title","data":{"title":"Reply with exactly: SDK snapshot","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"Before sign-out"}],"source":{"kind":"model","provider":"deepseek-account","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"interrupted":true,"stream":[{"type":"chunk","time":1789643933994,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1789643933994,"index":0,"dt":[],"texts":["Before sign-out"]}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"aborted","reason":{"kind":"hook","reason":"deepseek-account/signed-out"}}}} diff --git a/snapshots/sdk/account-provider-signout/snapshot.yml b/snapshots/sdk/account-provider-signout/snapshot.yml new file mode 100644 index 0000000000..b5300fde63 --- /dev/null +++ b/snapshots/sdk/account-provider-signout/snapshot.yml @@ -0,0 +1,10 @@ +version: 1 +scenario: account-provider-signout +profile: sdk +composition: sdk-account-provider-signout +recording: authored +header: + class: sdk-account-provider-signout + pin: true + systemPromptSource: text-turn + toolSchemasSource: text-turn diff --git a/snapshots/sdk/account-provider-signout/system-prompt.expected.md b/snapshots/sdk/account-provider-signout/system-prompt.expected.md new file mode 120000 index 0000000000..4ca0dd39b2 --- /dev/null +++ b/snapshots/sdk/account-provider-signout/system-prompt.expected.md @@ -0,0 +1 @@ +../text-turn/system-prompt.expected.md \ No newline at end of file diff --git a/snapshots/sdk/account-provider-signout/tool-schemas.expected.json b/snapshots/sdk/account-provider-signout/tool-schemas.expected.json new file mode 120000 index 0000000000..11d7ccbf24 --- /dev/null +++ b/snapshots/sdk/account-provider-signout/tool-schemas.expected.json @@ -0,0 +1 @@ +../text-turn/tool-schemas.expected.json \ No newline at end of file From cc478ac70a93806d28251b9450b234b79bd56bf0 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:59:31 +0800 Subject: [PATCH 005/192] feat(client,session-controller,llm): track available models and expose account settings --- apps/web/tests/default-model.e2e.ts | 15 +++- .../default-model/unselected.expected.md | 8 ++ docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 9 ++- docs/subsystems/core.zh.md | 9 ++- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 7 ++ docs/subsystems/session.zh.md | 7 ++ packages/api/remotes/src/remote-events.ts | 1 + .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 + packages/api/session-controller/README.zh.md | 2 + .../api/session-controller/src/catalog.ts | 14 +++- .../api/session-controller/src/commands.ts | 22 ++--- packages/api/session-controller/src/index.ts | 17 +++- packages/api/session-controller/src/types.ts | 2 +- .../tests/commands-upload-file.host.spec.ts | 1 + .../tests/session-cold.host.spec.ts | 1 + .../tests/session-models.host.spec.ts | 81 ++++++++++++------- .../session-controller/tests/test-remote.ts | 4 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../client/skeleton/ConversationContent.tsx | 4 +- .../ui-model-selection/README.i18n.yaml | 4 +- packages/client/ui-model-selection/README.md | 8 +- .../client/ui-model-selection/README.zh.md | 8 +- .../src/client/directory.ts | 29 ++----- .../ui-model-selection/src/client/locales.ts | 4 +- .../ui-model-selection/src/client/service.ts | 8 +- .../tests/browser-plugin.client.spec.ts | 40 ++++----- .../tests/expected/unselected-model.txt | 1 + .../tests/model-select.client.spec.tsx | 9 +++ .../ui-settings-account/README.i18n.yaml | 4 +- packages/client/ui-settings-account/README.md | 3 + .../client/ui-settings-account/README.zh.md | 3 + .../ui-settings-account/src/client/index.ts | 14 +++- .../tests/apply.client.spec.ts | 55 +++++++++++++ .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 2 + .../client/ui-settings-models/README.zh.md | 2 + .../src/client/CustomProviderCard.tsx | 2 +- .../src/client/ModelsSection.tsx | 6 +- .../src/client/ProviderEditor.tsx | 9 ++- .../ui-settings-models/src/client/index.ts | 3 +- .../ui-settings-models/src/client/locales.ts | 2 + .../src/client/operations.ts | 6 ++ .../ui-settings-models/src/client/store.ts | 15 +++- .../tests/apply.client.spec.ts | 3 +- .../tests/components.client.spec.tsx | 23 +++++- .../tests/expected/deepseek-account-en.txt | 1 + .../tests/expected/deepseek-account-zh.txt | 1 + .../tests/model-list-catalog.client.spec.tsx | 1 + .../tests/onboarding-dialog.client.spec.tsx | 2 +- .../tests/provider-form.client.spec.tsx | 4 +- .../tests/store.client.spec.ts | 18 ++++- .../core/agent-default-model/README.i18n.yaml | 4 +- packages/core/agent-default-model/README.md | 6 +- .../core/agent-default-model/README.zh.md | 6 +- .../core/agent-default-model/src/index.ts | 39 +++++++-- .../tests/agent-default-model.spec.ts | 23 ++++-- .../extensions/tool-cordis/src/api-catalog.ts | 14 +++- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 2 + packages/llm/llm-deepseek/README.zh.md | 2 + packages/llm/llm-deepseek/src/adapter.ts | 13 ++- packages/llm/llm-deepseek/src/index.ts | 1 + .../tests/account-routing.spec.ts | 17 ++++ .../llm/llm-deepseek/tests/adapter.spec.ts | 22 ++--- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 2 + packages/llm/llm-pi-ai/README.zh.md | 2 + packages/llm/llm-pi-ai/src/adapter.ts | 9 ++- packages/llm/llm-pi-ai/tests/catalog.spec.ts | 2 +- 77 files changed, 511 insertions(+), 189 deletions(-) create mode 100644 apps/web/tests/expected/default-model/unselected.expected.md create mode 100644 packages/client/ui-model-selection/tests/expected/unselected-model.txt create mode 100644 packages/client/ui-settings-account/tests/apply.client.spec.ts create mode 100644 packages/client/ui-settings-models/tests/expected/deepseek-account-en.txt create mode 100644 packages/client/ui-settings-models/tests/expected/deepseek-account-zh.txt diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index 304bdb97f6..f9fd8da7ea 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -17,7 +17,7 @@ import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { SessionId } from '@deepseek-ai/dsh-session' -import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts' +import { launchWebScaffold, watchConsole, captureStableAria, compareOrRefreshGolden, webSnapshotMode, type WebScaffold } from './scaffold.ts' import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts' /** Points the shipped shared Agent default at this scenario's own route. */ @@ -135,10 +135,17 @@ describe('web e2e: the composer model switch is the default for later sessions', // default still names the route, and nothing serves it any more. // `replace`, not `update`: a merge patch of `{providers: {}}` leaves every // stored profile in place. - await scaffold.ctx.settings.replace('llm-pi-ai', { providers: {} }) + await scaffold.ctx.settings.replace('llm-pi-ai', { providers: { + [START_ROUTE]: { displayName: 'Origin Gateway', api: 'openai-completions', + baseURL: 'https://gateway.origin.example/v1', models: [{ id: START_MODEL, name: 'Origin Large' }] }, + } }) await expect.poll(async () => box.isEnabled(), { timeout: 15_000 }).toBe(false) - expect(await box.getAttribute('data-placeholder')).toBe('当前模型不可用,请先选择模型') + const unset = page.getByRole('button', { name: '请选择模型', exact: true }) + await unset.waitFor() + expect(await page.getByRole('button', { name: '发送消息', exact: true }).isEnabled()).toBe(false) + const aria = await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd) + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/default-model/unselected.expected.md', import.meta.url)), aria, webSnapshotMode()) // The block is an affordance; the refusal is the Host's. A client that // never disabled anything still cannot start a turn on a dead route. @@ -151,7 +158,7 @@ describe('web e2e: the composer model switch is the default for later sessions', // The way out stays open. Locking the model seat with everything else // would leave the composer asking for the one thing it prevents. - const seat = page.getByRole('button', { name: /^选择模型/ }) + const seat = page.getByRole('button', { name: '请选择模型', exact: true }) expect(await seat.isEnabled()).toBe(true) await seat.click() await page.getByRole('menuitem', { name: /模型/ }).click() diff --git a/apps/web/tests/expected/default-model/unselected.expected.md b/apps/web/tests/expected/default-model/unselected.expected.md new file mode 100644 index 0000000000..c4fb007e9c --- /dev/null +++ b/apps/web/tests/expected/default-model/unselected.expected.md @@ -0,0 +1,8 @@ +- textbox "描述你想要构建的内容, / 调用指令, @ 文件或对话" [disabled] +- button "添加文件或调用指令" [disabled]: + - img +- button "访问模式,当前:工作区内修改" [disabled]: 工作区内修改 +- button "请选择模型": + - text: 请选择模型 + - img +- button "发送消息" [disabled] diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 72ffc23a6d..e174fcd03e 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 3d5ad12ef85b6b3c4fedde87cea8963f4a20d93b -event-producer-consumer.zh.md: c212611c25f60283f91ef58530121d84092be936 +event-producer-consumer.md: c5535c610ed88f265efe5065e2ff3374030192d4 +event-producer-consumer.zh.md: b2e558f5e711a3cf3628f1b9da802c1d69693090 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 3d5ad12ef8..c5535c610e 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -37,7 +37,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `cordis/inspect-query-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:398`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/request-run` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:368`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/request-run-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:374`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | -| `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:102`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization), [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | +| `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:102`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization), [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform), `remotes` | | `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:90`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` | | `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:11`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | [`llm-deepseek`](../packages/llm/llm-deepseek) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace), `workspace-controller` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index c212611c25..b2e558f5e7 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -39,7 +39,7 @@ | `cordis/inspect-query-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:398`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/request-run` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:368`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/request-run-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:374`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | -| `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:102`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization), [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | +| `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:102`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization), [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform), `remotes` | | `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:90`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` | | `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:11`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | [`llm-deepseek`](../packages/llm/llm-deepseek) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace), `workspace-controller` | diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 23bd8abfb0..d7679f8cfd 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/core.md -core.md: ad5bb66073bc7c3381739935eff593d49343b851 -core.zh.md: d0ec1aed5f3d504e4cd5e781c3a6927f311cbc7b +core.md: 5b51a81ff910072e67ffb796cbc9276a00827dcc +core.zh.md: 8c2fd014203500990b29ea374c45fad2f4ef2b42 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index ad5bb66073..5b51a81ff9 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -434,11 +434,18 @@ currentSelection(): ModelSelection /** * Save the complete default model selection. A deployment without a settings - * provider keeps its composition entry. + * provider retains the selection for this process. * @param next - resolved selection accepted by an entry point. * @returns fulfillment after the optional settings write settles. */ async saveSelection(next: ModelSelection): Promise + +/** + * Store the initial credential setup choice only before a user selection exists. + * @param next - available model belonging to the provider the user initialized. + * @returns after the initial selection is saved, or immediately if already selected. + */ +async initializeSelection(next: ModelSelection): Promise ``` Source: [`packages/core/agent-default-model/src/index.ts`](../../packages/core/agent-default-model/src/index.ts) diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index d0ec1aed5f..8c2fd01420 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -444,11 +444,18 @@ currentSelection(): ModelSelection /** * Save the complete default model selection. A deployment without a settings - * provider keeps its composition entry. + * provider retains the selection for this process. * @param next - resolved selection accepted by an entry point. * @returns fulfillment after the optional settings write settles. */ async saveSelection(next: ModelSelection): Promise + +/** + * Store the initial credential setup choice only before a user selection exists. + * @param next - available model belonging to the provider the user initialized. + * @returns after the initial selection is saved, or immediately if already selected. + */ +async initializeSelection(next: ModelSelection): Promise ``` Source: [`packages/core/agent-default-model/src/index.ts`](../../packages/core/agent-default-model/src/index.ts) diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 83eecfd1c7..84327833d3 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: 62f7785d4d1db9c30706a8b967e6f4073b810149 -session.zh.md: 7cd3c38162df209d245d3f1085ab1edce002531a +session.md: 202f6adf6e89e1293feb5181c1397b4725720ced +session.zh.md: feb2cc0845b426bad0046d0a8a7a4737b210a8f4 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 62f7785d4d..202f6adf6e 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -818,6 +818,13 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise +/** + * Initialize the default after the user first configures a provider credential. + * @param provider - the provider whose credential was configured. + * @returns after saving the initial available model; existing user choices are retained. + */ +@Remote async initializeDefaultModel(provider: string): Promise + /** * Describe every currently routable model for Host-generation selectors. * @returns provider-grouped models, the deployment default, and isolated provider failures. diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 7cd3c38162..feb2cc0845 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -822,6 +822,13 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise +/** + * Initialize the default after the user first configures a provider credential. + * @param provider - the provider whose credential was configured. + * @returns after saving the initial available model; existing user choices are retained. + */ +@Remote async initializeDefaultModel(provider: string): Promise + /** * Describe every currently routable model for Host-generation selectors. * @returns provider-grouped models, the deployment default, and isolated provider failures. diff --git a/packages/api/remotes/src/remote-events.ts b/packages/api/remotes/src/remote-events.ts index 115cbff692..b9cecc5b13 100644 --- a/packages/api/remotes/src/remote-events.ts +++ b/packages/api/remotes/src/remote-events.ts @@ -24,6 +24,7 @@ export const API_REMOTE_FORWARDED_EVENTS = [ { event: 'api-session/removed', mode: 'emit' }, { event: 'api-session/status', mode: 'emit' }, { event: 'commands/change', mode: 'emit' }, + { event: 'credentials/record-updated', mode: 'emit' }, { event: 'credentials/reference-updated', mode: 'emit' }, { event: 'goal/activation-changed', mode: 'emit' }, { event: 'cordis/request-run', mode: 'emit' }, diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index d04576c269..b178f956cf 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/session-controller/README.md -README.md: a0e18910d14f879171a1415476a7e05a0d261a8a -README.zh.md: 58466e387fd604137a2358e8b9a7de3320691034 +README.md: 5f09de61395c3d729144203b77eeb299c217128e +README.zh.md: dfb5b97b5804b4512d17eab47866ee079fbc047d diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index a0e18910d1..5f09de6139 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -68,6 +68,8 @@ References keep local Session data, scoped Contexts, and history streams alive, `SessionMediaReferences` mounts `GET|HEAD /api/file?path=` on the authenticated `connection.fetch` channel when `connection`, `fs`, and `attachments` are composed. It reads ordinary files through `ctx.fs`, including temporary paths outside registered workspaces and files in remote providers. Neither directory containment nor MIME categories restrict access; `mime-types` supplies the response type, with `application/octet-stream` for unknown extensions. GET reuses `readBytes` for preflight and ongoing byte limits; HEAD reads metadata only. All files use `ctx.attachments.imageLimits.maxImageBytes` (normally 20 MiB); exceeding this limit returns 413. Responses contain the complete file, ignore Range, and carry `private, no-store`, `nosniff`, and a sandbox CSP so directly opened HTML/SVG cannot execute with the API origin. The Client rewrite lives in `ui-chat` (`AssistantMarkdown`); audio/video responses are available, while Markdown audio/video player nodes remain separate work. +GUI model selection and prompt admission require the exact provider/model pair in the current available catalog. An unavailable selection rejects with `session/model-unavailable` before delivery; admission checks again after attachment preparation. `initializeDefaultModel(provider)` saves the first available model of the explicitly configured provider only before any user choice exists. Availability never substitutes another model or rewrites a Session selection. + ----- diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 58466e387f..dfb5b97b58 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -68,6 +68,8 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在 当 `connection`、`fs` 与 `attachments` 均被组合时,`SessionMediaReferences` 在鉴权 `connection.fetch` 通道上挂载 `GET|HEAD /api/file?path=<绝对路径>`。它通过 `ctx.fs` 读取普通文件,包括已注册工作区之外的临时路径与远程提供方中的文件。目录包含关系与 MIME 类别均不限制访问;`mime-types` 提供响应类型,未知扩展名使用 `application/octet-stream`。GET 复用 `readBytes` 执行读取前及读取中的字节限制;HEAD 只读取元数据。所有文件均使用 `ctx.attachments.imageLimits.maxImageBytes`(通常为 20 MiB);超过此上限返回 413。响应包含完整文件,忽略 Range,并携带 `private, no-store`、`nosniff` 与沙箱 CSP,使直接打开的 HTML/SVG 无法以 API 源身份执行脚本。客户端重写位于 `ui-chat`(`AssistantMarkdown`);音视频文件响应已可用,Markdown 音视频播放器节点仍是独立工作。 +GUI 模型选择与提示词准入要求确切提供方/模型对出现在当前可用目录中。不可用的选择在投递前以 `session/model-unavailable` 拒绝;附件准备完成后再次检查。`initializeDefaultModel(provider)` 仅在用户尚未选择时,保存其显式配置的提供方的第一个可用模型。可用性变化不会替换模型或改写会话选择。 + ----- diff --git a/packages/api/session-controller/src/catalog.ts b/packages/api/session-controller/src/catalog.ts index 0c97107f03..df751d86ba 100644 --- a/packages/api/session-controller/src/catalog.ts +++ b/packages/api/session-controller/src/catalog.ts @@ -59,9 +59,21 @@ export async function buildModelCatalog( })) return { default: { ...defaultSelection }, - routableProviders: providers.map(provider => provider.id), + routableProviders: catalog.flatMap(item => item.kind === 'group' && item.group.models.length > 0 ? [item.group.id] : []), groups: catalog.flatMap(item => item.kind === 'group' ? [item.group] : []) .filter(group => group.models.length > 0), failures: catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : []), } } + +/** + * Check a GUI selection against the current available provider catalog. + * @param ctx - Host LLM registry. + * @param selection - stored or explicitly requested selection. + * @returns whether the exact model is currently advertised as available. + */ +export async function modelAvailable(ctx: Context, selection: ModelSelection): Promise { + if (!ctx.llm.listProviders().some(provider => provider.id === selection.provider)) return false + const models = await ctx.llm.listModels(selection.provider) + return models.some(model => model.id === selection.model) +} diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 03c697734b..e73659d4cb 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -1,5 +1,6 @@ /** Session commands whose activation policy is explicit at each Remote method. */ +import { modelAvailable } from './catalog.ts' import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import { brandString } from '@deepseek-ai/dsh-brand' @@ -151,6 +152,7 @@ export class SessionCommandController { const agent = await this.resolveAgent(request.sessionId) return this.agents.serializeImageAdmission(agent, async () => { try { + await this.requireModel(request) const resolved = await this.ctx.llm.resolveCallConfig({ provider: request.provider, model: request.model, @@ -329,13 +331,7 @@ export class SessionCommandController { const agent = await this.resolveAgent(request.sessionId) if (hasPromptRequest(agent, request.requestId)) return { accepted: true } const selection = this.agents.selectionFor(agent).current - if (!routeServed(this.ctx, selection.provider)) { - throw new RemoteError( - 'session/model-unavailable', - `no adapter serves provider "${selection.provider}"; select a model for this session`, - { provider: selection.provider, model: selection.model }, - ) - } + await this.requireModel(selection) const source: MessageSource = { kind: 'user', rpcId: request.requestId, @@ -361,6 +357,7 @@ export class SessionCommandController { ) const content = await this.ctx.attachments.admitPromptContent(admission.content) const message: UserMessage = createUserMessage({ content, source }) + await this.requireModel(this.agents.selectionFor(agent).current) if (this.ctx.agents.get(agent.id) !== agent) { throw new RemoteError( 'session/not-found', @@ -384,6 +381,13 @@ export class SessionCommandController { return hasImage ? this.agents.serializeImageAdmission(agent, admit) : admit() } + private async requireModel(selection: Pick): Promise { + if (!await modelAvailable(this.ctx, selection)) { + throw new RemoteError('session/model-unavailable', 'Select an available model before sending a message.', + { provider: selection.provider, model: selection.model }) + } + } + /** * Read one durable image after proving the Session log references it. * @param request - Session and attachment identities used for authorization. @@ -690,7 +694,3 @@ function referencedImage( } return undefined } - -function routeServed(ctx: Context, provider: string): boolean { - return ctx.llm.listProviders().some(entry => entry.id === provider) -} diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index b6f5f0dfa0..6f51c15da4 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -6,7 +6,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-fs' import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { errorChain } from '@deepseek-ai/dsh-llm' +import { errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-client-file-upload' import { canOpenNativePath, nativeFileManager, openNativeAssociatedPath, revealNativePath } from '@deepseek-ai/dsh-native-command' import type { SessionId } from '@deepseek-ai/dsh-session' @@ -270,6 +270,21 @@ export class SessionController extends TypertRemoteService { return this.commands.selectModel(request) } + /** + * Initialize the default after the user first configures a provider credential. + * @param provider - the provider whose credential was configured. + * @returns after saving the initial available model; existing user choices are retained. + */ + @Remote + async initializeDefaultModel(provider: string): Promise { + const catalog = await buildModelCatalog(this.ctx) + const model = catalog.groups.find(group => group.id === provider)?.models[0] + if (model === undefined) throw new Error(`provider "${provider}" has no available models`) + await this.ctx.agentDefaultModel.initializeSelection({ provider, model: model.id, + ...model.reasoning?.defaultEffort === undefined ? {} : { reasoningEffort: ReasoningEffortId(model.reasoning.defaultEffort) }, + }) + } + /** * Describe every currently routable model for Host-generation selectors. * @returns provider-grouped models, the deployment default, and isolated provider failures. diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index a925e9da83..c5b92ec0cc 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -143,7 +143,7 @@ export interface ModelCatalogFailure { /** Host-generation model catalog and the default used by unconfigured Sessions. */ export interface ModelCatalog { readonly default: ModelSelection - /** Provider routes currently able to serve a request, including empty catalogs. */ + /** Provider routes with at least one currently available catalog model. */ readonly routableProviders: readonly string[] readonly groups: readonly ModelProviderGroup[] readonly failures: readonly ModelCatalogFailure[] diff --git a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts index 4ff61a9b9a..14527cccec 100644 --- a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts +++ b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts @@ -83,6 +83,7 @@ async function uploadHarness(origin?: 'subagent'): Promise<{ }, } as never) ctx.provide('llm', { + listModels: async () => [{ id: 'fixture-model', name: 'Model' }], listProviders: () => [{ id: 'fixture', name: 'Fixture' }], resolveModelInfo: () => Promise.resolve({ provider: 'fixture', id: 'fixture-model', name: 'Fixture' }), } as never) diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index c8be6f2252..8995fa87a1 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -816,6 +816,7 @@ describe('sessions.prompt synchronous rejection', () => { AttachmentStore.prototype, ) as never) ctx.provide('llm', { + listModels: async () => [{ id: 'm', name: 'Model' }], listProviders: () => [{ id: 'p', name: 'Provider' }], resolveModelInfo: () => Promise.resolve({ provider: 'p', id: 'm', name: 'Model', inputModalities: ['text', 'image'], diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts index 51133aafde..26d71d839e 100644 --- a/packages/api/session-controller/tests/session-models.host.spec.ts +++ b/packages/api/session-controller/tests/session-models.host.spec.ts @@ -1,7 +1,7 @@ /** * Session Controller model-directory and selection behavior: dynamic provider grouping, * provider-local catalog failures, logged-selection restoration without stale - * catalog injection, advisory pass-through models, and the prompt-assembly + * catalog injection, unavailable-model refusals, and the prompt-assembly * boundary for a running selection change. */ @@ -23,7 +23,8 @@ import { ApiSessionAgentController } from '../src/agent.ts' import { buildModelCatalog } from '../src/catalog.ts' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' -import { createSessionTestRemote } from './test-remote.ts' +import AgentDefaultModel from '@deepseek-ai/dsh-agent-default-model' +import { createSessionTestController, createSessionTestRemote } from './test-remote.ts' function request

(payload: P): P { return payload @@ -146,7 +147,7 @@ function registerTextOnly(ctx: Context): void { override resolveModel(provider: string, model: string): Promise { return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] }) } - }('Text Only', [])) + }('Text Only', [{ provider: 'text-only', id: 'plain', name: 'Plain' }])) } /** Resolve the Client-visible next selection from durable state and the Host default. */ @@ -266,7 +267,6 @@ describe('Web session model selection', () => { ], })) expect(result.ok).toBe(true) - expect(followup).not.toHaveBeenCalled() expect((steer.mock.calls[0]?.[0] as UserMessage).content).toEqual([ { type: 'text', text: 'look at this' }, { @@ -347,6 +347,23 @@ describe('Web session model selection', () => { expect(readImage).toHaveBeenCalledOnce() await ctx.fiber.dispose() }) + it('initializes only the first explicitly configured provider and retains later user choices', async () => { + const { ctx } = await harness() + await ctx.plugin(AgentDefaultModel, { provider: 'missing', model: 'missing' }) + const controller = createSessionTestController(ctx, { + defaultModelSelection: () => ctx.agentDefaultModel.currentSelection(), cwd: '/tmp', + }) + await controller.initializeDefaultModel('deepseek-official') + expect(ctx.agentDefaultModel.currentSelection()).toEqual({ + provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high', + }) + await ctx.agentDefaultModel.saveSelection({ provider: 'removed', model: 'removed' }) + await controller.initializeDefaultModel('deepseek-official') + expect(ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'removed', model: 'removed' }) + await expect(controller.initializeDefaultModel('empty')).rejects.toThrow('no available models') + await ctx.fiber.dispose() + }) + it('groups successful providers and leaves an unlisted current selection out of the catalog', async () => { const { ctx, sessionId } = await harness({ provider: 'deepseek-official', @@ -428,7 +445,7 @@ describe('Web session model selection', () => { await ctx.fiber.dispose() }) - it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => { + it('rejects unlisted models and switches available models only after the next assembly', async () => { const { ctx, agent, sessionId } = await harness() const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' }) const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 } @@ -440,12 +457,12 @@ describe('Web session model selection', () => { const selected = expectValue(await remote.selectModel(request({ sessionId, provider: 'deepseek-official', - model: 'private-preview', + model: 'deepseek-reasoner', reasoningEffort: 'max', }))) expect(selected.selected).toEqual({ provider: 'deepseek-official', - model: 'private-preview', + model: 'deepseek-reasoner', reasoningEffort: 'max', }) await expect(agentEvents(ctx, agent).waterfall( @@ -453,26 +470,26 @@ describe('Web session model selection', () => { )).resolves.toEqual(seed) expect((await ctx.systemPrompt.assemble()).variables) - .toMatchObject({ provider: 'deepseek-official', model: 'private-preview' }) + .toMatchObject({ provider: 'deepseek-official', model: 'deepseek-reasoner' }) await expect(agentEvents(ctx, agent).waterfall( 'agent/request', { turn: 1, step: 1, signal }, () => Promise.resolve(seed), )).resolves.toMatchObject({ provider: 'deepseek-official', - model: 'private-preview', + model: 'deepseek-reasoner', reasoningEffort: 'max', }) const unsupported = await remote.selectModel(request({ sessionId, provider: 'deepseek-official', - model: 'private-preview', + model: 'deepseek-reasoner', reasoningEffort: 'medium', })) expect(unsupported).toMatchObject({ ok: false, error: { code: 'session/model-unavailable', - message: 'provider "deepseek-official" model "private-preview" does not support reasoning effort "medium"', + message: 'provider "deepseek-official" model "deepseek-reasoner" does not support reasoning effort "medium"', }, }) @@ -485,7 +502,7 @@ describe('Web session model selection', () => { ok: false, error: { code: 'session/model-unavailable', - message: 'no adapter registered for provider "missing"', + message: 'Select an available model before sending a message.', details: { provider: 'missing', model: 'model' }, }, }) @@ -496,13 +513,13 @@ describe('Web session model selection', () => { }))).toMatchObject({ ok: false, error: { - code: 'gateway/internal', - message: 'fixture rejected the selection', - details: {}, + code: 'session/model-unavailable', + message: 'Select an available model before sending a message.', + details: { provider: 'remote-rejected', model: 'model' }, }, }) expect(currentSelection(ctx, sessionId)) - .toEqual({ provider: 'deepseek-official', model: 'private-preview', reasoningEffort: 'max' }) + .toEqual({ provider: 'deepseek-official', model: 'deepseek-reasoner', reasoningEffort: 'max' }) await ctx.fiber.dispose() }) @@ -614,15 +631,23 @@ describe('Web session model selection', () => { const unavailableCatalog = await buildModelCatalog(ctx) expect(unavailableCatalog.routableProviders.includes(currentSelection(ctx, sessionId).provider)).toBe(false) - // An advisory-unlisted model on a live route is NOT this: the route - // serves it, so the prompt goes through and nothing blocks. - expectValue(await remote.selectModel(request({ + expect(await remote.selectModel(request({ sessionId, provider: 'deepseek-official', model: 'unlisted-but-served', - }))) - const catalog = await buildModelCatalog(ctx) - expect(catalog.routableProviders.includes(currentSelection(ctx, sessionId).provider)).toBe(true) - expect(catalog.groups.flatMap(group => group.models.map(model => model.id))) - .not.toContain('unlisted-but-served') + }))).toMatchObject({ ok: false, error: { code: 'session/model-unavailable' } }) + expect(currentSelection(ctx, sessionId)).toEqual({ provider: 'deleted-gateway', model: 'deleted-model' }) + await ctx.fiber.dispose() + }) + + it('refuses a removed model while its provider still serves other models without changing the log', async () => { + const { ctx, sessionId, agent } = await harness({ provider: 'deepseek-official', model: 'removed-model' }) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', + }) + const events = [...agent.session.snapshotEvents()] + expect(await remote.prompt(promptRequest({ sessionId, mode: 'queue', content: [{ type: 'text', text: 'hi' }] }))) + .toMatchObject({ ok: false, error: { code: 'session/model-unavailable' } }) + expect(agent.session.snapshotEvents()).toEqual(events) + expect(currentSelection(ctx, sessionId)).toMatchObject({ provider: 'deepseek-official', model: 'removed-model' }) await ctx.fiber.dispose() }) @@ -654,13 +679,13 @@ describe('Web session model selection', () => { provider, id: model, name: model, inputModalities: ['text', 'image'], }) } - }('Image Capable', [])) + }('Image Capable', [{ provider: 'image-capable', id: 'vision', name: 'Vision' }])) ctx.llm.registerAdapter(['string-error'], new class extends CatalogAdapter { override resolveModel(): Promise { // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- non-Error provider normalization is the scenario. return Promise.reject('string selection failure') } - }('String Error', [])) + }('String Error', [{ provider: 'string-error', id: 'plain', name: 'Plain' }])) let saveMode: 'success' | 'error' | 'remote' = 'success' const savedRef = { attachmentId: 'saved-image', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1, @@ -725,12 +750,12 @@ describe('Web session model selection', () => { sessionId, provider: 'image-capable', model: 'vision', }))) expect(await remote.selectModel(request({ - sessionId, provider: 'metadata-broken', model: 'broken', + sessionId, provider: 'metadata-broken', model: 'listed', }))).toMatchObject({ ok: false, error: { code: 'session/model-unavailable', message: 'reasoning metadata offline' }, }) expect(await remote.selectModel(request({ - sessionId, provider: 'string-error', model: 'broken', + sessionId, provider: 'string-error', model: 'plain', }))).toMatchObject({ ok: false, error: { code: 'session/model-unavailable', message: 'string selection failure' }, diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index b4d3db8198..68e5f9f779 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -248,6 +248,10 @@ function installControllers( } if (ctx.get('llm') === undefined) { ctx.provide('llm', { + listModels: async () => { + const selection = defaults.defaultModelSelection() + return [{ id: selection.model, name: selection.model }] + }, listProviders: () => { const selection = defaults.defaultModelSelection() return [{ id: selection.provider, name: selection.provider }] diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 74cd3168df..f6eaa6d8b4 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: fef3a6551b517af2ac636687883864525aac70ee -README.zh.md: e9ab08c686420c38c11ca2bede2fd00099a247a5 +README.md: 7be555c8866a7d69631888be5f6383f8ec0a772a +README.zh.md: 74a4d083af5f2bb0ca8ea9a12cd3679c4860aaf1 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index fef3a6551b..7be555c886 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -60,7 +60,7 @@ The shell reads the persisted View preference before rendering when a Session fi Active transcripts in the main occurrence expose content-width drag handles in their uncovered side gutters; embedded occurrences omit them. A View that paints into a gutter raises only its concrete painted element above the handle; transparent full-width wrappers stay below so they do not claim empty gutter. This requires the path between that element and the Conversation body to remain outside an intermediate stacking context; the shipped Chromium behavior is pinned by the browser scenario. Chat applies the rule to table elements, while its column-bounded tool cards need no raise. Wheel motion over a handle still scrolls the transcript, while Ctrl+wheel remains a browser zoom gesture. The sticky composer intentionally owns its full footer band, which is not a resize target; an already-captured drag lifts its indicator until release ([handle styles](src/client/skeleton/ConversationRoot.module.css)). -The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start. +The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start. Model availability blocks retain the normal input placeholder while leaving the model picker enabled. Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; queue and history observation by that `rpcId` retires the echo once. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index e9ab08c686..74a4d083af 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -60,7 +60,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染 主 occurrence 的活跃 transcript 只在未被内容覆盖的两侧沟槽中提供正文宽度拖拽条;嵌入式 occurrence 省略这些拖拽条。View 如果绘制进沟槽,只将具体的可见元素提到拖拽条上方;透明的全宽包装层保持在下方,不会占用空白沟槽。该规则要求此元素与 Conversation body 之间不能引入中间堆叠上下文;浏览器场景固定了交付 Chromium 的行为。Chat 将该规则用于表格元素,其限定在阅读列内的工具卡片无需提高层级。指针位于拖拽条上时,滚轮仍会滚动 transcript,Ctrl+滚轮则保留为浏览器缩放手势。粘滞 composer 刻意拥有完整的底部区带,该区域不是宽度调整目标;已捕获的拖拽会将指示线提高到松开为止([拖动手柄样式](src/client/skeleton/ConversationRoot.module.css))。 -常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。 +常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。 模型不可用导致的阻塞保留常规输入占位文案,模型选择器仍可操作。 默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;queue 或历史以同一 `rpcId` 被观察后,回显只退休一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。 diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx index adace1c113..42bc9724df 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx @@ -133,7 +133,7 @@ export function ConversationContent(props: ConversationContentProps) { // bar is ONE session-maybe slot rendered unconditionally — inert is a prop, // not a different tree, so the textarea DOM survives the transition. const inert = sessionId === undefined || (hero && chipTitle === undefined) - // A raised block is the same inert posture with the blocker's own reason: + // A raised block keeps the same textarea mounted: // one disabled textarea, never a second tree. The no-workspace state wins // when both hold — picking a workspace is the earlier prerequisite. const blocked = !inert && composerBlock !== undefined @@ -150,7 +150,7 @@ export function ConversationContent(props: ConversationContentProps) { // `blocked`, not `disabled`: the bar refuses input either way, but a // block keeps the model seat live because choosing a model is how the // user clears it. - ? { blocked: composerBlock, placeholder: composerBlock.reason } + ? { blocked: composerBlock, ...(hero ? { placeholder: t('placeholder.hero') } : {}) } : hero ? { placeholder: t('placeholder.hero') } : {}), }) diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index 4ca0dee8fc..9ba09cab47 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-model-selection/README.md -README.md: 96bf249b8976ad56b5abb70975635fa452c700f8 -README.zh.md: 71eb0de85bd87e5b2b92bf33d19f925e649f92a7 +README.md: 7afc8f158509e18e90236d6302ab51125fa7a891 +README.zh.md: c14ad7ab05af24c35727d42e6d843cbdaf20f332 diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md index 96bf249b89..7afc8f1585 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The Web GUI lets users switch the model and reasoning effort for an existing session through either the `/model` popup or the composer's model control. Both surfaces present the same provider-grouped choices, and the selected model determines the available effort names and default. A complete selection applies to the next request; a running step keeps the model and effort it started with. If no adapter can serve the session's route, the composer remains disabled until routing becomes available. +The Web GUI lets users switch the model and reasoning effort for an existing session through either the `/model` popup or the composer's model control. Both surfaces present the same provider-grouped choices, and the selected model determines the available effort names and default. A complete selection applies to the next request; a running step keeps the model and effort it started with. If the selected model is unavailable, the composer stays disabled until the user selects an available model or that exact model becomes available again. ## Table of Contents @@ -27,7 +27,7 @@ The Web GUI lets users switch the model and reasoning effort for an existing ses DeepSeek account and API-key routes appear as separate provider groups, each exposing the same configured model catalog. -Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus across the rows of the shown pane, Tab settles the focused row, and Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling lands on the row of the value in use, and going back lands on the cell that opened the pane left. Both surfaces show the host-reported current selection when the exact provider/model pair remains in the advertised groups; a missing catalog row leaves the routable selection intact while the trigger prompts `Select model`. +Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus across the rows of the shown pane, Tab settles the focused row, and Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling lands on the row of the value in use, and going back lands on the cell that opened the pane left. Both surfaces show the host-reported current selection when the exact provider/model pair remains in the advertised groups; a missing catalog row clears the effective selection and the trigger prompts `Select model`, without changing the stored choice. ### Model and effort @@ -35,7 +35,7 @@ Models stay grouped by provider. The composer menu shows model and effort names ### Unroutable sessions -When the Host reports that no adapter serves the session's route, this plugin raises a composer block and the input goes inert with its own copy; recovering clears it without a reload. A `null` before the first load or after one failed never blocks, and catalog membership never blocks either — a route serving a model it does not advertise is missing from the groups yet usable. +The composer blocks sending while the exact provider/model pair is absent from the available catalog or availability is loading or unknown. It keeps the normal input placeholder and an enabled model picker. Sign-out, credential removal, and provider or model removal refresh availability without replacing the saved choice with another model. Existing session logs remain unchanged. Only the current Client binding's directory can publish its composer block. Cleanup from an older binding preserves a replacement directory's block; without a replacement directory, cleanup removes the obsolete block. @@ -51,7 +51,7 @@ When another writer owns the Session, model-selection failures tell the user to

Implementation internals — click to expand -Two entries over ONE per-session directory owned by `ModelDirectoryResolver` (`ctx.modelDirectories`): the `/model` popupSelect contribution (registered through `ctx.commandUi`) and the composer's named `conversation.input.model` seat both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance, so a switch made in either entry is what the other shows next. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored selection before display. Directories are per-session, resolved lazily, and disposed with the session scope; addressed subagent sessions expose neither entry. Every resident directory refetches directly on forwarded `llm/adapters-updated` and `settings/document-updated` owner events. +Two entries over ONE per-session directory owned by `ModelDirectoryResolver` (`ctx.modelDirectories`): the `/model` popupSelect contribution (registered through `ctx.commandUi`) and the composer's named `conversation.input.model` seat both load the session's available directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance, so a switch made in either entry is what the other shows next. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored selection before display. Directories are per-session, resolved lazily, and disposed with the session scope; addressed subagent sessions expose neither entry. Every resident directory refetches directly on forwarded `llm/adapters-updated`, `settings/document-updated`, and credential update events.
diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index 71eb0de85b..c14ad7ab05 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有会话使用的模型与推理(reasoning)强度。两个界面呈现同一组按提供方分组的选择;所选模型决定可用的推理强度名称与默认值。完整选择从下一次请求开始生效;运行中的步骤保留其启动时的模型与推理强度。如果没有适配器可以服务会话路由,composer 会保持停用,直至路由恢复可用。 +Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有会话使用的模型与推理(reasoning)强度。两个界面呈现同一组按提供方分组的选择;所选模型决定可用的推理强度名称与默认值。完整选择从下一次请求开始生效;运行中的步骤保留其启动时的模型与推理强度。所选模型不可用时,composer 保持停用,直到用户选择可用模型或同一模型恢复可用。 ## 目录 @@ -27,7 +27,7 @@ Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有 DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示相同的已配置模型目录。 -与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在所显示面板的行间移动焦点,`Tab` 选定聚焦行,Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻落在正在使用的那一行,返回则落在打开该面板的格子上。当确切提供方/模型对仍在已公布分组中时,两个界面都显示 Host 报告的当前选择;目录行缺席时,可路由的选择保持不变,触发器提示 `Select model`。 +与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在所显示面板的行间移动焦点,`Tab` 选定聚焦行,Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻落在正在使用的那一行,返回则落在打开该面板的格子上。当确切提供方/模型对仍在已公布分组中时,两个界面都显示 Host 报告的当前选择;目录行缺席时,有效选择置空,触发器提示“请选择模型”,已保存的选择不变。 ### 模型与推理强度 @@ -35,7 +35,7 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示 ### 不可路由的会话 -当 Host 报告没有适配器服务该会话的路由时,本插件注册一个 composer 阻塞块,输入框随之停用并显示本插件自己的文案;恢复后无需重新加载即清除。首次加载之前或加载失败之后的 `null` 绝不阻断;目录成员关系同样不阻断——一条仍在服务、只是不公布该模型的路由不在分组里,却可用。 +确切提供方/模型对不在可用目录中,或可用性仍在加载或未知时,composer 禁止发送。输入框保留常规占位文案,模型选择器仍可操作。退出账号、移除凭据、提供方或模型都会刷新可用性,不会用其他模型替换已保存的选择。既有会话日志保持不变。 只有当前 Client binding 的目录可以发布对应的 composer 阻塞块。旧 binding 的清理保留后继目录的阻塞块;若尚无后继目录,则移除已失效的阻塞块。 @@ -51,7 +51,7 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示
实现细节——点击展开 -两个入口共用一份由 `ModelDirectoryResolver`(`ctx.modelDirectories`)持有的会话级目录:`/model` popupSelect 贡献项(经 `ctx.commandUi` 注册)与 composer 的具名 `conversation.input.model` 位都经 `session.models` 加载会话的建议目录、经 `session.selectModel` 通过同一个 `ModelDirectory` 实例提交,因此任一入口所做的切换正是另一个入口接下来显示的。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置丢弃所有常驻投影,并在显示前重新拉取 Host 恢复的选择。目录按会话惰性解析,随会话作用域一并 dispose(资源释放);已寻址 subagent 会话不公开任一入口。每份常驻目录都会直接在转发的 `llm/adapters-updated` 与 `settings/document-updated` owner 事件上重拉。 +两个入口共用一份由 `ModelDirectoryResolver`(`ctx.modelDirectories`)持有的会话级目录:`/model` popupSelect 贡献项(经 `ctx.commandUi` 注册)与 composer 的具名 `conversation.input.model` 位都经 `session.models` 加载会话的可用目录、经 `session.selectModel` 通过同一个 `ModelDirectory` 实例提交,因此任一入口所做的切换正是另一个入口接下来显示的。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置丢弃所有常驻投影,并在显示前重新拉取 Host 恢复的选择。目录按会话惰性解析,随会话作用域一并 dispose(资源释放);已寻址 subagent 会话不公开任一入口。每份常驻目录都会直接在转发的 `llm/adapters-updated`、`settings/document-updated` 与凭据更新事件上重拉。
diff --git a/packages/client/ui-model-selection/src/client/directory.ts b/packages/client/ui-model-selection/src/client/directory.ts index b27811f501..8e03bcf206 100644 --- a/packages/client/ui-model-selection/src/client/directory.ts +++ b/packages/client/ui-model-selection/src/client/directory.ts @@ -15,15 +15,9 @@ import type { ModelCatalogDirectory } from './catalog.ts' /** Directory snapshot both entries render from. */ export interface ModelDirectoryState { - /** Effective selection: durable next-request projection, then Host default. */ + /** Available durable selection, or the available default for an unselected Session. */ current: ModelSelection | null - /** - * Whether an adapter serves the current selection's provider, as the host reports - * it — null before the first load, which is NOT the same as blocked. Read - * this rather than "current matches no group": catalog membership is - * advisory, so a route serving a model it stopped advertising is missing - * from the groups yet perfectly usable. - */ + /** Whether the current selection is present in the available catalog; null while unresolved. */ routable: boolean | null /** Successfully loaded provider groups (last good load). */ groups: readonly ModelProviderGroup[] @@ -45,7 +39,6 @@ export class ModelDirectory { /** Latest selection operation wins; an older response never overwrites a newer one. */ private generation = 0 private disposed = false - private resolved = false private readonly unsubscribeCatalog: () => void private readonly unsubscribeSelection: () => void @@ -69,7 +62,7 @@ export class ModelDirectory { } /** - * Ensure the Host generation's shared advisory catalog is loaded. + * Ensure the Host generation's shared available catalog is loaded. * @returns the fresh directory value. */ async load(): Promise { @@ -144,15 +137,6 @@ export class ModelDirectory { const catalog = this.catalog.store.getSnapshot() const projected = modelSelectionProjection(this.projected.getSnapshot()) if (catalog.status !== 'ready' || catalog.value === null || projected === undefined) { - if (this.resolved) { - if (catalog.status === 'error') { - this.store.update((state) => { - state.status = 'error' - state.error = catalog.error - }) - } - return - } this.store.set({ current: null, routable: null, @@ -163,11 +147,12 @@ export class ModelDirectory { }) return } - const current = projected.next ?? catalog.value.default - this.resolved = true + const intended = projected.next ?? catalog.value.default + const current = catalog.value.groups.some(group => group.id === intended.provider + && group.models.some(model => model.id === intended.model)) ? intended : null this.store.set({ current, - routable: catalog.value.routableProviders.includes(current.provider), + routable: current !== null, groups: catalog.value.groups, failures: catalog.value.failures, status: this.store.getSnapshot().status === 'selecting' diff --git a/packages/client/ui-model-selection/src/client/locales.ts b/packages/client/ui-model-selection/src/client/locales.ts index 2e573796ea..2ab727b353 100644 --- a/packages/client/ui-model-selection/src/client/locales.ts +++ b/packages/client/ui-model-selection/src/client/locales.ts @@ -15,9 +15,9 @@ export const zh = { 'option.loadError': '目录加载失败:{message}', 'option.deepseekV4Flash.description': '快速、高效且经济;适合目标明确、常规或并行任务。', 'option.deepseekV4Pro.description': '更强的自主编码、知识与复杂推理能力;适合复杂或质量优先的任务,但成本更高。', - 'trigger.fallback': '选择模型', + 'trigger.fallback': '请选择模型', 'trigger.loading': '正在加载模型…', - 'trigger.selectAria': '选择模型', + 'trigger.selectAria': '请选择模型', 'trigger.aria': '选择模型,当前 {model}', 'trigger.ariaEffort': '选择模型,当前 {model},推理等级 {effort}', 'menu.aria': '模型与推理等级', diff --git a/packages/client/ui-model-selection/src/client/service.ts b/packages/client/ui-model-selection/src/client/service.ts index d92c2c08b3..cee8e630d1 100644 --- a/packages/client/ui-model-selection/src/client/service.ts +++ b/packages/client/ui-model-selection/src/client/service.ts @@ -57,6 +57,7 @@ export class ModelDirectoryResolver extends Service { }) ctx.remote.$on('llm/adapters-updated', () => { this.catalog.refresh() }) ctx.remote.$on('settings/document-updated', () => { this.catalog.refresh() }) + ctx.remote.$on('credentials/record-updated', () => { this.catalog.refresh() }) ctx.remote.$on('credentials/reference-updated', () => { this.catalog.refresh() }) } @@ -83,16 +84,11 @@ export class ModelDirectoryResolver extends Service { binding.session.projections.faceOf('modelSelection'), ) live.directories.set(binding, directory) - // The composer cannot read this plugin (the dependency runs one way), so - // the block is pushed: the Host says whether an adapter serves the - // session's route, and only a definite `false` makes the input inert. - // `null` — before the first load, or after one failed — must not, or a - // slow or unreachable Host would lock a working composer. const conversation = this.ctx.get('conversation') if (conversation !== undefined) { const publish = (): void => { if (sessions.binding(sessionId) !== binding) return - conversation.blocks.set(sessionId, directory.store.getSnapshot().routable === false + conversation.blocks.set(sessionId, directory.store.getSnapshot().routable !== true ? { reason: this.blockReason() } : undefined) } diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index ecae6a1281..67484a5d95 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -83,7 +83,7 @@ async function bench(locale: 'zh' | 'en' = 'zh') { value: { default: defaultSelection, routableProviders: routable ? ['deepseek-official'] : [], - groups: GROUPS, + groups: routable ? GROUPS : [], failures: [], }, }) @@ -299,7 +299,7 @@ describe('ui-model-selection dual entry', () => { expect(b.calls.models).toBe(1) }) - it('keeps the durable projected selection while the eager catalog reconnects', async () => { + it('hides the effective selection until the reconnected catalog validates it', async () => { const b = await bench() b.mint('s1') const face = b.seat().inject!(sid('s1')) @@ -308,17 +308,17 @@ describe('ui-model-selection dual entry', () => { b.ctx.emit('connection/reset') expect(face.directory.getSnapshot()).toMatchObject({ - current: { provider: 'deepseek-official', model: 'deepseek-v4-pro' }, - status: 'ready', + current: null, + status: 'loading', }) face.load() expect(face.directory.getSnapshot()).toMatchObject({ - current: { provider: 'deepseek-official', model: 'deepseek-v4-pro' }, - status: 'ready', + current: null, + status: 'loading', }) }) - it('keeps the last complete view while a refreshed catalog catches up with projection', async () => { + it('blocks until a refreshed catalog validates the projected selection', async () => { const b = await bench() b.mint('s1') const face = b.seat().inject!(sid('s1')) @@ -331,8 +331,8 @@ describe('ui-model-selection dual entry', () => { next: { provider: 'deepseek-official', model: 'deepseek-v4-pro' }, }) expect(face.directory.getSnapshot()).toMatchObject({ - current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - status: 'ready', + current: null, + status: 'loading', }) await vi.waitFor(() => { @@ -415,13 +415,11 @@ describe('ui-model-selection dual entry', () => { } }) - it('blocks the composer only once the Host reports the route unservable', async () => { + it('blocks the composer when current catalog models disappear', async () => { const b = await bench() b.mint('s1') const face = b.seat().inject!(sid('s1')) - // Before the first load nothing is known. `null` is not `false`: a slow - // or unreachable Host must never lock a working composer. expect(b.blockOf('s1')).toBeUndefined() face.load() await Promise.resolve() @@ -445,20 +443,16 @@ describe('ui-model-selection dual entry', () => { expect(b.calls.models).toBe(3) }) - it('never blocks on catalog membership alone', async () => { + it('hides an unavailable durable selection without replacing or rewriting it', async () => { const b = await bench() b.mint('s1') const face = b.seat().inject!(sid('s1')) - // A model the route serves but no longer advertises: the seat prompts for - // a selection, the composer stays usable. Blocking here would break a - // supported configuration (a narrowed `models` list over a live route). - b.setHostCurrent({ provider: 'deepseek-official', model: 'unlisted' }) - face.load() - await Promise.resolve() - await Promise.resolve() - const snapshot = face.directory.getSnapshot() - expect(snapshot.groups.flatMap(group => group.models.map(model => model.id))).not.toContain('unlisted') - expect(b.blockOf('s1')).toBeUndefined() + const intended = { provider: 'deepseek-official', model: 'unlisted' } + b.setProjected(sid('s1'), { lastUsed: intended, next: intended }) + await vi.waitFor(() => { expect(face.directory.getSnapshot().status).toBe('ready') }) + expect(face.directory.getSnapshot().current).toBeNull() + expect(b.blockOf('s1')?.reason).toBe(zh['blocked.composer']) + expect(b.calls.select).toBe(0) }) it('clears its block when the session scope goes', async () => { diff --git a/packages/client/ui-model-selection/tests/expected/unselected-model.txt b/packages/client/ui-model-selection/tests/expected/unselected-model.txt new file mode 100644 index 0000000000..d6d4beb3b1 --- /dev/null +++ b/packages/client/ui-model-selection/tests/expected/unselected-model.txt @@ -0,0 +1 @@ +请选择模型 diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx index 4a5ee43b91..3ac3613aac 100644 --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx @@ -446,3 +446,12 @@ describe('ModelSelect keyboard walk', () => { expect(document.activeElement).toBe(rows[0]) }) }) + +it('shows the unselected model control without a stale model or effort', async () => { + const directory = createSnapshotStore(state({ current: null, routable: false })) + render() + const trigger = screen.getByRole('button', { name: '请选择模型' }) + expect(trigger.hasAttribute('disabled')).toBe(false) + await expect(`${trigger.textContent}\n`).toMatchFileSnapshot('./expected/unselected-model.txt') + expect(trigger.textContent).not.toContain('High') +}) diff --git a/packages/client/ui-settings-account/README.i18n.yaml b/packages/client/ui-settings-account/README.i18n.yaml index 3e8fa7838f..a9fcd71d5a 100644 --- a/packages/client/ui-settings-account/README.i18n.yaml +++ b/packages/client/ui-settings-account/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-account/README.md -README.md: e9df6c0f3dd20d47c688d3ca23b6cff88ab72e99 -README.zh.md: 775d518b1c0e75f7fbd191bc21e57ac24c7f8179 +README.md: d54687d083897842518a0c95dfc84d39e985f3a1 +README.zh.md: c4dbcf234fb72e826ccd742b70740744e98e8fcb diff --git a/packages/client/ui-settings-account/README.md b/packages/client/ui-settings-account/README.md index e9df6c0f3d..d54687d083 100644 --- a/packages/client/ui-settings-account/README.md +++ b/packages/client/ui-settings-account/README.md @@ -50,6 +50,9 @@ Account login uses a dismissible dialog before the model onboarding credential e The balance card shows recharge funds and positive bonus credit in separate rows. Empty or nonpositive bonus wallets hide the bonus row and its divider; currencies retain their own amounts. + +A successful first login initializes the default model from `deepseek-account` when no user choice has been saved. Later logins retain the saved choice, including an unavailable one. + ## Model Experience diff --git a/packages/client/ui-settings-account/README.zh.md b/packages/client/ui-settings-account/README.zh.md index 775d518b1c..c4dbcf234f 100644 --- a/packages/client/ui-settings-account/README.zh.md +++ b/packages/client/ui-settings-account/README.zh.md @@ -50,6 +50,9 @@ Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Pl 余额卡片分行展示充值余额与正额赠金余额。赠送钱包为空或没有正余额时,隐藏赠金行及其分隔线;不同币种分别显示。 + +首次成功登录且用户尚未保存模型选择时,从 `deepseek-account` 初始化默认模型。后续登录保留已保存的选择,包括不可用的选择。 + ## 模型体验 diff --git a/packages/client/ui-settings-account/src/client/index.ts b/packages/client/ui-settings-account/src/client/index.ts index a5b1c715ab..da26bbd8d8 100644 --- a/packages/client/ui-settings-account/src/client/index.ts +++ b/packages/client/ui-settings-account/src/client/index.ts @@ -22,7 +22,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { 'settings.account': AccountKey } } /** Services required by account settings. */ -export const inject = ['slots', 'locale', 'remote', 'remote.account'] +export const inject = ['slots', 'locale', 'remote', 'remote.account', 'remote.session'] /** @param ctx - browser plugin context. */ export function apply(ctx: Context): void { ctx.effect(() => ctx.locale.register('settings.account', { en, zh }), 'account: dictionaries') @@ -72,7 +72,17 @@ export function apply(ctx: Context): void { for await (const frame of stream) { revision++ refreshing = undefined - publish({ ...snapshot, view: frame.value, details: undefined, failed: false }) + let initializationFailed = false + if (frame.value.status === 'credential-stored' && frame.value.attempt?.phase === 'succeeded' + && snapshot.view?.attempt?.phase !== 'succeeded') { + try { + const initialized = await ctx.remote.session.initializeDefaultModel('deepseek-account') + initializationFailed = !initialized.ok + } catch (_error) { + initializationFailed = true + } + } + publish({ ...snapshot, view: frame.value, details: undefined, failed: initializationFailed }) browser.update(frame.value) frame.accept() void refresh() diff --git a/packages/client/ui-settings-account/tests/apply.client.spec.ts b/packages/client/ui-settings-account/tests/apply.client.spec.ts new file mode 100644 index 0000000000..7f01fc5f6e --- /dev/null +++ b/packages/client/ui-settings-account/tests/apply.client.spec.ts @@ -0,0 +1,55 @@ +// @vitest-environment jsdom +/** Account state updates continue after default-model initialization refuses. */ +import { expect, it, vi } from 'vitest' +import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types' +import { apply, type AccountSectionInjected } from '../src/client/index.ts' + +it.each(['accepted', 'refused', 'disconnected'] as const)('initializes account models and keeps reading account state: %s', async (outcome) => { + const abort = new AbortController() + const done = Promise.withResolvers() + const release = Promise.withResolvers() + const initializeDefaultModel = vi.fn(async () => { + if (outcome === 'disconnected') throw new Error('connection lost') + return { ok: outcome === 'accepted', value: undefined } + }) + const links = { usageUrl: 'https://example.test/usage', topUpUrl: 'https://example.test/top_up' } + const signedIn: AccountView = { + status: 'credential-stored', links, attempt: { id: 'login' as SignInAttemptId, phase: 'succeeded' }, + } + const frames: AccountView[] = [signedIn, signedIn, { status: 'signed-out', links, attempt: null }] + const disposers: (() => void)[] = [] + let operations: AccountSectionInjected | undefined + const ctx = { + effect: (install: () => (() => void) | undefined) => { const dispose = install(); if (dispose) disposers.push(dispose) }, + locale: { register: () => () => {}, bind: () => (key: string) => key }, + slots: { + inject: (_name: string, install: () => (() => void) | undefined) => { + const dispose = install() + if (dispose) disposers.push(dispose) + }, + register: (entry: { inject?: () => AccountSectionInjected }) => { + if (entry.inject) operations = entry.inject() + return () => {} + }, + }, + remote: { + session: { initializeDefaultModel }, + account: { getProfile: async () => ({ ok: true, value: null }), getBalance: async () => ({ ok: true, value: null }) }, + $stream: () => ({ + signal: abort.signal, dispose: () => { abort.abort(); release.resolve(undefined) }, + async *[Symbol.asyncIterator]() { + for (const value of frames) yield { value, accept: () => { if (value.status === 'signed-out') done.resolve(undefined) } } + await release.promise + }, + }), + }, + } + try { + apply(ctx as never) + await done.promise + expect(initializeDefaultModel).toHaveBeenCalledExactlyOnceWith('deepseek-account') + expect(operations?.hooks.account.getSnapshot()).toMatchObject({ view: { status: 'signed-out' }, failed: false }) + } finally { + for (const dispose of disposers.reverse()) dispose() + } +}) diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 59be36dcbd..c84f99abf1 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-models/README.md -README.md: 19cf77c41f3c391622945a8eb28ab2bfa104f270 -README.zh.md: 47d67039ab7fcdf7ca8e0781498aa795dac7e002 +README.md: ccce6981a618fd2dfa451f1600ae19e443d77973 +README.zh.md: 0d2f64a1fe4583493b192f33f128f86157a65f8f diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 19cf77c41f..ccce6981a6 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -55,6 +55,8 @@ After the versioned notice step completes, the DeepSeek step projects first-run The section declares two seats for plugins distributed outside this repository, typed in [`src/client/slot-contract.ts`](src/client/slot-contract.ts) and exported from `./client`. `settings.models.provider-card` (keyed) renders inside every card that shows a directory row — a saved row's card, its first-run setup posture, and the add-provider draft — dispatched with `entryKey = settingsNs` and owner props carrying the row's `ConfigurableProviderView`, its configured state, and its confirmed api-key credential state, so one registration under an adapter family's namespace receives every card of that family, hand-declared routes included; the hand-declared draft card has no directory row yet and dispatches nothing until saved. `settings.models.footer` (list) renders after the rows and the add controls. A registrant activates through `ctx.slots.inject` with a type-only import of this package's `/client` entry; without registrants both seats render nothing. +The Models page includes **DeepSeek Account** (`deepseek-account`, **DeepSeek 账号** in Chinese). Its editor exposes the shared DeepSeek model catalog without API-key or base-URL inputs. Account availability follows the stored account credential. The first successful API-key setup initializes the default from its own provider; later setup preserves the user’s saved model choice. + ----- diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 47d67039ab..0d2f64a1fe 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -55,6 +55,8 @@ Host 配置 `credentialOnboarding` 默认为 `true`。Electron preload 标记会 本分区为仓库外分发的插件声明两个席位,类型定义在 [`src/client/slot-contract.ts`](src/client/slot-contract.ts) 并从 `./client` 导出。`settings.models.provider-card`(keyed)渲染在每张展示目录行的卡片内部——已保存行的卡片、其首次运行 setup 形态、以及「添加提供方」草稿卡——以 `entryKey = settingsNs` 分发,owner props 携带该行的 `ConfigurableProviderView`、其 configured 状态与已确认的 api-key 凭据状态,因此以某适配器家族的 namespace 注册一次即可收到该家族的全部卡片,含手工声明的路由;手工声明的草稿卡尚无目录行,保存之前不分发。`settings.models.footer`(list)渲染在行列表与新增控件之后。注册方通过 `ctx.slots.inject` 激活,并以 type-only import 引入本包 `/client` 入口;没有注册方时两个席位均不渲染任何内容。 +Models 页面包含 **DeepSeek 账号**(`deepseek-account`,英文为 **DeepSeek Account**)。其编辑器展示共享的 DeepSeek 模型目录,不提供 API Key 或 Base URL 输入框。账号可用性取决于已保存的账号凭据。首次成功配置 API Key 时,从对应提供方初始化默认模型;后续配置保留用户已保存的模型选择。 + ----- diff --git a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx index 4f382e92a1..c84e72f743 100644 --- a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx @@ -178,7 +178,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { // and the retry above now goes straight back to this write. if (stored !== undefined) return stored } - return undefined + return operations.initializeModel(route.trim()) } const create = async (): Promise => { diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx index a9ce163a50..c61b380a25 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -139,7 +139,7 @@ export async function removeProviderProfile( * @returns whether to render the setup card. */ export function needsSetup(row: ProviderRow, anyUsable: boolean): boolean { - if (anyUsable) return false + if (anyUsable || row.entry.provider === 'deepseek-account') return false if (row.entry.settingsPath.length > 0) return false return row.credential?.configured !== true } @@ -203,7 +203,9 @@ export function ModelsSection(props: ModelsSectionProps): ReactNode { function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderSlot: ModelsRenderSlot }): ReactNode { const { controller, operations, schema, t } = injected - const state = injected.useSnapshot(snapshot => snapshot) + const snapshot = injected.useSnapshot(value => value) + const state = { ...snapshot, rows: snapshot.rows.map(row => row.entry.provider === 'deepseek-account' + ? { ...row, entry: { ...row.entry, displayName: t('deepSeekAccount') } } : row) } const [editing, setEditing] = useState(undefined) const [adding, setAdding] = useState(false) const [deleteTarget, setDeleteTarget] = useState(undefined) diff --git a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx index bc89f43fc8..93e1fa7d1e 100644 --- a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx @@ -173,6 +173,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { const fallback = schema.getPath(namespace.value, settingsPath) const disabled = props.readOnly || busy const layout = layoutOf(namespace.ns) + const accountProvider = props.provider === 'deepseek-account' const keyRef = refFor(schema, namespace, settingsPath, props.provider) // The same schema read the create card makes, so the choices offered here // and there cannot drift apart: both come from the adapter's own `Config`. @@ -184,6 +185,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { ) useEffect(() => { + if (accountProvider) return let stale = false setKeyState(undefined) // The key state is a placeholder hint, not a precondition for editing: a @@ -193,7 +195,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { setKeyState(described) }) return () => { stale = true } - }, [operations, keyRef]) + }, [operations, keyRef, accountProvider]) const stringAt = (source: unknown, key: string): string | undefined => { const value = schema.getPath(source, [key]) @@ -286,6 +288,8 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { if (keyValue.length > 0) { const stored = await operations.storeCredential(keyRef, keyValue) if (stored !== undefined) return stored + const initialized = await operations.initializeModel(props.provider) + if (initialized !== undefined) return initialized } setKeyDraft('') return undefined @@ -358,6 +362,9 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { }, onReset: () => { setDraft(current => schema.deletePath(current, ['models'])) }, } + if (accountProvider) return return ( <>
diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts index ddce8c9e49..9ce0e71534 100644 --- a/packages/client/ui-settings-models/src/client/index.ts +++ b/packages/client/ui-settings-models/src/client/index.ts @@ -64,7 +64,7 @@ export function refreshIfLoaded(controller: ModelsSettingsStore): void { * constrained; registration depends on each slot through `slots.inject()`. */ export const inject = [ - 'slots', 'locale', 'remote', 'remote.credentials', 'remote.llm', 'remote.settings', + 'slots', 'locale', 'remote', 'remote.credentials', 'remote.llm', 'remote.settings', 'remote.session', 'settingsScope', 'settingsSchema', ] @@ -125,6 +125,7 @@ export function apply(ctx: ClientContext): void { const refreshModels = (): void => { refreshIfLoaded(controller) } const disposers = [ ctx.remote.$on('settings/document-updated', () => { refreshModels() }), + ctx.remote.$on('credentials/record-updated', refreshModels), ctx.remote.$on('credentials/reference-updated', refreshModels), ctx.remote.$on('llm/adapters-updated', refreshModels), ctx.on('connection/reset', refreshModels), diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts index 2b0f92d8df..1bafbdc514 100644 --- a/packages/client/ui-settings-models/src/client/locales.ts +++ b/packages/client/ui-settings-models/src/client/locales.ts @@ -3,6 +3,7 @@ /** English strings (the key-set source of truth for this pair). */ export const en = { nav: 'Models', + deepSeekAccount: 'DeepSeek Account', title: 'Models', intro: 'Enter your API keys to use models from the following providers.', edit: 'Edit', @@ -115,6 +116,7 @@ export type ModelsKey = keyof typeof en /** Chinese strings (same keys as {@link en}). */ export const zh: { [Key in keyof typeof en]: string } = { nav: '模型', + deepSeekAccount: 'DeepSeek 账号', title: '模型', intro: '填入各提供方的 API 密钥即可使用其模型。', edit: '编辑', diff --git a/packages/client/ui-settings-models/src/client/operations.ts b/packages/client/ui-settings-models/src/client/operations.ts index 8ca6beb798..75d87ac821 100644 --- a/packages/client/ui-settings-models/src/client/operations.ts +++ b/packages/client/ui-settings-models/src/client/operations.ts @@ -32,6 +32,8 @@ export type ModelDiscoveryOutcome = /** The Host operations the Models page and its cards invoke. */ export interface ModelsOperations { + /** @param provider - newly configured provider. @returns refusal text, or undefined after initialization. */ + initializeModel(provider: string): Promise /** * Read one credential reference's state. * @param ref - credential reference name. @@ -81,6 +83,10 @@ export interface ModelsOperations { */ export function createModelsOperations(ctx: ClientContext): ModelsOperations { return { + initializeModel: async (provider) => { + const response = await ctx.remote.session.initializeDefaultModel(provider) + return response.ok ? undefined : response.error.message + }, describeCredential: async (ref) => { const response = await ctx.remote.credentials.describe([ref]) return response.ok ? response.value[ref] : undefined diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index 2af1d54d5c..f3e948b79f 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -69,6 +69,8 @@ export function joinProviderDirectory( /** One provider row the page renders. */ export interface ProviderRow { + /** Account route has usable credentials for the configured inference origin. */ + accountAvailable?: boolean /** The directory entry (route id, display name, settings address, live state). */ entry: ProviderDirectoryEntry /** Whether any layer configures this provider (its profile resolves). */ @@ -208,11 +210,18 @@ export class ModelsSettingsStore { entry, configured, removable, - apiKeyEnv: apiKeyEnvOf(namespace, entry.settingsPath, this.schema), + apiKeyEnv: entry.provider === 'deepseek-account' ? undefined : apiKeyEnvOf(namespace, entry.settingsPath, this.schema), credential: undefined, } }) - const refs = [...new Set(rows.map(row => row.apiKeyEnv ?? deriveKeyRef(row.entry.provider)))] + if (rows.some(row => row.entry.provider === 'deepseek-account')) { + const catalog = await this.ctx.remote.session.modelCatalog() + for (const row of rows) { + if (row.entry.provider === 'deepseek-account') row.accountAvailable = catalog.ok + && catalog.value.groups.some(group => group.id === 'deepseek-account' && group.models.length > 0) + } + } + const refs = [...new Set(rows.filter(row => row.entry.provider !== 'deepseek-account').map(row => row.apiKeyEnv ?? deriveKeyRef(row.entry.provider)))] let credentials: Record = {} let credentialError: string | null = null if (refs.length > 0) { @@ -230,6 +239,7 @@ export class ModelsSettingsStore { s.credentialError = credentialError s.writable = writable s.rows = rows.map((row) => { + if (row.entry.provider === 'deepseek-account') return row const named = row.apiKeyEnv === undefined ? undefined : credentials[row.apiKeyEnv] const derived = row.apiKeyEnv !== undefined ? undefined : credentials[deriveKeyRef(row.entry.provider)] return { @@ -264,6 +274,7 @@ export class ModelsSettingsStore { */ export function providerUsable(row: ProviderRow): boolean { if (!row.entry.active) return false + if (row.entry.provider === 'deepseek-account') return row.accountAvailable === true if (row.apiKeyEnv === undefined) return true return row.credential?.configured === true } diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index 22e1d04f06..408de5248f 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -45,6 +45,7 @@ async function bench(isLoopback = true, mock = RemoteMock.create().load(remoteDe ...services, }, settings: mock.remote.settings, + session: { initializeDefaultModel: vi.fn(async () => ({ ok: true, value: undefined })) }, }) // The fixed Host facts the settings provider reads its persistence from. remote.$host = { home: undefined, isLoopback } @@ -107,7 +108,7 @@ describe('ui-settings-models apply', () => { it('declares the services it uses', () => { expect(inject).toEqual([ - 'slots', 'locale', 'remote', 'remote.credentials', 'remote.llm', 'remote.settings', + 'slots', 'locale', 'remote', 'remote.credentials', 'remote.llm', 'remote.settings', 'remote.session', 'settingsScope', 'settingsSchema', ]) }) diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index e9cec03e60..88e43e599f 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -12,7 +12,7 @@ import { ModelsSection, needsSetup, providerCopy, providerTargetLabel, removeProviderProfile, } from '../src/client/ModelsSection.tsx' import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx' -import { pathOps } from '../src/client/ProviderEditor.tsx' +import { ProviderEditor, pathOps } from '../src/client/ProviderEditor.tsx' import { DeepSeekModelsEditor, formatCapacity, modelDrafts, parseCapacity, validateDeepSeekModels, } from '../src/client/DeepSeekModelsEditor.tsx' @@ -22,7 +22,7 @@ import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts' import { createModelsOperations } from '../src/client/operations.ts' import type { ModelsOperations } from '../src/client/operations.ts' import type { ProviderRow } from '../src/client/store.ts' -import { en } from '../src/client/locales.ts' +import { en, zh } from '../src/client/locales.ts' import { settingsSchema } from './settings-schema.client.ts' afterEach(cleanup) @@ -218,7 +218,9 @@ const contexts = new WeakMap() function ctxWith(face: object): PageContext { const existing = contexts.get(face) if (existing !== undefined) return existing - const ctx = { remote: face } as unknown as PageContext + const ctx = { remote: { ...face, + session: { initializeDefaultModel: async () => ({ ok: true, value: undefined }) }, + } } as unknown as PageContext contexts.set(face, ctx) return ctx } @@ -1620,3 +1622,18 @@ describe('apiKeyFailure', () => { expect(apiKeyFailure('"a')).toBeUndefined() }) }) + +it.each([en, zh])('edits the account model catalog without credential or endpoint fields', async (copy) => { + const scripted = scriptedFace({}) + const ops = operationsWith(scripted.face) + const describe = vi.spyOn(ops, 'describeCredential') + render( copy[key]} readOnly={false} onClose={() => {}} />) + expect(screen.queryByLabelText(copy.keyInput)).toBeNull() + expect(screen.queryByLabelText(copy.baseUrl)).toBeNull() + expect(describe).not.toHaveBeenCalled() + expect(screen.getByDisplayValue('deepseek-v4-flash')).toBeTruthy() + await expect(`${document.body.textContent}\n`) + .toMatchFileSnapshot(`./expected/deepseek-account-${copy === en ? 'en' : 'zh'}.txt`) +}) diff --git a/packages/client/ui-settings-models/tests/expected/deepseek-account-en.txt b/packages/client/ui-settings-models/tests/expected/deepseek-account-en.txt new file mode 100644 index 0000000000..c9841889a6 --- /dev/null +++ b/packages/client/ui-settings-models/tests/expected/deepseek-account-en.txt @@ -0,0 +1 @@ +DeepSeek Accountdeepseek-accountModelsUsing the adapter defaultsAdd modelCancelApply diff --git a/packages/client/ui-settings-models/tests/expected/deepseek-account-zh.txt b/packages/client/ui-settings-models/tests/expected/deepseek-account-zh.txt new file mode 100644 index 0000000000..e11c2e1969 --- /dev/null +++ b/packages/client/ui-settings-models/tests/expected/deepseek-account-zh.txt @@ -0,0 +1 @@ +DeepSeek 账号deepseek-account模型目录正在使用适配器默认模型添加模型取消保存 diff --git a/packages/client/ui-settings-models/tests/model-list-catalog.client.spec.tsx b/packages/client/ui-settings-models/tests/model-list-catalog.client.spec.tsx index 60e7d79165..f5c50b3056 100644 --- a/packages/client/ui-settings-models/tests/model-list-catalog.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/model-list-catalog.client.spec.tsx @@ -12,6 +12,7 @@ function operations(discoverModels: ModelsOperations['discoverModels']): ModelsO return { discoverModels, describeCredential: vi.fn(), + initializeModel: vi.fn(), storeCredential: vi.fn(), removeCredential: vi.fn(), writeSettings: vi.fn(), diff --git a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx index 89bc6b2ca7..d19873f3b2 100644 --- a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx @@ -136,7 +136,7 @@ function harness(options: { }, } // The page plugin's context, scripted down to the namespaces it reaches. - const ctx = { remote: face } as never + const ctx = { remote: { ...face, session: { initializeDefaultModel: async () => ({ ok: true, value: undefined }) } } } as never const operations = createModelsOperations(ctx) const controller = new ModelsSettingsStore(ctx, settingsSchema, new SettingsDescribeMirror(ctx)) const openSection = vi.fn() diff --git a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx index 57c61d702b..ae71cf0557 100644 --- a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx @@ -153,7 +153,9 @@ const contexts = new WeakMap() function ctxWith(face: object): PageContext { const existing = contexts.get(face) if (existing !== undefined) return existing - const ctx = { remote: face } as unknown as PageContext + const ctx = { remote: { ...face, + session: { initializeDefaultModel: async () => ({ ok: true, value: undefined }) }, + } } as unknown as PageContext contexts.set(face, ctx) return ctx } diff --git a/packages/client/ui-settings-models/tests/store.client.spec.ts b/packages/client/ui-settings-models/tests/store.client.spec.ts index 0ce5a995a1..d181edc12e 100644 --- a/packages/client/ui-settings-models/tests/store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/store.client.spec.ts @@ -4,7 +4,7 @@ import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client' import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { settingsSchema } from './settings-schema.client.ts' -import { joinProviderDirectory, ModelsSettingsStore } from '../src/client/store.ts' +import { joinProviderDirectory, ModelsSettingsStore, providerUsable } from '../src/client/store.ts' it.each([false, true])('retains configuration diagnostics when the route is active: %s', (active) => { expect(joinProviderDirectory(active ? [{ id: 'openai', name: 'openai' }] : [], [{ @@ -64,6 +64,7 @@ const NAMESPACES = [ ] function api(overrides: { + accountAvailable?: boolean providers?: () => Promise> describeSettings?: () => Promise> describeCredentials?: (refs: readonly string[]) => Promise>> @@ -88,6 +89,8 @@ function api(overrides: { : remoteFail(response.result.error.message) } const face = { + session: { modelCatalog: async () => remoteOk({ groups: overrides.accountAvailable + ? [{ id: 'deepseek-account', models: [{ id: 'deepseek-flash' }] }] : [] }) }, llm: { listProviders: () => mapProviderBatch(rows => rows .filter(row => row.active) @@ -329,3 +332,16 @@ describe('edge joins', () => { expect(store.store.getSnapshot().rows).toHaveLength(4) }) }) + + +it.each([false, true])('uses account availability without asking for an API key: %s', async (accountAvailable) => { + const { ctx, mirror, seenRefs } = api({ accountAvailable, providers: async () => ok({ providers: [{ + provider: 'deepseek-account', displayName: 'DeepSeek Account', settingsNs: 'llm-deepseek', settingsPath: [], active: true, + }] }) }) + const store = new ModelsSettingsStore(ctx, settingsSchema, mirror) + await store.load() + const row = store.store.getSnapshot().rows[0]! + expect(row).toMatchObject({ accountAvailable, apiKeyEnv: undefined, credential: undefined }) + expect(providerUsable(row)).toBe(accountAvailable) + expect(seenRefs).toEqual([]) +}) diff --git a/packages/core/agent-default-model/README.i18n.yaml b/packages/core/agent-default-model/README.i18n.yaml index d32bdafa59..421bdb1e70 100644 --- a/packages/core/agent-default-model/README.i18n.yaml +++ b/packages/core/agent-default-model/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent-default-model/README.md -README.md: df8b9c37ea58f4d35d2f9bda1f1e7001c8b2de59 -README.zh.md: d9d78452de9e0fa2a20bcc0bd9d16c41c69e1729 +README.md: 97130a007d91f936450e3593a4ea823ca90d4ae2 +README.zh.md: 2cb9d0b837c55c976c712f286f89a65eb0afab3a diff --git a/packages/core/agent-default-model/README.md b/packages/core/agent-default-model/README.md index df8b9c37ea..97130a007d 100644 --- a/packages/core/agent-default-model/README.md +++ b/packages/core/agent-default-model/README.md @@ -54,7 +54,7 @@ const selection = ctx.agentDefaultModel.currentSelection() await ctx.agentDefaultModel.saveSelection({ provider, model, reasoningEffort: 'high' }) ``` -Without a settings provider, `saveSelection()` is a no-op and the composition entry remains current. The service does not validate catalog membership: a provider route may serve an unadvertised model, and the consumer that opens a model request owns availability diagnostics. +Without a settings provider, `saveSelection()` retains the choice in memory for later agents in this process. `initializeSelection()` saves the first setup choice only when no user choice exists; an unavailable saved choice remains intact. The service does not validate catalog membership: a provider route may serve an unadvertised model, and the consumer that opens a model request owns availability diagnostics. ----- @@ -79,7 +79,7 @@ The service is a composition entry with a settings-backed source. The plugin con ### Behavior notes -Both public methods are thin reads and writes over that source: `currentSelection()` returns a fresh detached object so a caller can hold it without aliasing service state, and `saveSelection()` writes the whole selection through `ctx.settings` when present. +Reads return detached values and writes are serialized: `currentSelection()` returns a fresh detached object so a caller can hold it without aliasing service state, and `saveSelection()` writes the whole selection through `ctx.settings` when present. @@ -114,7 +114,7 @@ Changing the default affects only agents that subsequently resolve from it. An e These limits define the service's scope. They are current package constraints, not a task backlog. - **One process-wide default** — the service owns a single default; per-session model selection remains the entry point's responsibility. -- **No retention without a settings provider** — `saveSelection()` cannot keep a selection for a later agent when no settings provider is mounted. +- **No persistence without a settings provider** — in-memory selections last only for this process. ### Dev Note diff --git a/packages/core/agent-default-model/README.zh.md b/packages/core/agent-default-model/README.zh.md index d9d78452de..2cb9d0b837 100644 --- a/packages/core/agent-default-model/README.zh.md +++ b/packages/core/agent-default-model/README.zh.md @@ -54,7 +54,7 @@ const selection = ctx.agentDefaultModel.currentSelection() await ctx.agentDefaultModel.saveSelection({ provider, model, reasoningEffort: 'high' }) ``` -未挂载设置提供方时,`saveSelection()` 不执行任何操作,组合配置项仍为当前值。该服务不校验目录成员关系:提供方路由可以服务未在目录中公布的模型;发起模型请求的消费方负责可用性诊断。 +未挂载设置提供方时,`saveSelection()` 在内存中保留选择,供本进程后续 agent 使用。`initializeSelection()` 仅在用户尚未选择时保存首次配置的模型;已保存的选择即使不可用也保持不变。该服务不校验目录成员关系:提供方路由可以服务未在目录中公布的模型;发起模型请求的消费方负责可用性诊断。 ----- @@ -79,7 +79,7 @@ await ctx.agentDefaultModel.saveSelection({ provider, model, reasoningEffort: 'h ### 行为说明 -两个公开方法都只是对该数据源进行简单读写:`currentSelection()` 返回一个全新、独立的对象,因此调用方可以持有它,而不会与服务状态共享引用;`saveSelection()` 在存在 `ctx.settings` 时写入完整选择。 +读取返回独立值,写入按序执行:`currentSelection()` 返回一个全新、独立的对象,因此调用方可以持有它,而不会与服务状态共享引用;`saveSelection()` 在存在 `ctx.settings` 时写入完整选择。 @@ -114,7 +114,7 @@ await ctx.agentDefaultModel.saveSelection({ provider, model, reasoningEffort: 'h 这些限制界定该服务的范围。它们是当前包约束,不是任务积压。 - **单一的进程级默认值**——该服务只拥有一个默认值;按会话的模型选择仍由入口负责。 -- **没有设置提供方时无法保留**——未挂载设置提供方时,`saveSelection()` 无法为后续 agent 保留选择。 +- **没有设置提供方时无法持久化**——内存中的选择仅在本进程内保留。 ### 开发备注 diff --git a/packages/core/agent-default-model/src/index.ts b/packages/core/agent-default-model/src/index.ts index 82e4e0da6d..a2c804836c 100644 --- a/packages/core/agent-default-model/src/index.ts +++ b/packages/core/agent-default-model/src/index.ts @@ -68,6 +68,8 @@ export class AgentDefaultModelConfig extends Service { }) private source: () => AgentDefaultModelSettings + private saved: ModelSelection | undefined + private writes: Promise = Promise.resolve() constructor(ctx: Context, config: Config) { super(ctx, 'agentDefaultModel') @@ -88,22 +90,45 @@ export class AgentDefaultModelConfig extends Service { * @returns a detached provider, model, and optional reasoning selection. */ currentSelection(): ModelSelection { - return selection(this.source()) + return this.ctx.get('settings') === undefined && this.saved !== undefined + ? { ...this.saved } : selection(this.source()) } /** * Save the complete default model selection. A deployment without a settings - * provider keeps its composition entry. + * provider retains the selection for this process. * @param next - resolved selection accepted by an entry point. * @returns fulfillment after the optional settings write settles. */ async saveSelection(next: ModelSelection): Promise { - await this.ctx.get('settings')?.replace(AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE, { - provider: next.provider, - model: next.model, - ...next.reasoningEffort === undefined ? {} : { reasoningEffort: String(next.reasoningEffort) }, - }) + return this.writeSelection(next, false) } + + /** + * Store the initial credential setup choice only before a user selection exists. + * @param next - available model belonging to the provider the user initialized. + * @returns after the initial selection is saved, or immediately if already selected. + */ + async initializeSelection(next: ModelSelection): Promise { + return this.writeSelection(next, true) + } + + private writeSelection(next: ModelSelection, initialize: boolean): Promise { + const operation = this.writes.then(async () => { + const user = this.ctx.get('settings')?.describe().find(entry => entry.ns === AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE)?.user + if (initialize && (this.saved !== undefined + || (typeof user === 'object' && user !== null && ('provider' in user || 'model' in user)))) return + await this.ctx.get('settings')?.replace(AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE, { + provider: next.provider, + model: next.model, + ...next.reasoningEffort === undefined ? {} : { reasoningEffort: String(next.reasoningEffort) }, + }) + this.saved = { ...next } + }) + this.writes = operation.catch(() => { /* A refused write does not block the next user choice. */ }) + return operation + } + } export default AgentDefaultModelConfig diff --git a/packages/core/agent-default-model/tests/agent-default-model.spec.ts b/packages/core/agent-default-model/tests/agent-default-model.spec.ts index b16091d809..be76848e67 100644 --- a/packages/core/agent-default-model/tests/agent-default-model.spec.ts +++ b/packages/core/agent-default-model/tests/agent-default-model.spec.ts @@ -77,22 +77,33 @@ describe('AgentDefaultModelConfig', () => { await bench.ctx.fiber.dispose() }) - it('falls back to the composition entry when the settings provider detaches', async () => { + it('retains the saved selection when the settings provider detaches', async () => { const bench = await boot() await bench.defaultModel.saveSelection({ provider: 'acme-gateway', model: 'acme-large' }) expect(bench.defaultModel.currentSelection().provider).toBe('acme-gateway') await bench.settingsFiber.dispose() - expect(bench.defaultModel.currentSelection()).toEqual({ - provider: 'deepseek-official', model: 'deepseek-v4-flash', - }) + expect(bench.defaultModel.currentSelection()).toEqual({ provider: 'acme-gateway', model: 'acme-large' }) await bench.ctx.fiber.dispose() }) - it('keeps the composition entry when no settings provider is mounted', async () => { + it('retains a user selection when no settings provider is mounted', async () => { const ctx = new Context() await ctx.plugin(AgentDefaultModelConfig, { provider: 'p', model: 'm' }) await ctx.agentDefaultModel.saveSelection({ provider: 'other', model: 'other' }) - expect(ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'p', model: 'm' }) + expect(ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'other', model: 'other' }) await ctx.fiber.dispose() }) }) + +it('initializes once and retains an unavailable user selection instead of replacing it', async () => { + const bench = await boot() + try { + await bench.defaultModel.initializeSelection({ provider: 'deepseek-account', model: 'deepseek-v4-flash' }) + expect(bench.defaultModel.currentSelection().provider).toBe('deepseek-account') + await bench.defaultModel.initializeSelection({ provider: 'deepseek-official', model: 'deepseek-v4-pro' }) + expect(bench.defaultModel.currentSelection().provider).toBe('deepseek-account') + await bench.defaultModel.saveSelection({ provider: 'deleted-provider', model: 'removed-model' }) + await bench.defaultModel.initializeSelection({ provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + expect(bench.defaultModel.currentSelection()).toEqual({ provider: 'deleted-provider', model: 'removed-model' }) + } finally { await bench.ctx.fiber.dispose() } +}) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 9e8c680d18..5706f2a77d 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -94,10 +94,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async saveSelection(next: ModelSelection): Promise', - description: 'Save the complete default model selection. A deployment without a settings provider keeps its composition entry.', + description: 'Save the complete default model selection. A deployment without a settings provider retains the selection for this process.', parameters: [{ name: 'next', description: 'resolved selection accepted by an entry point.' }], returns: 'fulfillment after the optional settings write settles.', }, + { + signature: 'async initializeSelection(next: ModelSelection): Promise', + description: 'Store the initial credential setup choice only before a user selection exists.', + parameters: [{ name: 'next', description: 'available model belonging to the provider the user initialized.' }], + returns: 'after the initial selection is saved, or immediately if already selected.', + }, ], }, { @@ -1780,6 +1786,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'request', description: 'Session identity and requested model selection.' }], returns: 'the normalized selection installed for the Session.', }, + { + signature: '@Remote async initializeDefaultModel(provider: string): Promise', + description: 'Initialize the default after the user first configures a provider credential.', + parameters: [{ name: 'provider', description: 'the provider whose credential was configured.' }], + returns: 'after saving the initial available model; existing user choices are retained.', + }, { signature: '@Remote(\'modelCatalog\') modelCatalog(): Promise', description: 'Describe every currently routable model for Host-generation selectors.', diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 11c68612d8..fcc298d024 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: abd29fbdc49a100fa3e5a92ad4db05fc487ef456 -README.zh.md: d955dc40190a2b00d9034859c99c004ba06a4fa3 +README.md: 7ded16b5e1d8c87b924aeb02c8eda0a3033bd3ee +README.zh.md: de612f7b2153eab35e8a00db71aeb1bb92824eb1 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index abd29fbdc4..7ded16b5e1 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -117,6 +117,8 @@ Successful Files responses must contain valid JSON. JSON decoding failures from Non-2xx responses fail with stable codes: `AUTH` (401/403), `QUOTA`, `RATE_LIMIT`, `CONTEXT_WINDOW_EXCEEDED`, `INVALID_REQUEST`, `SERVER`, and `HTTP_` otherwise; pre-response transport failures throw `TRANSPORT`, caller aborts throw `ABORTED`, and stream-idle expiry throws `TIMEOUT`. Request-extension preparation, field collision, or post-2xx acceptance fails with `REQUEST_EXTENSION`. A normalized-image rejection names every plausible attachment and its durable position when the provider does not identify a file id. Stale-file rejection invalidates the named mappings (or every mapping used by the attempt) and permits one replacement model request. Protocol violations throw `STREAM_CLOSED` or `MALFORMED_RESPONSE`, and a terminal `stop` with no content blocks becomes `EMPTY_RESPONSE`, which the default retry policy retries. A request with no key anywhere fails with `MISSING_CREDENTIAL`, and a malformed credential fails with `INVALID_CREDENTIAL` naming the reference to fix — never any part of the key. +Each route advertises models only while its own credential resolves. Missing API keys and missing or ineligible account grants yield an empty catalog; other credential errors remain visible. This check does not probe the inference endpoint. Both configurable provider entries share the same model catalog. + ----- diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index d955dc4019..de612f7b21 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -117,6 +117,8 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留 非 2xx 响应以稳定 code 失败:`AUTH`(401/403)、`QUOTA`、`RATE_LIMIT`、`CONTEXT_WINDOW_EXCEEDED`、`INVALID_REQUEST`、`SERVER` 以及其他情况的 `HTTP_`;响应前传输失败抛出 `TRANSPORT`,调用方中止抛出 `ABORTED`,流空闲超时抛出 `TIMEOUT`。请求扩展准备、字段冲突或 2xx 后接受失败使用 `REQUEST_EXTENSION`。当提供方未指出 file id 时,规范化图片拒绝会列出所有可能附件及其持久位置。陈旧文件拒绝会使点名映射(或该次尝试使用的全部映射)失效,并允许一次替换模型请求。协议违规抛出 `STREAM_CLOSED` 或 `MALFORMED_RESPONSE`;不带内容块的终止 `stop` 变成 `EMPTY_RESPONSE`,默认重试策略会重试它。任何位置都没有密钥的请求以 `MISSING_CREDENTIAL` 失败;格式错误的凭据以 `INVALID_CREDENTIAL` 失败,并点名需要修复的引用——绝不包含密钥的任何部分。 +每条路由仅在其自身凭据可解析时公布模型。缺少 API Key 或账号授权缺失、不适用时返回空目录;其他凭据错误仍会暴露。此检查不会探测推理端点。两个可配置提供方条目共用同一份模型目录。 + ----- diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index c25125987e..568b3190e6 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -1,6 +1,6 @@ /** Select a DeepSeek wire implementation from one validated configuration generation. */ import { assertNever } from '@deepseek-ai/dsh-util-values' -import { LlmAdapter } from '@deepseek-ai/dsh-llm' +import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, PreparedAdapterCall, StreamChunk } from '@deepseek-ai/dsh-llm' import type { DeepSeekAdapterOptions } from './common/types.ts' import { ChatCompletionsAdapter } from './protocols/chat-completions/adapter.ts' @@ -41,9 +41,16 @@ export class DeepSeekAdapter extends LlmAdapter { } } - override providerInfo(provider: string) { return { id: provider, name: provider === 'deepseek-account' ? 'DeepSeek (Account)' : 'DeepSeek (API Key)' } } + override providerInfo(provider: string) { return { id: provider, name: provider === 'deepseek-account' ? 'DeepSeek Account' : 'DeepSeek (API Key)' } } override providerRetryPolicy(provider: string) { return this.implementation().providerRetryPolicy(provider) } - override listModels(provider: string) { return this.implementation().listModels(provider) } + override async listModels(provider: string) { + try { await this.dependencies.resolveApiKey(this.dependencies.options()) } + catch (error) { + if (error instanceof LlmError && ['MISSING_CREDENTIAL', 'ACCOUNT_SIGN_IN_REQUIRED'].includes(error.code)) return [] + throw error + } + return this.implementation().listModels(provider) + } override resolveModel(provider: string, model: string, signal?: AbortSignal) { return this.implementation().resolveModel(provider, model, signal) } diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index c740a9e449..f0c262c722 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -134,6 +134,7 @@ export function apply(ctx: Context, config: Config): void { }) ctx.llm.registerConfigurableProviders([ { provider: PROVIDER, displayName: 'DeepSeek (API Key)', settingsNs: NS, settingsPath: [] }, + { provider: 'deepseek-account', displayName: 'DeepSeek Account', settingsNs: NS, settingsPath: [] }, ]) // Route effects bind to this apply fiber via the stable `ctx` reference, // even when a swap runs inside the scoped settings callback below. diff --git a/packages/llm/llm-deepseek/tests/account-routing.spec.ts b/packages/llm/llm-deepseek/tests/account-routing.spec.ts index 64b050fd02..95ffb361ca 100644 --- a/packages/llm/llm-deepseek/tests/account-routing.spec.ts +++ b/packages/llm/llm-deepseek/tests/account-routing.spec.ts @@ -214,3 +214,20 @@ it('cancels an account child without stopping its API-key parent', async () => { parent.cancel({ kind: 'user' }) await parent.whenIdle() }) + +it('advertises account and API-key models only while their own credential is available', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const ctx = await harness() + let token: string | undefined + ctx.provide('deepseekAccount', { resolveToken: async (url: string) => url === 'https://api.deepseek.com' ? token : undefined } as DeepSeekAccount) + expect(await ctx.llm.listModels('deepseek-account')).toEqual([]) + expect(await ctx.llm.listModels('deepseek-official')).toEqual([]) + token = 'fixture-account-token' + expect(await ctx.llm.listModels('deepseek-account')).not.toHaveLength(0) + expect(await ctx.llm.listModels('deepseek-official')).toEqual([]) + vi.stubEnv('DEEPSEEK_API_KEY', 'fixture-key') + expect(await ctx.llm.listModels('deepseek-official')).not.toHaveLength(0) + token = undefined + expect(await ctx.llm.listModels('deepseek-account')).toEqual([]) + expect(await ctx.llm.listModels('deepseek-official')).not.toHaveLength(0) +}) diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 2c95e5db00..89d33d64b4 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -1697,13 +1697,13 @@ describe('plugin registration and config', () => { protocol: 'chat-completions', baseURL: server.url, }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek (Account)' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) expect(ctx.llm.listConfigurableProviders()).toEqual([{ provider: 'deepseek-official', displayName: 'DeepSeek (API Key)', settingsNs: 'llm-deepseek', settingsPath: [], - }]) + }, { provider: 'deepseek-account', displayName: 'DeepSeek Account', settingsNs: 'llm-deepseek', settingsPath: [] }]) await fiber.dispose() expect(ctx.llm.listProviders()).toEqual([]) expect(ctx.llm.listConfigurableProviders()).toEqual([]) @@ -1730,10 +1730,11 @@ describe('plugin registration and config', () => { }) it('owns the deepseek provider and advertises the default models', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'catalog-fixture-key') const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: 'http://127.0.0.1:1' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek (Account)' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ { provider: 'deepseek-official', id: 'deepseek-flash', name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'] }, { @@ -1861,6 +1862,7 @@ describe('plugin registration and config', () => { }) it('uses the default model catalog when apply is called directly', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'catalog-fixture-key') const ctx = new Context() await ctx.plugin(LlmRuntime) LlmDeepSeek.apply(ctx, { baseURL: 'http://127.0.0.1:1' }) @@ -1893,6 +1895,7 @@ describe('plugin registration and config', () => { }) it('advertises configured models without restricting arbitrary request ids', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'catalog-fixture-key') const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(LlmDeepSeek, { @@ -2186,18 +2189,17 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek (Account)' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) }) - it('loads keyless, keeps the catalog browsable, and fails the request actionably', async () => { + it('loads keyless, hides unavailable models, and fails the request actionably', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: 'http://127.0.0.1:1' }) - // First-boot onboarding: the route registers so models stay discoverable; - // only the request itself needs a key. - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek (Account)' }]) - await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2) + // The registered provider remains configurable before its credential exists. + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) + await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(0) const first = await assemble(ctx, { model: 'deepseek-flash', messages: [] }) expect(first.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) // The guidance leads with the managed credential store. @@ -2277,7 +2279,7 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmRuntime) // Registration succeeds; no call is made (would hit api.deepseek.com). await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek (Account)' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) }) it('adapter is constructible directly for embedding over the shared resolver', async () => { diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 175e1ab36e..dae9ded55a 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md -README.md: 4b3297ab96c2c691f9bbf849d8e49cb10a126a6a -README.zh.md: b33381468891fa81b199cca40f22a59183f5076a +README.md: ad39c0d53bc41b6c0da4d046a5d0cadd6ce6b3dc +README.zh.md: 082e8e4da8fd9b9948e9284cf9441f1e7d3e9c81 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 4b3297ab96..ad39c0d53b 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -120,6 +120,8 @@ Settings writes strictly validate each new or changed provider after merging its Changing `displayName`, `apiKeyEnv`, or `baseURL` without resolving the provider's model errors still rejects the save. For example, renaming an OpenRouter route whose model `111` needs an `api` cannot be saved on its own: repair or remove that model in the same editor draft, then save the complete provider configuration. Intermediate repairs remain in the draft until the whole provider validates; other providers can be saved independently. +Model discovery resolves each route’s credential configuration. A missing required credential yields an empty catalog; other resolution errors propagate. Routes with native authentication or no required key retain their configured catalog. This check does not probe the inference endpoint. + ----- diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index b333814688..082e8e4da8 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -120,6 +120,8 @@ Settings 写入会在合并组合层与用户层后严格校验每个新增或 只修改 `displayName`、`apiKeyEnv` 或 `baseURL` 而未解决提供方的模型配置错误时,保存仍会被拒绝。例如,OpenRouter 路由的模型 `111` 缺少 `api` 时,不能单独保存路由名称的修改:需要在同一份编辑草稿中修复或删除该模型,再保存完整的提供方配置。中间修复状态保留在草稿中,直到整条提供方配置通过校验;其他提供方可以独立保存。 +模型发现会解析各路由的凭据配置。缺少必需凭据时返回空目录;其他解析错误继续抛出。使用原生认证或无需密钥的路由保留其配置的目录。此检查不会探测推理端点。 + ----- diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index de9f3e0e42..3370e1ffcc 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -274,9 +274,14 @@ export class PiAiAdapter extends LlmAdapter { } override listModels(provider: string): Promise { - return Promise.resolve().then(() => { + return Promise.resolve().then(async () => { const snapshot = this.current() - this.profileOf(snapshot, provider) + const profile = this.profileOf(snapshot, provider) + try { await this.config.resolveApiKey(provider, profile) } + catch (error) { + if (error instanceof LlmError && error.code === 'MISSING_CREDENTIAL') return [] + throw error + } return snapshot.models.getModels(provider).map(model => ({ provider, id: model.id, diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index 85534d6773..ef4e3aa4be 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -1143,7 +1143,7 @@ describe('resolution snapshots', () => { profiles: () => current, // Credential resolution is the real await inside a stream call, and the // window a configuration change has to land in. - resolveApiKey: async () => { await held; return 'k' }, + resolveApiKey: async (provider) => { if (provider === 'deepseek') await held; return 'k' }, auth: memoryAuth(), }) From 325e28bfa0e310fd36dd8ecb40ee8f565cc1fb66 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Sun, 20 Sep 2026 11:42:43 +0800 Subject: [PATCH 006/192] fix(ui-model-selection,llm-deepseek): prioritize account models and simplify provider label --- .../client/ui-model-selection/README.i18n.yaml | 4 ++-- packages/client/ui-model-selection/README.md | 2 +- .../client/ui-model-selection/README.zh.md | 2 +- .../src/client/ModelSelect.tsx | 8 +++++--- .../tests/expected/account-first.txt | 8 ++++++++ .../tests/model-select.client.spec.tsx | 18 ++++++++++++++++++ packages/llm/llm-deepseek/src/adapter.ts | 2 +- packages/llm/llm-deepseek/src/index.ts | 2 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 12 ++++++------ 9 files changed, 43 insertions(+), 15 deletions(-) create mode 100644 packages/client/ui-model-selection/tests/expected/account-first.txt diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index 9ba09cab47..2b7317e90e 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-model-selection/README.md -README.md: 7afc8f158509e18e90236d6302ab51125fa7a891 -README.zh.md: c14ad7ab05af24c35727d42e6d843cbdaf20f332 +README.md: 9b73298b776d2f3d869a5da88db8d17e14f0f3a9 +README.zh.md: 4dbd077a30cbbaaefd9fabcbf1cc4661367a3069 diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md index 7afc8f1585..9b73298b77 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -31,7 +31,7 @@ Mount this plugin alongside `ui-conversation` and the commands package; the comp ### Model and effort -Models stay grouped by provider. The composer menu shows model and effort names only. The `/model` popup shows provider names and catalog descriptions; it localizes the two built-in DeepSeek descriptions and leaves external provider descriptions verbatim. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input. +Models stay grouped by provider. The composer menu shows model and effort names only, with DeepSeek Account first; other providers retain their catalog order. The `/model` popup shows provider names and catalog descriptions; it localizes the two built-in DeepSeek descriptions and leaves external provider descriptions verbatim. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input. ### Unroutable sessions diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index c14ad7ab05..4dbd077a30 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -31,7 +31,7 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示 ### 模型与推理强度 -模型按提供方分组。composer 菜单只显示模型与推理强度名称。`/model` 弹窗显示提供方名称与目录说明;其中两个内置 DeepSeek 模型的说明使用当前语言,外部提供方说明保持原文。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。 +模型按提供方分组。composer 菜单只显示模型与推理强度名称,DeepSeek 账号置顶,其他提供方保持目录原有顺序。`/model` 弹窗显示提供方名称与目录说明;其中两个内置 DeepSeek 模型的说明使用当前语言,外部提供方说明保持原文。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。 ### 不可路由的会话 diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx index b9c8993563..5e33fbe526 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx +++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx @@ -75,7 +75,9 @@ export function ModelSelect( const itemRefs = useRef<(HTMLButtonElement | null)[]>([]) const id = useId() - const choices = useMemo(() => state.groups.flatMap(group => + const groups = useMemo(() => state.groups.toSorted((left, right) => + Number(right.id === 'deepseek-account') - Number(left.id === 'deepseek-account')), [state.groups]) + const choices = useMemo(() => groups.flatMap(group => group.models.map(model => ({ group, model, @@ -86,7 +88,7 @@ export function ModelSelect( ? {} : { reasoningEffort: model.reasoning.defaultEffort }, } satisfies ModelSelection, - }))), [state.groups]) + }))), [groups]) const selectedIndex = state.current === null ? -1 : choices.findIndex(c => c.selection.provider === state.current?.provider && c.selection.model === state.current.model) @@ -409,7 +411,7 @@ export function ModelSelect(
))}
- {state.groups.map((group) => { + {groups.map((group) => { const headingId = `${id}-${group.id}` return (
diff --git a/packages/client/ui-model-selection/tests/expected/account-first.txt b/packages/client/ui-model-selection/tests/expected/account-first.txt new file mode 100644 index 0000000000..304c901c9f --- /dev/null +++ b/packages/client/ui-model-selection/tests/expected/account-first.txt @@ -0,0 +1,8 @@ +deepseek-account-1 +deepseek-account-2 +custom-1 +custom-2 +deepseek-official-1 +deepseek-official-2 +another-1 +another-2 diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx index 3ac3613aac..e71062bbdc 100644 --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx @@ -455,3 +455,21 @@ it('shows the unselected model control without a stale model or effort', async ( await expect(`${trigger.textContent}\n`).toMatchFileSnapshot('./expected/unselected-model.txt') expect(trigger.textContent).not.toContain('High') }) + + +it('places account models first while preserving other provider and model order', async () => { + const groups = ['custom', 'deepseek-official', 'deepseek-account', 'another'].map(id => ({ + id, name: id, models: [1, 2].map(index => ({ id: `${id}-${index}`, name: `${id}-${index}` })), + })) + const directory = createSnapshotStore(state({ current: null, groups })) + render() + fireEvent.click(screen.getByRole('button', { name: '请选择模型' })) + fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) + const names = screen.getAllByRole('menuitemradio').map(row => row.textContent) + expect(names).toEqual([ + 'deepseek-account-1', 'deepseek-account-2', 'custom-1', 'custom-2', + 'deepseek-official-1', 'deepseek-official-2', 'another-1', 'another-2', + ]) + expect(groups.map(group => group.id)).toEqual(['custom', 'deepseek-official', 'deepseek-account', 'another']) + await expect(`${names.join('\n')}\n`).toMatchFileSnapshot('./expected/account-first.txt') +}) diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 568b3190e6..95add7b318 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -41,7 +41,7 @@ export class DeepSeekAdapter extends LlmAdapter { } } - override providerInfo(provider: string) { return { id: provider, name: provider === 'deepseek-account' ? 'DeepSeek Account' : 'DeepSeek (API Key)' } } + override providerInfo(provider: string) { return { id: provider, name: provider === 'deepseek-account' ? 'DeepSeek Account' : 'DeepSeek' } } override providerRetryPolicy(provider: string) { return this.implementation().providerRetryPolicy(provider) } override async listModels(provider: string) { try { await this.dependencies.resolveApiKey(this.dependencies.options()) } diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index f0c262c722..bd159998e3 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -133,7 +133,7 @@ export function apply(ctx: Context, config: Config): void { }, }) ctx.llm.registerConfigurableProviders([ - { provider: PROVIDER, displayName: 'DeepSeek (API Key)', settingsNs: NS, settingsPath: [] }, + { provider: PROVIDER, displayName: 'DeepSeek', settingsNs: NS, settingsPath: [] }, { provider: 'deepseek-account', displayName: 'DeepSeek Account', settingsNs: NS, settingsPath: [] }, ]) // Route effects bind to this apply fiber via the stable `ctx` reference, diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 89d33d64b4..fb8c01a25a 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -1697,10 +1697,10 @@ describe('plugin registration and config', () => { protocol: 'chat-completions', baseURL: server.url, }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) expect(ctx.llm.listConfigurableProviders()).toEqual([{ provider: 'deepseek-official', - displayName: 'DeepSeek (API Key)', + displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], }, { provider: 'deepseek-account', displayName: 'DeepSeek Account', settingsNs: 'llm-deepseek', settingsPath: [] }]) @@ -1734,7 +1734,7 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: 'http://127.0.0.1:1' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ { provider: 'deepseek-official', id: 'deepseek-flash', name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'] }, { @@ -2189,7 +2189,7 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) }) it('loads keyless, hides unavailable models, and fails the request actionably', async () => { @@ -2198,7 +2198,7 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: 'http://127.0.0.1:1' }) // The registered provider remains configurable before its credential exists. - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(0) const first = await assemble(ctx, { model: 'deepseek-flash', messages: [] }) expect(first.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) @@ -2279,7 +2279,7 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmRuntime) // Registration succeeds; no call is made (would hit api.deepseek.com). await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek (API Key)' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }, { id: 'deepseek-account', name: 'DeepSeek Account' }]) }) it('adapter is constructible directly for embedding over the shared resolver', async () => { From 7dc86578d8870fdaea99361670f6d1448c43a86f Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Sun, 20 Sep 2026 11:50:14 +0800 Subject: [PATCH 007/192] fix(ui-settings-account,ui-model-selection): align with updated primitives and catalog checks --- .../tests/expected/default-model/unselected.expected.md | 7 ++----- .../ui-model-selection/tests/browser-plugin.client.spec.ts | 7 +++---- .../ui-settings-account/src/client/SignOutDialog.tsx | 4 ++-- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/apps/web/tests/expected/default-model/unselected.expected.md b/apps/web/tests/expected/default-model/unselected.expected.md index c4fb007e9c..6228bea41c 100644 --- a/apps/web/tests/expected/default-model/unselected.expected.md +++ b/apps/web/tests/expected/default-model/unselected.expected.md @@ -1,8 +1,5 @@ - textbox "描述你想要构建的内容, / 调用指令, @ 文件或对话" [disabled] -- button "添加文件或调用指令" [disabled]: - - img +- button "添加文件或调用指令" [disabled] - button "访问模式,当前:工作区内修改" [disabled]: 工作区内修改 -- button "请选择模型": - - text: 请选择模型 - - img +- button "请选择模型" - button "发送消息" [disabled] diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index 67484a5d95..4fa513e9ba 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -71,8 +71,7 @@ async function bench(locale: 'zh' | 'en' = 'zh') { let selected = defaultSelection const calls = { models: 0, select: 0 } const projections = new Map>() - // Whether the Host reports an adapter for the current route; the composer - // block follows this, never catalog membership. + // Whether the Host advertises available models for the current route. let routable = true let selectionFailure: RemoteError<'session/writer-held'> | undefined const sessionRemote = { @@ -382,14 +381,14 @@ describe('ui-model-selection dual entry', () => { await oldDirectory.load() const replacement = b.mint('s1') replacement.projection.set({ lastUsed: null, - next: { provider: routable ? 'deepseek-official' : 'missing', model: 'replacement' } }) + next: { provider: routable ? 'deepseek-official' : 'missing', model: 'deepseek-v4-flash' } }) const directory = b.ctx.modelDirectories.directoryFor(sid('s1')) try { expect(directory.store.getSnapshot().routable).toBe(routable) const expected = b.blockOf('s1') await Promise.resolve().then(() => { first.projection.set({ lastUsed: null, - next: { provider: routable ? 'missing' : 'deepseek-official', model: 'late-old' } }) + next: { provider: routable ? 'missing' : 'deepseek-official', model: 'deepseek-v4-pro' } }) }) expect(oldDirectory.store.getSnapshot().routable).toBe(!routable) expect(b.blockOf('s1')).toBe(expected) diff --git a/packages/client/ui-settings-account/src/client/SignOutDialog.tsx b/packages/client/ui-settings-account/src/client/SignOutDialog.tsx index c0679a0b2d..f9b59e8ea8 100644 --- a/packages/client/ui-settings-account/src/client/SignOutDialog.tsx +++ b/packages/client/ui-settings-account/src/client/SignOutDialog.tsx @@ -1,6 +1,6 @@ /** Account sign-out confirmation with the current task impact. */ import { useState } from 'react' -import { Button, IconCloseOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button, IconCloseOutlineRegular, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { AccountKey } from './locales.ts' import css from './SignInDialog.module.css' @@ -26,7 +26,7 @@ export function SignOutDialog({ running, signOut, close, t }: {

{t('signOut')}

{t(running ? 'signOutRunningDescription' : 'signOutDescription')}

From 9cf12959f22efe0e43ac097f69a503077a265c12 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:05:58 +0800 Subject: [PATCH 008/192] fix(ui-model-selection): retain effort and open unselected models directly --- apps/web/tests/default-model.e2e.ts | 3 ++- .../ui-model-selection/README.i18n.yaml | 4 ++-- packages/client/ui-model-selection/README.md | 2 ++ .../client/ui-model-selection/README.zh.md | 2 ++ .../src/client/ModelSelect.module.css | 6 ++--- .../src/client/ModelSelect.tsx | 9 ++++---- .../ui-model-selection/src/client/catalog.ts | 19 ++++++++++++++- .../src/client/directory.ts | 15 +++++++++--- .../tests/browser-plugin.client.spec.ts | 23 +++++++++++++++++++ .../tests/expected/unselected-model.txt | 2 +- .../tests/model-select.client.spec.tsx | 13 +++++++---- 11 files changed, 79 insertions(+), 19 deletions(-) diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index f9fd8da7ea..40b3911071 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -104,6 +104,7 @@ describe('web e2e: the composer model switch is the default for later sessions', const trigger = page.getByRole('button', { name: /^选择模型/ }) await trigger.waitFor({ timeout: 15_000 }) + expect(await trigger.evaluate(element => getComputedStyle(element).fontWeight)).toBe('400') await trigger.click() await page.getByRole('menuitem', { name: /模型/ }).click() await page.getByRole('menuitemradio', { name: 'Acme Large' }).click() @@ -143,6 +144,7 @@ describe('web e2e: the composer model switch is the default for later sessions', await expect.poll(async () => box.isEnabled(), { timeout: 15_000 }).toBe(false) const unset = page.getByRole('button', { name: '请选择模型', exact: true }) await unset.waitFor() + expect(await unset.evaluate(element => getComputedStyle(element).fontWeight)).toBe('400') expect(await page.getByRole('button', { name: '发送消息', exact: true }).isEnabled()).toBe(false) const aria = await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd) await compareOrRefreshGolden(fileURLToPath(new URL('./expected/default-model/unselected.expected.md', import.meta.url)), aria, webSnapshotMode()) @@ -161,7 +163,6 @@ describe('web e2e: the composer model switch is the default for later sessions', const seat = page.getByRole('button', { name: '请选择模型', exact: true }) expect(await seat.isEnabled()).toBe(true) await seat.click() - await page.getByRole('menuitem', { name: /模型/ }).click() await page.getByRole('menuitemradio').first().click() await expect.poll(async () => box.isEnabled(), { timeout: 15_000 }).toBe(true) expect(tripwire.pageErrors).toEqual([]) diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index 2b7317e90e..db4fe73951 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-model-selection/README.md -README.md: 9b73298b776d2f3d869a5da88db8d17e14f0f3a9 -README.zh.md: 4dbd077a30cbbaaefd9fabcbf1cc4661367a3069 +README.md: 3c73a42b34ad9075eaaab9e256767b7250ea0513 +README.zh.md: 87cccdeacde3183427a1ff08e1d1288e5cf170be diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md index 9b73298b77..3c73a42b34 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -27,6 +27,8 @@ The Web GUI lets users switch the model and reasoning effort for an existing ses DeepSeek account and API-key routes appear as separate provider groups, each exposing the same configured model catalog. +The unselected model label uses the same regular weight as an available model name and retains the saved reasoning effort caption for existing and new sessions; effort editing requires an available model. Clicking the unselected trigger opens the model list directly; Escape closes it. + Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus across the rows of the shown pane, Tab settles the focused row, and Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling lands on the row of the value in use, and going back lands on the cell that opened the pane left. Both surfaces show the host-reported current selection when the exact provider/model pair remains in the advertised groups; a missing catalog row clears the effective selection and the trigger prompts `Select model`, without changing the stored choice. ### Model and effort diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index 4dbd077a30..87cccdeacd 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -27,6 +27,8 @@ Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有 DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示相同的已配置模型目录。 +未选模型文案与可用模型名使用相同的常规字重,并在既有会话与新会话中保留已保存的推理强度显示;调整推理强度需要先选择可用模型。点击未选模型控件直接打开模型列表,Escape 关闭列表。 + 与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在所显示面板的行间移动焦点,`Tab` 选定聚焦行,Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻落在正在使用的那一行,返回则落在打开该面板的格子上。当确切提供方/模型对仍在已公布分组中时,两个界面都显示 Host 报告的当前选择;目录行缺席时,有效选择置空,触发器提示“请选择模型”,已保存的选择不变。 ### 模型与推理强度 diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.module.css b/packages/client/ui-model-selection/src/client/ModelSelect.module.css index 799c10776c..3f42221fca 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.module.css +++ b/packages/client/ui-model-selection/src/client/ModelSelect.module.css @@ -3,7 +3,7 @@ min-width: 0; } -/* Figma 313:14108 ToggleButton: 13/20 medium secondary label, 4px gap, +/* Figma 313:14108 ToggleButton: 13/20 regular secondary label, 4px gap, 12px caption chevron; 28px chip height matches the sibling Plan / Read-only selects in the same tool row. */ .trigger { @@ -25,7 +25,7 @@ color: var(--dsw-alias-label-secondary); font-size: 13px; line-height: 20px; - font-weight: 500; + font-weight: 400; cursor: pointer; } @@ -49,7 +49,7 @@ white-space: nowrap; } -/* Effort value beside the model name (mock's 'High': same 13/20/500, caption +/* Effort value beside the model name (mock's 'High': same 13/20/400, caption tone). Adaptive priority: the huge shrink factor makes the effort absorb all deficit first — it ellipsizes away to nothing before the model name loses a single pixel, and the chevron stays snug throughout. */ diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx index 5e33fbe526..2223cd3c21 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx +++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx @@ -96,7 +96,7 @@ export function ModelSelect( const reasoning = currentChoice?.model.reasoning const effectiveEffort = state.current?.reasoningEffort ?? reasoning?.defaultEffort const effortLabel = reasoning === undefined - ? undefined + ? state.retainedEffort : effectiveEffort === undefined ? t('effort.providerDefault') : reasoning.efforts.find(level => level.id === effectiveEffort)?.name ?? effectiveEffort @@ -192,7 +192,8 @@ export function ModelSelect( if (!available) return null const show = (): void => { - setPane('root') + if (state.current === null) paneFocus.current = 'drill' + setPane(state.current === null ? 'model' : 'root') setOpen(true) reload() } @@ -231,7 +232,7 @@ export function ModelSelect( if (event.key === 'Escape' && open) { event.preventDefault() // Escape backs out of a drilled pane first, then closes. - if (pane !== 'root') back(pane) + if (pane !== 'root' && state.current !== null) back(pane) else close(true) return } @@ -242,7 +243,7 @@ export function ModelSelect( if (event.key === 'Tab') { if (event.shiftKey) { event.preventDefault() - if (pane !== 'root') back(pane) + if (pane !== 'root' && state.current !== null) back(pane) else close(true) return } diff --git a/packages/client/ui-model-selection/src/client/catalog.ts b/packages/client/ui-model-selection/src/client/catalog.ts index d5073d2ab0..cbc92642e7 100644 --- a/packages/client/ui-model-selection/src/client/catalog.ts +++ b/packages/client/ui-model-selection/src/client/catalog.ts @@ -1,7 +1,7 @@ /** One Host-generation model catalog shared by every Session selector. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' -import type { ModelCatalog } from '@deepseek-ai/dsh-api-remotes/client' +import type { ModelCatalog, ModelSelection, ModelProviderGroup } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' /** Observable lifecycle of the shared model catalog. */ @@ -20,6 +20,17 @@ export class ModelCatalogDirectory { error: null, }) + private readonly reasoning = new Map() + + /** + * Read the last advertised reasoning metadata, including unavailable models. + * @param selection - provider and model whose effort is displayed. + * @returns reasoning metadata observed during this Host generation. + */ + reasoningFor(selection: ModelSelection): ModelProviderGroup['models'][number]['reasoning'] { + return this.reasoning.get(JSON.stringify([selection.provider, selection.model])) + } + private generation = 0 private inflight: Promise | undefined @@ -47,6 +58,11 @@ export class ModelCatalogDirectory { throw new Error(`${response.error.code}: ${response.error.message}`) } if (generation === this.generation) { + for (const group of response.value.groups) { + for (const model of group.models) { + this.reasoning.set(JSON.stringify([group.id, model.id]), model.reasoning) + } + } this.store.set({ value: response.value, status: 'ready', error: null }) } return response.value @@ -84,6 +100,7 @@ export class ModelCatalogDirectory { /** Clear Host-specific values and load the replacement Host generation. */ resetGeneration(): void { + this.reasoning.clear() this.invalidate(true) void this.load().catch(() => { /* the selector exposes the shared error */ }) } diff --git a/packages/client/ui-model-selection/src/client/directory.ts b/packages/client/ui-model-selection/src/client/directory.ts index 8e03bcf206..162ad5db7d 100644 --- a/packages/client/ui-model-selection/src/client/directory.ts +++ b/packages/client/ui-model-selection/src/client/directory.ts @@ -17,6 +17,8 @@ import type { ModelCatalogDirectory } from './catalog.ts' export interface ModelDirectoryState { /** Available durable selection, or the available default for an unselected Session. */ current: ModelSelection | null + /** Saved effort caption retained when the selected model is unavailable. */ + retainedEffort?: string /** Whether the current selection is present in the available catalog; null while unresolved. */ routable: boolean | null /** Successfully loaded provider groups (last good load). */ @@ -136,9 +138,15 @@ export class ModelDirectory { if (this.disposed) return const catalog = this.catalog.store.getSnapshot() const projected = modelSelectionProjection(this.projected.getSnapshot()) + const intended = projected?.next ?? catalog.value?.default + const reasoning = intended === undefined ? undefined : this.catalog.reasoningFor(intended) + const effort = intended?.reasoningEffort ?? reasoning?.defaultEffort + const retainedEffort = effort === undefined ? undefined + : reasoning?.efforts.find(level => level.id === effort)?.name ?? effort if (catalog.status !== 'ready' || catalog.value === null || projected === undefined) { this.store.set({ current: null, + ...retainedEffort === undefined ? {} : { retainedEffort }, routable: null, groups: [], failures: [], @@ -147,11 +155,12 @@ export class ModelDirectory { }) return } - const intended = projected.next ?? catalog.value.default - const current = catalog.value.groups.some(group => group.id === intended.provider - && group.models.some(model => model.id === intended.model)) ? intended : null + const selection = projected.next ?? catalog.value.default + const current = catalog.value.groups.some(group => group.id === selection.provider + && group.models.some(model => model.id === selection.model)) ? selection : null this.store.set({ current, + ...retainedEffort === undefined ? {} : { retainedEffort }, routable: current !== null, groups: catalog.value.groups, failures: catalog.value.failures, diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index 4fa513e9ba..af7d3dee20 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -414,6 +414,29 @@ describe('ui-model-selection dual entry', () => { } }) + it('retains saved effort for existing and new sessions after credentials disappear', async () => { + const b = await bench() + try { + b.setHostCurrent({ provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'max' }) + b.mint('existing') + const existing = b.ctx.modelDirectories.directoryFor(sid('existing')) + await existing.load() + b.setProjected(sid('existing'), { lastUsed: null, + next: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'high' } }) + b.setRoutable(false) + b.remote.emit('settings/document-updated', ['llm-deepseek', 1]) + expect(existing.store.getSnapshot().retainedEffort).toBe('High') + await vi.waitFor(() => { + expect(existing.store.getSnapshot()).toMatchObject({ current: null, routable: false, retainedEffort: 'High' }) + }) + b.mint('new') + const fresh = b.ctx.modelDirectories.directoryFor(sid('new')) + expect(fresh.store.getSnapshot()).toMatchObject({ current: null, routable: false, retainedEffort: 'Max' }) + } finally { + await b.ctx.fiber.dispose() + } + }) + it('blocks the composer when current catalog models disappear', async () => { const b = await bench() b.mint('s1') diff --git a/packages/client/ui-model-selection/tests/expected/unselected-model.txt b/packages/client/ui-model-selection/tests/expected/unselected-model.txt index d6d4beb3b1..2e251b01d4 100644 --- a/packages/client/ui-model-selection/tests/expected/unselected-model.txt +++ b/packages/client/ui-model-selection/tests/expected/unselected-model.txt @@ -1 +1 @@ -请选择模型 +请选择模型High diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx index e71062bbdc..2d8df4fad0 100644 --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx @@ -447,13 +447,19 @@ describe('ModelSelect keyboard walk', () => { }) }) -it('shows the unselected model control without a stale model or effort', async () => { - const directory = createSnapshotStore(state({ current: null, routable: false })) +it('shows the unselected model control with the inherited effort', async () => { + const directory = createSnapshotStore(state({ current: null, routable: false, retainedEffort: 'High' })) render() const trigger = screen.getByRole('button', { name: '请选择模型' }) expect(trigger.hasAttribute('disabled')).toBe(false) await expect(`${trigger.textContent}\n`).toMatchFileSnapshot('./expected/unselected-model.txt') - expect(trigger.textContent).not.toContain('High') + expect(trigger.textContent).toContain('High') + fireEvent.click(trigger) + expect(screen.queryByRole('menuitem', { name: /模型/ })).toBeNull() + const model = screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' }) + expect(document.activeElement).toBe(model) + fireEvent.keyDown(model, { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() }) @@ -464,7 +470,6 @@ it('places account models first while preserving other provider and model order' const directory = createSnapshotStore(state({ current: null, groups })) render() fireEvent.click(screen.getByRole('button', { name: '请选择模型' })) - fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) const names = screen.getAllByRole('menuitemradio').map(row => row.textContent) expect(names).toEqual([ 'deepseek-account-1', 'deepseek-account-2', 'custom-1', 'custom-2', From 0d2fbaad67707f95b3281a1e39340c606283246a Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:27:35 +0800 Subject: [PATCH 009/192] fix(session-controller,ui-model-selection,ui-settings): handle catalog and account setup failures --- apps/web/tests/default-model.e2e.ts | 6 +-- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 10 ++-- docs/event-producer-consumer.zh.md | 10 ++-- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 4 +- docs/persistence-catalog.zh.md | 4 +- docs/persistence-schema.json | 4 +- docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 8 ++-- docs/subsystems/llm-streaming.zh.md | 8 ++-- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 +- packages/api/session-controller/README.zh.md | 2 +- .../api/session-controller/src/catalog.ts | 17 +++++-- packages/api/session-controller/src/index.ts | 3 +- packages/api/session-controller/src/types.ts | 1 + .../tests/session-models.host.spec.ts | 46 +++++++++++++++++- .../client/skeleton/ConversationContent.tsx | 2 +- .../tests/skeleton.client.spec.tsx | 7 ++- .../ui-model-selection/README.i18n.yaml | 4 +- packages/client/ui-model-selection/README.md | 4 +- .../client/ui-model-selection/README.zh.md | 4 +- .../src/client/ModelSelect.tsx | 7 +-- .../src/client/directory.ts | 8 ++-- .../ui-model-selection/src/client/index.ts | 5 +- .../ui-model-selection/src/client/locales.ts | 2 + .../tests/browser-plugin.client.spec.ts | 48 ++++++++++++++++++- .../tests/expected/account-first.txt | 4 +- .../tests/model-select.client.spec.tsx | 21 ++++++-- .../ui-settings-account/README.i18n.yaml | 4 +- packages/client/ui-settings-account/README.md | 4 +- .../client/ui-settings-account/README.zh.md | 4 +- .../src/client/AccountMenu.tsx | 4 +- .../src/client/SignOutDialog.tsx | 4 +- .../ui-settings-account/src/client/index.ts | 22 ++++----- .../ui-settings-account/src/client/locales.ts | 2 + .../tests/account.client.spec.tsx | 6 +-- .../tests/apply.client.spec.ts | 26 ++++++++-- .../tests/expected/sign-out-unknown-en.txt | 1 + .../tests/expected/sign-out-unknown-zh.txt | 1 + .../tests/sign-out-dialog.client.spec.tsx | 6 +-- .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 4 ++ .../client/ui-settings-models/README.zh.md | 4 ++ .../src/client/CustomProviderCard.tsx | 3 +- .../src/client/ProviderEditor.tsx | 3 +- .../src/client/operations.ts | 15 ++++-- .../ui-settings-models/src/client/store.ts | 6 ++- .../tests/components.client.spec.tsx | 23 +++++++++ .../tests/provider-form.client.spec.tsx | 24 ++++++++++ .../tests/store.client.spec.ts | 10 ++++ .../extensions/tool-cordis/src/api-catalog.ts | 2 +- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/README.zh.md | 2 +- packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 2 + packages/llm/llm/README.zh.md | 2 + packages/llm/llm/src/index.ts | 8 ++-- 63 files changed, 348 insertions(+), 123 deletions(-) create mode 100644 packages/client/ui-settings-account/tests/expected/sign-out-unknown-en.txt create mode 100644 packages/client/ui-settings-account/tests/expected/sign-out-unknown-zh.txt diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index 40b3911071..45975fe2df 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -132,10 +132,8 @@ describe('web e2e: the composer model switch is the default for later sessions', const box = page.locator('[data-composer-input]').first() await expect.poll(async () => box.isEnabled(), { timeout: 10_000 }).toBe(true) - // What removing the provider on the Models page leaves behind: the saved - // default still names the route, and nothing serves it any more. - // `replace`, not `update`: a merge patch of `{providers: {}}` leaves every - // stored profile in place. + // Removing the selected provider preserves the other route and the saved + // selection. A merge update would retain the removed profile. await scaffold.ctx.settings.replace('llm-pi-ai', { providers: { [START_ROUTE]: { displayName: 'Origin Gateway', api: 'openai-completions', baseURL: 'https://gateway.origin.example/v1', models: [{ id: START_MODEL, name: 'Origin Large' }] }, diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index d642a66c82..d1ac605308 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 3e651fa4433f0d6bab0ba73cd85929ed45e9ee79 -architecture.zh.md: a30bb804c419f38e3f68a06c252e90e4993b9e42 +architecture.md: fd3e04f7287ce97628b162becf647284221c8f64 +architecture.zh.md: f5e3bf8a3a6746044d5fbd229997eeb21303576c diff --git a/docs/architecture.md b/docs/architecture.md index 3e651fa443..fd3e04f728 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -112,7 +112,7 @@ One inbox feeds the driver; injected context waits for a waking message. AgentLo `agent/pre-step` decides the accepted input. Listeners may rewrite or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, `agent/request` and `prepareCall()` resolve the actual route before the system prompt and accepted users are committed; cancellation during either async phase commits neither. The prepared call capability governs prompt admission, not the preceding `request/context`. Every attempt synchronously reconciles the same rendered assembly, appends users only on the first attempt, logs header/context as needed, and derives and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The first admitted step reserves the system head before user messages even for an empty prompt (no wire message). The prompt travels only as `system/message` history: an empty rendering clears all active system nodes, leaving no old prompt model-visible; capable routes can append non-empty updates after the cached prefix; incapable routes and new request series consolidate non-empty prompt text at the first system node, with logged empty replacements for non-empty later system nodes ([decision](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md); [decision rule](../packages/core/agent-loop/README.md#understand-the-implementation)). -Immutable requests retain cancellation. `Agent.activeProvider` exposes current provider ownership. It reuses message-freeze evidence only for identities it has fully frozen; [agent-loop](../packages/core/agent-loop/README.md) owns the request construction rules. +The loop sends immutable requests with live cancellation and reuses message-freeze evidence only for identities it fully froze; [agent-loop](../packages/core/agent-loop/README.md) owns request construction. `Agent.activeProvider` identifies the provider of the latest prepared request. Details: the [sequence diagram](agent-lifecycle.md), the [tool pipeline](tool-execution-pipeline.md), and [cancellation and error recovery](subsystems/core.md#the-agent-handle). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index a30bb804c4..f5e3bf8a3a 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -116,7 +116,7 @@ turn/end `agent/pre-step` 决定接纳的输入。监听器可以改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,`agent/request` 和 `prepareCall()` 先解析实际路由,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试同步协调同一份已渲染组装结果、仅在首次尝试追加用户消息、按需记录 header/context、派生并冻结请求,再通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。首次接纳的步骤在用户消息之前预留系统头节点,即使提示词为空(不产生协议消息)。提示词仅通过 `system/message` 历史传递:空渲染文本清除所有生效的系统节点,模型不再看到旧提示词;具备能力的路由可在缓存前缀之后追加非空更新;不具备能力的路由与新请求序列将非空提示词文本归并到首个系统节点,并为非空的后续系统节点记录空内容替换([决策](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md);[决策规则](../packages/core/agent-loop/README.zh.md#understand-the-implementation))。 -循环发送不可变请求,同时保留实时取消能力。只有已由该循环完整冻结的消息对象身份才能复用冻结证明;[agent-loop](../packages/core/agent-loop/README.zh.md)拥有请求构造规则。 `Agent.activeProvider` 暴露当前提供方归属。 +循环发送不可变请求并保留实时取消能力,只对已完整冻结的消息对象身份复用冻结证明;[agent-loop](../packages/core/agent-loop/README.zh.md)拥有请求构造规则。`Agent.activeProvider` 标识最近一次已准备请求的提供方。 详情见[时序图](agent-lifecycle.zh.md)、[工具流水线](tool-execution-pipeline.zh.md)和[取消与错误恢复](subsystems/core.zh.md#the-agent-handle)。 diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index e174fcd03e..ba7cde7178 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: c5535c610ed88f265efe5065e2ff3374030192d4 -event-producer-consumer.zh.md: b2e558f5e711a3cf3628f1b9da802c1d69693090 +event-producer-consumer.md: f8160835916b5f1e6e71583c2adf647e4d996316 +event-producer-consumer.zh.md: 7a17b855917b7cb35d0fe67a56a9040dcd615978 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index c5535c610e..f816083591 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:355`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:282`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:383`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:600`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:580`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:601`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:581`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:608`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:587`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:594`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index b2e558f5e7..7a17b85591 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -23,11 +23,11 @@ | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:355`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:282`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:383`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:600`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:580`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:601`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:581`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:608`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:587`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:594`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index ae6c4b51eb..d59f81da1a 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: 43b4f86125847625026bd5619216ccdc7be926c1 -persistence-catalog.zh.md: c655d97086fa60630aa5dae1bf3f93be595f179b +persistence-catalog.md: f71cb89c5494c77df8d036faddea811a110444e1 +persistence-catalog.zh.md: abd0280d094b9f24fea7d9abe59d2ad17af263c3 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 43b4f86125..f71cb89c54 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -5304,7 +5304,7 @@ Array of `number`. SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -Sources: [`packages/api/session-controller/src/types.ts:380`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:381`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -7195,7 +7195,7 @@ Sources: [`packages/core/tools/src/index.ts:34`](../packages/core/tools/src/inde SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` -Sources: [`packages/api/session-controller/src/types.ts:385`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:386`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index c655d97086..abd0280d09 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -5306,7 +5306,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`packages/api/session-controller/src/types.ts:380`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:381`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -7197,7 +7197,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` -来源:[`packages/api/session-controller/src/types.ts:385`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:386`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 428d512264..db3a0f152a 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -44144,7 +44144,7 @@ "packages/workflow/workflow/src/types.ts#WorkflowRunId" ], "sources": [ - "packages/api/session-controller/src/types.ts:380", + "packages/api/session-controller/src/types.ts:381", "packages/attachment/attachment/src/brand.ts:6", "packages/compaction/compaction/src/brand.ts:4", "packages/core/session/src/types.ts:20", @@ -57727,7 +57727,7 @@ }, "names": [], "sources": [ - "packages/api/session-controller/src/types.ts:385" + "packages/api/session-controller/src/types.ts:386" ] }, { diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 757474dd41..2a34dc3c29 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md -llm-streaming.md: 07fc6599bc5d4eb5a4af9b566032004932052585 -llm-streaming.zh.md: 079a33599d1e5169af6402db2b2abdd7dc978369 +llm-streaming.md: 6ff93698cb877591fb85bc1a05d1dabc95b6561b +llm-streaming.zh.md: 400fcd5a96de0097ec827055fe6f96fc3eef0846 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 07fc6599bc..6ff93698cb 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -837,8 +837,9 @@ declare abstract class LlmAdapter { imageRequestPricing(_provider: string, _model: string): LlmImageRequestPricing | undefined; /** * List models this adapter can currently advertise for one owned provider. - * The result is advisory: an adapter may accept unlisted model ids, and - * consumers must not turn absence into request rejection. + * Core routing accepts unlisted model ids; catalog-driven entry points such + * as the GUI may require membership. Adapters used there must advertise + * their available models; the base empty catalog offers no GUI selection. * @param _provider - one provider route owned by this adapter. * @returns discoverable models in adapter-preferred order. */ @@ -1014,7 +1015,8 @@ fileRequestText(ref: FileAttachmentRef): string /** * Discover models advertised by one registered provider. Catalog membership - * is advisory and never changes routing or request validation. + * does not constrain core routing. Catalog-driven entry points may restrict + * selection and submission to the advertised models. * @param provider - registered provider route to inspect. * @returns detached model metadata in adapter-preferred order. */ diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 079a33599d..400fcd5a96 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -843,8 +843,9 @@ declare abstract class LlmAdapter { imageRequestPricing(_provider: string, _model: string): LlmImageRequestPricing | undefined; /** * List models this adapter can currently advertise for one owned provider. - * The result is advisory: an adapter may accept unlisted model ids, and - * consumers must not turn absence into request rejection. + * Core routing accepts unlisted model ids; catalog-driven entry points such + * as the GUI may require membership. Adapters used there must advertise + * their available models; the base empty catalog offers no GUI selection. * @param _provider - one provider route owned by this adapter. * @returns discoverable models in adapter-preferred order. */ @@ -1020,7 +1021,8 @@ fileRequestText(ref: FileAttachmentRef): string /** * Discover models advertised by one registered provider. Catalog membership - * is advisory and never changes routing or request validation. + * does not constrain core routing. Catalog-driven entry points may restrict + * selection and submission to the advertised models. * @param provider - registered provider route to inspect. * @returns detached model metadata in adapter-preferred order. */ diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index b178f956cf..c7614d54c7 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/session-controller/README.md -README.md: 5f09de61395c3d729144203b77eeb299c217128e -README.zh.md: dfb5b97b5804b4512d17eab47866ee079fbc047d +README.md: 5b96af2040c95e6e9195d55207ead0e0acbdba12 +README.zh.md: 42961830f1731ad9ce3efe048ebde03490f80637 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 5f09de6139..5b96af2040 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -68,7 +68,7 @@ References keep local Session data, scoped Contexts, and history streams alive, `SessionMediaReferences` mounts `GET|HEAD /api/file?path=` on the authenticated `connection.fetch` channel when `connection`, `fs`, and `attachments` are composed. It reads ordinary files through `ctx.fs`, including temporary paths outside registered workspaces and files in remote providers. Neither directory containment nor MIME categories restrict access; `mime-types` supplies the response type, with `application/octet-stream` for unknown extensions. GET reuses `readBytes` for preflight and ongoing byte limits; HEAD reads metadata only. All files use `ctx.attachments.imageLimits.maxImageBytes` (normally 20 MiB); exceeding this limit returns 413. Responses contain the complete file, ignore Range, and carry `private, no-store`, `nosniff`, and a sandbox CSP so directly opened HTML/SVG cannot execute with the API origin. The Client rewrite lives in `ui-chat` (`AssistantMarkdown`); audio/video responses are available, while Markdown audio/video player nodes remain separate work. -GUI model selection and prompt admission require the exact provider/model pair in the current available catalog. An unavailable selection rejects with `session/model-unavailable` before delivery; admission checks again after attachment preparation. `initializeDefaultModel(provider)` saves the first available model of the explicitly configured provider only before any user choice exists. Availability never substitutes another model or rewrites a Session selection. +GUI model selection and prompt admission require the exact provider/model pair in the current available catalog. An unavailable selection or a failed catalog query rejects with `session/model-unavailable` before delivery; admission checks again after attachment preparation with the same error code. `initializeDefaultModel(provider)` saves the first available model of the explicitly configured provider only before any user choice exists. A provider with no available models rejects initialization with `session/provider-models-unavailable`. Availability never substitutes another model or rewrites a Session selection. These catalog requirements belong to GUI admission; direct LLM calls can use uncatalogued models. ----- diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index dfb5b97b58..42961830f1 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -68,7 +68,7 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在 当 `connection`、`fs` 与 `attachments` 均被组合时,`SessionMediaReferences` 在鉴权 `connection.fetch` 通道上挂载 `GET|HEAD /api/file?path=<绝对路径>`。它通过 `ctx.fs` 读取普通文件,包括已注册工作区之外的临时路径与远程提供方中的文件。目录包含关系与 MIME 类别均不限制访问;`mime-types` 提供响应类型,未知扩展名使用 `application/octet-stream`。GET 复用 `readBytes` 执行读取前及读取中的字节限制;HEAD 只读取元数据。所有文件均使用 `ctx.attachments.imageLimits.maxImageBytes`(通常为 20 MiB);超过此上限返回 413。响应包含完整文件,忽略 Range,并携带 `private, no-store`、`nosniff` 与沙箱 CSP,使直接打开的 HTML/SVG 无法以 API 源身份执行脚本。客户端重写位于 `ui-chat`(`AssistantMarkdown`);音视频文件响应已可用,Markdown 音视频播放器节点仍是独立工作。 -GUI 模型选择与提示词准入要求确切提供方/模型对出现在当前可用目录中。不可用的选择在投递前以 `session/model-unavailable` 拒绝;附件准备完成后再次检查。`initializeDefaultModel(provider)` 仅在用户尚未选择时,保存其显式配置的提供方的第一个可用模型。可用性变化不会替换模型或改写会话选择。 +GUI 模型选择与提示词准入要求确切提供方/模型对出现在当前可用目录中。选择不可用或目录查询失败时,投递前以 `session/model-unavailable` 拒绝;附件准备完成后再次检查,并使用相同错误码。`initializeDefaultModel(provider)` 仅在用户尚未选择时,保存其显式配置的提供方的第一个可用模型。提供方没有可用模型时,初始化以 `session/provider-models-unavailable` 拒绝。可用性变化不会替换模型或改写会话选择。这些目录要求属于 GUI 准入;直接 LLM 调用可使用未列入目录的模型。 ----- diff --git a/packages/api/session-controller/src/catalog.ts b/packages/api/session-controller/src/catalog.ts index df751d86ba..e66c6961cb 100644 --- a/packages/api/session-controller/src/catalog.ts +++ b/packages/api/session-controller/src/catalog.ts @@ -1,5 +1,7 @@ /** Shared projection of the live LLM registry into the browser model catalog. */ +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import type { LlmModelInfo } from '@deepseek-ai/dsh-llm' import type { Context } from '@deepseek-ai/cordis' import type { ModelCatalog, @@ -57,11 +59,12 @@ export async function buildModelCatalog( } } })) + const groups = catalog.flatMap(item => item.kind === 'group' ? [item.group] : []) + .filter(group => group.models.length > 0) return { default: { ...defaultSelection }, - routableProviders: catalog.flatMap(item => item.kind === 'group' && item.group.models.length > 0 ? [item.group.id] : []), - groups: catalog.flatMap(item => item.kind === 'group' ? [item.group] : []) - .filter(group => group.models.length > 0), + routableProviders: groups.map(group => group.id), + groups, failures: catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : []), } } @@ -74,6 +77,12 @@ export async function buildModelCatalog( */ export async function modelAvailable(ctx: Context, selection: ModelSelection): Promise { if (!ctx.llm.listProviders().some(provider => provider.id === selection.provider)) return false - const models = await ctx.llm.listModels(selection.provider) + let models: readonly LlmModelInfo[] + try { models = await ctx.llm.listModels(selection.provider) } + catch (error) { + throw new RemoteError('session/model-unavailable', + error instanceof Error ? error.message : String(error), + { provider: selection.provider, model: selection.model }) + } return models.some(model => model.id === selection.model) } diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 6f51c15da4..2cc81e3097 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -279,7 +279,8 @@ export class SessionController extends TypertRemoteService { async initializeDefaultModel(provider: string): Promise { const catalog = await buildModelCatalog(this.ctx) const model = catalog.groups.find(group => group.id === provider)?.models[0] - if (model === undefined) throw new Error(`provider "${provider}" has no available models`) + if (model === undefined) throw new RemoteError('session/provider-models-unavailable', + `provider "${provider}" has no available models`, { provider }) await this.ctx.agentDefaultModel.initializeSelection({ provider, model: model.id, ...model.reasoning?.defaultEffort === undefined ? {} : { reasoningEffort: ReasoningEffortId(model.reasoning.defaultEffort) }, }) diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index c5b92ec0cc..e1021e8c0e 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -187,6 +187,7 @@ export const SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS = 240 declare module '@deepseek-ai/dsh-typert-protocol' { interface RemoteErrorDetailsMap { + 'session/provider-models-unavailable': { readonly provider: string } 'session/model-unavailable': { readonly provider: string; readonly model: string } 'session/conflict': { readonly sessionId: SessionId diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts index 26d71d839e..0994ed1474 100644 --- a/packages/api/session-controller/tests/session-models.host.spec.ts +++ b/packages/api/session-controller/tests/session-models.host.spec.ts @@ -360,7 +360,9 @@ describe('Web session model selection', () => { await ctx.agentDefaultModel.saveSelection({ provider: 'removed', model: 'removed' }) await controller.initializeDefaultModel('deepseek-official') expect(ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'removed', model: 'removed' }) - await expect(controller.initializeDefaultModel('empty')).rejects.toThrow('no available models') + await expect(controller.initializeDefaultModel('empty')).rejects.toMatchObject({ + code: 'session/provider-models-unavailable', details: { provider: 'empty' }, + }) await ctx.fiber.dispose() }) @@ -612,6 +614,48 @@ describe('Web session model selection', () => { await ctx.fiber.dispose() }) + it.each([1, 2])('reports catalog errors as model-unavailable at prompt check %s', async (check) => { + const { ctx, sessionId, agent } = await harness() + const followup = vi.fn() + Object.assign(agent, { followup }) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', + }) + const list = vi.spyOn(ctx.llm, 'listModels') + if (check === 2) list.mockResolvedValueOnce([{ provider: 'deepseek-official', id: 'deepseek-chat', name: 'Chat' }]) + list.mockRejectedValueOnce(new Error('credential storage offline')) + try { + expect(await remote.prompt(promptRequest({ + sessionId, mode: 'queue', content: [{ type: 'text', text: 'hello' }], + }))).toMatchObject({ ok: false, error: { + code: 'session/model-unavailable', message: 'credential storage offline', + details: { provider: 'deepseek-official', model: 'deepseek-chat' }, + } }) + expect(list).toHaveBeenCalledTimes(check) + expect(followup).not.toHaveBeenCalled() + } finally { + list.mockRestore() + await ctx.fiber.dispose() + } + }) + + it('leaves uncatalogued adapters usable by core callers but unavailable to GUI selection', async () => { + const { ctx, sessionId } = await harness() + ctx.llm.registerAdapter(['uncatalogued'], new class extends LlmAdapter { + override async *stream(_options: GenerateOptions): AsyncIterable {} + }()) + try { + await expect(ctx.llm.resolveModelInfo('uncatalogued', 'custom-model')).resolves.toMatchObject({ id: 'custom-model' }) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'uncatalogued', model: 'custom-model' }), cwd: '/tmp', + }) + expect(await remote.selectModel({ sessionId, provider: 'uncatalogued', model: 'custom-model' })) + .toMatchObject({ ok: false, error: { code: 'session/model-unavailable' } }) + } finally { + await ctx.fiber.dispose() + } + }) + it('refuses a prompt no adapter can route, and reports it on the directory', async () => { const { ctx, sessionId } = await harness() const remote = createSessionTestRemote(ctx, { diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx index 42bc9724df..dc7e753c88 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx @@ -150,7 +150,7 @@ export function ConversationContent(props: ConversationContentProps) { // `blocked`, not `disabled`: the bar refuses input either way, but a // block keeps the model seat live because choosing a model is how the // user clears it. - ? { blocked: composerBlock, ...(hero ? { placeholder: t('placeholder.hero') } : {}) } + ? { blocked: composerBlock, placeholder: t(hero ? 'placeholder.hero' : 'placeholder.default') } : hero ? { placeholder: t('placeholder.hero') } : {}), }) diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index 17aadfb056..c7720a7db2 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -424,15 +424,14 @@ describe('ConversationRoot resident composer', () => { expect(dispatchCount()).toBe(before) }) - it('renders the composer inert with the blocker\u2019s own reason', () => { + it('keeps the normal placeholder while model availability blocks sending', () => { const b = mount(sessionSnapshotOf(), undefined, undefined, { composerBlock: { reason: 'select a model first' }, }) const box = b.view.getByRole('textbox') - // One disabled composer with the blocker's placeholder, never a second - // tree: the DOM survives the block being raised and cleared. + // Blocking keeps the mounted composer and its normal placeholder. expect(box.getAttribute('aria-disabled')).toBe('true') - expect(box.getAttribute('data-placeholder')).toBe('select a model first') + expect(box.getAttribute('data-placeholder')).toBe(zh['placeholder.default']) fireEvent.keyDown(box, { key: 'Enter' }) expect(b.sink).not.toHaveBeenCalled() diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index db4fe73951..496f41b644 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-model-selection/README.md -README.md: 3c73a42b34ad9075eaaab9e256767b7250ea0513 -README.zh.md: 87cccdeacde3183427a1ff08e1d1288e5cf170be +README.md: 25945f012b2c1f6cfc0f36203fac632c01a86101 +README.zh.md: 6c59586a08b7833f40491f61b5f57cd2b76e5735 diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md index 3c73a42b34..25945f012b 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -33,11 +33,11 @@ Mount this plugin alongside `ui-conversation` and the commands package; the comp ### Model and effort -Models stay grouped by provider. The composer menu shows model and effort names only, with DeepSeek Account first; other providers retain their catalog order. The `/model` popup shows provider names and catalog descriptions; it localizes the two built-in DeepSeek descriptions and leaves external provider descriptions verbatim. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input. +Models stay grouped by provider. The composer menu shows model and effort names only, with DeepSeek Account first and DeepSeek second; third-party providers retain their catalog order. The `/model` popup shows provider names and catalog descriptions; it localizes the two built-in DeepSeek descriptions and leaves external provider descriptions verbatim. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input. ### Unroutable sessions -The composer blocks sending while the exact provider/model pair is absent from the available catalog or availability is loading or unknown. It keeps the normal input placeholder and an enabled model picker. Sign-out, credential removal, and provider or model removal refresh availability without replacing the saved choice with another model. Existing session logs remain unchanged. +The composer blocks sending while the exact provider/model pair is absent from the available catalog or availability is loading or unknown. It keeps the normal input placeholder and an enabled model picker. Refreshes and refresh failures retain the last displayed selection and groups while sending remains blocked; retry revalidates availability. A Host reset clears that display. The account provider name follows the active locale in both selectors. Sign-out, credential removal, and provider or model removal refresh availability without replacing the saved choice with another model. Existing session logs remain unchanged. Only the current Client binding's directory can publish its composer block. Cleanup from an older binding preserves a replacement directory's block; without a replacement directory, cleanup removes the obsolete block. diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index 87cccdeacd..6c59586a08 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -33,11 +33,11 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示 ### 模型与推理强度 -模型按提供方分组。composer 菜单只显示模型与推理强度名称,DeepSeek 账号置顶,其他提供方保持目录原有顺序。`/model` 弹窗显示提供方名称与目录说明;其中两个内置 DeepSeek 模型的说明使用当前语言,外部提供方说明保持原文。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。 +模型按提供方分组。composer 菜单只显示模型与推理强度名称,DeepSeek 账号排第一,DeepSeek 排第二,第三方提供方保持目录原有顺序。`/model` 弹窗显示提供方名称与目录说明;其中两个内置 DeepSeek 模型的说明使用当前语言,外部提供方说明保持原文。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。 ### 不可路由的会话 -确切提供方/模型对不在可用目录中,或可用性仍在加载或未知时,composer 禁止发送。输入框保留常规占位文案,模型选择器仍可操作。退出账号、移除凭据、提供方或模型都会刷新可用性,不会用其他模型替换已保存的选择。既有会话日志保持不变。 +确切提供方/模型对不在可用目录中,或可用性仍在加载或未知时,composer 禁止发送。输入框保留常规占位文案,模型选择器仍可操作。刷新及刷新失败期间保留上次显示的选择和分组,同时继续禁发;重试会重新确认可用性。Host 重置时清空这些显示。两个选择器中的账号提供方名称均随当前语言显示。退出账号、移除凭据、提供方或模型都会刷新可用性,不会用其他模型替换已保存的选择。既有会话日志保持不变。 只有当前 Client binding 的目录可以发布对应的 composer 阻塞块。旧 binding 的清理保留后继目录的阻塞块;若尚无后继目录,则移除已失效的阻塞块。 diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx index 2223cd3c21..d209e23cc7 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx +++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx @@ -76,7 +76,8 @@ export function ModelSelect( const id = useId() const groups = useMemo(() => state.groups.toSorted((left, right) => - Number(right.id === 'deepseek-account') - Number(left.id === 'deepseek-account')), [state.groups]) + (left.id === 'deepseek-account' ? 0 : left.id === 'deepseek-official' ? 1 : 2) + - (right.id === 'deepseek-account' ? 0 : right.id === 'deepseek-official' ? 1 : 2)), [state.groups]) const choices = useMemo(() => groups.flatMap(group => group.models.map(model => ({ group, @@ -407,7 +408,7 @@ export function ModelSelect( )} {state.failures.map(failure => (
- {t('warning.groupLoad', { name: failure.name, message: failure.message })} + {t('warning.groupLoad', { name: failure.id === 'deepseek-account' ? t('provider.account') : failure.name, message: failure.message })}
))} @@ -416,7 +417,7 @@ export function ModelSelect( const headingId = `${id}-${group.id}` return (
-
{group.name}
+
{group.id === 'deepseek-account' ? t('provider.account') : group.name}
{group.models.map((model) => { const selected = state.current?.provider === group.id && state.current.model === model.id return ( diff --git a/packages/client/ui-model-selection/src/client/directory.ts b/packages/client/ui-model-selection/src/client/directory.ts index 162ad5db7d..355dfc7fda 100644 --- a/packages/client/ui-model-selection/src/client/directory.ts +++ b/packages/client/ui-model-selection/src/client/directory.ts @@ -15,7 +15,7 @@ import type { ModelCatalogDirectory } from './catalog.ts' /** Directory snapshot both entries render from. */ export interface ModelDirectoryState { - /** Available durable selection, or the available default for an unselected Session. */ + /** Last catalog-confirmed selection, retained for display during same-Host refreshes. */ current: ModelSelection | null /** Saved effort caption retained when the selected model is unavailable. */ retainedEffort?: string @@ -145,11 +145,11 @@ export class ModelDirectory { : reasoning?.efforts.find(level => level.id === effort)?.name ?? effort if (catalog.status !== 'ready' || catalog.value === null || projected === undefined) { this.store.set({ - current: null, + current: catalog.value === null ? null : this.store.getSnapshot().current, ...retainedEffort === undefined ? {} : { retainedEffort }, routable: null, - groups: [], - failures: [], + groups: catalog.value?.groups ?? [], + failures: catalog.value?.failures ?? [], status: catalog.status === 'error' ? 'error' : 'loading', error: catalog.error, }) diff --git a/packages/client/ui-model-selection/src/client/index.ts b/packages/client/ui-model-selection/src/client/index.ts index 201ac8c732..12f81cec2f 100644 --- a/packages/client/ui-model-selection/src/client/index.ts +++ b/packages/client/ui-model-selection/src/client/index.ts @@ -67,12 +67,13 @@ function descriptionOf( function optionsOf(directory: ModelDirectoryState, t: TranslateNS<'model'>): SelectOption[] { const rows: SelectOption[] = [] for (const group of directory.groups) { + const name = group.id === 'deepseek-account' ? t('provider.account') : group.name for (const model of group.models) { const description = descriptionOf(group.id, model, t) rows.push({ id: rowId(group.id, model.id), label: model.name, - detail: description !== undefined ? `${group.name} · ${description}` : group.name, + detail: description !== undefined ? `${name} · ${description}` : name, ...(directory.current !== null && directory.current.provider === group.id && directory.current.model === model.id @@ -83,7 +84,7 @@ function optionsOf(directory: ModelDirectoryState, t: TranslateNS<'model'>): Sel for (const failure of directory.failures) { rows.push({ id: `failure/${failure.id}`, - label: failure.name, + label: failure.id === 'deepseek-account' ? t('provider.account') : failure.name, detail: t('option.loadError', { message: failure.message }), }) } diff --git a/packages/client/ui-model-selection/src/client/locales.ts b/packages/client/ui-model-selection/src/client/locales.ts index 2ab727b353..9d60b95926 100644 --- a/packages/client/ui-model-selection/src/client/locales.ts +++ b/packages/client/ui-model-selection/src/client/locales.ts @@ -10,6 +10,7 @@ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { + 'provider.account': 'DeepSeek 账号', 'command.label': '模型', 'command.description': '选择本会话使用的模型', 'option.loadError': '目录加载失败:{message}', @@ -39,6 +40,7 @@ export type ModelKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { + 'provider.account': 'DeepSeek Account', 'command.label': 'Model', 'command.description': 'Select the model for this conversation', 'option.loadError': 'Catalog failed to load: {message}', diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index af7d3dee20..ffd02ee21b 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -73,16 +73,19 @@ async function bench(locale: 'zh' | 'en' = 'zh') { const projections = new Map>() // Whether the Host advertises available models for the current route. let routable = true + let catalogFailure = false + let groups = GROUPS let selectionFailure: RemoteError<'session/writer-held'> | undefined const sessionRemote = { modelCatalog: () => { calls.models += 1 + if (catalogFailure) return Promise.reject(new Error('catalog offline')) return Promise.resolve({ ok: true as const, value: { default: defaultSelection, routableProviders: routable ? ['deepseek-official'] : [], - groups: routable ? GROUPS : [], + groups: routable ? groups : [], failures: [], }, }) @@ -185,6 +188,8 @@ async function bench(locale: 'zh' | 'en' = 'zh') { setHostCurrent: (selection: ModelSelection) => { defaultSelection = selection }, setProjected: (id: SessionId, value: ModelSelectionProjection) => { projections.get(id)?.set(value) }, address: (id: SessionId) => { addressed.add(id) }, + setGroups: (next: typeof GROUPS) => { groups = next }, + setCatalogFailure: (next: boolean) => { catalogFailure = next }, setRoutable: (next: boolean) => { routable = next }, blockOf: (key: string) => blocks.get(sid(key)), } @@ -280,6 +285,19 @@ describe('ui-model-selection dual entry', () => { }) }) + it.each(['en', 'zh'] as const)('localizes account provider details in the %s model popup', async (locale) => { + const b = await bench(locale) + try { + b.setGroups([{ ...GROUPS[0]!, id: 'deepseek-account', name: 'DeepSeek Account' }]) + b.remote.emit('llm/adapters-updated', []) + b.mint('s1') + const options = await b.popup().options(projection('s1'), new AbortController().signal) + expect(options[0]?.detail).toContain(locale === 'zh' ? 'DeepSeek 账号' : 'DeepSeek Account') + } finally { + await b.ctx.fiber.dispose() + } + }) + it('both entries share one directory instance per session, isolated across sessions', async () => { const b = await bench() b.mint('a') @@ -330,7 +348,9 @@ describe('ui-model-selection dual entry', () => { next: { provider: 'deepseek-official', model: 'deepseek-v4-pro' }, }) expect(face.directory.getSnapshot()).toMatchObject({ - current: null, + current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + groups: GROUPS, + routable: null, status: 'loading', }) @@ -342,6 +362,30 @@ describe('ui-model-selection dual entry', () => { }) }) + it('retains the last catalog on refresh failure, blocks sending, and recovers on retry', async () => { + const b = await bench() + try { + b.mint('s1') + const face = b.seat().inject!(sid('s1')) + await b.ctx.modelDirectories.directoryFor(sid('s1')).load() + b.setCatalogFailure(true) + b.remote.emit('credentials/record-updated', ['DEEPSEEK_API_KEY']) + await vi.waitFor(() => { + expect(face.directory.getSnapshot()).toMatchObject({ + current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + groups: GROUPS, routable: null, status: 'error', error: 'catalog offline', + }) + }) + expect(b.blockOf('s1')).toBeDefined() + b.setCatalogFailure(false) + await b.ctx.modelDirectories.directoryFor(sid('s1')).load() + expect(face.directory.getSnapshot().routable).toBe(true) + expect(b.blockOf('s1')).toBeUndefined() + } finally { + await b.ctx.fiber.dispose() + } + }) + it('scope disposal drops the directory; a reborn scope gets a fresh one', async () => { const b = await bench() const first = b.mint('s1') diff --git a/packages/client/ui-model-selection/tests/expected/account-first.txt b/packages/client/ui-model-selection/tests/expected/account-first.txt index 304c901c9f..6a8a6e2c70 100644 --- a/packages/client/ui-model-selection/tests/expected/account-first.txt +++ b/packages/client/ui-model-selection/tests/expected/account-first.txt @@ -1,8 +1,8 @@ deepseek-account-1 deepseek-account-2 -custom-1 -custom-2 deepseek-official-1 deepseek-official-2 +custom-1 +custom-2 another-1 another-2 diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx index 2d8df4fad0..feef74b1bd 100644 --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx @@ -8,7 +8,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ComponentProps } from 'react' import type { ModelDirectoryState } from '../src/client/directory.ts' import { ModelSelect } from '../src/client/ModelSelect.tsx' -import { zh } from '../src/client/locales.ts' +import { en, zh } from '../src/client/locales.ts' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' // The seat's key domain is model ∪ common; the stub mirrors the real lookup @@ -463,7 +463,7 @@ it('shows the unselected model control with the inherited effort', async () => { }) -it('places account models first while preserving other provider and model order', async () => { +it('places account and official models before third-party models', async () => { const groups = ['custom', 'deepseek-official', 'deepseek-account', 'another'].map(id => ({ id, name: id, models: [1, 2].map(index => ({ id: `${id}-${index}`, name: `${id}-${index}` })), })) @@ -472,9 +472,22 @@ it('places account models first while preserving other provider and model order' fireEvent.click(screen.getByRole('button', { name: '请选择模型' })) const names = screen.getAllByRole('menuitemradio').map(row => row.textContent) expect(names).toEqual([ - 'deepseek-account-1', 'deepseek-account-2', 'custom-1', 'custom-2', - 'deepseek-official-1', 'deepseek-official-2', 'another-1', 'another-2', + 'deepseek-account-1', 'deepseek-account-2', 'deepseek-official-1', 'deepseek-official-2', + 'custom-1', 'custom-2', 'another-1', 'another-2', ]) expect(groups.map(group => group.id)).toEqual(['custom', 'deepseek-official', 'deepseek-account', 'another']) await expect(`${names.join('\n')}\n`).toMatchFileSnapshot('./expected/account-first.txt') }) + +it.each([en, zh])('localizes the account group while preserving external names', (copy) => { + const groups = ['deepseek-account', 'custom'].map(id => ({ + id, name: id === 'deepseek-account' ? 'DeepSeek Account' : 'My Gateway', + models: [{ id: 'model', name: 'Model' }], + })) + render( key in copy ? copy[key as keyof typeof copy] : key} />) + fireEvent.click(screen.getByRole('button', { name: copy['trigger.selectAria'] })) + expect(screen.getByRole('group', { name: copy['provider.account'] })).toBeTruthy() + expect(screen.getByRole('group', { name: 'My Gateway' })).toBeTruthy() +}) diff --git a/packages/client/ui-settings-account/README.i18n.yaml b/packages/client/ui-settings-account/README.i18n.yaml index a9fcd71d5a..a3c8fbdf6f 100644 --- a/packages/client/ui-settings-account/README.i18n.yaml +++ b/packages/client/ui-settings-account/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-account/README.md -README.md: d54687d083897842518a0c95dfc84d39e985f3a1 -README.zh.md: c4dbcf234fb72e826ccd742b70740744e98e8fcb +README.md: 615d2ef3466fc748d7b8c65c65c356afdd4f3cf3 +README.zh.md: 39d8a94b2e8d98896b13b8c76f7e5ff6390b5d0d diff --git a/packages/client/ui-settings-account/README.md b/packages/client/ui-settings-account/README.md index d54687d083..615d2ef346 100644 --- a/packages/client/ui-settings-account/README.md +++ b/packages/client/ui-settings-account/README.md @@ -34,7 +34,7 @@ The sidebar account menu uses the shared Menu surface, backdrop blur, spacing, a The account card’s More account information link opens `https://platform.deepseek.com` in the system browser on Desktop or a new tab on Web. -Sign out first queries running account-token tasks and opens a confirmation dialog. The warning describes interruption when such tasks exist; otherwise it explains that data is retained and the account can be signed in again. Cancel, close, and Escape dismiss without signing out. Failed queries keep the menu available; failed sign-out keeps the dialog available for retry. +Sign out first queries running account-token tasks and opens a confirmation dialog. The warning describes interruption when such tasks exist; otherwise it explains that data is retained and the account can be signed in again. Cancel, close, and Escape dismiss without signing out. Failed impact queries still open confirmation with an explicit unknown-task warning; failed sign-out keeps the dialog available for retry. ## Understand the implementation @@ -74,3 +74,5 @@ No model request prefix changes. The [desktop login decision](../../../.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md) records cancellation and storage ownership. Usage and top-up show a centered 24px loading indicator without visible loading text until the native document loads; the return action remains available. The loading SVG is embedded locally from Figma node 2957:72553. + +Default-model initialization runs after publishing and accepting the sign-in frame, without delaying subsequent account frames. Failures are recorded in diagnostics and do not mark the signed-in account as failed. diff --git a/packages/client/ui-settings-account/README.zh.md b/packages/client/ui-settings-account/README.zh.md index c4dbcf234f..39d8a94b2e 100644 --- a/packages/client/ui-settings-account/README.zh.md +++ b/packages/client/ui-settings-account/README.zh.md @@ -34,7 +34,7 @@ Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Pl 账号卡片的“更多账号信息”链接在 Desktop 中通过系统浏览器打开 `https://platform.deepseek.com`,在 Web 中打开新标签页。 -退出登录先查询正在使用账号 token 的任务,再打开确认弹窗。有此类任务时提示执行将被打断,否则提示数据保留且可重新登录。取消、关闭和 Escape 均不退登。查询失败保留菜单;退登失败保留弹窗以便重试。 +退出登录先查询正在使用账号 token 的任务,再打开确认弹窗。有此类任务时提示执行将被打断,否则提示数据保留且可重新登录。取消、关闭和 Escape 均不退登。影响查询失败仍打开确认弹窗,并明确提示无法确认任务状态;退登失败保留弹窗以便重试。 ## 理解实现 @@ -74,3 +74,5 @@ Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Pl [桌面登录决策](../../../.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md)记录取消和存储的职责。 用量和充值在原生页面加载完成前显示居中的 24px 加载图标,不显示加载文字;返回操作始终可用。加载 SVG 从 Figma 节点 2957:72553 内嵌到本地样式。 + +默认模型初始化在登录帧发布并确认接收后运行,不延迟后续账号帧。初始化失败会记录诊断日志,不会将已登录账号标记为失败。 diff --git a/packages/client/ui-settings-account/src/client/AccountMenu.tsx b/packages/client/ui-settings-account/src/client/AccountMenu.tsx index 6122728558..7570a13190 100644 --- a/packages/client/ui-settings-account/src/client/AccountMenu.tsx +++ b/packages/client/ui-settings-account/src/client/AccountMenu.tsx @@ -27,12 +27,12 @@ export function AccountMenu({ const [open, setOpen] = useState(false) const [busy, setBusy] = useState(false) const [failed, setFailed] = useState(false) - const [signOutImpact, setSignOutImpact] = useState() + const [signOutImpact, setSignOutImpact] = useState() const requestSignOut = async () => { setBusy(true) setFailed(false) try { setSignOutImpact(await hasRunningAccountTasks()); setOpen(false) } - catch { setFailed(true) } + catch (_error) { setSignOutImpact('unknown'); setOpen(false) } finally { setBusy(false) } } return
diff --git a/packages/client/ui-settings-account/src/client/SignOutDialog.tsx b/packages/client/ui-settings-account/src/client/SignOutDialog.tsx index f9b59e8ea8..d364f41ede 100644 --- a/packages/client/ui-settings-account/src/client/SignOutDialog.tsx +++ b/packages/client/ui-settings-account/src/client/SignOutDialog.tsx @@ -6,7 +6,7 @@ import css from './SignInDialog.module.css' /** @param props - task impact at opening, localized copy, and account actions. @returns sign-out confirmation. */ export function SignOutDialog({ running, signOut, close, t }: { - running: boolean + running: boolean | 'unknown' signOut: () => Promise close: () => void t: (key: AccountKey) => string @@ -29,7 +29,7 @@ export function SignOutDialog({ running, signOut, close, t }: {
-

{t(running ? 'signOutRunningDescription' : 'signOutDescription')}

+

{t(running === 'unknown' ? 'signOutUnknownDescription' : running ? 'signOutRunningDescription' : 'signOutDescription')}

{failed &&

{t('failed')}

}
diff --git a/packages/client/ui-settings-account/src/client/index.ts b/packages/client/ui-settings-account/src/client/index.ts index da26bbd8d8..c718292cbf 100644 --- a/packages/client/ui-settings-account/src/client/index.ts +++ b/packages/client/ui-settings-account/src/client/index.ts @@ -72,19 +72,19 @@ export function apply(ctx: Context): void { for await (const frame of stream) { revision++ refreshing = undefined - let initializationFailed = false - if (frame.value.status === 'credential-stored' && frame.value.attempt?.phase === 'succeeded' - && snapshot.view?.attempt?.phase !== 'succeeded') { - try { - const initialized = await ctx.remote.session.initializeDefaultModel('deepseek-account') - initializationFailed = !initialized.ok - } catch (_error) { - initializationFailed = true - } - } - publish({ ...snapshot, view: frame.value, details: undefined, failed: initializationFailed }) + const initialize = frame.value.status === 'credential-stored' && frame.value.attempt?.phase === 'succeeded' + && snapshot.view?.attempt?.phase !== 'succeeded' + publish({ ...snapshot, view: frame.value, details: undefined, failed: false }) browser.update(frame.value) frame.accept() + if (initialize) void (async () => { + try { + const initialized = await ctx.remote.session.initializeDefaultModel('deepseek-account') + if (!initialized.ok) console.info('[deepseek-account] default model initialization failed', { reason: 'refused' }) + } catch (_error) { + console.info('[deepseek-account] default model initialization failed', { reason: 'disconnected' }) + } + })() void refresh() } })().catch(() => { if (!stream.signal.aborted) publish({ ...snapshot, failed: true }) }) diff --git a/packages/client/ui-settings-account/src/client/locales.ts b/packages/client/ui-settings-account/src/client/locales.ts index b93c8e61c0..e3a07f3f8f 100644 --- a/packages/client/ui-settings-account/src/client/locales.ts +++ b/packages/client/ui-settings-account/src/client/locales.ts @@ -10,6 +10,7 @@ export const en = { settings: 'Settings', contactUs: 'Contact us', menu: 'Account menu', nav: 'Account', signedIn: 'Signed in to DeepSeek', signedOut: 'Not signed in', signIn: 'Sign in', signOut: 'Sign out', + signOutUnknownDescription: 'Could not check running tasks. Signing out may interrupt tasks using this account. Sign out now?', signOutDescription: 'Signing out will not delete any data. You can sign in to this account again.', signOutRunningDescription: 'Tasks are currently running. Signing out will interrupt them. Sign out now?', cancel: 'Cancel', open: 'Open browser', initializing: 'Starting sign in…', waiting: 'Continue in your browser', @@ -36,6 +37,7 @@ export const zh: Record = { settings: '设置', contactUs: '联系我们', menu: '账号菜单', nav: '账号与余额', signedIn: '已登录 DeepSeek', signedOut: '尚未登录', signIn: '登录', signOut: '退出登录', + signOutUnknownDescription: '暂时无法确认任务状态。退出登录可能会中断使用此账号的任务,是否继续?', signOutDescription: '退出登录不会丢失任何数据,你仍可以登录此账号。', signOutRunningDescription: '当前有进程正在执行中,点击“退出登录”,将会打断进程,是否即刻退出登录?', cancel: '取消', open: '打开浏览器', initializing: '正在发起登录…', waiting: '请在浏览器中继续', diff --git a/packages/client/ui-settings-account/tests/account.client.spec.tsx b/packages/client/ui-settings-account/tests/account.client.spec.tsx index 3757579a79..dbf81ece0e 100644 --- a/packages/client/ui-settings-account/tests/account.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/account.client.spec.tsx @@ -51,14 +51,14 @@ it('starts sign-in and disables cancellation during persistence', async () => { expect(screen.queryByRole('button', { name: en.signIn })).toBeNull() }) -it.each([en, zh].flatMap(copy => [false, true].map(running => ({ copy, running }))))('confirms sidebar sign-out with task impact $running', async ({ copy, running }) => { +it.each([en, zh].flatMap(copy => ([false, true, 'unknown'] as const).map(running => ({ copy, running }))))('confirms sidebar sign-out with task impact $running', async ({ copy, running }) => { const signOut = vi.fn(() => Promise.resolve()) const openSettings = vi.fn() const operations = mount({ status: 'credential-stored', attempt: null }, copy) cleanup() const { AccountMenu } = await import('../src/client/AccountMenu.tsx') render( running} + hasRunningAccountTasks={async () => { if (running === 'unknown') throw new Error('offline'); return running }} useAccount={selector => selector(operations.hooks.account.getSnapshot())} wide openOnboarding={() => {}} openSettings={openSettings} t={key => key in copy ? copy[key as AccountKey] : key} />) fireEvent.click(screen.getByRole('button', { name: copy.menu })) @@ -73,7 +73,7 @@ it.each([en, zh].flatMap(copy => [false, true].map(running => ({ copy, running } fireEvent.click(screen.getByRole('button', { name: copy.menu })) await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: copy.signOut })) }) expect(signOut).not.toHaveBeenCalled() - expect(screen.getByText(running ? copy.signOutRunningDescription : copy.signOutDescription)).toBeTruthy() + expect(screen.getByText(running === 'unknown' ? copy.signOutUnknownDescription : running ? copy.signOutRunningDescription : copy.signOutDescription)).toBeTruthy() await act(async () => { fireEvent.click(screen.getByRole('button', { name: copy.signOut })) }) expect(signOut).toHaveBeenCalledOnce() expect(screen.queryByRole('menu')).toBeNull() diff --git a/packages/client/ui-settings-account/tests/apply.client.spec.ts b/packages/client/ui-settings-account/tests/apply.client.spec.ts index 7f01fc5f6e..6a33cda9d4 100644 --- a/packages/client/ui-settings-account/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-account/tests/apply.client.spec.ts @@ -4,13 +4,17 @@ import { expect, it, vi } from 'vitest' import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types' import { apply, type AccountSectionInjected } from '../src/client/index.ts' -it.each(['accepted', 'refused', 'disconnected'] as const)('initializes account models and keeps reading account state: %s', async (outcome) => { +it.each(['accepted', 'refused', 'disconnected', 'pending'] as const)('initializes account models and keeps reading account state: %s', async (outcome) => { + const log = vi.spyOn(console, 'info').mockImplementation(() => {}) + const observed: { status: string | undefined; failed: boolean | undefined }[] = [] const abort = new AbortController() const done = Promise.withResolvers() const release = Promise.withResolvers() + const initialization = Promise.withResolvers() const initializeDefaultModel = vi.fn(async () => { + if (outcome === 'pending') await initialization.promise if (outcome === 'disconnected') throw new Error('connection lost') - return { ok: outcome === 'accepted', value: undefined } + return { ok: outcome === 'accepted' || outcome === 'pending', value: undefined } }) const links = { usageUrl: 'https://example.test/usage', topUpUrl: 'https://example.test/top_up' } const signedIn: AccountView = { @@ -38,7 +42,11 @@ it.each(['accepted', 'refused', 'disconnected'] as const)('initializes account m $stream: () => ({ signal: abort.signal, dispose: () => { abort.abort(); release.resolve(undefined) }, async *[Symbol.asyncIterator]() { - for (const value of frames) yield { value, accept: () => { if (value.status === 'signed-out') done.resolve(undefined) } } + for (const value of frames) yield { value, accept: () => { + const snapshot = operations?.hooks.account.getSnapshot() + observed.push({ status: snapshot?.view?.status, failed: snapshot?.failed }) + if (value.status === 'signed-out') done.resolve(undefined) + } } await release.promise }, }), @@ -48,8 +56,20 @@ it.each(['accepted', 'refused', 'disconnected'] as const)('initializes account m apply(ctx as never) await done.promise expect(initializeDefaultModel).toHaveBeenCalledExactlyOnceWith('deepseek-account') + expect(observed).toEqual([ + { status: 'credential-stored', failed: false }, + { status: 'credential-stored', failed: false }, + { status: 'signed-out', failed: false }, + ]) + if (outcome === 'accepted' || outcome === 'pending') expect(log).not.toHaveBeenCalled() + else expect(log).toHaveBeenCalledWith('[deepseek-account] default model initialization failed', { + reason: outcome === 'refused' ? 'refused' : 'disconnected', + }) expect(operations?.hooks.account.getSnapshot()).toMatchObject({ view: { status: 'signed-out' }, failed: false }) } finally { + initialization.resolve(undefined) + await Promise.resolve() for (const dispose of disposers.reverse()) dispose() + log.mockRestore() } }) diff --git a/packages/client/ui-settings-account/tests/expected/sign-out-unknown-en.txt b/packages/client/ui-settings-account/tests/expected/sign-out-unknown-en.txt new file mode 100644 index 0000000000..d8b1fd328b --- /dev/null +++ b/packages/client/ui-settings-account/tests/expected/sign-out-unknown-en.txt @@ -0,0 +1 @@ +Sign outCould not check running tasks. Signing out may interrupt tasks using this account. Sign out now?CancelSign out diff --git a/packages/client/ui-settings-account/tests/expected/sign-out-unknown-zh.txt b/packages/client/ui-settings-account/tests/expected/sign-out-unknown-zh.txt new file mode 100644 index 0000000000..3578b1f9bf --- /dev/null +++ b/packages/client/ui-settings-account/tests/expected/sign-out-unknown-zh.txt @@ -0,0 +1 @@ +退出登录暂时无法确认任务状态。退出登录可能会中断使用此账号的任务,是否继续?取消退出登录 diff --git a/packages/client/ui-settings-account/tests/sign-out-dialog.client.spec.tsx b/packages/client/ui-settings-account/tests/sign-out-dialog.client.spec.tsx index 9ee95e897f..d2fbb29028 100644 --- a/packages/client/ui-settings-account/tests/sign-out-dialog.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/sign-out-dialog.client.spec.tsx @@ -6,16 +6,16 @@ import { SignOutDialog } from '../src/client/SignOutDialog.tsx' import { en, zh } from '../src/client/locales.ts' afterEach(cleanup) -function mount(running: boolean, copy: typeof en | typeof zh = en, signOut = vi.fn(async () => {})) { +function mount(running: boolean | 'unknown', copy: typeof en | typeof zh = en, signOut = vi.fn(async () => {})) { const close = vi.fn() render( copy[key]} />) return { signOut, close } } -it.each([en, zh].flatMap(copy => [false, true].map(running => ({ copy, running }))))( +it.each([en, zh].flatMap(copy => ([false, true, 'unknown'] as const).map(running => ({ copy, running }))))( 'confirms sign-out with localized task impact: $running', async ({ copy, running }) => { const props = mount(running, copy) await expect(`${screen.getByRole('dialog').textContent}\n`) - .toMatchFileSnapshot(`./expected/sign-out-${running ? 'running' : 'idle'}-${copy === en ? 'en' : 'zh'}.txt`) + .toMatchFileSnapshot(`./expected/sign-out-${running === 'unknown' ? 'unknown' : running ? 'running' : 'idle'}-${copy === en ? 'en' : 'zh'}.txt`) expect(props.signOut).not.toHaveBeenCalled() await act(async () => { fireEvent.click(screen.getByRole('button', { name: copy.signOut })) }) expect(props.signOut).toHaveBeenCalledOnce() diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index c84f99abf1..3147eeab54 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-models/README.md -README.md: ccce6981a618fd2dfa451f1600ae19e443d77973 -README.zh.md: 0d2f64a1fe4583493b192f33f128f86157a65f8f +README.md: 289a0ec0b3603590dddcab848a79235ca4579bcf +README.zh.md: 10c61a464d48933a692a265b5d5b0812bf79cdbb diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index ccce6981a6..289a0ec0b3 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -25,6 +25,10 @@ English | [中文](README.zh.md) ## Use this package +Saving credentials or a custom provider completes independently of default-model initialization. Initialization failures are logged; they do not report a successful save as failed or prevent closing the editor. The user can select an available model from the composer. + +DeepSeek Account appears first and DeepSeek second in the provider list; third-party providers retain their directory order. + Open the Models page from the Settings navigation to see every configured provider as a row. A whole-section provider whose key is not configured anywhere renders as its open setup card instead, but only in the first-run posture and only until the user closes that card. Each card kind owns its own open state, so closing one never discards a draft in another. A provider with a stored catalog error remains visible with its diagnostic and edit/delete actions. Add actions are offered only for registered settings namespaces, so an unavailable namespace cannot leave a button that opens no editor. A rejected save leaves the editor open and displays the Host diagnostic. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 0d2f64a1fe..10c61a464d 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -25,6 +25,10 @@ kind: "package-reference" ## 使用本包 +凭据或自定义提供方保存成功后,不等待默认模型初始化即可完成操作。初始化失败记录日志,不会将已成功的保存报告为失败,也不会阻止编辑器关闭。用户可在 composer 中选择可用模型。 + +提供方列表中 DeepSeek 账号排第一,DeepSeek 排第二;第三方提供方保留目录中的相对顺序。 + 从设置导航打开 Models 页面,即可看到每个已配置的提供方都有一行。其配置键未在任何位置配置的整分节提供方会渲染为其展开的设置卡片而非一行,但仅限首次运行姿态,且仅持续到用户关闭该卡片为止。每一类卡片各自持有自己的展开状态,因此关掉其中一张绝不会丢弃另一张里的草稿。 存在已存储目录错误的提供方仍显示诊断以及编辑、删除入口。添加操作只面向已注册的 settings 命名空间,因此不可用的命名空间不会留下无法打开编辑器的按钮。保存被拒绝时,编辑器保持打开并展示 Host 诊断。 diff --git a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx index c84e72f743..38def6a132 100644 --- a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx @@ -178,7 +178,8 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { // and the retry above now goes straight back to this write. if (stored !== undefined) return stored } - return operations.initializeModel(route.trim()) + void operations.initializeModel(route.trim()) + return undefined } const create = async (): Promise => { diff --git a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx index 93e1fa7d1e..a91cc0d744 100644 --- a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx @@ -288,8 +288,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { if (keyValue.length > 0) { const stored = await operations.storeCredential(keyRef, keyValue) if (stored !== undefined) return stored - const initialized = await operations.initializeModel(props.provider) - if (initialized !== undefined) return initialized + void operations.initializeModel(props.provider) } setKeyDraft('') return undefined diff --git a/packages/client/ui-settings-models/src/client/operations.ts b/packages/client/ui-settings-models/src/client/operations.ts index 75d87ac821..071ab72454 100644 --- a/packages/client/ui-settings-models/src/client/operations.ts +++ b/packages/client/ui-settings-models/src/client/operations.ts @@ -32,8 +32,11 @@ export type ModelDiscoveryOutcome = /** The Host operations the Models page and its cards invoke. */ export interface ModelsOperations { - /** @param provider - newly configured provider. @returns refusal text, or undefined after initialization. */ - initializeModel(provider: string): Promise + /** + * @param provider - newly configured provider. + * @returns after optional initialization; failures are logged without changing the save outcome. + */ + initializeModel(provider: string): Promise /** * Read one credential reference's state. * @param ref - credential reference name. @@ -84,8 +87,12 @@ export interface ModelsOperations { export function createModelsOperations(ctx: ClientContext): ModelsOperations { return { initializeModel: async (provider) => { - const response = await ctx.remote.session.initializeDefaultModel(provider) - return response.ok ? undefined : response.error.message + try { + const response = await ctx.remote.session.initializeDefaultModel(provider) + if (!response.ok) console.info('[models] default model initialization failed', { provider, reason: 'refused' }) + } catch (_error) { + console.info('[models] default model initialization failed', { provider, reason: 'disconnected' }) + } }, describeCredential: async (ref) => { const response = await ctx.remote.credentials.describe([ref]) diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index f3e948b79f..f693b5960d 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -37,7 +37,7 @@ export interface ProviderDirectoryEntry { * Join declared configurable providers with the currently registered routes. * @param registered - live provider routes in registration order. * @param directory - declared configurable providers in declaration order. - * @returns declared rows followed by live routes with no declaration. + * @returns account and official routes first, then other routes in their original order. */ export function joinProviderDirectory( registered: readonly LlmProviderInfo[], @@ -64,7 +64,9 @@ export function joinProviderDirectory( active: true, }) } - return rows + return rows.toSorted((left, right) => + (left.provider === 'deepseek-account' ? 0 : left.provider === 'deepseek-official' ? 1 : 2) + - (right.provider === 'deepseek-account' ? 0 : right.provider === 'deepseek-official' ? 1 : 2)) } /** One provider row the page renders. */ diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index 88e43e599f..c1752b9204 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -1111,6 +1111,29 @@ describe('ModelsSection', () => { expect(baseURL.value).toBe('') }) + it.each(['refused', 'disconnected', 'pending'] as const)('finishes credential saving when default initialization is %s', async (outcome) => { + const log = vi.spyOn(console, 'info').mockImplementation(() => {}) + const pending = Promise.withResolvers() + const { ctx, set } = await mountDeepSeekCard() + const initialize = vi.spyOn(ctx.remote.session, 'initializeDefaultModel') + if (outcome === 'pending') initialize.mockReturnValue(pending.promise) + else if (outcome === 'disconnected') initialize.mockRejectedValue(new Error('offline')) + else initialize.mockResolvedValue({ ok: false, + error: new RemoteError('session/provider-models-unavailable', 'empty catalog', { provider: 'deepseek-official' }) }) + try { + fireEvent.change(await screen.findByLabelText(en.keyInput), { target: { value: 'test-key' } }) + fireEvent.click(screen.getByText(en.apply)) + await waitFor(() => { expect(screen.queryByText(en.apply)).toBeNull() }) + expect(set).toHaveBeenCalledOnce() + expect(initialize).toHaveBeenCalledExactlyOnceWith('deepseek-official') + expect(screen.queryByText('empty catalog')).toBeNull() + } finally { + if (outcome === 'pending') { pending.reject(new Error('closed')); await Promise.resolve() } + initialize.mockRestore() + log.mockRestore() + } + }) + it('rejects an invalid draft before writing', async () => { const { mutate } = await mountDeepSeekCard() fireEvent.click(screen.getByText(en.customized)) diff --git a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx index ae71cf0557..370027cdd7 100644 --- a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx @@ -847,6 +847,30 @@ describe('hand-declared providers', () => { return { ...scripted, onClose } } + it('closes a saved custom provider when default initialization is refused', async () => { + const log = vi.spyOn(console, 'info').mockImplementation(() => {}) + const { face, mutate, set, onClose } = mountCard() + const initialize = vi.spyOn(ctxWith(face).remote.session, 'initializeDefaultModel') + .mockResolvedValue({ ok: false, error: new RemoteError('session/provider-models-unavailable', + 'empty catalog', { provider: 'acme-gateway' }) }) + try { + fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme-gateway' } }) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.example/v1' } }) + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'test-key' } }) + fireEvent.click(screen.getByRole('button', { name: en.addModel })) + fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'model' } }) + fireEvent.click(screen.getByText(en.create)) + await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) }) + expect(mutate).toHaveBeenCalledOnce() + expect(set).toHaveBeenCalledOnce() + expect(initialize).toHaveBeenCalledExactlyOnceWith('acme-gateway') + expect(screen.queryByText('empty catalog')).toBeNull() + } finally { + initialize.mockRestore() + log.mockRestore() + } + }) + it('writes the whole profile and the key under the derived reference', async () => { const { mutate, set, onClose } = mountCard() diff --git a/packages/client/ui-settings-models/tests/store.client.spec.ts b/packages/client/ui-settings-models/tests/store.client.spec.ts index d181edc12e..5a56062da4 100644 --- a/packages/client/ui-settings-models/tests/store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/store.client.spec.ts @@ -16,6 +16,16 @@ it.each([false, true])('retains configuration diagnostics when the route is acti }]) }) +it('places account and official before third-party providers', () => { + const providers = ['custom', 'deepseek-official', 'deepseek-account', 'openai'] + const directory = providers.map(provider => ({ + provider, displayName: provider, settingsNs: 'fixture', settingsPath: [], + })) + expect(joinProviderDirectory([], directory).map(row => row.provider)) + .toEqual(['deepseek-account', 'deepseek-official', 'custom', 'openai']) + expect(directory.map(row => row.provider)).toEqual(providers) +}) + let nextRpc = 0 function ok(value: T): RpcResponse { return { rpcId: `r-${nextRpc++}` as never, result: { ok: true, value } } diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 5706f2a77d..f05c4ed4f6 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1398,7 +1398,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async listModels(provider: string): Promise', - description: 'Discover models advertised by one registered provider. Catalog membership is advisory and never changes routing or request validation.', + description: 'Discover models advertised by one registered provider. Catalog membership does not constrain core routing. Catalog-driven entry points may restrict selection and submission to the advertised models.', parameters: [{ name: 'provider', description: 'registered provider route to inspect.' }], returns: 'detached model metadata in adapter-preferred order.', }, diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index fcc298d024..8bfe705158 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: 7ded16b5e1d8c87b924aeb02c8eda0a3033bd3ee -README.zh.md: de612f7b2153eab35e8a00db71aeb1bb92824eb1 +README.md: be2694c0f7cd1fd19b7f17cabb0e2598e995a065 +README.zh.md: e07afc9f9084c04bd0605de284e40c4f8c44b445 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 7ded16b5e1..be2694c0f7 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -47,7 +47,7 @@ Choose this adapter for DeepSeek's official API or a gateway that supports the s filesApiTimeoutMs: 60000 ``` -A request selects the route with `provider: deepseek-official`; the model id passes through to the wire, so new DeepSeek models need no re-registration. Omitted `models` advertises the text- and image-capable `deepseek-flash` alongside the text-only `deepseek-v4-pro`, each with a 1,000,000-token context window. An explicit list replaces those defaults, and unlisted model ids still pass through as text-only routes. Clients, including model discovery tools, can read the advisory entries through `ctx.llm.listModels('deepseek-official')`. Image-capable entries may set `imagePixelBudget` to a positive integer or `low`, and may set `imageMaxBytes`. An entry may declare `systemPromptUpdate: in-history` when its endpoint reads the latest `system` message at any position of `messages` as the complete effective system prompt; the adapter reports the mode on the resolved model and the prepared call, and the agent loop then appends a changed prompt after the cached history instead of rewriting the leading system message ([decision rule](../../core/agent-loop/README.md#understand-the-implementation)). The default `deepseek-flash` entry declares this mode; other models require an explicit `models` declaration, and any value other than `in-history` fails at load with `llm-deepseek: catalog model "" systemPromptUpdate must be "in-history" when present`. +A request selects the route with `provider: deepseek-official`; the model id passes through to the wire, so new DeepSeek models need no re-registration. Omitted `models` advertises the text- and image-capable `deepseek-flash` alongside the text-only `deepseek-v4-pro`, each with a 1,000,000-token context window. An explicit list replaces those defaults, and core calls still pass unlisted model ids through as text-only routes. GUI selection and submission require a catalog entry. Clients, including model discovery tools, can read the advisory entries through `ctx.llm.listModels('deepseek-official')`. Image-capable entries may set `imagePixelBudget` to a positive integer or `low`, and may set `imageMaxBytes`. An entry may declare `systemPromptUpdate: in-history` when its endpoint reads the latest `system` message at any position of `messages` as the complete effective system prompt; the adapter reports the mode on the resolved model and the prepared call, and the agent loop then appends a changed prompt after the cached history instead of rewriting the leading system message ([decision rule](../../core/agent-loop/README.md#understand-the-implementation)). The default `deepseek-flash` entry declares this mode; other models require an explicit `models` declaration, and any value other than `in-history` fails at load with `llm-deepseek: catalog model "" systemPromptUpdate must be "in-history" when present`. | Field | Default | Meaning | |---|---|---| diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index de612f7b21..e07afc9f90 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -47,7 +47,7 @@ kind: "package-reference" filesApiTimeoutMs: 60000 ``` -请求用 `provider: deepseek-official` 选择路由;模型 id 原样传到协议,因此新增 DeepSeek 模型无需重新注册。省略 `models` 时公布支持文本和图像的 `deepseek-flash`,以及仅支持文本的 `deepseek-v4-pro`,各自的上下文窗口均为 1,000,000 token。显式列表会替换这些默认值,未列出的模型 id 仍作为纯文本路由原样通过。包括模型发现工具在内的客户端可通过 `ctx.llm.listModels('deepseek-official')` 读取这些建议性条目。支持图片的条目可把 `imagePixelBudget` 设置为正整数或 `low`,也可以设置 `imageMaxBytes`。当端点把 `messages` 中任意位置最新的 `system` 消息读作完整的有效系统提示词时,条目可以声明 `systemPromptUpdate: in-history`;适配器会在已解析模型与已准备调用上报告该模式,agent loop(智能体循环)随后把变化后的提示词追加到已缓存历史之后,而不是改写开头的 system 消息([决策规则](../../core/agent-loop/README.zh.md#understand-the-implementation))。默认的 `deepseek-flash` 条目声明该模式;其他模型需通过 `models` 显式声明,`in-history` 以外的任何值都会在加载时以 `llm-deepseek: catalog model "" systemPromptUpdate must be "in-history" when present` 失败。 +请求用 `provider: deepseek-official` 选择路由;模型 id 原样传到协议,因此新增 DeepSeek 模型无需重新注册。省略 `models` 时公布支持文本和图像的 `deepseek-flash`,以及仅支持文本的 `deepseek-v4-pro`,各自的上下文窗口均为 1,000,000 token。显式列表会替换这些默认值,核心调用中未列出的模型 id 仍作为纯文本路由原样通过。GUI 选择与提交要求模型具有目录条目。包括模型发现工具在内的客户端可通过 `ctx.llm.listModels('deepseek-official')` 读取这些建议性条目。支持图片的条目可把 `imagePixelBudget` 设置为正整数或 `low`,也可以设置 `imageMaxBytes`。当端点把 `messages` 中任意位置最新的 `system` 消息读作完整的有效系统提示词时,条目可以声明 `systemPromptUpdate: in-history`;适配器会在已解析模型与已准备调用上报告该模式,agent loop(智能体循环)随后把变化后的提示词追加到已缓存历史之后,而不是改写开头的 system 消息([决策规则](../../core/agent-loop/README.zh.md#understand-the-implementation))。默认的 `deepseek-flash` 条目声明该模式;其他模型需通过 `models` 显式声明,`in-history` 以外的任何值都会在加载时以 `llm-deepseek: catalog model "" systemPromptUpdate must be "in-history" when present` 失败。 | 字段 | 默认值 | 含义 | |---|---|---| diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 6fac0fb24a..0adf149bab 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm/README.md -README.md: cdda682cebc720d51692a1b0c4c8691fa84359a3 -README.zh.md: 8c679ecc6910376f7270450ae5be867d0da353fc +README.md: 1abdc8e332410ccd69066af6944bc4a32221635e +README.zh.md: 320a232c13fd145b65d53860c0cb1a4f7bfeb5a7 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index cdda682ceb..1abdc8e332 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -25,6 +25,8 @@ Use `@deepseek-ai/dsh-llm` to stream model calls through configured provider ada ## Use this package +`listModels` describes models offered by catalog-driven interfaces. Core resolution and streaming can still accept unlisted ids. The GUI requires catalog membership for selection and submission; adapters intended for GUI use must implement `listModels` and advertise their available models. The base implementation returns an empty list and therefore offers no GUI models. + Any composition that calls a model provider — an agent loop, a session-title generator, a compaction summarizer — streams its requests through this service. Mount it together with at least one provider adapter; the service itself has no configuration and no provider wire code. ### When to choose it diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 8c679ecc69..320a232c13 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -25,6 +25,8 @@ kind: "package-reference" ## 使用本包 +`listModels` 描述目录驱动界面提供的模型。核心解析与流式调用仍可接受未列出的 ID。GUI 的模型选择与提交要求模型出现在目录中;供 GUI 使用的适配器必须实现 `listModels`,公布其可用模型。基类实现返回空列表,因此不向 GUI 提供模型。 + 任何调用模型提供方的组合——agent loop(智能体循环)、会话标题生成器、压缩(compaction)摘要器——都会通过本服务流式发起请求。与至少一个提供方适配器一起挂载它;服务本身没有任何配置,也不包含提供方协议代码。 ### 何时选择 diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 5f633619ff..64252cb643 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -235,8 +235,9 @@ export abstract class LlmAdapter { /** * List models this adapter can currently advertise for one owned provider. - * The result is advisory: an adapter may accept unlisted model ids, and - * consumers must not turn absence into request rejection. + * Core routing accepts unlisted model ids; catalog-driven entry points such + * as the GUI may require membership. Adapters used there must advertise + * their available models; the base empty catalog offers no GUI selection. * @param _provider - one provider route owned by this adapter. * @returns discoverable models in adapter-preferred order. */ @@ -689,7 +690,8 @@ export class LlmRuntime extends TypertRemoteService { /** * Discover models advertised by one registered provider. Catalog membership - * is advisory and never changes routing or request validation. + * does not constrain core routing. Catalog-driven entry points may restrict + * selection and submission to the advertised models. * @param provider - registered provider route to inspect. * @returns detached model metadata in adapter-preferred order. */ From 1dc518f217297e6369fc3ecd7a53ff66df2dd5fb Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 21 Sep 2026 16:37:10 +0800 Subject: [PATCH 010/192] feat(agent-team): drive the Web Team panel from the agentTeam projection The Team panel read a Remote snapshot on open and behind an explicit refresh button, while the subagent catalog panel updates from Host projection frames. The Team projection was Host-only and its `apply` mutated state in place, so the projection registry could never publish a client view for it. Publish a durable-only `agentTeam` wire view: roster rows with their durable phase, non-deleted task views through the same readiness, owner-name, and write-overlap derivation the task board returns to agents, and `failure` beside the last valid state when a persisted Team record is rejected. `apply` now returns a new state per applied Team event and replaces only the touched collection, so mailbox-only events reuse the previous view reference and publish nothing. The Web panel derives every row from the shared Session store: the Lead from the current Session's subagent address, running/inactive from Session status, and the model from the member Session's durable `modelSelection` projection. Opening requests the Lead's projection baseline when the store has none, a failed read shows a retry control, and the refresh button and Remote mount are gone. The `agentTeams/view` Remote method remains for callers that need live availability and the configured model. The Team panel web e2e now asserts that Host-side task creation and claiming reach the open panel without any user action. --- .../feature/2026-08-05-agent-teams.i18n.yaml | 4 +- .../feature/2026-08-05-agent-teams.md | 4 + .../feature/2026-08-05-agent-teams.zh.md | 4 + apps/web/tests/agent-team-panel.e2e.ts | 12 +- .../agent-team-panel/task.expected.md | 3 +- .../experimental/agent-team/README.i18n.yaml | 4 +- packages/experimental/agent-team/README.md | 7 +- packages/experimental/agent-team/README.zh.md | 7 +- .../experimental/agent-team/src/client.ts | 5 +- .../experimental/agent-team/src/invariant.ts | 4 +- .../experimental/agent-team/src/projection.ts | 154 +++++-- .../experimental/agent-team/src/task-board.ts | 47 +-- .../experimental/agent-team/src/task-view.ts | 62 +++ packages/experimental/agent-team/src/types.ts | 31 ++ .../agent-team/tests/persistence.spec.ts | 6 +- .../tests/projection-events.spec.ts | 159 +++++++- .../agent-team/tests/team.spec.ts | 6 +- .../client-ui-agent-team/README.i18n.yaml | 4 +- .../client-ui-agent-team/README.md | 21 +- .../client-ui-agent-team/README.zh.md | 21 +- .../client-ui-agent-team/package.json | 6 +- .../src/client/TeamAction.tsx | 155 +++---- .../client-ui-agent-team/src/client/index.ts | 16 +- .../src/client/locales.ts | 6 +- .../client-ui-agent-team/src/client/mount.ts | 57 +-- .../tests/browser-plugin.client.spec.ts | 196 ++------- .../tests/team-action.client.spec.tsx | 381 +++++++++--------- .../client-ui-agent-team/tsconfig.json | 5 +- pnpm-lock.yaml | 9 +- 29 files changed, 777 insertions(+), 619 deletions(-) create mode 100644 packages/experimental/agent-team/src/task-view.ts diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml index a4f2c8968a..f2228c4045 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-agent-teams.md -2026-08-05-agent-teams.md: 45c7e599e03bb26dc9224c74a6d3bc0ab8a74dcc -2026-08-05-agent-teams.zh.md: dbb2931bdc40da5ddf2c8091dd8c401029e0445f +2026-08-05-agent-teams.md: 29b53ee5057c44b9ca4d959cf865e4b989b4070e +2026-08-05-agent-teams.zh.md: e41812e25b41c96cf92cc2ee876ebc0d6c566bcd diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md index 45c7e599e0..29b53ee505 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md @@ -54,6 +54,10 @@ All members use the same cwd and observe writes immediately. The policy tells me Worktree isolation is not a harness runtime behavior. A deployment or prompt may arrange separate worktrees, but the Team domain does not infer branches, merge changes, or silently change cwd. This preserves the existing same-world subagent and sandbox contracts. +## Web projection + +The Web panel reads the Lead Session's `agentTeam` wire projection instead of polling a Remote read, because the projection registry already broadcasts one Host-wide frame per changed client view to every connected browser, and the subagent catalog panel proves that path needs no refresh control. The wire view carries durable facts only: roster rows with their durable phase, non-deleted task views with the same owner-name, readiness, and write-overlap derivation the task board returns to agents, and the projection `failure` beside the last valid state so a rejected persisted record is visible instead of appearing as a stale but healthy board. Live turn activity and the model stay out of the view because they are process-local; the browser overlays Session status and the member Session's `modelSelection` projection, and shows no model before a durable selection or request exists. Publication requires the projection `apply` to return a new state object per applied Team event and to replace only the touched collection, so mailbox-only events keep the previous view reference and publish nothing. The `agentTeams/view` Remote method remains for callers that need live availability and the configured model. + ## Alternatives considered **Extend direct-child subagent tools with peer ids.** Rejected because parent/child authority and Team peer membership are different domains. Adding peer access to the continuation seam would weaken its exact-parent authorization and still leave roster and tasks without a persistence owner. diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md index dbb2931bdc..e41812e25b 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md @@ -54,6 +54,10 @@ Peer 通讯使用 Lead 日志 mailbox。投递前先追加并 flush `team/messag Worktree isolation 不是 harness runtime 行为。deployment 或 prompt 可以安排独立 worktree,但 Team 领域不会推断 branch、merge 变更或静默改变 cwd。这样保留既有 same-world subagent 与 sandbox 契约。 +## Web projection + +Web panel 读取 Lead Session 的 `agentTeam` wire projection 而不是轮询 Remote 读取,因为 projection registry 已经向每个已连接浏览器广播每次客户端视图变化的一帧 Host 级 frame,而 subagent catalog panel 证明这条路径不需要刷新控件。wire 视图只承载持久事实:带持久 phase 的 roster row、与任务板返回给 agent 相同的 owner 名称、就绪状态与写入范围重叠派生的未删除任务视图,以及与最后有效状态并列的 projection `failure`,使被拒绝的持久记录可见而不是表现为陈旧但健康的任务板。实时轮次活动与模型不进入视图,因为它们是进程本地的;浏览器叠加 Session 状态与成员 Session 的 `modelSelection` projection,并在持久选择或请求存在之前不显示模型。发布要求 projection `apply` 对每个已应用的 Team 事件返回新的 state 对象并只替换被触及的集合,因此仅邮箱事件保留上一个视图引用且不发布。`agentTeams/view` Remote method 保留给需要实时可用性与配置模型的调用方。 + ## Alternatives considered **用 peer id 扩展 direct-child subagent tool。** 拒绝,因为 parent/child 权限与 Team peer membership 是不同领域。向 continuation seam 增加 peer access 会削弱 exact-parent authorization,仍无法为 roster 与 task 提供持久 owner。 diff --git a/apps/web/tests/agent-team-panel.e2e.ts b/apps/web/tests/agent-team-panel.e2e.ts index 3e5ad5e0c2..3ffb5dedff 100644 --- a/apps/web/tests/agent-team-panel.e2e.ts +++ b/apps/web/tests/agent-team-panel.e2e.ts @@ -1,5 +1,5 @@ // Keyless assembled-browser coverage for the opt-in Agent Teams bundle -// over the real Host Typert Remote flow. +// over the real Host projection frame flow. import { fileURLToPath } from 'node:url' import { join } from 'node:path' import { readFileSync } from 'node:fs' @@ -80,7 +80,7 @@ describe('web e2e: Agent Teams panel', () => { await scaffold?.close() }) - it('displays agent-owned tasks through a read-only board', async () => { + it('displays agent-owned task changes through a read-only board without a refresh action', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-team-panel')) await page.locator('[data-team-action]').getByRole('button', { name: /Agent Team/iu }).click() const action = page.getByRole('dialog', { name: 'Agent Team', exact: true }) @@ -88,18 +88,18 @@ describe('web e2e: Agent Teams panel', () => { await action.getByText('lead').waitFor() expect(await action.getByRole('button', { name: 'New task' }).count()).toBe(0) + expect(await action.getByRole('button', { name: /Refresh/u }).count()).toBe(0) + // Host-side Team mutations reach the open panel through projection frames only. const agent = scaffold.ctx.agents.list()[0]! const task = await scaffold.ctx.agentTeams.createTask(agent, { subject: 'Agent task', description: 'Created by the Team Lead', writeScopes: ['src/web'], }) - await action.getByRole('button', { name: 'Refresh Team' }).click() await action.getByText('Agent task', { exact: true }).waitFor() await action.getByText('Owner: Unowned', { exact: true }).waitFor() await scaffold.ctx.agentTeams.updateTask(agent, { taskId: task.id, expectedRevision: task.revision, action: 'claim', }) - await action.getByRole('button', { name: 'Refresh Team' }).click() await action.getByText('In progress', { exact: true }).waitFor() await action.getByText('Owner: lead', { exact: true }).waitFor() expect(await action.getByRole('button', { name: /^(New task|Edit|Complete|Reopen|Delete)$/u }).count()).toBe(0) @@ -141,9 +141,6 @@ describe('web e2e: Agent Teams panel', () => { await panel.getByRole('heading', { name: 'Shared tasks' }).waitFor() expect(await panel.evaluate(element => element === document.activeElement)).toBe(true) await page.keyboard.press('Tab') - expect(await panel.getByRole('button', { name: 'Refresh Team' }) - .evaluate(element => element === document.activeElement)).toBe(true) - await page.keyboard.press('Tab') expect(await panel.getByRole('button', { name: 'Close', exact: true }) .evaluate(element => element === document.activeElement)).toBe(true) await page.keyboard.press('Escape') @@ -152,7 +149,6 @@ describe('web e2e: Agent Teams panel', () => { await page.keyboard.press('Enter') await panel.waitFor() await page.keyboard.press('Tab') - await page.keyboard.press('Tab') await page.keyboard.press('Enter') await panel.waitFor({ state: 'detached' }) expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true) diff --git a/apps/web/tests/snapshots/agent-team-panel/task.expected.md b/apps/web/tests/snapshots/agent-team-panel/task.expected.md index 5184f24d29..d0a286ce9a 100644 --- a/apps/web/tests/snapshots/agent-team-panel/task.expected.md +++ b/apps/web/tests/snapshots/agent-team-panel/task.expected.md @@ -1,9 +1,8 @@ - dialog "Agent Team": - strong: Agent Team - - button "Refresh Team" - button "Close" - heading "Members" [level=3] - - 'button "lead Inactive · Model: deepseek-v4-flash" [disabled]' + - button "lead Inactive" [disabled] - heading "Shared tasks" [level=3] - article: - strong: Agent task diff --git a/packages/experimental/agent-team/README.i18n.yaml b/packages/experimental/agent-team/README.i18n.yaml index 52019ce198..00326bcff4 100644 --- a/packages/experimental/agent-team/README.i18n.yaml +++ b/packages/experimental/agent-team/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/agent-team/README.md -README.md: 6ae7d82214ca9e041324e353057ba6850675c043 -README.zh.md: 89176c285a8bd9d5a1875618dffb0b2eb4890a3e +README.md: cc2a7a546715d88c5fc7bacf103fbde2e51befc5 +README.zh.md: 321200cf7395277933b00b62f370e2b14629720d diff --git a/packages/experimental/agent-team/README.md b/packages/experimental/agent-team/README.md index 6ae7d82214..cc2a7a5467 100644 --- a/packages/experimental/agent-team/README.md +++ b/packages/experimental/agent-team/README.md @@ -118,7 +118,8 @@ The [Agent Teams Agent Note](../../../.agents/notes/implemented/feature/2026-08- | [`src/mailbox.ts`](src/mailbox.ts) | Durable queue, target-local dispatch, acknowledgement, and recovery | | [`src/task-board.ts`](src/task-board.ts) | Task CAS commands, DAG validation, and derived views | | [`src/journal.ts`](src/journal.ts) | Serialized Lead-log transactions and commit notification | -| [`src/projection.ts`](src/projection.ts) | Strict replay projection that decodes and validates Team events | +| [`src/projection.ts`](src/projection.ts) | Strict replay projection that decodes and validates Team events and publishes the `agentTeam` client view | +| [`src/task-view.ts`](src/task-view.ts) | Pure task readiness, owner-name, and write-overlap derivation shared by the task board and the client view | | [`src/activity.ts`](src/activity.ts) | One-shot change waiters and disposal release | | [`src/lifecycle.ts`](src/lifecycle.ts) | Shared admission cutoff and bounded settlement | | [`src/invariant.ts`](src/invariant.ts) | Invariant companion that replays candidate events before append | @@ -171,9 +172,9 @@ Read these pages when the package-level contract is not enough. They move from t -### Browser Remote +### Browser projection and Remote -`TeamService` exposes the read-only `agentTeams/view` Remote method for browser clients. Task creation and updates belong to Team agents through the service and model tools. The `./remote` export supplies the Client contribution mounted by the Web UI, while `./client` exports browser-safe roster and task views. +The `agentTeam` Session projection publishes a durable client view of the Lead Session: the roster with each member's durable phase, the non-deleted task board with owner names, readiness, and write-scope overlap warnings, and `failure` when a persisted Team record was rejected, after which the roster and tasks stay at the last valid state. Every applied Team event replaces the projection state object and only the collection it touched, so the projection registry publishes one Host-wide frame per roster or task change and none for mailbox-only changes. Live turn activity and the selected model are not part of the view; the Web UI overlays them from Session status and the member Session's `modelSelection` projection. `TeamService` also exposes the read-only `agentTeams/view` Remote method, which adds live availability and the configured model; task creation and updates belong to Team agents through the service and model tools. The `./remote` export supplies the generated Client contribution, and `./client` exports the browser-safe roster, task, and projection types. ## Model Experience diff --git a/packages/experimental/agent-team/README.zh.md b/packages/experimental/agent-team/README.zh.md index 89176c285a..321200cf73 100644 --- a/packages/experimental/agent-team/README.zh.md +++ b/packages/experimental/agent-team/README.zh.md @@ -118,7 +118,8 @@ Lead 可以停止 teammate 的当前轮次,而不会删除其排队的消息 | [`src/mailbox.ts`](src/mailbox.ts) | 持久队列、目标本地投递、确认与恢复 | | [`src/task-board.ts`](src/task-board.ts) | 任务 CAS 命令、DAG 校验与派生视图 | | [`src/journal.ts`](src/journal.ts) | 串行化的 Lead 日志事务与提交通知 | -| [`src/projection.ts`](src/projection.ts) | 解码并校验 Team 事件的严格回放投影 | +| [`src/projection.ts`](src/projection.ts) | 解码并校验 Team 事件、发布 `agentTeam` 客户端视图的严格回放投影 | +| [`src/task-view.ts`](src/task-view.ts) | 任务板与客户端视图共用的纯任务派生:就绪状态、owner 名称与写入范围重叠 | | [`src/activity.ts`](src/activity.ts) | 一次性变更等待者与 dispose(资源释放)时的等待解除 | | [`src/lifecycle.ts`](src/lifecycle.ts) | 共享准入截止与有界结算 | | [`src/invariant.ts`](src/invariant.ts) | 在 append 前回放候选事件的不变式伴生插件 | @@ -171,9 +172,9 @@ dispose 会关闭准入、中止并等待已获准的创建与 mailbox dispatch -### 浏览器 Remote +### 浏览器投影与 Remote -`TeamService` 向浏览器客户端公开只读的 `agentTeams/view` Remote method。任务创建与更新由 Team agent 通过服务和模型工具执行。`./remote` 导出由 Web UI 挂载的 Client contribution,`./client` 导出可供浏览器使用的成员与任务视图。 +`agentTeam` Session 投影发布 Lead Session 的持久客户端视图:带每个成员持久 phase 的 roster、带 owner 名称、就绪状态与写入范围重叠警告的未删除任务板,以及在某条持久 Team 记录被拒绝时的 `failure`,此后 roster 与任务停留在最后有效状态。每个已应用的 Team 事件都会替换投影 state 对象,且只替换它触及的集合,因此投影注册表对每次 roster 或任务变化发布一帧 Host 级 frame,对仅邮箱的变化不发布。实时轮次活动与所选模型不在视图内;Web UI 从 Session 状态和成员 Session 的 `modelSelection` 投影叠加它们。`TeamService` 同时公开只读的 `agentTeams/view` Remote method,它额外提供实时可用性与配置的模型;任务创建与更新由 Team agent 通过服务和模型工具执行。`./remote` 导出生成的 Client contribution,`./client` 导出可供浏览器使用的 roster、任务与投影类型。 ## 模型体验 diff --git a/packages/experimental/agent-team/src/client.ts b/packages/experimental/agent-team/src/client.ts index b1fd173d10..fc5e7e66e5 100644 --- a/packages/experimental/agent-team/src/client.ts +++ b/packages/experimental/agent-team/src/client.ts @@ -1,7 +1,10 @@ -/** Client-safe Agent Teams roster and task views. */ +/** Client-safe Agent Teams roster and task views plus the `agentTeam` projection vocabulary. */ export type { + TeamMemberPhase, + TeamMemberProjection, TeamMemberView, + TeamProjection, TeamTaskId, TeamTaskStatus, TeamTaskView, diff --git a/packages/experimental/agent-team/src/invariant.ts b/packages/experimental/agent-team/src/invariant.ts index dc5fd3ddc1..442df6a6e6 100644 --- a/packages/experimental/agent-team/src/invariant.ts +++ b/packages/experimental/agent-team/src/invariant.ts @@ -16,7 +16,7 @@ export const name = 'team-invariant' /** Invariant registry required by the companion. */ export const inject = ['invariants'] -/** Validate candidate Team events against the projected committed prefix. */ +/** Validate candidate Team events against the projected committed prefix; `apply` never mutates the committed state. */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return @@ -24,7 +24,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant /* v8 ignore next -- non-Team Session events have no Agent Teams invariant. */ if (!isTeamEvent(event)) return const state = ctx.sessionProjections.stateOf(session, 'agentTeam') as TeamProjectionState - const candidate = teamProjectionDefinition.apply(structuredClone(state), event) + const candidate = teamProjectionDefinition.apply(state, event) if (candidate.failure !== undefined) { fail(`session event ${event.seq} violates the Agent Teams stream: ${candidate.failure}`) } diff --git a/packages/experimental/agent-team/src/projection.ts b/packages/experimental/agent-team/src/projection.ts index e4ade61e9e..13c83d4175 100644 --- a/packages/experimental/agent-team/src/projection.ts +++ b/packages/experimental/agent-team/src/projection.ts @@ -1,4 +1,4 @@ -/** Host-only Team state projected incrementally from committed Session events. */ +/** Team state projected incrementally from committed Session events, with a durable-only client view. */ import { z } from 'zod' import { brandString } from '@deepseek-ai/dsh-brand' @@ -7,10 +7,13 @@ import type { SessionEvent, SessionEventMap, SessionId } from '@deepseek-ai/dsh- import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import type { TeamId, + TeamMemberProjection, TeamMemberSnapshot, TeamMessageId, TeamMessageSnapshot, + TeamProjection, TeamTaskSnapshot, + TeamTaskView, } from './types.ts' import { TeamId as toTeamId, @@ -18,6 +21,7 @@ import { TeamTaskId as toTeamTaskId, } from './types.ts' import { assertTaskGraphCandidate } from './task-graph.ts' +import { projectTaskView } from './task-view.ts' const nonNegativeSafeInteger = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) const positiveSafeInteger = nonNegativeSafeInteger.min(1) @@ -119,20 +123,24 @@ const teamMessageDeliveredEventSchema = z.object({ targetId: sessionIdSchema, }).strict() as z.ZodType -/** Current Team state selected by durable Team identity. */ +/** + * Current Team state selected by durable Team identity. Every applied Team + * event produces a new state object and replaces only the collection it + * touched; untouched collections keep their references. + */ export interface TeamState { readonly id: TeamId - readonly members: TeamMemberSnapshot[] - readonly tasks: TeamTaskSnapshot[] - readonly messages: TeamMessageSnapshot[] - readonly delivered: TeamMessageId[] - nextTaskNumber: number + readonly members: readonly TeamMemberSnapshot[] + readonly tasks: readonly TeamTaskSnapshot[] + readonly messages: readonly TeamMessageSnapshot[] + readonly delivered: readonly TeamMessageId[] + readonly nextTaskNumber: number } /** * Construct empty state for one Team identity. * @param rootId - root Session identity. - * @returns mutable empty Team state. + * @returns empty Team state. */ export function emptyTeamState(rootId: SessionId): TeamProjectionState { return { @@ -147,7 +155,7 @@ export function emptyTeamState(rootId: SessionId): TeamProjectionState { /** Checkpoint-safe state for the Team owned by the projected Session. */ export interface TeamProjectionState extends TeamState { - failure?: string + readonly failure?: string } declare module '@deepseek-ai/dsh-session-projection/types' { @@ -214,23 +222,30 @@ function parseCurrentTeamEvent(event: TeamSessionEvent): TeamSessionEvent { } } -function applyProjectionEvent(state: TeamProjectionState, event: SessionEvent): void { - if (state.failure !== undefined) return - if (!isTeamEvent(event)) return +function applyProjectionEvent(state: TeamProjectionState, event: SessionEvent): TeamProjectionState { + if (state.failure !== undefined) return state + if (!isTeamEvent(event)) return state try { const selector = parsePersisted(event.type, teamEventSelectorSchema, event.data) - if (selector.teamId !== state.id) return + if (selector.teamId !== state.id) return state if (selector.version !== 2) { throw new Error(`unsupported Agent Teams event version ${String(selector.version)}`) } - applyCurrentTeamEvent(state, parseCurrentTeamEvent(event)) + return applyCurrentTeamEvent(state, parseCurrentTeamEvent(event)) } catch (error: unknown) { /* v8 ignore next -- the owned Team transition throws Error instances. */ - state.failure = error instanceof Error ? error.message : String(error) + return { ...state, failure: error instanceof Error ? error.message : String(error) } } } -function applyCurrentTeamEvent(state: TeamState, event: TeamSessionEvent): void { +function replaceAt(items: readonly T[], index: number, item: T): T[] { + const next = [...items] + if (index < 0) next.push(item) + else next[index] = item + return next +} + +function applyCurrentTeamEvent(state: TeamProjectionState, event: TeamSessionEvent): TeamProjectionState { switch (event.type) { case 'team/member': { const member = event.data.member @@ -250,9 +265,7 @@ function applyCurrentTeamEvent(state: TeamState, event: TeamSessionEvent): void throw new Error(`teammate "${member.name}" has an invalid ${prior.phase} -> ${member.phase} transition`) } } - if (index < 0) state.members.push(member) - else state.members[index] = member - break + return { ...state, members: replaceAt(state.members, index, member) } } case 'team/task': { const task = event.data.task @@ -265,48 +278,121 @@ function applyCurrentTeamEvent(state: TeamState, event: TeamSessionEvent): void throw new Error(`team task "${task.id}" revision is not contiguous`) } assertTaskGraphCandidate(state.tasks, task) + let nextTaskNumber = state.nextTaskNumber const match = numericTaskIdPattern.exec(task.id) if (match !== null) { const number = Number(match[1]) - state.nextTaskNumber = Math.max( - state.nextTaskNumber, + nextTaskNumber = Math.max( + nextTaskNumber, number === Number.MAX_SAFE_INTEGER ? number : number + 1, ) } - if (index < 0) state.tasks.push(task) - else state.tasks[index] = task - break + return { ...state, tasks: replaceAt(state.tasks, index, task), nextTaskNumber } } case 'team/message/queued': { const message = event.data.message if (state.messages.some(candidate => candidate.id === message.id)) { throw new Error(`team message "${message.id}" was queued twice`) } - state.messages.push(message) - break + return { ...state, messages: [...state.messages, message] } } case 'team/message/delivered': { const queued = state.messages.find(message => message.id === event.data.messageId) if (queued === undefined) throw new Error(`team message "${event.data.messageId}" was delivered before queueing`) if (queued.targetId !== event.data.targetId) throw new Error(`team message "${event.data.messageId}" target changed`) if (state.delivered.includes(event.data.messageId)) throw new Error(`team message "${event.data.messageId}" was delivered twice`) - state.delivered.push(event.data.messageId) - break + return { ...state, delivered: [...state.delivered, event.data.messageId] } } /* v8 ignore next 2 -- TeamEventType is closed and every member is handled above. */ default: - return + return state } } -/** Host-only Team projection selected by the projected Session identity. */ +const teamMemberProjectionSchema = z.object({ + id: sessionIdSchema, + name: z.string(), + role: z.enum(['lead', 'teammate']), + phase: z.enum(['provisioning', 'active', 'failed']), + description: z.string().optional(), + provider: z.string().optional(), + context: z.enum(['fresh', 'fork']).optional(), + error: z.string().optional(), +}).strict() as z.ZodType + +const teamTaskViewSchema = z.object({ + id: teamTaskIdSchema, + revision: positiveSafeInteger, + subject: z.string(), + description: z.string(), + status: z.enum(['pending', 'in_progress', 'completed', 'deleted']), + blockedBy: z.array(teamTaskIdSchema), + writeScopes: z.array(z.string()), + ownerName: z.string().optional(), + ready: z.boolean(), + writeScopeWarnings: z.array(z.string()), +}).strict() as z.ZodType + +const teamProjectionSchema = z.object({ + members: z.array(teamMemberProjectionSchema), + tasks: z.array(teamTaskViewSchema), + failure: z.string().optional(), +}).strict() as z.ZodType + +/** Client views keyed by the member and task collections they were derived from. */ +const teamProjectionViews = new WeakMap>() + +function buildTeamProjection(state: TeamProjectionState): TeamProjection { + const rootId = brandString(state.id) + const members: TeamMemberProjection[] = [{ id: rootId, name: 'lead', role: 'lead', phase: 'active' }] + for (const member of state.members) { + members.push({ + id: member.id, + name: member.name, + role: 'teammate', + phase: member.phase, + description: member.description, + provider: member.provider, + context: member.context, + ...member.error === undefined ? {} : { error: member.error }, + }) + } + return { + members, + tasks: state.tasks + .filter(task => task.status !== 'deleted') + .map(task => projectTaskView(rootId, state, task)), + ...state.failure === undefined ? {} : { failure: state.failure }, + } +} + +/** + * Durable client view of one Team state. Mailbox-only state changes reuse the + * previous view reference, so the live drive publishes nothing for them. + * @param state - current Team state. + * @returns the roster and non-deleted task board, plus any projection failure. + */ +export function teamProjectionView(state: TeamProjectionState): TeamProjection { + if (state.failure !== undefined) return buildTeamProjection(state) + let byTasks = teamProjectionViews.get(state.members) + if (byTasks === undefined) { + byTasks = new WeakMap() + teamProjectionViews.set(state.members, byTasks) + } + let view = byTasks.get(state.tasks) + if (view === undefined) { + view = buildTeamProjection(state) + byTasks.set(state.tasks, view) + } + return view +} + +/** Team projection selected by the projected Session identity; the wire view carries durable roster and task state only. */ export const teamProjectionDefinition = { key: 'agentTeam', stateVersion: 4, stateSchema: teamProjectionEntrySchema, init: header => emptyTeamState(header.id), - apply: (state, event) => { - applyProjectionEvent(state, event) - return state - }, + apply: applyProjectionEvent, + wire: { viewSchema: teamProjectionSchema, view: teamProjectionView }, } satisfies ProjectionDefinition<'agentTeam', TeamProjectionState> diff --git a/packages/experimental/agent-team/src/task-board.ts b/packages/experimental/agent-team/src/task-board.ts index cc7064ceb6..88f2baeb4e 100644 --- a/packages/experimental/agent-team/src/task-board.ts +++ b/packages/experimental/agent-team/src/task-board.ts @@ -15,13 +15,9 @@ import type { TeamTaskView, UpdateTeamTaskRequest, } from './types.ts' +import { projectTaskView, taskReady } from './task-view.ts' import { requiredText, writeScope } from './validation.ts' -/** Whether two normalized file or directory prefixes overlap on path components. */ -function scopesOverlap(left: string, right: string): boolean { - return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`) -} - const TASK_GRAPH_ERROR_CODES: Record = { missing: 'TEAM_TASK_NOT_FOUND', duplicate: 'TEAM_INVALID_ARGUMENT', @@ -134,7 +130,7 @@ export class TeamTaskBoard { if (current.ownerId !== undefined && current.ownerId !== caller.id) { throw new TeamError(`team task "${current.id}" is owned by another member`, 'TEAM_TASK_ALREADY_CLAIMED') } - if (current.status !== 'pending' || !this.taskReady(state, current)) { + if (current.status !== 'pending' || !taskReady(state, current)) { throw new TeamError(`team task "${current.id}" is not ready to claim`, 'TEAM_TASK_BLOCKED') } next = { ...current, status: 'in_progress', ownerId: caller.id } @@ -185,7 +181,7 @@ export class TeamTaskBoard { next = this.withoutOwner({ ...current, status: 'pending' }) break } - if (!this.taskReady(state, current)) throw new TeamError(`team task "${current.id}" is blocked`, 'TEAM_TASK_BLOCKED') + if (!taskReady(state, current)) throw new TeamError(`team task "${current.id}" is blocked`, 'TEAM_TASK_BLOCKED') const assignee = resolveActiveMember(root, state, request.owner) next = { ...current, status: 'in_progress', ownerId: assignee.id } break @@ -251,47 +247,14 @@ export class TeamTaskBoard { } } - /** Whether all current blockers completed. */ - private taskReady(state: TeamState, task: TeamTaskSnapshot): boolean { - return task.blockedBy.every(id => state.tasks.find(candidate => candidate.id === id)?.status === 'completed') - } - /** Remove an optional owner field under exactOptionalPropertyTypes. */ private withoutOwner(task: TeamTaskSnapshot): TeamTaskSnapshot { const { ownerId: _ownerId, ...without } = task return without } - /** - * Build one task view with owner name, readiness, and advisory write overlaps. - * A committing caller may pass its pre-append state because `task` supplies the - * new value explicitly; owner names, blocker readiness, and other task scopes - * do not change when that snapshot is appended. - */ + /** View one task against the supplied state through the shared derivation. */ private taskView(root: Agent, state: TeamState, task: TeamTaskSnapshot): TeamTaskView { - const ownerName = task.ownerId === undefined - ? undefined - : task.ownerId === root.id - ? 'lead' - : state.members.find(member => member.id === task.ownerId)?.name - const warnings = new Set() - for (const other of state.tasks) { - if (other.id === task.id || other.status !== 'in_progress') continue - if (task.writeScopes.some(left => other.writeScopes.some(right => scopesOverlap(left, right)))) { - warnings.add(`write scopes overlap with ${other.id}`) - } - } - return { - id: task.id, - revision: task.revision, - subject: task.subject, - description: task.description, - status: task.status, - blockedBy: structuredClone(task.blockedBy), - writeScopes: structuredClone(task.writeScopes), - ...ownerName === undefined ? {} : { ownerName }, - ready: task.status === 'pending' && this.taskReady(state, task), - writeScopeWarnings: [...warnings], - } + return projectTaskView(root.id, state, task) } } diff --git a/packages/experimental/agent-team/src/task-view.ts b/packages/experimental/agent-team/src/task-view.ts new file mode 100644 index 0000000000..a2f425782e --- /dev/null +++ b/packages/experimental/agent-team/src/task-view.ts @@ -0,0 +1,62 @@ +/** Pure task-view derivation shared by the task board and the client projection. */ + +import type { SessionId } from '@deepseek-ai/dsh-session' +import type { TeamState } from './projection.ts' +import type { TeamTaskSnapshot, TeamTaskView } from './types.ts' + +/** + * Whether two normalized file or directory prefixes overlap on path components. + * @param left - normalized write scope. + * @param right - normalized write scope. + * @returns whether either scope contains the other. + */ +export function scopesOverlap(left: string, right: string): boolean { + return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`) +} + +/** + * Whether every current blocker of one task completed. + * @param state - Team state supplying sibling tasks. + * @param task - durable task snapshot to test. + * @returns whether the task has no incomplete blockers. + */ +export function taskReady(state: TeamState, task: TeamTaskSnapshot): boolean { + return task.blockedBy.every(id => state.tasks.find(candidate => candidate.id === id)?.status === 'completed') +} + +/** + * Derive one task view with owner name, readiness, and advisory write overlaps. + * A committing caller may pass its pre-append state because `task` supplies the + * new value explicitly; owner names, blocker readiness, and other task scopes + * do not change when that snapshot is appended. + * @param rootId - Team Lead Session identity named `lead` in owner names. + * @param state - Team state supplying members and sibling tasks. + * @param task - durable task snapshot to view. + * @returns a detached task view. + */ +export function projectTaskView(rootId: SessionId, state: TeamState, task: TeamTaskSnapshot): TeamTaskView { + const ownerName = task.ownerId === undefined + ? undefined + : task.ownerId === rootId + ? 'lead' + : state.members.find(member => member.id === task.ownerId)?.name + const warnings = new Set() + for (const other of state.tasks) { + if (other.id === task.id || other.status !== 'in_progress') continue + if (task.writeScopes.some(left => other.writeScopes.some(right => scopesOverlap(left, right)))) { + warnings.add(`write scopes overlap with ${other.id}`) + } + } + return { + id: task.id, + revision: task.revision, + subject: task.subject, + description: task.description, + status: task.status, + blockedBy: [...task.blockedBy], + writeScopes: [...task.writeScopes], + ...ownerName === undefined ? {} : { ownerName }, + ready: task.status === 'pending' && taskReady(state, task), + writeScopeWarnings: [...warnings], + } +} diff --git a/packages/experimental/agent-team/src/types.ts b/packages/experimental/agent-team/src/types.ts index 77aa6fe620..10e82ff32d 100644 --- a/packages/experimental/agent-team/src/types.ts +++ b/packages/experimental/agent-team/src/types.ts @@ -102,6 +102,37 @@ export interface TeamView { readonly tasks: TeamTaskView[] } +/** One durable roster row published through the `agentTeam` Session projection. */ +export interface TeamMemberProjection { + readonly id: SessionId + readonly name: string + readonly role: 'lead' | 'teammate' + /** Durable lifecycle; the Lead row is always `active`. Turn activity comes from Session status. */ + readonly phase: TeamMemberPhase + readonly description?: string + readonly provider?: string + readonly context?: 'fresh' | 'fork' + readonly error?: string +} + +/** + * Durable Team state published to browser clients through the Lead Session's + * `agentTeam` projection. `failure` names the first rejected persisted Team + * record; members and tasks then stay at the last valid state. + */ +export interface TeamProjection { + readonly members: TeamMemberProjection[] + readonly tasks: TeamTaskView[] + readonly failure?: string +} + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionMap { + /** Durable roster and non-deleted task board of the Team rooted at the projected Session. */ + agentTeam: TeamProjection + } +} + /** One peer message retained until its target Session records it. */ export interface TeamMessageSnapshot { readonly id: TeamMessageId diff --git a/packages/experimental/agent-team/tests/persistence.spec.ts b/packages/experimental/agent-team/tests/persistence.spec.ts index 5237fc0301..f7ee5d41cb 100644 --- a/packages/experimental/agent-team/tests/persistence.spec.ts +++ b/packages/experimental/agent-team/tests/persistence.spec.ts @@ -26,9 +26,9 @@ const contexts = new Set() /** Detached durable Team read through the same projection definition as the service. */ function durable(agent: Agent): { - members: TeamMemberSnapshot[] - tasks: TeamTaskSnapshot[] - pendingMessages: TeamMessageSnapshot[] + members: readonly TeamMemberSnapshot[] + tasks: readonly TeamTaskSnapshot[] + pendingMessages: readonly TeamMessageSnapshot[] } { let projected = teamProjectionDefinition.init(agent.session.header) for (const event of agent.session.snapshotEvents()) projected = teamProjectionDefinition.apply(projected, event) diff --git a/packages/experimental/agent-team/tests/projection-events.spec.ts b/packages/experimental/agent-team/tests/projection-events.spec.ts index 333f157eb0..02a837a0f3 100644 --- a/packages/experimental/agent-team/tests/projection-events.spec.ts +++ b/packages/experimental/agent-team/tests/projection-events.spec.ts @@ -4,7 +4,7 @@ import { Context } from '@deepseek-ai/cordis' import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionEventMap, SessionEventType } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { teamProjectionDefinition } from '../src/projection.ts' +import { teamProjectionDefinition, teamProjectionView } from '../src/projection.ts' import type { TeamProjectionState, TeamState } from '../src/projection.ts' import { TeamId, TeamMessageId, TeamTaskId } from '../src/types.ts' import type { TeamMemberSnapshot, TeamMessageSnapshot, TeamTaskSnapshot } from '../src/types.ts' @@ -424,4 +424,161 @@ describe('Agent Teams projection events', () => { } as unknown as SessionEvent expect(isEmptyState(projectTeam(ROOT, [inherited]))).toBe(true) }) + + describe('copy-on-write state and client view', () => { + const memberEvent = event('team/member', { version: 2, teamId: TEAM, member: member() }, SessionSeq(0)) + const taskEvent = event('team/task', { version: 2, teamId: TEAM, task: task() }, SessionSeq(1)) + const queuedEvent = event('team/message/queued', { version: 2, teamId: TEAM, message: message() }, SessionSeq(2)) + const deliveredEvent = event('team/message/delivered', { + version: 2, teamId: TEAM, messageId: TeamMessageId('message-1'), targetId: CHILD, + }, SessionSeq(3)) + + it('returns the same state for unrelated events and a new state that replaces only the touched collection', () => { + const initial = project(ROOT, []) + expect(teamProjectionDefinition.apply(initial, { type: 'turn/start', data: { turn: 1 }, seq: 0, time: 0 } as SessionEvent)).toBe(initial) + expect(teamProjectionDefinition.apply(initial, event('team/member', { + version: 2, teamId: TeamId('ancestor'), member: member(), + }, SessionSeq(0)))).toBe(initial) + + const withMember = teamProjectionDefinition.apply(initial, memberEvent) + expect(withMember).not.toBe(initial) + expect(initial.members).toEqual([]) + expect(withMember.members).toHaveLength(1) + expect(withMember.tasks).toBe(initial.tasks) + + const withTask = teamProjectionDefinition.apply(withMember, taskEvent) + expect(withTask.members).toBe(withMember.members) + expect(withTask.tasks).not.toBe(withMember.tasks) + expect(withMember.tasks).toEqual([]) + + const withMail = teamProjectionDefinition.apply(withTask, queuedEvent) + expect(withMail.members).toBe(withTask.members) + expect(withMail.tasks).toBe(withTask.tasks) + expect(withMail.messages).not.toBe(withTask.messages) + expect(withTask.messages).toEqual([]) + + const withReceipt = teamProjectionDefinition.apply(withMail, deliveredEvent) + expect(withReceipt.delivered).not.toBe(withMail.delivered) + expect(withMail.delivered).toEqual([]) + expect(teamProjectionDefinition.apply(withReceipt, { ...deliveredEvent, seq: SessionSeq(4) })).not.toBe(withReceipt) + }) + + it('enters failure through a new state that keeps the last valid collections', () => { + const valid = project(ROOT, [memberEvent, taskEvent]) + const failed = teamProjectionDefinition.apply(valid, event('team/task', { + version: 1 as 2, teamId: TEAM, task: task(), + }, SessionSeq(2))) + expect(failed).not.toBe(valid) + expect(valid.failure).toBeUndefined() + expect(failed.failure).toMatch(/unsupported Agent Teams event version 1/) + expect(failed.members).toBe(valid.members) + expect(failed.tasks).toBe(valid.tasks) + expect(teamProjectionDefinition.apply(failed, queuedEvent)).toBe(failed) + }) + + it('views durable roster and non-deleted derived tasks and reuses the view across mailbox-only changes', () => { + const activeMember = event('team/member', { version: 2, teamId: TEAM, member: member({ phase: 'active' }) }, SessionSeq(1)) + const claimed = event('team/task', { + version: 2, teamId: TEAM, task: task({ revision: 2, status: 'in_progress', ownerId: CHILD, writeScopes: ['src'] }), + }, SessionSeq(2)) + const blocked = event('team/task', { + version: 2, teamId: TEAM, task: task({ id: TeamTaskId('task-2'), blockedBy: [TeamTaskId('task-1')], writeScopes: ['src/web'] }), + }, SessionSeq(3)) + const leadOwned = event('team/task', { + version: 2, teamId: TEAM, task: task({ id: TeamTaskId('task-3'), revision: 1, status: 'in_progress', ownerId: ROOT }), + }, SessionSeq(4)) + const deleted = event('team/task', { + version: 2, teamId: TEAM, task: task({ id: TeamTaskId('task-3'), revision: 2, status: 'deleted', ownerId: ROOT }), + }, SessionSeq(5)) + const state = project(ROOT, [memberEvent, activeMember, taskEvent, claimed, blocked, leadOwned, deleted]) + const view = teamProjectionView(state) + expect(teamProjectionDefinition.wire.viewSchema.parse(view)).toEqual(view) + expect(view).toEqual({ + members: [ + { id: ROOT, name: 'lead', role: 'lead', phase: 'active' }, + { id: CHILD, name: 'worker-a', role: 'teammate', phase: 'active', description: 'worker', provider: 'spawn', context: 'fresh' }, + ], + tasks: [ + { + id: 'task-1', revision: 2, subject: 'subject', description: 'description', status: 'in_progress', + blockedBy: [], writeScopes: ['src'], ownerName: 'worker-a', ready: false, writeScopeWarnings: [], + }, + { + id: 'task-2', revision: 1, subject: 'subject', description: 'description', status: 'pending', + blockedBy: ['task-1'], writeScopes: ['src/web'], ready: false, writeScopeWarnings: ['write scopes overlap with task-1'], + }, + ], + }) + expect(view).not.toHaveProperty('failure') + expect(teamProjectionView(state)).toBe(view) + + const withMail = teamProjectionDefinition.apply(state, { ...queuedEvent, seq: SessionSeq(6) }) + expect(teamProjectionView(withMail)).toBe(view) + const withMember = teamProjectionDefinition.apply(withMail, event('team/member', { + version: 2, teamId: TEAM, member: member({ id: SessionId('child-b'), name: 'worker-b' }), + }, SessionSeq(7))) + const memberView = teamProjectionView(withMember) + expect(memberView).not.toBe(view) + expect(memberView.members.at(-1)).toEqual({ + id: 'child-b', name: 'worker-b', role: 'teammate', phase: 'provisioning', description: 'worker', provider: 'spawn', context: 'fresh', + }) + + const failedMember = event('team/member', { + version: 2, teamId: TEAM, member: member({ id: SessionId('child-b'), name: 'worker-b', phase: 'failed', error: 'provider failed' }), + }, SessionSeq(8)) + expect(teamProjectionView(teamProjectionDefinition.apply(withMember, failedMember)).members.at(-1)) + .toMatchObject({ phase: 'failed', error: 'provider failed' }) + }) + + it('publishes the failure with the last valid roster and tasks', () => { + const state = project(ROOT, [memberEvent, taskEvent, event('team/task', { + version: 1 as 2, teamId: TEAM, task: task(), + }, SessionSeq(2))]) + const view = teamProjectionView(state) + expect(view.failure).toMatch(/unsupported Agent Teams event version 1/) + expect(view.members).toHaveLength(2) + expect(view.tasks).toHaveLength(1) + expect(teamProjectionDefinition.wire.viewSchema.parse(view)).toEqual(view) + }) + + it('drives live agentTeam publications per Session through the projection registry', async () => { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + ctx.effect(() => ctx.sessionProjections.register(teamProjectionDefinition)) + const published: Array<{ session: SessionId; key: string; value: unknown }> = [] + ctx.sessionProjections.onChanged((session, key, value) => { + published.push({ session: session.id, key, value }) + }) + const lead = ctx.sessions.create(ROOT) + const other = ctx.sessions.create(SessionId('other-root')) + expect(ctx.sessionProjections.snapshot(lead).values['agentTeam']).toEqual({ + members: [{ id: ROOT, name: 'lead', role: 'lead', phase: 'active' }], + tasks: [], + }) + + lead.append('team/task', taskEvent.data) + expect(published).toHaveLength(1) + expect(published[0]).toMatchObject({ + session: ROOT, + key: 'agentTeam', + value: { tasks: [{ id: 'task-1', ready: true }] }, + }) + + lead.append('team/message/queued', queuedEvent.data) + lead.append('turn/start', { turn: 1 }) + expect(published).toHaveLength(1) + + other.append('team/task', { version: 2, teamId: TeamId(other.id), task: task() }) + expect(published).toHaveLength(2) + expect(published[1]).toMatchObject({ + session: other.id, + value: { members: [{ id: other.id, name: 'lead', role: 'lead', phase: 'active' }] }, + }) + expect(ctx.sessionProjections.snapshot(lead).values['agentTeam']).toMatchObject({ + tasks: [{ id: 'task-1' }], + }) + }) + }) }) diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts index 87d63353ed..15c227b303 100644 --- a/packages/experimental/agent-team/tests/team.spec.ts +++ b/packages/experimental/agent-team/tests/team.spec.ts @@ -30,9 +30,9 @@ afterEach(() => { /** Detached durable Team read through the same projection definition as the service. */ function durable(agent: Agent): { - members: TeamMemberSnapshot[] - tasks: TeamTaskSnapshot[] - pendingMessages: TeamMessageSnapshot[] + members: readonly TeamMemberSnapshot[] + tasks: readonly TeamTaskSnapshot[] + pendingMessages: readonly TeamMessageSnapshot[] } { let projected = teamProjectionDefinition.init(agent.session.header) for (const event of agent.session.snapshotEvents()) projected = teamProjectionDefinition.apply(projected, event) diff --git a/packages/experimental/client-ui-agent-team/README.i18n.yaml b/packages/experimental/client-ui-agent-team/README.i18n.yaml index 5965f254f5..77f7ef28a4 100644 --- a/packages/experimental/client-ui-agent-team/README.i18n.yaml +++ b/packages/experimental/client-ui-agent-team/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/client-ui-agent-team/README.md -README.md: 253c243a680a989988ea65e553c14eff9a699155 -README.zh.md: 143c855b8b8a45a7d3777700aefa6a378401f20d +README.md: d08b19f19849a1da517a980adf427bdd6fddcd76 +README.zh.md: 91137d2d527c87200fe6720079d639695ac7033e diff --git a/packages/experimental/client-ui-agent-team/README.md b/packages/experimental/client-ui-agent-team/README.md index 253c243a68..d08b19f198 100644 --- a/packages/experimental/client-ui-agent-team/README.md +++ b/packages/experimental/client-ui-agent-team/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package adds an Agent Teams action to the Web conversation header, where a user can inspect the current roster, inspect the shared task board, and navigate into a teammate's conversation. It reads authoritative Team state through the generated `ctx.remote.agentTeams` contribution and keeps ordinary child-history navigation on the stable addressed-subagent path. Choose it through the published experimental Agent Teams bundle. The browser projection does not extend the stable API Proxy, store Team state, or register model-facing input. +This package adds an Agent Teams action to the Web conversation header, where a user can inspect the current roster, inspect the shared task board, and navigate into a teammate's conversation. It reads the Lead Session's `agentTeam` projection from the shared Session store, where Host projection frames keep it current without a refresh control, and keeps ordinary child-history navigation on the stable addressed-subagent path. Choose it through the published experimental Agent Teams bundle. The browser projection does not extend the stable API Proxy, store Team state, or register model-facing input. ## Table of Contents @@ -29,13 +29,13 @@ Enable this package through [`@deepseek-ai/dsh-experimental-agent-team-profile`] ### Inspect and navigate the roster -Opening the panel calls `agentTeams/view`. Roster rows show durable names, turn availability, model, and diagnostics. Provisioning and running members use the shared ongoing loader, inactive members use idle, and failed members use error. Selecting a healthy teammate opens the ordinary `{ parentSessionId, childSessionId, mode: 'continuable' }` address from its Lead and roster identity without refreshing or checking the parent catalog. The Host validates the parent, child, and mode when history opens. History and later human prompts continue through the stable addressed-subagent conversation path; this package adds no Team-specific address field. +The trigger shows the teammate count, and the panel shows the roster and task board of the Lead Session's `agentTeam` projection, so a task created by an agent or a teammate reaching `active` appears while the panel is open. Opening the panel requests the Lead's projection baseline when the shared store has none; a failed read shows the failure with a retry control, and a reconnect reloads it. Roster rows show durable names and a status: `failed` and `provisioning` come from the durable member phase, and `running` or `inactive` comes from the member Session's live status. A model appears when the member Session's `modelSelection` projection records a durable selection or request. Provisioning and running members use the shared ongoing loader, inactive members use idle, and failed members use error. Selecting a healthy teammate opens the ordinary `{ parentSessionId, childSessionId, mode: 'continuable' }` address from its Lead and roster identity without refreshing or checking the parent catalog. The Host validates the parent, child, and mode when history opens. History and later human prompts continue through the stable addressed-subagent conversation path; this package adds no Team-specific address field. ### Inspect the task board Ready pending tasks use idle, blocked pending tasks use warning, in-progress tasks use ongoing, and completed tasks use done. -The read-only task board shows task identity, owner, blockers, readiness, advisory write scopes, and overlap warnings. Team agents create and update tasks through their tools; the panel provides no task mutation controls. +The read-only task board shows task identity, owner, blockers, readiness, advisory write scopes, and overlap warnings. Team agents create and update tasks through their tools; the panel provides no task mutation controls. When the projection reports a rejected persisted Team record, the panel shows that failure above the last valid roster and tasks. ----- @@ -45,14 +45,14 @@ The read-only task board shows task identity, owner, blockers, readiness, adviso
Implementation internals — click to expand -The Client export mounts the generated `ctx.remote.agentTeams` contribution from [`@deepseek-ai/dsh-experimental-agent-team/remote`](../agent-team/README.md), then registers its locale dictionaries and one conversation-header slot through Cordis effects. Disposing the plugin fiber removes both registrations. +The Client export registers its locale dictionaries and one conversation-header slot through Cordis effects; it mounts no Remote namespace. Disposing the plugin fiber removes both registrations. -The panel renders outside the conversation container and stays within the viewport. Opening moves focus into the panel; Escape or Close returns focus to its trigger. Clicking outside or moving focus outside the panel and trigger closes it without moving focus back. Opening or refreshing the panel reads the complete Team view. Overlapping refreshes keep the newest response, and responses for a previous conversation are ignored. +The panel renders outside the conversation container and stays within the viewport. Opening moves focus into the panel; Escape or Close returns focus to its trigger. Clicking outside or moving focus outside the panel and trigger closes it without moving focus back. The component derives every row from the `useSessions`, `useSessionStatus`, and `useSession` seats: the Lead identity comes from the current Session's subagent address, the Team view from `projectionsBySession[lead].values.agentTeam`, member activity from Session status with the list summary as fallback, and the model from `projectionsBySession[member].values.modelSelection.next`. Its only injected callbacks read the Lead's projection baseline and open a teammate. Switching conversations closes the panel and clears a navigation failure. | File | Role | |---|---| -| [`src/client/mount.ts`](src/client/mount.ts) | Generated Remote, locale, navigation, and slot registrations | -| [`src/client/TeamAction.tsx`](src/client/TeamAction.tsx) | Roster and task-board interaction state | +| [`src/client/mount.ts`](src/client/mount.ts) | Locale, projection-read, navigation, and slot registrations | +| [`src/client/TeamAction.tsx`](src/client/TeamAction.tsx) | Projection-derived roster and task board with panel interaction state | | [`src/client/locales.ts`](src/client/locales.ts) | English and Chinese panel copy | | [`src/index.ts`](src/index.ts) | Inert Host entry | @@ -64,7 +64,7 @@ The panel renders outside the conversation container and stays within the viewpo ## Further Exploration - [Agent Teams bundle](../agent-team-profile/README.md) — the published opt-in bundle that mounts this Client plugin. -- [Agent Teams service](../agent-team/README.md) — authoritative roster, task, and Remote behavior. +- [Agent Teams service](../agent-team/README.md) — authoritative roster, task, projection, and Remote behavior. - [Conversation UI](../../client/ui-conversation/README.md) — the stable header slot and addressed-subagent navigation surface. - [Experimental packages](../README.md) — incubation status and publication policy. @@ -83,7 +83,8 @@ No direct effect; the Team tools and ordinary conversation submission own any la -- **Snapshot refresh** — the panel refreshes on open and explicit refresh; it has no live event subscription or mailbox timeline. +- **No mailbox timeline** — the projection view carries roster and tasks only; peer messages are not shown. +- **Model before the first request** — a member without a durable model selection or request shows no model, and a teammate Session that is not loaded on the Host shows no model or activity until its projections arrive. - **Ordinary child continuation** — a human message sent after navigation uses the stable addressed-subagent prompt path, not the Team peer mailbox. - **No lifecycle or workspace controls** — the panel cannot spawn, rename, delete, or interrupt teammates, and write scopes remain advisory metadata. @@ -97,4 +98,4 @@ None.
-**Runtime invariant:** No companion is published. RPC is authoritative and the package owns only one disposable slot registration. +**Runtime invariant:** No companion is published. The Host projection is authoritative and the package owns only one disposable slot registration. diff --git a/packages/experimental/client-ui-agent-team/README.zh.md b/packages/experimental/client-ui-agent-team/README.zh.md index 143c855b8b..91137d2d52 100644 --- a/packages/experimental/client-ui-agent-team/README.zh.md +++ b/packages/experimental/client-ui-agent-team/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包向 Web 会话页头添加 Agent Teams action,让用户检查当前 roster、查看共享任务板并导航到 teammate 会话。它通过生成的 `ctx.remote.agentTeams` contribution 读取权威 Team 状态,并让普通 child history 导航继续使用稳定的 addressed-subagent 路径。通过公开发布的实验性 Agent Teams bundle 选择本包。这个浏览器 projection 不扩展稳定 API Proxy、不存储 Team 状态,也不注册面向模型的输入。 +本包向 Web 会话页头添加 Agent Teams action,让用户检查当前 roster、查看共享任务板并导航到 teammate 会话。它从共享 Session store 读取 Lead Session 的 `agentTeam` 投影,Host 投影 frame 使其保持最新而无需刷新控件,并让普通 child history 导航继续使用稳定的 addressed-subagent 路径。通过公开发布的实验性 Agent Teams bundle 选择本包。这个浏览器 projection 不扩展稳定 API Proxy、不存储 Team 状态,也不注册面向模型的输入。 ## 目录 @@ -29,13 +29,13 @@ kind: "package-reference" ### 检查并导航 roster -打开 panel 会调用 `agentTeams/view`。Roster row 展示持久 name、轮次可用状态、model 与 diagnostics。provisioning 和 running 成员使用共享 ongoing loading,inactive 成员使用 idle 灰点,failed 成员使用 error 红点。选择健康 teammate 时,系统直接根据其 Lead 与 roster 身份打开普通的 `{ parentSessionId, childSessionId, mode: 'continuable' }` address,不刷新或检查 parent catalog。Host 在打开历史时校验 parent、child 与 mode。History 与后续人类提示词继续使用稳定 addressed-subagent 会话路径;本包不会添加 Team 专用 address 字段。 +触发按钮显示 teammate 数量,panel 显示 Lead Session `agentTeam` 投影中的 roster 与任务板,因此 agent 创建的任务或进入 `active` 的 teammate 会在 panel 打开期间直接出现。打开 panel 时,若共享 store 尚无 Lead 的投影基线则请求一次;读取失败会显示失败原因与重试控件,重连后会重新加载。Roster row 展示持久 name 与状态:`failed` 与 `provisioning` 来自持久成员 phase,`running` 或 `inactive` 来自成员 Session 的实时状态。当成员 Session 的 `modelSelection` 投影记录了持久选择或请求时显示 model。provisioning 和 running 成员使用共享 ongoing loading,inactive 成员使用 idle 灰点,failed 成员使用 error 红点。选择健康 teammate 时,系统直接根据其 Lead 与 roster 身份打开普通的 `{ parentSessionId, childSessionId, mode: 'continuable' }` address,不刷新或检查 parent catalog。Host 在打开历史时校验 parent、child 与 mode。History 与后续人类提示词继续使用稳定 addressed-subagent 会话路径;本包不会添加 Team 专用 address 字段。 ### 查看任务板 可开始的 pending 任务使用 idle 灰点,被依赖阻塞的 pending 任务使用 warning 橙点,in-progress 任务使用 ongoing loading,completed 任务使用 done 绿点。 -只读任务板展示任务标识、负责人、依赖、就绪状态、提示性写入范围与重叠警告。Team agent 通过工具创建和更新任务;面板不提供任务修改控件。 +只读任务板展示任务标识、负责人、依赖、就绪状态、提示性写入范围与重叠警告。Team agent 通过工具创建和更新任务;面板不提供任务修改控件。当投影报告某条持久 Team 记录被拒绝时,面板在最后有效的 roster 与任务上方显示该失败。 ----- @@ -45,14 +45,14 @@ kind: "package-reference"
实现细节——点击展开 -Client export 挂载来自 [`@deepseek-ai/dsh-experimental-agent-team/remote`](../agent-team/README.zh.md) 的生成的 `ctx.remote.agentTeams` contribution,然后通过 Cordis effect 注册 locale dictionary 与一个 conversation-header slot。Dispose plugin fiber 会移除这两项 registration。 +Client export 通过 Cordis effect 注册 locale dictionary 与一个 conversation-header slot;它不挂载任何 Remote namespace。Dispose plugin fiber 会移除这两项 registration。 -面板渲染在会话容器外,并保持在视口范围内。打开时焦点移入面板;按 Escape 或选择关闭按钮时,焦点返回触发按钮。点击外部或将焦点移出面板与触发按钮时,面板关闭,但不会将焦点移回。打开或刷新面板会读取完整 Team view。并行刷新只保留最新响应,属于上一个会话的响应会被忽略。 +面板渲染在会话容器外,并保持在视口范围内。打开时焦点移入面板;按 Escape 或选择关闭按钮时,焦点返回触发按钮。点击外部或将焦点移出面板与触发按钮时,面板关闭,但不会将焦点移回。组件从 `useSessions`、`useSessionStatus` 与 `useSession` 座位派生每一行:Lead 身份来自当前 Session 的 subagent address,Team 视图来自 `projectionsBySession[lead].values.agentTeam`,成员活动来自 Session 状态并以列表摘要为后备,model 来自 `projectionsBySession[member].values.modelSelection.next`。它仅有的注入回调是读取 Lead 的投影基线与打开 teammate。切换会话会关闭面板并清除导航失败。 | 文件 | 职责 | |---|---| -| [`src/client/mount.ts`](src/client/mount.ts) | 生成的 Remote、locale、导航与 slot registration | -| [`src/client/TeamAction.tsx`](src/client/TeamAction.tsx) | Roster 与任务板交互状态 | +| [`src/client/mount.ts`](src/client/mount.ts) | locale、投影读取、导航与 slot registration | +| [`src/client/TeamAction.tsx`](src/client/TeamAction.tsx) | 由投影派生的 roster 与任务板及面板交互状态 | | [`src/client/locales.ts`](src/client/locales.ts) | 中英文 panel 文案 | | [`src/index.ts`](src/index.ts) | 不执行行为的 Host entry | @@ -64,7 +64,7 @@ Client export 挂载来自 [`@deepseek-ai/dsh-experimental-agent-team/remote`](. ## 进一步探索 - [Agent Teams bundle](../agent-team-profile/README.zh.md)——挂载本 Client plugin 的公开 opt-in bundle。 -- [Agent Teams service](../agent-team/README.zh.md)——权威 roster、task 与 Remote 行为。 +- [Agent Teams service](../agent-team/README.zh.md)——权威 roster、task、投影与 Remote 行为。 - [会话 UI](../../client/ui-conversation/README.zh.md)——稳定 header slot 与 addressed-subagent 导航表层。 - [实验性包](../README.zh.md)——孵化状态与发布规则。 @@ -83,7 +83,8 @@ Client export 挂载来自 [`@deepseek-ai/dsh-experimental-agent-team/remote`](. -- **Snapshot refresh**——panel 会在打开和显式 refresh 时刷新;它没有实时事件订阅或 mailbox timeline。 +- **没有 mailbox timeline**——投影视图只承载 roster 与任务;不显示 peer 消息。 +- **首次请求前没有 model**——没有持久模型选择或请求的成员不显示 model;未在 Host 加载的 teammate Session 在其投影到达前不显示 model 与活动。 - **普通 child continuation**——导航后发送的人类消息使用稳定 addressed-subagent 提示词路径,而不是 Team peer mailbox。 - **没有 lifecycle 或 workspace control**——panel 不能 spawn、rename、delete 或 interrupt teammate,write scope 仍只是提示性 metadata。 @@ -97,4 +98,4 @@ Client export 挂载来自 [`@deepseek-ai/dsh-experimental-agent-team/remote`](.
-**运行时不变式:** 不发布伴生入口。RPC 是权威来源,本包只持有一个可释放的 slot 注册。 +**运行时不变式:** 不发布伴生入口。Host 投影是权威来源,本包只持有一个可释放的 slot 注册。 diff --git a/packages/experimental/client-ui-agent-team/package.json b/packages/experimental/client-ui-agent-team/package.json index cbde881879..aee71e7a99 100644 --- a/packages/experimental/client-ui-agent-team/package.json +++ b/packages/experimental/client-ui-agent-team/package.json @@ -29,7 +29,6 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation", @@ -45,7 +44,6 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", @@ -56,13 +54,12 @@ "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-experimental-agent-team": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", @@ -72,7 +69,6 @@ "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-experimental-agent-team": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", diff --git a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx index bce5aa56e0..c9d678b050 100644 --- a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx +++ b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx @@ -1,12 +1,11 @@ -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { - TeamMemberView as TeamRosterMember, + TeamMemberProjection, TeamTaskView as TeamTask, - TeamView, } from '@deepseek-ai/dsh-experimental-agent-team/client' -import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' +import type {} from '@deepseek-ai/dsh-api-session-controller/client' import { IconCloseOutlineRegular, IconRefreshOutlineRegular, IconUserOutlineRegular, StateDot, useAnchoredPosition, useDismissOnOutsidePointer, type StateDotState, @@ -16,19 +15,20 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import { NS, type TeamKey } from './locales.ts' import css from './TeamAction.module.css' -/** Generated Remote result consumed directly by the Team UI. */ -export type TeamActionResult = RemoteResult - /** Business actions injected by the browser plugin. */ export interface TeamActionInjected { - load: (sessionId: SessionId) => Promise> - openTeammate: (sessionId: SessionId, member: TeamRosterMember) => void + /** Read the Lead Session's projection baseline when the shared store has none, or retry a failed read. */ + loadProjections: (leadSessionId: SessionId) => void + openTeammate: (sessionId: SessionId, member: TeamMemberProjection) => void } /** Full props of the Team conversation-header action. */ export type TeamActionProps = PropsRuntime<'conversation.session.header.actions'> & TeamActionInjected & PropsLocale +/** Durable lifecycle overlaid with the member Session's live turn activity. */ +type MemberStatus = 'running' | 'inactive' | 'provisioning' | 'failed' + function failureText(error: { readonly code: string; readonly message: string }): string { return `${error.message} (${error.code})` } @@ -38,12 +38,12 @@ function statusKey(status: TeamTask['status']): TeamKey { case 'pending': return 'status.pending' case 'in_progress': return 'status.in_progress' case 'completed': return 'status.completed' - /* v8 ignore next -- Team views omit deleted task tombstones. */ + /* v8 ignore next -- the Team projection omits deleted task tombstones. */ case 'deleted': return 'status.completed' } } -function memberStatusKey(status: TeamRosterMember['status']): TeamKey { +function memberStatusKey(status: MemberStatus): TeamKey { switch (status) { case 'running': return 'memberStatus.running' case 'inactive': return 'memberStatus.inactive' @@ -52,7 +52,7 @@ function memberStatusKey(status: TeamRosterMember['status']): TeamKey { } } -function memberDotState(status: TeamRosterMember['status']): StateDotState { +function memberDotState(status: MemberStatus): StateDotState { switch (status) { case 'running': case 'provisioning': return 'ongoing' @@ -66,18 +66,16 @@ function taskDotState(task: TeamTask): StateDotState { case 'pending': return task.ready ? 'idle' : 'warning' case 'in_progress': return 'ongoing' case 'completed': return 'done' - /* v8 ignore next -- Team views omit deleted task tombstones. */ + /* v8 ignore next -- the Team projection omits deleted task tombstones. */ case 'deleted': return 'idle' } } -/** Render the Team roster and read-only task board. */ +/** Render the Team roster and read-only task board from the Lead Session's `agentTeam` projection. */ export function TeamAction({ - sessionId, load, openTeammate, t, + sessionId, useSession, useSessions, useSessionStatus, loadProjections, openTeammate, t, }: TeamActionProps) { const [open, setOpen] = useState(false) - const [loading, setLoading] = useState(false) - const [view, setView] = useState(null) const [error, setError] = useState(null) const rootRef = useRef(null) const triggerRef = useRef(null) @@ -87,18 +85,24 @@ export function TeamAction({ }) const positioned = position !== null useDismissOnOutsidePointer(rootRef, open, setOpen, panelRef) - const sessionRef = useRef(sessionId) - const refreshGeneration = useRef(0) - sessionRef.current = sessionId + + const leadSessionId = useSession(snapshot => snapshot.subagent?.address.parentSessionId) ?? sessionId + const projections = useSessions(state => state.projectionsBySession) + const summaries = useSessions(state => state.byId) + const statuses = useSessionStatus(snapshot => snapshot) + const projection = projections[leadSessionId] + const team = projection?.values.agentTeam + const readError = team === undefined && projection?.state === 'error' ? projection.error : null useEffect(() => { - refreshGeneration.current += 1 setOpen(false) - setLoading(false) - setView(null) setError(null) }, [sessionId]) + useEffect(() => { + if (open) loadProjections(leadSessionId) + }, [open, leadSessionId, loadProjections]) + useLayoutEffect(() => { if (open && positioned) panelRef.current?.focus() }, [open, positioned]) @@ -108,22 +112,15 @@ export function TeamAction({ triggerRef.current?.focus() } - const refresh = useCallback(async (): Promise => { - const requestedSession = sessionId - const generation = ++refreshGeneration.current - setLoading(true) - const result = await load(requestedSession) - if (sessionRef.current !== requestedSession || refreshGeneration.current !== generation) return - setLoading(false) - if (result.ok) { - setView(result.value) - setError(null) - } else { - setError(failureText(result.error)) - } - }, [load, sessionId]) + const memberStatus = (member: TeamMemberProjection): MemberStatus => { + if (member.phase === 'failed') return 'failed' + if (member.phase === 'provisioning') return 'provisioning' + return (statuses.get(member.id)?.running ?? summaries[member.id]?.running) === true ? 'running' : 'inactive' + } + const memberModel = (member: TeamMemberProjection): string | undefined => + projections[member.id]?.values.modelSelection?.next?.model - const teammates = view?.members.filter(member => member.role === 'teammate') ?? [] + const teammates = team?.members.filter(member => member.role === 'teammate') ?? [] return (
{ @@ -142,11 +139,7 @@ export function TeamAction({ className={css.trigger} aria-haspopup="dialog" aria-expanded={open} - onClick={() => { - const next = !open - setOpen(next) - if (next) void refresh() - }} + onClick={() => { setOpen(!open) }} > {t('trigger')} @@ -165,12 +158,6 @@ export function TeamAction({
{t('trigger')} - {loading && view !== null && ( - - )} - @@ -178,44 +165,60 @@ export function TeamAction({ {error !== null && (
{error}
)} - {loading && view === null && ( + {readError !== null && ( +
+ + {failureText(readError)} + +
+ )} + {team === undefined && readError === null && (
{t('loading')}
)} - {view !== null && ( + {team !== undefined && ( <> + {team.failure !== undefined && ( +
{t('failure', { message: team.failure })}
+ )}

{t('roster')}

- {view.members.map(member => ( - - ))} + {team.members.map((member) => { + const status = memberStatus(member) + const model = memberModel(member) + return ( + + ) + })}

{t('tasks')}

- {view.tasks.length === 0 &&
{t('empty')}
} + {team.tasks.length === 0 &&
{t('empty')}
}
- {view.tasks.map(task => ( + {team.tasks.map(task => (
{task.subject} diff --git a/packages/experimental/client-ui-agent-team/src/client/index.ts b/packages/experimental/client-ui-agent-team/src/client/index.ts index 5e2093105e..a4896c82af 100644 --- a/packages/experimental/client-ui-agent-team/src/client/index.ts +++ b/packages/experimental/client-ui-agent-team/src/client/index.ts @@ -1,14 +1,16 @@ -/** Browser entry binding the generated Team Remote artifact to its Client UI. */ +/** Browser entry registering the Agent Teams conversation-header action. */ -import agentTeamsRemote from '@deepseek-ai/dsh-experimental-agent-team/remote' import type { Context as ClientContext } from '@deepseek-ai/cordis' -import { mountAgentTeamUi } from './mount.ts' +import { registerAgentTeamUi } from './mount.ts' export { inject } from './mount.ts' -export type { TeamActionInjected, TeamActionProps, TeamActionResult } from './TeamAction.tsx' +export type { TeamActionInjected, TeamActionProps } from './TeamAction.tsx' export type { TeamKey } from './locales.ts' -/** Mount the generated Team Remote contribution and its browser UI. */ -export async function apply(ctx: ClientContext): Promise<() => Promise> { - return await mountAgentTeamUi(ctx, agentTeamsRemote) +/** + * Register the Team locale dictionaries and header action on the Client Context. + * @param ctx - Client Context with the declared `inject` services available. + */ +export function apply(ctx: ClientContext): void { + registerAgentTeamUi(ctx) } diff --git a/packages/experimental/client-ui-agent-team/src/client/locales.ts b/packages/experimental/client-ui-agent-team/src/client/locales.ts index 9cef05d89c..8d3c45d513 100644 --- a/packages/experimental/client-ui-agent-team/src/client/locales.ts +++ b/packages/experimental/client-ui-agent-team/src/client/locales.ts @@ -6,9 +6,10 @@ export const NS = 'agent-team' /** Simplified Chinese dictionary and key source. */ export const zh = { trigger: 'Agent Team', - refresh: '刷新 Team', close: '关闭', + retry: '重试读取 Team', loading: '正在加载 Team…', + failure: 'Team 持久记录无效:{message}', empty: '还没有共享任务', roster: '成员', tasks: '共享任务', @@ -35,9 +36,10 @@ export type TeamKey = keyof typeof zh /** English dictionary checked against the Chinese key set. */ export const en = { trigger: 'Agent Team', - refresh: 'Refresh Team', close: 'Close', + retry: 'Retry reading Team', loading: 'Loading Team…', + failure: 'Invalid persisted Team record: {message}', empty: 'No shared tasks yet', roster: 'Members', tasks: 'Shared tasks', diff --git a/packages/experimental/client-ui-agent-team/src/client/mount.ts b/packages/experimental/client-ui-agent-team/src/client/mount.ts index ad9648e322..d7e9083307 100644 --- a/packages/experimental/client-ui-agent-team/src/client/mount.ts +++ b/packages/experimental/client-ui-agent-team/src/client/mount.ts @@ -1,22 +1,14 @@ -/** Source-safe Agent Teams browser registration and Remote mount lifecycle. */ +/** Source-safe Agent Teams browser registration. */ -import type { - TeamMemberView as TeamRosterMember, - TeamView, -} from '@deepseek-ai/dsh-experimental-agent-team/client' -import type {} from '@deepseek-ai/dsh-experimental-agent-team/remote' +import type { TeamMemberProjection } from '@deepseek-ai/dsh-experimental-agent-team/client' import type { Context as ClientContext } from '@deepseek-ai/cordis' -import type {} from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' -import type { TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol' -import { - TeamAction, type TeamActionInjected, type TeamActionResult, -} from './TeamAction.tsx' +import { TeamAction, type TeamActionInjected } from './TeamAction.tsx' import { en, NS, zh, type TeamKey } from './locales.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { @@ -26,10 +18,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } } -/** Required browser services for RPC, navigation, slots, and localized copy. */ -export const inject = ['sessions', 'uiWorkspace', 'remote', 'slots', 'locale'] +/** Required browser services for projection reads, navigation, slots, and localized copy. */ +export const inject = ['sessions', 'uiWorkspace', 'slots', 'locale'] -function registerUi(ctx: ClientContext): void { +/** + * Register the Team locale dictionaries and the conversation-header action. + * The panel reads the Lead Session's `agentTeam` projection from the shared + * Session store; this registration performs no Team RPC. + * @param ctx - Client Context carrying the injected navigation, locale, slot, and Session services. + */ +export function registerAgentTeamUi(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'client-ui-agent-team: dictionaries') const sessions = ctx.sessions const leadSessionId = (sessionId: SessionId): SessionId => { @@ -38,10 +36,10 @@ function registerUi(ctx: ClientContext): void { } const actions: TeamActionInjected = { - async load(sessionId): Promise> { - return await ctx.remote.agentTeams.view(leadSessionId(sessionId)) + loadProjections(leadId: SessionId): void { + void sessions.refreshProjections(leadId) }, - openTeammate(sessionId: SessionId, member: TeamRosterMember): void { + openTeammate(sessionId: SessionId, member: TeamMemberProjection): void { if (member.role !== 'teammate') return const parentSessionId = leadSessionId(sessionId) if ((sessions.retainInfo(sessionId).getSnapshot().retainedBy.mainView ?? 0) === 0) return @@ -64,28 +62,3 @@ function registerUi(ctx: ClientContext): void { }, TeamAction), ) } - -/** - * Mount one generated Team Remote contribution, then register its browser UI. - * @param ctx - Client Context carrying navigation, locale, slot, and Remote services. - * @param contribution - generated Team descriptors selected by the browser entry. - * @returns disposer for both the UI registrations and Remote namespace. - */ -export async function mountAgentTeamUi( - ctx: ClientContext, - contribution: TypertRemoteContribution, -): Promise<() => Promise> { - const disposeRemote = await ctx.remote.$mount(contribution) - const ui = ctx.inject(['sessions', 'uiWorkspace', 'remote.agentTeams', 'slots', 'locale'], registerUi) - try { - await ui - } catch (error) { - await ui.dispose() - await disposeRemote() - throw error - } - return async () => { - await ui.dispose() - await disposeRemote() - } -} diff --git a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts index a153ec2870..6e5bcaefb8 100644 --- a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts +++ b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts @@ -1,78 +1,23 @@ -import { Context, Service } from '@deepseek-ai/cordis' -import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' -import type { TeamMemberView as TeamRosterMember } from '@deepseek-ai/dsh-experimental-agent-team/client' -import type {} from '@deepseek-ai/dsh-experimental-agent-team/remote' -import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' -import type { TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol' +import type { TeamMemberProjection } from '@deepseek-ai/dsh-experimental-agent-team/client' import { TeamAction, type TeamActionInjected } from '../src/client/TeamAction.tsx' -import { inject, mountAgentTeamUi } from '../src/client/mount.ts' +import { apply, inject } from '../src/client/index.ts' import { apply as nodeApply } from '../src/index.ts' const SESSION = 'team-session' as SessionId const CHILD = 'team-child' as SessionId -const REMOTE: TypertRemoteContribution = { - package: '@deepseek-ai/dsh-experimental-agent-team', - descriptors: [], -} +const LEAD: TeamMemberProjection = { id: SESSION, name: 'lead', role: 'lead', phase: 'active' } +const WORKER: TeamMemberProjection = { id: CHILD, name: 'worker', role: 'teammate', phase: 'active' } -async function bench(options: { - addressed?: boolean - registrationFailure?: boolean - remoteFailure?: boolean - catalog?: 'missing' | 'empty' -} = {}) { +async function bench(options: { addressed?: boolean } = {}) { const ctx = new Context() - const calls: { method: string; args: unknown[] }[] = [] - const task = { - id: 'task-1', - revision: 1, subject: 'Task', description: 'Description', status: 'pending' as const, - blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [], - } - class RemoteService extends Service { - readonly disposeMount = vi.fn(() => Promise.resolve()) - readonly mount = vi.fn((_contribution: unknown) => Promise.resolve(this.disposeMount)) - - constructor(serviceCtx: Context) { - super(serviceCtx, 'remote') - } - - $mount(contribution: unknown): Promise<() => Promise> { - return this.mount(contribution) - } - } - const remote = new RemoteService(ctx) - const failure = { - ok: false as const, - error: new RemoteError('gateway/internal', 'offline', {}), - } - const view = { - members: [{ - id: SESSION, name: 'lead', role: 'lead' as const, status: 'inactive' as const, diagnostics: [], - }], tasks: [task], - } - ctx.provide('remote.agentTeams', { - view: (...args: unknown[]) => { - calls.push({ method: 'agentTeams/view', args }) - return Promise.resolve(options.remoteFailure - ? failure - : { ok: true as const, value: view }) - }, - }) const navigation: unknown[] = [] let mainSessionId = options.addressed === true ? CHILD : SESSION - const projectionsBySession = options.catalog === 'missing' - ? {} - : { - [SESSION]: { state: 'ready' as const, error: null, values: { subagentCatalog: options.catalog === 'empty' ? [] : [{ createdAt: 1, id: CHILD, - mode: 'continuable' as const, - label: 'worker' as const, - }] } }, - } ctx.provide('sessions', { - list: { getSnapshot: () => ({ projectionsBySession }) }, binding: (id: SessionId) => options.addressed === true && id === CHILD ? { session: { getSnapshot: () => ({ subagent: { @@ -106,153 +51,66 @@ async function bench(options: { name: 'root', children: { 'conversation.session.header.actions': { kind: 'list', scope: 'session' } }, } as never, () => null) - if (options.registrationFailure === true) { - vi.spyOn(ctx.slots, 'inject').mockImplementationOnce(() => { throw new Error('slot registration failed') }) - } - const fiber = options.registrationFailure === true - ? ctx.plugin({ apply() {} }) - : ctx.plugin({ inject: [...inject], apply: clientCtx => mountAgentTeamUi(clientCtx, REMOTE) }) - const activation: Promise = options.registrationFailure === true - ? mountAgentTeamUi(ctx, REMOTE).catch((error: unknown) => error) - : fiber.await() - if (options.registrationFailure !== true) { - await activation - } else { - await fiber.await() - } + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() const entry = () => ctx.slots.entries('conversation.session.header.actions') .find(candidate => candidate.component === TeamAction) + const actions = () => (entry()!.inject as unknown as () => TeamActionInjected)() return { ctx, fiber, - activation, - calls, navigation, - remote, entry, + actions, collapseHeader, select: (sessionId: SessionId) => { mainSessionId = sessionId }, } } describe('ui-team browser plugin', () => { - it('registers one disposable header action with a read-only RPC-backed task board', async () => { + it('registers one disposable header action without a Team Remote namespace', async () => { const b = await bench() - expect(inject).toEqual(['sessions', 'uiWorkspace', 'remote', 'slots', 'locale']) + expect(inject).toEqual(['sessions', 'uiWorkspace', 'slots', 'locale']) expect(b.entry()).toMatchObject({ options: { id: 'agent-team', order: 20 }, locale: 'agent-team', }) - expect(b.remote.mount).toHaveBeenCalledOnce() - expect(b.remote.mount).toHaveBeenCalledWith(REMOTE) - const actions = (b.entry()!.inject as unknown as () => TeamActionInjected)() - expect((await actions.load(SESSION)).ok).toBe(true) - expect(b.calls).toEqual([{ method: 'agentTeams/view', args: [SESSION] }]) + const t = b.ctx.locale.bind('agent-team') + expect(t('trigger')).toBe('Agent Team') - actions.openTeammate(SESSION, { - id: SESSION, - name: 'lead', - role: 'lead', - status: 'inactive', - diagnostics: [], - }) - expect(b.navigation).toEqual([]) + b.actions().loadProjections(SESSION) + expect(b.navigation).toEqual([['refresh', SESSION]]) + b.actions().openTeammate(SESSION, LEAD) + expect(b.navigation).toEqual([['refresh', SESSION]]) await b.fiber.dispose() expect(b.entry()).toBeUndefined() - expect(b.remote.disposeMount).toHaveBeenCalledOnce() + expect(t('empty')).toBe('empty') }) - it('unmounts the Remote contribution when later Client registration fails', async () => { - const b = await bench({ registrationFailure: true }) - await expect(b.activation).resolves.toMatchObject({ message: 'slot registration failed' }) - expect(b.remote.mount).toHaveBeenCalledOnce() - expect(b.remote.disposeMount).toHaveBeenCalledOnce() - }) - - it('returns Remote carrier failures unchanged', async () => { - const view = await bench({ remoteFailure: true }) - const viewActions = (view.entry()!.inject as unknown as () => TeamActionInjected)() - await expect(viewActions.load(SESSION)).resolves.toMatchObject({ - ok: false, - error: { code: 'gateway/internal', message: 'offline' }, - }) - - }) - - it('opens a continuable teammate address without refreshing the parent catalog', async () => { + it('opens a continuable teammate address without touching the parent catalog', async () => { const b = await bench() - const actions = (b.entry()!.inject as unknown as () => TeamActionInjected)() - const member: TeamRosterMember = { - id: CHILD, - name: 'worker', - role: 'teammate', - status: 'inactive', - diagnostics: [], - } - actions.openTeammate(SESSION, member) + b.actions().openTeammate(SESSION, WORKER) expect(b.navigation).toEqual([ - ['open', { - parentSessionId: SESSION, - childSessionId: CHILD, - mode: 'continuable', - }], + ['open', { parentSessionId: SESSION, childSessionId: CHILD, mode: 'continuable' }], ]) }) - it('routes Team actions from an addressed teammate conversation back through its Lead', async () => { + it('routes teammate navigation from an addressed teammate conversation back through its Lead', async () => { const b = await bench({ addressed: true }) - const actions = (b.entry()!.inject as unknown as () => TeamActionInjected)() - await actions.load(CHILD) - actions.openTeammate(CHILD, { - id: CHILD, - name: 'worker', - role: 'teammate', - status: 'inactive', - diagnostics: [], - }) - expect(b.calls[0]).toEqual({ method: 'agentTeams/view', args: [SESSION] }) + b.actions().openTeammate(CHILD, WORKER) expect(b.navigation).toEqual([ - ['open', { - parentSessionId: SESSION, - childSessionId: CHILD, - mode: 'continuable', - }], + ['open', { parentSessionId: SESSION, childSessionId: CHILD, mode: 'continuable' }], ]) }) it('does not open a teammate from a conversation outside the main view', async () => { const b = await bench() - const actions = (b.entry()!.inject as unknown as () => TeamActionInjected)() b.select('other-session' as SessionId) - actions.openTeammate(SESSION, { - id: CHILD, - name: 'worker', - role: 'teammate', - status: 'inactive', - diagnostics: [], - }) + b.actions().openTeammate(SESSION, WORKER) expect(b.navigation).toEqual([]) }) - it('opens a teammate when the parent catalog is missing or empty', async () => { - for (const catalog of ['missing', 'empty'] as const) { - const b = await bench({ catalog }) - const actions = (b.entry()!.inject as unknown as () => TeamActionInjected)() - actions.openTeammate(SESSION, { - id: CHILD, - name: 'worker', - role: 'teammate', - status: 'inactive', - diagnostics: [], - }) - expect(b.navigation).toEqual([ - ['open', { parentSessionId: SESSION, childSessionId: CHILD, mode: 'continuable' }], - ]) - await b.fiber.dispose() - } - }) - it('re-registers after the conversation header slot is collapsed and declared again', async () => { const b = await bench() expect(b.entry()).toBeDefined() diff --git a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx index 82a90c7e02..90aecc495b 100644 --- a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx +++ b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx @@ -1,22 +1,25 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { - TeamTaskId, TeamTaskView as TeamTask, TeamView, + TeamMemberProjection, TeamProjection, TeamTaskId, TeamTaskView as TeamTask, } from '@deepseek-ai/dsh-experimental-agent-team/client' -import { makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionListState, SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { bindSnapshotSelector, makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import { - TeamAction, type TeamActionInjected, type TeamActionProps, type TeamActionResult, -} from '../src/client/TeamAction.tsx' +import { TeamAction, type TeamActionInjected, type TeamActionProps } from '../src/client/TeamAction.tsx' import { zh } from '../src/client/locales.ts' afterEach(cleanup) const SESSION = 'lead' as SessionId +const WORKER = 'worker-id' as SessionId const TASK_1 = 'task-1' as TeamTaskId +const TASK_2 = 'task-2' as TeamTaskId const task: TeamTask = { id: TASK_1, revision: 1, @@ -29,214 +32,231 @@ const task: TeamTask = { ready: false, writeScopeWarnings: ['write scopes overlap with task-2'], } -const view: TeamView = { - members: [ - { id: SESSION, name: 'lead', role: 'lead', status: 'inactive', model: 'model-a', diagnostics: [] }, - { - id: 'worker-id' as SessionId, - name: 'worker', - role: 'teammate', - status: 'inactive', - model: 'model-a', - diagnostics: [], - }, - ], - tasks: [task], +const lead: TeamMemberProjection = { id: SESSION, name: 'lead', role: 'lead', phase: 'active' } +const worker: TeamMemberProjection = { + id: WORKER, name: 'worker', role: 'teammate', phase: 'active', description: 'worker', provider: 'spawn', context: 'fresh', } +const team: TeamProjection = { members: [lead, worker], tasks: [task] } -function remoteFailure(message: string): TeamActionResult { - return { ok: false, error: new RemoteError('gateway/internal', message, {}) } -} +type Projections = SessionListState['projectionsBySession'] -function props(actions: TeamActionInjected, sessionId: SessionId = SESSION): TeamActionProps { - return { +function bench(options: { + projections?: Projections + sessionId?: SessionId + parentSessionId?: SessionId + statuses?: SessionStatusSnapshot + running?: Record +} = {}) { + const sessionId = options.sessionId ?? SESSION + const byId = Object.fromEntries(Object.entries(options.running ?? {}).map(([id, running]) => [id, { sessionId: id, running }])) + const sessions = createSnapshotStore({ + ids: Object.keys(byId), byId, phase: 'ready', + projectionsBySession: options.projections ?? { [SESSION]: { state: 'ready', error: null, values: { agentTeam: team } } }, + } as unknown as SessionListState) + const statuses = createSnapshotStore(options.statuses ?? new Map()) + const session = createSnapshotStore({ sessionId, - ...actions, + subagent: options.parentSessionId === undefined + ? null + : { address: { parentSessionId: options.parentSessionId, childSessionId: sessionId, mode: 'continuable' } }, + } as unknown as SessionSnapshot) + const injected: TeamActionInjected = { loadProjections: vi.fn(), openTeammate: vi.fn() } + const props = { + sessionId, + useSession: bindSnapshotSelector(session), + useSessions: bindSnapshotSelector(sessions), + useSessionStatus: bindSnapshotSelector(statuses), + ...injected, t: makeTranslate(zh, commonZh), } as unknown as TeamActionProps + return { props, injected, sessions, statuses, session } } -function actions(overrides: Partial = {}): TeamActionInjected { - return { - load: () => Promise.resolve({ ok: true, value: view }), - openTeammate: () => {}, - ...overrides, - } +function openPanel(): void { + fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) +} + +function setProjectionSnapshot( + sessions: ReturnType['sessions'], + sessionId: SessionId, + snapshot: Projections[SessionId], +): void { + act(() => { + const current = sessions.getSnapshot() + sessions.set({ ...current, projectionsBySession: { ...current.projectionsBySession, [sessionId]: snapshot } }) + }) +} + +function setProjection(sessions: ReturnType['sessions'], sessionId: SessionId, value: TeamProjection): void { + setProjectionSnapshot(sessions, sessionId, { state: 'ready', error: null, values: { agentTeam: value } }) } describe('TeamAction', () => { - it('ignores a stale Team load after the conversation switches sessions', async () => { - const nextSession = 'next-lead' as SessionId - const firstLoad = Promise.withResolvers<{ ok: true; value: TeamView }>() - const nextView: TeamView = { - ...view, - members: [{ id: nextSession, name: 'lead', role: 'lead', status: 'inactive', diagnostics: [] }], - tasks: [{ ...task, id: 'task-next' as TeamTaskId, subject: 'Next session task' }], - } - const load = vi.fn((sessionId: SessionId) => sessionId === SESSION - ? firstLoad.promise - : Promise.resolve({ ok: true as const, value: nextView })) - const injected = actions({ load }) - const rendered = render() - fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) - await waitFor(() => { expect(load).toHaveBeenCalledWith(SESSION) }) - - rendered.rerender() - fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) - expect(await screen.findByText('Next session task')).toBeTruthy() - firstLoad.resolve({ ok: true, value: view }) - await Promise.resolve() - - await waitFor(() => { - expect(screen.getByText('Next session task')).toBeTruthy() - expect(screen.queryByText('Implement runtime')).toBeNull() - }) - }) - - it('loads roster/task diagnostics on open and navigates a healthy teammate', async () => { - const openTeammate = vi.fn() - render() - fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) - const worker = await screen.findByRole('button', { name: /worker/u }) + it('renders the Lead projection and applies later projection frames without any user action', async () => { + const b = bench() + render() + expect(screen.getByRole('button', { name: /Agent Team/u }).textContent).toContain('1') + openPanel() + expect(await screen.findByText('Implement runtime')).toBeTruthy() expect(screen.getByText('write scopes overlap with task-2')).toBeTruthy() - fireEvent.click(worker) - await waitFor(() => { expect(openTeammate).toHaveBeenCalledWith(SESSION, view.members[1]) }) + expect(b.injected.loadProjections).toHaveBeenCalledWith(SESSION) + expect(screen.queryByRole('button', { name: /刷新|Refresh/u })).toBeNull() + + setProjection(b.sessions, SESSION, { + members: [lead, worker, { id: 'worker-b' as SessionId, name: 'worker-b', role: 'teammate', phase: 'provisioning' }], + tasks: [task, { ...task, id: TASK_2, subject: 'Pushed task', status: 'pending', ready: true, writeScopeWarnings: [] }], + }) + expect(screen.getByText('Pushed task')).toBeTruthy() + expect(screen.getByRole('button', { name: /worker-b/u })).toHaveProperty('disabled', true) + expect(screen.getByRole('button', { name: /Agent Team/u }).textContent).toContain('2') }) - it('keeps only the newest overlapping refresh for one session', async () => { - const older = Promise.withResolvers>() - const newer = Promise.withResolvers>() - const newestView = { - ...view, - tasks: [{ ...task, id: 'newest-task' as TeamTaskId, subject: 'Newest task' }], - } - const load = vi.fn() - .mockResolvedValueOnce({ ok: true, value: view }) - .mockImplementationOnce(() => older.promise) - .mockImplementationOnce(() => newer.promise) - render() - fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) - await screen.findByText('Implement runtime') + it('overlays live Session status and the durable model selection on roster rows', () => { + const statuses: SessionStatusSnapshot = new Map([[WORKER, { running: true, pendingInteraction: undefined, completionUnread: false }]]) + const b = bench({ + statuses, + running: { [SESSION]: true }, + projections: { + [SESSION]: { + state: 'ready', error: null, + values: { agentTeam: team, modelSelection: { lastUsed: null, next: { provider: 'p', model: 'lead-model' } } }, + }, + [WORKER]: { + state: 'ready', error: null, + values: { modelSelection: { lastUsed: { provider: 'p', model: 'worker-model' }, next: { provider: 'p', model: 'worker-model' } } }, + }, + }, + }) + render() + openPanel() + expect(screen.getByRole('button', { name: `lead${zh['memberStatus.running']} · ${zh.model}: lead-model` })).toBeTruthy() + const row = screen.getByRole('button', { name: `worker${zh['memberStatus.running']} · ${zh.model}: worker-model` }) + expect(row.querySelector('[data-state="ongoing"]')).not.toBeNull() - const refresh = screen.getByRole('button', { name: zh.refresh }) - fireEvent.click(refresh) - expect(screen.getByRole('status', { name: zh.loading })).toBeTruthy() - fireEvent.click(refresh) - newer.resolve({ ok: true, value: newestView }) - expect(await screen.findByText('Newest task')).toBeTruthy() - older.resolve({ ok: true, value: view }) - await Promise.resolve() + act(() => { b.statuses.set(new Map([[WORKER, { running: false, pendingInteraction: undefined, completionUnread: false }]])) }) + expect(screen.getByRole('button', { name: /^worker未运行/u })).toBeTruthy() - expect(screen.getByText('Newest task')).toBeTruthy() - expect(screen.queryByText('Implement runtime')).toBeNull() + act(() => { + b.statuses.set(new Map()) + b.sessions.update((draft) => { draft.byId[WORKER] = { sessionId: WORKER, running: true } as never }) + }) + expect(screen.getByRole('button', { name: /^worker运行中/u })).toBeTruthy() }) - it('renders roster/task state variants and contains navigation, refresh, and close actions', async () => { + it('reads the Lead projection from an addressed teammate conversation', () => { + const b = bench({ sessionId: WORKER, parentSessionId: SESSION }) + render() + openPanel() + expect(screen.getByText('Implement runtime')).toBeTruthy() + expect(b.injected.loadProjections).toHaveBeenCalledWith(SESSION) + fireEvent.click(screen.getByRole('button', { name: /^worker/u })) + expect(b.injected.openTeammate).toHaveBeenCalledWith(WORKER, worker) + }) + + it('shows loading until the projection arrives and a retryable read failure', () => { + const b = bench({ projections: {} }) + render() + openPanel() + expect(screen.getByRole('status').textContent).toBe(zh.loading) + + setProjectionSnapshot(b.sessions, SESSION, { state: 'error', error: new RemoteError('gateway/internal', 'offline', {}), values: {} }) + expect(screen.getByRole('alert').textContent).toBe('offline (gateway/internal)') + expect(screen.queryByRole('status')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: zh.retry })) + expect(b.injected.loadProjections).toHaveBeenCalledTimes(2) + + setProjectionSnapshot(b.sessions, SESSION, { state: 'idle', error: null, values: {} }) + expect(screen.getByRole('status').textContent).toBe(zh.loading) + + setProjection(b.sessions, SESSION, { members: [lead], tasks: [] }) + expect(screen.getByText(zh.empty)).toBeTruthy() + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('surfaces a Team projection failure beside the last valid state', () => { + const b = bench({ + projections: { [SESSION]: { state: 'ready', error: null, values: { agentTeam: { ...team, failure: 'revision is not contiguous' } } } }, + }) + render() + openPanel() + expect(screen.getByRole('alert').textContent).toBe('Team 持久记录无效:revision is not contiguous') + expect(screen.getByText('Implement runtime')).toBeTruthy() + }) + + it('renders roster/task state variants and reports navigation failures', () => { const { ownerName: _ownerName, ...unownedTask } = task - const richView: TeamView = { - ...view, - members: [ - view.members[0]!, - { ...view.members[1]!, status: 'running' }, - { - id: 'failed-id' as SessionId, - name: 'failed-worker', - role: 'teammate', - status: 'failed', - diagnostics: ['provider failed'], + const b = bench({ + projections: { + [SESSION]: { + state: 'ready', error: null, + values: { + agentTeam: { + members: [ + lead, + worker, + { id: 'failed-id' as SessionId, name: 'failed-worker', role: 'teammate', phase: 'failed', error: 'provider failed' }, + { id: 'provisioning-id' as SessionId, name: 'provisioning-worker', role: 'teammate', phase: 'provisioning' }, + ], + tasks: [ + { ...unownedTask, id: 'ready-task' as TeamTaskId, status: 'pending', ready: true }, + { ...unownedTask, id: 'blocked-task' as TeamTaskId, status: 'pending', ready: false, blockedBy: [TASK_1] }, + { ...task, id: 'completed-task' as TeamTaskId, status: 'completed', ownerName: 'worker' }, + ], + }, + }, }, - { - id: 'provisioning-id' as SessionId, - name: 'provisioning-worker', - role: 'teammate', - status: 'provisioning', - diagnostics: [], - }, - ], - tasks: [ - { ...unownedTask, id: 'ready-task' as TeamTaskId, status: 'pending', ready: true }, - { ...unownedTask, id: 'blocked-task' as TeamTaskId, status: 'pending', ready: false, blockedBy: [TASK_1] }, - { ...task, id: 'completed-task' as TeamTaskId, status: 'completed' }, - ], - } - const load = vi.fn(() => Promise.resolve({ ok: true as const, value: richView })) - const openTeammate = vi.fn(() => { throw new Error('navigation failed') }) - render() - fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) - expect(await screen.findByText('provider failed')).toBeTruthy() + }, + }) + b.injected.openTeammate = vi.fn(() => { throw new Error('navigation failed') }) + render() + openPanel() + expect(screen.getByText('provider failed')).toBeTruthy() expect(screen.getByText(zh.ready)).toBeTruthy() expect(screen.getByText(zh.blocked)).toBeTruthy() + expect(screen.getAllByText('Owner: 未分配')).toHaveLength(2) + expect(screen.getByText('Owner: worker')).toBeTruthy() const failedMember = screen.getByRole('button', { name: /failed-worker/u }) const provisioningMember = screen.getByRole('button', { name: /provisioning-worker/u }) expect(failedMember.disabled).toBe(true) expect(failedMember.querySelector('[data-state="error"]')).not.toBeNull() expect(provisioningMember.disabled).toBe(true) expect(provisioningMember.querySelector('[data-state="ongoing"]')).not.toBeNull() + expect(screen.getByRole('button', { name: /^lead/u }).disabled).toBe(true) const tasks = [...document.querySelectorAll('article')] expect(tasks.map(card => card.querySelector('[data-state]')?.getAttribute('data-state'))) .toEqual(['idle', 'warning', 'done']) + for (const card of tasks) expect(card.querySelector('button, input, select, textarea')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: /^worker运行中/u })) - expect(await screen.findByText('Error: navigation failed')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: zh.refresh })) - await waitFor(() => { expect(load).toHaveBeenCalledTimes(2) }) - fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) - expect(screen.queryByRole('dialog')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) - await screen.findByRole('dialog') + fireEvent.click(screen.getByRole('button', { name: /^worker/u })) + expect(screen.getByRole('alert').textContent).toBe('Error: navigation failed') fireEvent.click(screen.getByRole('button', { name: zh.close })) expect(screen.queryByRole('dialog')).toBeNull() expect(document.activeElement).toBe(screen.getByRole('button', { name: /Agent Team/u })) }) - it('shows load failures', async () => { - render( Promise.resolve(remoteFailure('load failed')), - }))} />) - fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) - expect(await screen.findByRole('alert')).toHaveProperty('textContent', 'load failed (gateway/internal)') + it('closes the panel and clears a navigation failure when the conversation switches sessions', () => { + const b = bench() + b.injected.openTeammate = vi.fn(() => { throw new Error('navigation failed') }) + const rendered = render() + openPanel() + fireEvent.click(screen.getByRole('button', { name: /^worker/u })) + expect(screen.getByRole('alert')).toBeTruthy() + + const next = bench({ sessionId: 'next-lead' as SessionId, projections: {} }) + rendered.rerender() + expect(screen.queryByRole('dialog')).toBeNull() + openPanel() + expect(screen.queryByRole('alert')).toBeNull() + expect(screen.getByRole('status').textContent).toBe(zh.loading) + expect(next.injected.loadProjections).toHaveBeenCalledWith('next-lead') }) - it('shows an empty task board without a create action', async () => { - render( Promise.resolve({ ok: true, value: { members: [], tasks: [] } }), - }))} />) - fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) - expect(await screen.findByText(zh.empty)).toBeTruthy() - expect(screen.queryByRole('button', { name: /新建任务/u })).toBeNull() - expect(screen.queryByRole('textbox')).toBeNull() - }) - - it('displays task ownership and status without mutation controls', async () => { - const { ownerName: _ownerName, ...unowned } = task - const tasks: TeamTask[] = [ - { ...unowned, status: 'pending', ready: true }, - { ...task, id: 'task-2' as TeamTaskId }, - { ...task, id: 'task-3' as TeamTaskId, status: 'completed', ownerName: 'worker' }, - ] - render( Promise.resolve({ ok: true, value: { ...view, tasks } }), - }))} />) - fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) - await screen.findAllByRole('article') - expect(screen.getByText('Owner: 未分配')).toBeTruthy() - expect(screen.getByText('Owner: lead')).toBeTruthy() - expect(screen.getByText('Owner: worker')).toBeTruthy() - expect(screen.getByText(zh['status.pending'])).toBeTruthy() - expect(screen.getByText(zh['status.in_progress'])).toBeTruthy() - expect(screen.getByText(zh['status.completed'])).toBeTruthy() - expect(screen.queryByRole('button', { name: /新建任务|编辑|完成|重开|删除/u })).toBeNull() - expect(screen.queryByRole('combobox')).toBeNull() - expect(screen.queryByRole('textbox')).toBeNull() - for (const card of screen.getAllByRole('article')) { - expect(card.querySelector('button, input, select, textarea')).toBeNull() - } - }) - it('keeps panel interactions open and dismisses on outside pointer or Escape', async () => { - const rendered = render() + it('keeps panel interactions open and dismisses on outside pointer or Escape', () => { + const b = bench() + const rendered = render() const trigger = screen.getByRole('button', { name: /Agent Team/u }) fireEvent.click(trigger) - const panel = await screen.findByRole('dialog') + const panel = screen.getByRole('dialog') expect(rendered.container.contains(panel)).toBe(false) expect(document.activeElement).toBe(panel) fireEvent.pointerDown(panel) @@ -246,22 +266,22 @@ describe('TeamAction', () => { fireEvent.pointerDown(document.body) expect(screen.queryByRole('dialog')).toBeNull() fireEvent.click(trigger) - await screen.findByText('Implement runtime') - fireEvent.keyDown(screen.getByRole('button', { name: zh.refresh }), { key: 'Enter' }) + fireEvent.keyDown(screen.getByRole('button', { name: zh.close }), { key: 'Enter' }) expect(screen.queryByRole('dialog')).not.toBeNull() - fireEvent.keyDown(screen.getByRole('button', { name: zh.refresh }), { key: 'Escape' }) + fireEvent.keyDown(screen.getByRole('button', { name: zh.close }), { key: 'Escape' }) expect(screen.queryByRole('dialog')).toBeNull() expect(document.activeElement).toBe(trigger) fireEvent.keyDown(trigger, { key: 'Escape' }) expect(screen.queryByRole('dialog')).toBeNull() }) - it('keeps focus within the trigger or panel and closes when focus moves elsewhere', async () => { - render(<>) + it('keeps focus within the trigger or panel and closes when focus moves elsewhere', () => { + const b = bench() + render(<>) const trigger = screen.getByRole('button', { name: /Agent Team/u }) fireEvent.click(trigger) - const panel = await screen.findByRole('dialog') - fireEvent.blur(panel, { relatedTarget: screen.getByRole('button', { name: zh.refresh }) }) + const panel = screen.getByRole('dialog') + fireEvent.blur(panel, { relatedTarget: screen.getByRole('button', { name: zh.close }) }) expect(screen.getByRole('dialog')).toBe(panel) fireEvent.blur(panel, { relatedTarget: trigger }) expect(screen.getByRole('dialog')).toBe(panel) @@ -270,5 +290,4 @@ describe('TeamAction', () => { fireEvent.blur(panel, { relatedTarget: screen.getByRole('button', { name: 'Outside' }) }) expect(screen.queryByRole('dialog')).toBeNull() }) - }) diff --git a/packages/experimental/client-ui-agent-team/tsconfig.json b/packages/experimental/client-ui-agent-team/tsconfig.json index c26bb4b7b6..206b674bb8 100644 --- a/packages/experimental/client-ui-agent-team/tsconfig.json +++ b/packages/experimental/client-ui-agent-team/tsconfig.json @@ -8,16 +8,15 @@ "references": [ { "path": "../../../vendor/cordis" }, { "path": "../agent-team" }, - { "path": "../../api/remotes/tsconfig.client.json" }, { "path": "../../api/session-controller/tsconfig.client.json" }, { "path": "../../client/locale" }, + { "path": "../../client/store" }, { "path": "../../client/ui-conversation" }, { "path": "../../client/ui-primitives" }, { "path": "../../client/ui-renderer" }, { "path": "../../client/ui-session" }, { "path": "../../client/ui-slots" }, { "path": "../../client/ui-workspace" }, - { "path": "../../core/session" }, - { "path": "../../typert/protocol" } + { "path": "../../core/session" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62104b7db4..c2e6a4d236 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6805,15 +6805,15 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-api-remotes': - specifier: workspace:^ - version: link:../../api/remotes '@deepseek-ai/dsh-api-session-controller': specifier: workspace:^ version: link:../../api/session-controller '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../../client/locale + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../../client/store '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime @@ -6841,9 +6841,6 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - '@deepseek-ai/dsh-typert-protocol': - specifier: workspace:^ - version: link:../../typert/protocol '@testing-library/react': specifier: ^16.1.0 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) From d685383ee64336517f80daf26f6ee7e4828a874f Mon Sep 17 00:00:00 2001 From: dsh-agent Date: Mon, 21 Sep 2026 16:39:34 +0800 Subject: [PATCH 011/192] fix(native-command): resolve packaged Windows app icons Windows 11 ships some applications (Notepad, other Store apps) as MSIX packages whose Shell handler reports an indirect icon reference such as @{Microsoft.WindowsNotepad_...?ms-resource://.../NotepadAppList.png} instead of a file path. SHDefExtractIcon cannot read that reference, so the menu showed the generic fallback glyph. Resolve an indirect reference with SHLoadIndirectString before extracting, and fall back to the handler's executable when the resolved source still yields no image. --- packages/util/native-command/README.i18n.yaml | 4 +- packages/util/native-command/README.md | 2 +- packages/util/native-command/README.zh.md | 2 +- .../src/file-applications-windows.ts | 39 +++++++++++++++++-- 4 files changed, 39 insertions(+), 8 deletions(-) diff --git a/packages/util/native-command/README.i18n.yaml b/packages/util/native-command/README.i18n.yaml index e0280c9599..317286a9d9 100644 --- a/packages/util/native-command/README.i18n.yaml +++ b/packages/util/native-command/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/native-command/README.md -README.md: 2ae2fd047e6359428debfe9252dc87146b646596 -README.zh.md: 62bb667af4705aac1373d9d0dda809dbab60e7da +README.md: 1d42f4f15b4d36a09dfb76db7e951d4ec217df86 +README.zh.md: e9a0f690c556ce963635047f6d14dc4fbe473437 diff --git a/packages/util/native-command/README.md b/packages/util/native-command/README.md index 2ae2fd047e..1d42f4f15b 100644 --- a/packages/util/native-command/README.md +++ b/packages/util/native-command/README.md @@ -49,7 +49,7 @@ The `NativeCommandRunner` type is the injectable command boundary for host integ `revealNativePath(path, signal)` selects the file in Finder or Explorer, including WSL path translation, and opens its parent directory through `xdg-open` on desktop Linux. `nativeFileManager()` identifies that action for Host-derived UI labels; desktop availability remains a separate `canOpenNativePath()` check. Callers must authorize the absolute file path before invoking either operation. Platform dispatch is covered by injected-runner tests; native desktop verification belongs to the corresponding platform. Explorer receives an encoded file URI as a separate argument. Its exit code 1 is accepted as a delegated handoff; cancellation, missing executables, and other exit codes still reject. This acknowledgement does not prove that a desktop window selected the file. -`nativeFileApplications(path, signal)` returns registered applications, localized names, icons, and the current default. macOS 12 and later use LaunchServices; Windows uses Shell association handlers; Linux uses GIO with shared XDG desktop-entry and icon lookup. `openNativeFileApplication(path, application, signal)` revalidates the handler without changing the system default. Windows delegates invocation to the Shell, and Linux delegates argument expansion to `gio launch`. WSL translates the path and uses the Windows adapter. Callers authorize the local file path. Native integration tests use private Windows file associations and Linux XDG roots on their respective platforms. +`nativeFileApplications(path, signal)` returns registered applications, localized names, icons, and the current default. macOS 12 and later use LaunchServices; Windows uses Shell association handlers and resolves a packaged application’s indirect icon reference before extraction; Linux uses GIO with shared XDG desktop-entry and icon lookup. `openNativeFileApplication(path, application, signal)` revalidates the handler without changing the system default. Windows delegates invocation to the Shell, and Linux delegates argument expansion to `gio launch`. WSL translates the path and uses the Windows adapter. Callers authorize the local file path. Native integration tests use private Windows file associations and Linux XDG roots on their respective platforms. ----- diff --git a/packages/util/native-command/README.zh.md b/packages/util/native-command/README.zh.md index 62bb667af4..e9a0f690c5 100644 --- a/packages/util/native-command/README.zh.md +++ b/packages/util/native-command/README.zh.md @@ -49,7 +49,7 @@ const { stdout, stderr } = await runNativeCommand('osascript', ['-e', script], s `revealNativePath(path, signal)` 在 Finder 或文件资源管理器中选中文件,包含 WSL 路径转换;在桌面 Linux 上通过 `xdg-open` 打开上层目录。`nativeFileManager()` 标识该操作,供 UI 根据 Host 选择文案;桌面是否可用仍由独立的 `canOpenNativePath()` 检查决定。调用方必须先授权绝对文件路径,再执行操作。平台分派由注入运行器的测试覆盖;原生桌面验证由对应平台负责。 Explorer 接收独立参数中的编码文件 URI。退出码 1 按已转交请求处理;取消、找不到可执行文件和其他退出码仍然报错。该确认不能证明桌面窗口已选中文件。 -`nativeFileApplications(path, signal)` 返回关联应用、本地化名称、图标和当前默认项。macOS 12 及以上版本使用 LaunchServices,Windows 使用 Shell 关联处理器,Linux 使用 GIO,并共用 XDG 桌面文件和图标读取逻辑。`openNativeFileApplication(path, application, signal)` 重新验证关联应用,不修改系统默认值。Windows 交给 Shell 启动应用,Linux 交给 `gio launch` 展开参数。WSL 转换路径后使用 Windows 适配器。调用方负责验证本地文件路径。原生集成测试分别在相应平台使用独立的 Windows 文件关联和 Linux XDG 目录。 +`nativeFileApplications(path, signal)` 返回关联应用、本地化名称、图标和当前默认项。macOS 12 及以上版本使用 LaunchServices,Windows 使用 Shell 关联处理器并在提取前解析打包应用的间接图标引用,Linux 使用 GIO,并共用 XDG 桌面文件和图标读取逻辑。`openNativeFileApplication(path, application, signal)` 重新验证关联应用,不修改系统默认值。Windows 交给 Shell 启动应用,Linux 交给 `gio launch` 展开参数。WSL 转换路径后使用 Windows 适配器。调用方负责验证本地文件路径。原生集成测试分别在相应平台使用独立的 Windows 文件关联和 Linux XDG 目录。 ----- diff --git a/packages/util/native-command/src/file-applications-windows.ts b/packages/util/native-command/src/file-applications-windows.ts index a468c9979c..c9950875cd 100644 --- a/packages/util/native-command/src/file-applications-windows.ts +++ b/packages/util/native-command/src/file-applications-windows.ts @@ -41,6 +41,8 @@ public static class DshFileAssociations { static extern int SHDefExtractIcon(string path, int index, uint flags, out IntPtr large, out IntPtr small, uint size); [DllImport("user32.dll")] static extern bool DestroyIcon(IntPtr icon); + [DllImport("shlwapi.dll", CharSet = CharSet.Unicode)] + static extern int SHLoadIndirectString(string source, StringBuilder output, uint size, IntPtr reserved); [DllImport("shlwapi.dll", PreserveSig = false)] static extern void SHCreateThreadRef(IntPtr count, out IntPtr reference); @@ -118,12 +120,18 @@ public static class DshFileAssociations { var text = new StringBuilder((int)size); return AssocQueryString(0, kind, extension, null, text, ref size) == 0 ? text.ToString() : null; } - static string IconData(IHandler handler) { + // Resolve a packaged application's indirect icon reference, for example + // @{Microsoft.WindowsNotepad_...?ms-resource://.../NotepadAppList.png}, to + // the resource file the Shell would draw; an unresolved reference is null. + static string ResolveIndirectIcon(string source) { + var output = new StringBuilder(1024); + return SHLoadIndirectString(source, output, (uint)output.Capacity, IntPtr.Zero) == 0 ? output.ToString() : null; + } + // Read one icon source into a 32px PNG: an image file directly, otherwise the + // resource the Shell extracts at the given index. A missing resource is null. + static string PngFromIconSource(string source, int index) { IntPtr large = IntPtr.Zero, small = IntPtr.Zero; try { - string source; int index; - handler.GetIconLocation(out source, out index); - source = Environment.ExpandEnvironmentVariables(source); if (Path.GetExtension(source).Equals(".png", StringComparison.OrdinalIgnoreCase)) { using (var original = Image.FromFile(source)) using (var resized = new Bitmap(original, new Size(32, 32))) @@ -147,6 +155,29 @@ public static class DshFileAssociations { if (small != IntPtr.Zero) DestroyIcon(small); } } + static string IconData(IHandler handler) { + string source = null; int index = 0; + try { handler.GetIconLocation(out source, out index); } catch (Exception) { + // A handler without a drawable icon location falls back to its executable. + } + if (!String.IsNullOrEmpty(source)) { + source = Environment.ExpandEnvironmentVariables(source); + if (source.StartsWith("@{", StringComparison.Ordinal) && source.EndsWith("}", StringComparison.Ordinal)) { + var resolved = ResolveIndirectIcon(source); + if (!String.IsNullOrEmpty(resolved)) source = resolved; + } + var icon = PngFromIconSource(source, index); + if (icon != null) return icon; + } + // Packaged handlers name the executable only in GetName; extract there too. + string executable = null; + try { handler.GetName(out executable); } catch (Exception) { + // A handler that names no executable has no icon to fall back to. + } + return String.IsNullOrEmpty(executable) || String.Equals(executable, source, StringComparison.OrdinalIgnoreCase) + ? null + : PngFromIconSource(executable, 0); + } static void Visit(string path, Action visit) { var extension = Path.GetExtension(path); if (extension.Length == 0) return; From 6fa5151d8a7cea9433b570861f7a9458bc0dc151 Mon Sep 17 00:00:00 2001 From: dsh-agent Date: Mon, 21 Sep 2026 16:39:38 +0800 Subject: [PATCH 012/192] fix(ui-open-in-app): keep an application as the default file action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Host's default marker is best effort: on Windows it comes from the Shell's ASSOCSTR_EXECUTABLE/APPID answer, which does not always name an enumerated handler. When no application carried that marker the split button promoted 显示文件位置 to the primary action even though the menu listed applications that can open the file. Select the OS-marked default when present, otherwise the first registered handler in Shell preference order, and mark it as the menu default. Reveal is promoted only when no application is registered at all. --- .../client/ui-open-in-app/README.i18n.yaml | 4 ++-- packages/client/ui-open-in-app/README.md | 2 +- packages/client/ui-open-in-app/README.zh.md | 2 +- .../src/client/OpenTargetButton.tsx | 13 +++++++--- .../tests/open-path-action.client.spec.tsx | 24 ++++++++++++------- 5 files changed, 29 insertions(+), 16 deletions(-) diff --git a/packages/client/ui-open-in-app/README.i18n.yaml b/packages/client/ui-open-in-app/README.i18n.yaml index b0fa75db3e..4ceb2983f9 100644 --- a/packages/client/ui-open-in-app/README.i18n.yaml +++ b/packages/client/ui-open-in-app/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-open-in-app/README.md -README.md: 138be128c291fc4a0611295c54768a95f33f3ae1 -README.zh.md: b81f62f9dbf8ccfbc1822b8a72df95e7169228c0 +README.md: 4f8462b29579b7290398eac802a614c250f7501b +README.zh.md: df58e838628df98e0c95f6f67aab76299002b3c8 diff --git a/packages/client/ui-open-in-app/README.md b/packages/client/ui-open-in-app/README.md index 138be128c2..4f8462b295 100644 --- a/packages/client/ui-open-in-app/README.md +++ b/packages/client/ui-open-in-app/README.md @@ -31,7 +31,7 @@ Mount this plugin in the Web composition beside [`dsh-host-open-in-app`](../../h The Session header and document header use the same 24px-high split button with 9px corners. Both headers show only the icon; their tooltip names the default application or the reveal action. Both show the default action’s icon, mark its application with “(default)” in the menu, disable while their own gesture runs, and report failures through a transient toast. The directory adapter uses the existing cross-platform application catalog and remembers the last successful choice in `dsh.open-in-app.choice`; a missing choice falls back to the first available application. -File menus list the discovered associated applications without a separate “Open in default app” row. “Show file location” stays in a fixed footer separated from the scrolling applications. A successful query with only one available action renders a single button without a dropdown. Initial file-association loading uses a gray skeleton icon. When no default application is identified, that row reads “Show file location (default)” and the main button reveals the file. Directory menus have no reveal row. Choosing a file application does not change the system default. An unpreviewable file uses the same menu in a 40px-high split button with an icon and action text; its label is “Open” or “Show file location” according to the default action. +File menus list the discovered associated applications without a separate “Open in default app” row. “Show file location” stays in a fixed footer separated from the scrolling applications. A successful query with only one available action renders a single button without a dropdown. Initial file-association loading uses a gray skeleton icon. When the Host identifies a default application the menu marks it “(default)” and the main button opens it; otherwise the first associated application takes that place, so the main button reveals the file only when no application is registered. Directory menus have no reveal row. Choosing a file application does not change the system default. An unpreviewable file uses the same menu in a 40px-high split button with an icon and action text; its label is “Open” or “Show file location” according to the default action. Mounted controls with the same association reader and file share one query and its results; opening either menu refreshes all of them. Releasing the last control cancels the query and discards its state. Cancelled queries cannot replace another file’s results. A failed query displays a menu message and leaves reveal available as the default action. File-association discovery follows [native-command](../../util/native-command/README.md); an empty result, including a platform without a discovery adapter, uses the same reveal fallback without platform branches in the control. diff --git a/packages/client/ui-open-in-app/README.zh.md b/packages/client/ui-open-in-app/README.zh.md index b81f62f9db..df58e83862 100644 --- a/packages/client/ui-open-in-app/README.zh.md +++ b/packages/client/ui-open-in-app/README.zh.md @@ -31,7 +31,7 @@ kind: "package-reference" 会话标题栏和文档标题栏共用同一个高 24px、圆角 9px 的分体按钮。两个标题栏都只显示图标,悬停提示显示默认应用名称或文件定位动作。两者都显示默认动作的图标,在菜单的默认应用后标注“(默认)”,仅在自己的操作执行期间禁用,并通过短暂提示报告失败。目录适配器使用已有的跨平台应用列表,将最后一次成功选择保存在 `dsh.open-in-app.choice` 中;原选择不可用时回退到第一个可用应用。 -文件菜单列出已发现的关联应用,不单列“用默认应用打开”。“显示文件位置”固定在菜单底部,通过分隔线与滚动的应用列表分开。查询成功且只有一个可用操作时显示单按钮,不再显示下拉箭头。文件关联首次加载时使用灰色骨架图标。未识别到默认应用时,该项显示为“显示文件位置(默认)”,主按钮也执行文件定位。目录菜单不包含定位项。选择文件应用不修改系统默认应用。无法预览文件时,空态使用同一菜单,按钮增大到 40px 高并显示图标和动作文字;根据默认动作显示“打开”或“显示文件位置”。 +文件菜单列出已发现的关联应用,不单列“用默认应用打开”。“显示文件位置”固定在菜单底部,通过分隔线与滚动的应用列表分开。查询成功且只有一个可用操作时显示单按钮,不再显示下拉箭头。文件关联首次加载时使用灰色骨架图标。Host 识别出默认应用时,菜单将该项标注为“(默认)”,主按钮打开该应用;否则第一个关联应用占据该位置,只有在一个关联应用都没有时主按钮才执行文件定位。目录菜单不包含定位项。选择文件应用不修改系统默认应用。无法预览文件时,空态使用同一菜单,按钮增大到 40px 高并显示图标和动作文字;根据默认动作显示“打开”或“显示文件位置”。 使用同一查询函数和文件的已挂载控件共用查询与结果,打开任一菜单会刷新所有相关控件。最后一个控件释放后取消查询并清除状态。已取消的查询不会覆盖其他文件的结果。查询失败时菜单显示提示,并以文件定位作为默认动作。文件关联查询的平台支持范围见 [native-command](../../util/native-command/README.zh.md);查询结果为空时,包括平台尚无查询适配器的情况,控件统一使用定位动作,不按操作系统分支处理。 diff --git a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx index 93a3a34657..9c4fa97242 100644 --- a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx @@ -71,7 +71,8 @@ function ApplicationIcon({ source, size = 14 }: { source: string | null; size?: } /** - * Render identical split buttons for files and directories. File reveal always stays last and becomes the default when no app is selected. + * Render identical split buttons for files and directories. File reveal always + * stays last; it is the default only when no application is registered. * @param props - target applications, default selection, and operations. * @returns the control and its transient failure feedback. */ @@ -79,7 +80,13 @@ export function OpenTargetButton(props: OpenTargetButtonProps): ReactNode { const { applications, defaultId, kind, t } = props const [menuOpen, setMenuOpen] = useState(false) const { pending, toast, act } = useOpenTargetGesture(props.execute, t) - const preferred = applications.find(app => app.id === defaultId) + // Files open through an application whenever one is registered: the OS-marked + // default when the Host reported one, else the first entry in the Shell's + // preference order. Reveal is only promoted with no registered application, so + // a missing default marker never hides the applications behind it. + const preferred = kind === 'file' + ? applications.find(app => app.id === defaultId) ?? applications[0] + : applications.find(app => app.id === defaultId) const disabled = pending || props.busy === true || props.loading === true const hasMenu = props.loading === true || props.failed || applications.length + (kind === 'file' ? 1 : 0) > 1 const revealDefault = kind === 'file' && preferred === undefined && props.loading !== true @@ -105,7 +112,7 @@ export function OpenTargetButton(props: OpenTargetButtonProps): ReactNode { ...applications.map(app => ({ id: `app:${app.id}`, icon: , - label: app.id === defaultId ? t('path.appDefault', { app: app.name }) : app.name, + label: app.id === preferred?.id ? t('path.appDefault', { app: app.name }) : app.name, })), ...(props.failed ? [{ id: 'unavailable', label: t('path.appsError'), disabled: true }] : []), ]} diff --git a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx index 0a4a13f090..a504c3b158 100644 --- a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx @@ -196,16 +196,22 @@ it('keeps reveal last and makes it the default when the file query fails', async expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'reveal', undefined) }) -it.each([{ apps: [] }, { apps: [{ id: '/Player.app', name: 'Player', default: false, icon: null }] }])('uses reveal as the default after the available applications when none is default', async ({ apps }) => { +it('opens with the first registered application when none is marked default', async () => { const b = bench() - render( apps} />) + render( [{ id: '/Player.app', name: 'Player', default: false, icon: null }]} />) await act(async () => {}) - if (apps.length > 0) { - await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) }) - expect(screen.getAllByRole('menuitem').at(-1)?.textContent).toBe('显示文件位置(默认)') - expect(screen.queryByText('用默认应用打开')).toBeNull() - fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' }) - } else expect(screen.queryByRole('button', { name: zh['path.more'] })).toBeNull() + const main = screen.getByRole('button', { name: t('open.title', { app: 'Player' }) }) + await act(async () => { fireEvent.click(main) }) + expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open', undefined) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) }) + expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['Player(默认)', zh['path.reveal']]) +}) + +it('uses reveal as the default only when no application is registered', async () => { + const b = bench() + render( []} />) + await act(async () => {}) + expect(screen.queryByRole('button', { name: zh['path.more'] })).toBeNull() await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.reveal'] })) }) expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'reveal', undefined) }) @@ -262,7 +268,7 @@ it('uses the same application menu in the prominent empty-state control', async expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open', '/Music.app') }) -it('labels the prominent default action as reveal when no default application is discovered', async () => { +it('labels the prominent default action as reveal when no application is registered', async () => { const b = bench() render( []} />) await act(async () => {}) From 38dbb56237b9347a7b97d0e4c51c46a64fe24657 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 21 Sep 2026 16:45:40 +0800 Subject: [PATCH 013/192] test(agent-team): type Team panel fixtures without unknown casts and regenerate catalogs The static CI gate rejected four new assertions to unknown in the rewritten Team panel specs. Build the Session list, Session snapshot, and injected-callback fixtures as typed values, prune the retired baseline fingerprints, and regenerate the module graph, config, and persistence catalogs for the removed Remote dependency edges and the shifted source lines. --- docs/config-catalog.md | 2 +- docs/module-graph.i18n.yaml | 4 +-- docs/module-graph.md | 4 +-- docs/module-graph.zh.md | 4 +-- docs/persistence-catalog.i18n.yaml | 4 +-- docs/persistence-catalog.md | 20 +++++------ docs/persistence-catalog.zh.md | 20 +++++------ docs/persistence-schema.json | 12 +++---- .../tests/browser-plugin.client.spec.ts | 12 ++++++- .../tests/team-action.client.spec.tsx | 35 ++++++++++++++----- scripts/no-unknown-casts.baseline.json | 7 ---- 11 files changed, 70 insertions(+), 54 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4ce0bc0a4f..fd150c7e69 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -627,7 +627,7 @@ export interface Config { } ``` -Source: [`packages/experimental/agent-team/src/types.ts:130`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:161`](../packages/experimental/agent-team/src/types.ts) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index ec94e38ea0..69049de561 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 936324af096fa6b17e260e9d9cd6d03a34fb9bd7 -module-graph.zh.md: c68f95c65af7df50dbfd61bf79d754c137c7f709 +module-graph.md: e6e025766974c2bb5286bfeb2333044f83b19a25 +module-graph.zh.md: d092387c3a04d930042357baade4d66407a26b2c diff --git a/docs/module-graph.md b/docs/module-graph.md index 936324af09..e6e0257669 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1259,7 +1259,6 @@ flowchart TD pkg_subagent_fork_in_process --> pkg_subagent_in_process_driver pkg_subagent_spawn_in_process --> pkg_subagent pkg_subagent_spawn_in_process --> pkg_subagent_in_process_driver - pkg_experimental_client_ui_agent_team --> pkg_api_remotes pkg_experimental_client_ui_agent_team --> pkg_api_session_controller pkg_experimental_client_ui_agent_team --> pkg_client_locale pkg_experimental_client_ui_agent_team --> pkg_client_ui_conversation @@ -1270,7 +1269,6 @@ flowchart TD pkg_experimental_client_ui_agent_team --> pkg_client_ui_workspace pkg_experimental_client_ui_agent_team --> pkg_experimental_agent_team pkg_experimental_client_ui_agent_team --> pkg_session - pkg_experimental_client_ui_agent_team --> pkg_typert_protocol pkg_experimental_client_ui_voice_input --> pkg_api_gateway pkg_experimental_client_ui_voice_input --> pkg_api_remotes pkg_experimental_client_ui_voice_input --> pkg_api_session_controller @@ -1622,7 +1620,7 @@ flowchart TD | [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | -| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session) | | [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | [`api-gateway`](../packages/api/gateway), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-store`](../packages/client/store), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-plugin-manager`](../packages/client/ui-plugin-manager), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text), [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index c68f95c65a..d092387c3a 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1261,7 +1261,6 @@ flowchart TD pkg_subagent_fork_in_process --> pkg_subagent_in_process_driver pkg_subagent_spawn_in_process --> pkg_subagent pkg_subagent_spawn_in_process --> pkg_subagent_in_process_driver - pkg_experimental_client_ui_agent_team --> pkg_api_remotes pkg_experimental_client_ui_agent_team --> pkg_api_session_controller pkg_experimental_client_ui_agent_team --> pkg_client_locale pkg_experimental_client_ui_agent_team --> pkg_client_ui_conversation @@ -1272,7 +1271,6 @@ flowchart TD pkg_experimental_client_ui_agent_team --> pkg_client_ui_workspace pkg_experimental_client_ui_agent_team --> pkg_experimental_agent_team pkg_experimental_client_ui_agent_team --> pkg_session - pkg_experimental_client_ui_agent_team --> pkg_typert_protocol pkg_experimental_client_ui_voice_input --> pkg_api_gateway pkg_experimental_client_ui_voice_input --> pkg_api_remotes pkg_experimental_client_ui_voice_input --> pkg_api_session_controller @@ -1624,7 +1622,7 @@ flowchart TD | [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | -| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session) | | [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | [`api-gateway`](../packages/api/gateway), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-store`](../packages/client/store), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-plugin-manager`](../packages/client/ui-plugin-manager), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text), [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 42b628640b..0143723b78 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: 30fc6dc277bdbdaf5330740a8a291101358dbaa2 -persistence-catalog.zh.md: fff627b8ff3a8c1c97f7793b0abca7008f395eb0 +persistence-catalog.md: e9b0fd69f14d44e992146d003f9d1bd031ff4e3d +persistence-catalog.zh.md: 699ca6996ba87c12b44e99a3cdd8bed6a8a5e196 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 30fc6dc277..e9b0fd69f1 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -975,7 +975,7 @@ Source: [`packages/core/session/src/types.ts:330`](../packages/core/session/src/ Types: [TeamId](subsystems/agent-team.md) · [TeamMemberSnapshot](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:210`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:241`](../packages/experimental/agent-team/src/types.ts) @@ -993,7 +993,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:210`](../packages/experi Types: [TeamId](subsystems/agent-team.md) · [TeamMessageId](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:216`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:247`](../packages/experimental/agent-team/src/types.ts) @@ -1006,7 +1006,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:216`](../packages/experi Types: [TeamId](subsystems/agent-team.md) · [TeamMessageSnapshot](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:214`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:245`](../packages/experimental/agent-team/src/types.ts) @@ -1019,7 +1019,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:214`](../packages/experi Types: [TeamId](subsystems/agent-team.md) · [TeamTaskSnapshot](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:212`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:243`](../packages/experimental/agent-team/src/types.ts) ### `todo/*` @@ -4713,7 +4713,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:47`](../packages/experi SHA-256: `2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e` -Sources: [`packages/experimental/agent-team/src/types.ts:106`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:137`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4733,7 +4733,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:106`](../packages/exper SHA-256: `2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0` -Sources: [`packages/experimental/agent-team/src/types.ts:115`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:146`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7373,7 +7373,7 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:61`](../packages/web SHA-256: `b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1` -Sources: [`packages/experimental/agent-team/src/types.ts:210`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:241`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7401,7 +7401,7 @@ Sources: [`packages/core/session/src/types.ts:330`](../packages/core/session/src SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403` -Sources: [`packages/experimental/agent-team/src/types.ts:214`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:245`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7415,7 +7415,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:214`](../packages/exper SHA-256: `0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1` -Sources: [`packages/experimental/agent-team/src/types.ts:216`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:247`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7561,7 +7561,7 @@ Sources: [`packages/compaction/compaction-image-offload/src/projection.ts:25`](. SHA-256: `a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c` -Sources: [`packages/experimental/agent-team/src/types.ts:212`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:243`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index fff627b8ff..699ca6996b 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -977,7 +977,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMemberSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:210`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:241`](../packages/experimental/agent-team/src/types.ts) @@ -995,7 +995,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMessageId](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:216`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:247`](../packages/experimental/agent-team/src/types.ts) @@ -1008,7 +1008,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMessageSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:214`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:245`](../packages/experimental/agent-team/src/types.ts) @@ -1021,7 +1021,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamTaskSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:212`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:243`](../packages/experimental/agent-team/src/types.ts) ### `todo/*` @@ -4715,7 +4715,7 @@ SHA-256: `6598cd6da253948d6b207c6374a45335804c79b576866d6215649c2d1bc85e25` SHA-256: `2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e` -来源:[`packages/experimental/agent-team/src/types.ts:106`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:137`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4735,7 +4735,7 @@ SHA-256: `2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e` SHA-256: `2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0` -来源:[`packages/experimental/agent-team/src/types.ts:115`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:146`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7375,7 +7375,7 @@ SHA-256: `930a6567a10bb62ddd157bd8abdf4b182810c8c3b49d91309b3b009a5fed9731` SHA-256: `b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1` -来源:[`packages/experimental/agent-team/src/types.ts:210`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:241`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7403,7 +7403,7 @@ SHA-256: `6bc8e51124d0874bc89e1d0ceff381a9c3cd0fc240667d69f841ce4f60f65fe5` SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403` -来源:[`packages/experimental/agent-team/src/types.ts:214`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:245`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7417,7 +7417,7 @@ SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403` SHA-256: `0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1` -来源:[`packages/experimental/agent-team/src/types.ts:216`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:247`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7563,7 +7563,7 @@ SHA-256: `477614bca595a9e7adb5a1870be446b9a8684f3ba8950259d6289f2d3b74f028` SHA-256: `a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c` -来源:[`packages/experimental/agent-team/src/types.ts:212`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:243`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index df74feb6a1..f0f2891aea 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -22575,7 +22575,7 @@ }, "names": [], "sources": [ - "packages/experimental/agent-team/src/types.ts:216" + "packages/experimental/agent-team/src/types.ts:247" ] }, { @@ -31770,7 +31770,7 @@ "packages/experimental/agent-team/src/types.ts#TeamMessageSource" ], "sources": [ - "packages/experimental/agent-team/src/types.ts:115" + "packages/experimental/agent-team/src/types.ts:146" ] }, { @@ -33739,7 +33739,7 @@ "packages/experimental/agent-team/src/types.ts#TeamMessageSnapshot" ], "sources": [ - "packages/experimental/agent-team/src/types.ts:106" + "packages/experimental/agent-team/src/types.ts:137" ] }, { @@ -69181,7 +69181,7 @@ }, "names": [], "sources": [ - "packages/experimental/agent-team/src/types.ts:212" + "packages/experimental/agent-team/src/types.ts:243" ] }, { @@ -71163,7 +71163,7 @@ }, "names": [], "sources": [ - "packages/experimental/agent-team/src/types.ts:210" + "packages/experimental/agent-team/src/types.ts:241" ] }, { @@ -83593,7 +83593,7 @@ }, "names": [], "sources": [ - "packages/experimental/agent-team/src/types.ts:214" + "packages/experimental/agent-team/src/types.ts:245" ] }, { diff --git a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts index 6e5bcaefb8..d63394fd64 100644 --- a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts +++ b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts @@ -55,7 +55,17 @@ async function bench(options: { addressed?: boolean } = {}) { await fiber.await() const entry = () => ctx.slots.entries('conversation.session.header.actions') .find(candidate => candidate.component === TeamAction) - const actions = () => (entry()!.inject as unknown as () => TeamActionInjected)() + const actions = (): TeamActionInjected => { + const injected = entry()!.inject!() + const { loadProjections, openTeammate } = injected + if (typeof loadProjections !== 'function' || typeof openTeammate !== 'function') { + throw new Error('Team header action lacks its injected callbacks') + } + return { + loadProjections: loadProjections as TeamActionInjected['loadProjections'], + openTeammate: openTeammate as TeamActionInjected['openTeammate'], + } + } return { ctx, fiber, diff --git a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx index 90aecc495b..a8f8713a5e 100644 --- a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx +++ b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx @@ -6,7 +6,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { TeamMemberProjection, TeamProjection, TeamTaskId, TeamTaskView as TeamTask, } from '@deepseek-ai/dsh-experimental-agent-team/client' -import type { SessionListState, SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionListState, SessionSnapshot, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { bindSnapshotSelector, makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' @@ -40,35 +40,52 @@ const team: TeamProjection = { members: [lead, worker], tasks: [task] } type Projections = SessionListState['projectionsBySession'] +function summary(id: SessionId, running: boolean): SessionSummary { + return { id, displayTitle: id, running, retainedBy: {}, blank: false, updatedAt: 0 } +} + function bench(options: { projections?: Projections sessionId?: SessionId parentSessionId?: SessionId statuses?: SessionStatusSnapshot - running?: Record + running?: Record } = {}) { const sessionId = options.sessionId ?? SESSION - const byId = Object.fromEntries(Object.entries(options.running ?? {}).map(([id, running]) => [id, { sessionId: id, running }])) + const byId: Record = {} + for (const [id, running] of Object.entries(options.running ?? {}) as [SessionId, boolean][]) byId[id] = summary(id, running) const sessions = createSnapshotStore({ - ids: Object.keys(byId), byId, phase: 'ready', + ids: Object.keys(byId) as SessionId[], byId, phase: 'ready', projectionsBySession: options.projections ?? { [SESSION]: { state: 'ready', error: null, values: { agentTeam: team } } }, - } as unknown as SessionListState) + }) const statuses = createSnapshotStore(options.statuses ?? new Map()) const session = createSnapshotStore({ sessionId, + pendingSubmissions: [], + running: false, subagent: options.parentSessionId === undefined ? null : { address: { parentSessionId: options.parentSessionId, childSessionId: sessionId, mode: 'continuable' } }, - } as unknown as SessionSnapshot) + removed: false, + openState: 'open', + openError: null, + hasMore: false, + loadingOlder: false, + promptError: null, + blank: false, + lastAgentError: null, + promptAttempted: false, + awaitingFirstTurn: false, + }) const injected: TeamActionInjected = { loadProjections: vi.fn(), openTeammate: vi.fn() } - const props = { + const props: TeamActionProps = { sessionId, useSession: bindSnapshotSelector(session), useSessions: bindSnapshotSelector(sessions), useSessionStatus: bindSnapshotSelector(statuses), ...injected, t: makeTranslate(zh, commonZh), - } as unknown as TeamActionProps + } as TeamActionProps return { props, injected, sessions, statuses, session } } @@ -138,7 +155,7 @@ describe('TeamAction', () => { act(() => { b.statuses.set(new Map()) - b.sessions.update((draft) => { draft.byId[WORKER] = { sessionId: WORKER, running: true } as never }) + b.sessions.update((draft) => { draft.byId[WORKER] = summary(WORKER, true) }) }) expect(screen.getByRole('button', { name: /^worker运行中/u })).toBeTruthy() }) diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index b274bbe4fe..1c65d33a6d 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -1187,13 +1187,6 @@ "019c354b579e6d5cfa63e106e3b3047817fe5a37eee532abd7ab4aa80d28676a": 1, "4777453719b1a12ac03d7405a7cc1662ac1873b8eb0016a08ccd11f27076e803": 1 }, - "packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts": { - "22baf2573ca146a7904b9ffc8b34a43692c4e042f01f43016d85443828bc88b1": 1, - "87ef7d60538b938bd8b5748cf4c0a5956ecedb860826c57d9ec789daea2f0355": 5 - }, - "packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx": { - "032fa1f91833ea216b76d2724f073741e70ad3eafebe4fbfcb166b950618e415": 1 - }, "packages/experimental/computer-use-cua-driver-mcp/tests/composition.spec.ts": { "154da39139a0074bad3d13a624e5fa62d1f7ce19dc8b9c28a73af3263cd7f10e": 1 }, From 2545414c02216a843406979bcc5b4abb8c8ce58a Mon Sep 17 00:00:00 2001 From: dsh-agent Date: Mon, 21 Sep 2026 16:46:45 +0800 Subject: [PATCH 014/192] test(preview): lock ini admission to the code renderer The issue reported .ini files with no preview. On master the ini grammar is already in CODE_HIGHLIGHT_EXTENSIONS, so the code renderer claims the suffix and .ini previews. No production change is required; this test records the admission behavior so a future allowlist change cannot silently drop the reported suffix. --- .../tests/code-registration.client.spec.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/client/ui-sidebar-documentpreview/tests/code-registration.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/code-registration.client.spec.tsx index 17fe414745..c429935443 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/code-registration.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/code-registration.client.spec.tsx @@ -48,6 +48,15 @@ describe('code renderer registration', () => { expect(h.previews.candidates('notes.unknown')).toEqual([]) }) + it('admits ini and other text configuration suffixes to the code renderer', async () => { + const h = await boot() + await h.rt.mount(plugin) + for (const path of ['config.ini', 'C:\\work\\settings.INI', 'pyproject.toml']) { + expect(h.previews.candidates(path).map(item => item.id), path).toEqual([ID]) + } + expect(h.previews.candidates('notes.unknown')).toEqual([]) + }) + it('resolves localized names at read time and removes metadata, locale, and body on disposal', async () => { const h = await boot() await h.declare() From 552235aab6a33cdeb52f02317aed69a5bd518d27 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 21 Sep 2026 16:52:05 +0800 Subject: [PATCH 015/192] docs: align the Chinese config catalog with the regenerated source line --- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.zh.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 1bb4e69c24..582b239257 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 4ce0bc0a4f660fe3f27091012072c5799231f12b -config-catalog.zh.md: b8077cd13fff1f7c3b0a89f9f9c335a8716c18ac +config-catalog.md: fd150c7e69403e9edbcf871a9d58cdd322bfeaa3 +config-catalog.zh.md: e125bce7258fd86f84ed77332d3656c058223c02 diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index b8077cd13f..e125bce725 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -631,7 +631,7 @@ export interface Config { } ``` -来源:[`packages/experimental/agent-team/src/types.ts:130`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:161`](../packages/experimental/agent-team/src/types.ts) From 6f056da42f5156df6951706c1772b4a62b274f9e Mon Sep 17 00:00:00 2001 From: mektpoy Date: Mon, 21 Sep 2026 21:17:55 +0800 Subject: [PATCH 016/192] chore(deps): keep only the lockfile delta the settings portal needs The consolidated commit refreshed unrelated transitive resolutions while it resolved the settings portal's new react-dom dependency. Restore those entries to their previous versions and keep only the two importer entries the portal needs; the lockfile already carries both snapshots. --- pnpm-lock.yaml | 164 ++++++++++++++++++++----------------------------- 1 file changed, 65 insertions(+), 99 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b88b5fb63..8661bd2da8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,10 +65,10 @@ importers: version: 5.10.0(eslint@10.5.0(jiti@2.7.0)) '@testing-library/dom': specifier: ^10.4.1 - version: 10.4.2 + version: 10.4.1 '@testing-library/react': specifier: ^16.3.2 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 @@ -2333,7 +2333,7 @@ importers: version: 15.0.0 open: specifier: ^11.0.0 - version: 11.0.4 + version: 11.0.0 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -2737,7 +2737,7 @@ importers: version: link:../ui-sidebar '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -3193,7 +3193,7 @@ importers: version: link:../ui-workspace '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -3223,7 +3223,7 @@ importers: version: link:../ui-workspace '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -3247,7 +3247,7 @@ importers: version: link:../ui-primitives '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -3313,7 +3313,7 @@ importers: version: link:../../typert/protocol '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -3502,7 +3502,7 @@ importers: version: link:../../typert/protocol '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -3814,7 +3814,7 @@ importers: version: link:../../util/crypto '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -3976,7 +3976,7 @@ importers: version: 18.3.7(@types/react@18.3.31) '@types/use-sync-external-store': specifier: ^1.5.0 - version: 1.7.0 + version: 1.5.0 react: specifier: ^18.2.0 version: 18.3.1 @@ -4024,7 +4024,7 @@ importers: version: link:../../core/session '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -4260,7 +4260,7 @@ importers: version: link:../../util/package-manifest '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -4389,7 +4389,7 @@ importers: version: link:../ui-slots '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -4471,7 +4471,7 @@ importers: version: link:../../util/workspace-path '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -4543,7 +4543,7 @@ importers: version: link:../../util/workspace-path '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -4609,7 +4609,7 @@ importers: version: link:../../util/crypto '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/picomatch': specifier: ^4.0.2 version: 4.0.3 @@ -4687,7 +4687,7 @@ importers: version: link:../../core/session '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -4756,7 +4756,7 @@ importers: version: link:../../util/workspace-path '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -4979,7 +4979,7 @@ importers: version: link:../../util/workspace-path '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -6616,7 +6616,7 @@ importers: version: link:../../typert/protocol '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -7901,10 +7901,10 @@ importers: version: link:../../../vendor/schemastery compression: specifier: ^1.8.1 - version: 1.8.2(supports-color@9.4.0) + version: 1.8.1(supports-color@9.4.0) negotiator: specifier: ^1.0.0 - version: 1.1.0 + version: 1.0.0 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -11555,10 +11555,10 @@ importers: dependencies: '@testing-library/dom': specifier: ^10.4.1 - version: 10.4.2 + version: 10.4.1 '@testing-library/react': specifier: ^16.3.2 - version: 16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) js-yaml: specifier: ^4.2.0 version: 4.3.1 @@ -13521,13 +13521,13 @@ packages: os: [linux] '@deepseek-ai/libreoffice-kit-win32-arm64@0.0.1': - resolution: {integrity: sha512-9G7YxXHEFaVn5iFM6IaHusWuXAd3cEprwTYSA1OsYMUREliHzdcmJEqcEyePDpX1y39KNKWQgowYeSpFbfrbgg==, tarball: https://registry.npmjs.org/@deepseek-ai/libreoffice-kit-win32-arm64/-/libreoffice-kit-win32-arm64-0.0.1.tgz} + resolution: {integrity: sha512-9G7YxXHEFaVn5iFM6IaHusWuXAd3cEprwTYSA1OsYMUREliHzdcmJEqcEyePDpX1y39KNKWQgowYeSpFbfrbgg==} engines: {node: '>=22.19.0'} cpu: [arm64] os: [win32] '@deepseek-ai/libreoffice-kit-win32-x64@0.0.1': - resolution: {integrity: sha512-+DPPT5V6rfwWfMf7scfgzxwNBQiuePdY4dmVvWb4QAINgquOXUAyWVNZaThWNCO8baRn1l4fb3L5sdbLWib3zQ==, tarball: https://registry.npmjs.org/@deepseek-ai/libreoffice-kit-win32-x64/-/libreoffice-kit-win32-x64-0.0.1.tgz} + resolution: {integrity: sha512-+DPPT5V6rfwWfMf7scfgzxwNBQiuePdY4dmVvWb4QAINgquOXUAyWVNZaThWNCO8baRn1l4fb3L5sdbLWib3zQ==} engines: {node: '>=22.19.0'} cpu: [x64] os: [win32] @@ -15616,8 +15616,8 @@ packages: '@tanstack/virtual-core@3.17.7': resolution: {integrity: sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==} - '@testing-library/dom@10.4.2': - resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} '@testing-library/react@16.3.2': @@ -15945,8 +15945,8 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - '@types/use-sync-external-store@1.7.0': - resolution: {integrity: sha512-v8435kzWm3FuxyUa4r8o4sG6b0D+r+uOrzF5MXHxZKW4Haoo6txu/lq56Vio45e2i1nkmFPA+R7myWcmV7Z7Bg==} + '@types/use-sync-external-store@1.5.0': + resolution: {integrity: sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==} '@types/web-bluetooth@0.0.21': resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} @@ -16692,8 +16692,8 @@ packages: resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} engines: {node: '>= 0.6'} - compression@1.8.2: - resolution: {integrity: sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==} + compression@1.8.1: + resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} engines: {node: '>= 0.8.0'} concat-map@0.0.1: @@ -16711,10 +16711,6 @@ packages: resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} engines: {node: '>=18'} - content-type@2.1.0: - resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} - engines: {node: '>=18'} - convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -16986,8 +16982,8 @@ packages: resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} engines: {node: '>=18'} - default-browser@5.5.1: - resolution: {integrity: sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==} + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} engines: {node: '>=18'} defer-to-connect@2.0.1: @@ -17024,10 +17020,6 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} - destroy@1.2.0: - resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -17573,13 +17565,9 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true - glob@7.2.0: - resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-agent@3.0.0: resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} @@ -18307,8 +18295,8 @@ packages: mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} - media-typer@1.1.1: - resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} merge-descriptors@2.0.0: @@ -18510,9 +18498,9 @@ packages: resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} engines: {node: '>= 0.6'} - negotiator@1.1.0: - resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} - engines: {node: '>=18'} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} node-abi@3.96.0: resolution: {integrity: sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==} @@ -18668,8 +18656,8 @@ packages: oniguruma-to-es@4.3.6: resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} - open@11.0.4: - resolution: {integrity: sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg==} + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} engines: {node: '>=20'} openai@6.40.0: @@ -18873,10 +18861,6 @@ packages: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} - powershell-utils@0.2.1: - resolution: {integrity: sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==} - engines: {node: '>=20'} - preact@10.29.7: resolution: {integrity: sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==} peerDependencies: @@ -18932,8 +18916,8 @@ packages: resolution: {integrity: sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==} engines: {node: '>=12.0.0'} - proxy-addr@2.0.8: - resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} psl@1.15.0: @@ -20054,8 +20038,8 @@ packages: utf-8-validate: optional: true - wsl-utils@1.0.0: - resolution: {integrity: sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA==} + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} engines: {node: '>=20'} xml-name-validator@5.0.0: @@ -22413,7 +22397,7 @@ snapshots: '@tanstack/virtual-core@3.17.7': {} - '@testing-library/dom@10.4.2': + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.7 '@babel/runtime': 7.29.7 @@ -22424,10 +22408,10 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@testing-library/react@16.3.2(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.29.7 - '@testing-library/dom': 10.4.2 + '@testing-library/dom': 10.4.1 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: @@ -22787,7 +22771,7 @@ snapshots: '@types/unist@3.0.3': {} - '@types/use-sync-external-store@1.7.0': {} + '@types/use-sync-external-store@1.5.0': {} '@types/web-bluetooth@0.0.21': {} @@ -23152,7 +23136,7 @@ snapshots: accepts@2.0.0: dependencies: mime-types: 3.0.2 - negotiator: 1.1.0 + negotiator: 1.0.0 acorn-jsx@5.3.2(acorn@8.17.0): dependencies: @@ -23591,12 +23575,11 @@ snapshots: dependencies: mime-db: 1.54.0 - compression@1.8.2(supports-color@9.4.0): + compression@1.8.1(supports-color@9.4.0): dependencies: bytes: 3.1.2 compressible: 2.0.18 debug: 2.6.9(supports-color@9.4.0) - destroy: 1.2.0 negotiator: 0.6.4 on-headers: 1.1.0 safe-buffer: 5.2.1 @@ -23612,8 +23595,6 @@ snapshots: content-type@2.0.0: {} - content-type@2.1.0: {} - convert-source-map@2.0.0: {} cookie-signature@1.2.2: {} @@ -23917,7 +23898,7 @@ snapshots: default-browser-id@5.0.1: {} - default-browser@5.5.1: + default-browser@5.5.0: dependencies: bundle-name: 4.1.0 default-browser-id: 5.0.1 @@ -23952,8 +23933,6 @@ snapshots: dequal@2.0.3: {} - destroy@1.2.0: {} - detect-libc@2.1.2: {} detect-node@2.1.0: @@ -24406,7 +24385,7 @@ snapshots: on-finished: 2.4.1 once: 1.4.0 parseurl: 1.3.3 - proxy-addr: 2.0.8 + proxy-addr: 2.0.7 qs: 6.15.3 range-parser: 1.3.0 router: 2.2.0 @@ -24688,15 +24667,6 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 - glob@7.2.0: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.5 - once: 1.4.0 - path-is-absolute: 1.0.1 - glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -25490,7 +25460,7 @@ snapshots: mdn-data@2.27.1: {} - media-typer@1.1.1: {} + media-typer@1.1.0: {} merge-descriptors@2.0.0: {} @@ -25794,9 +25764,7 @@ snapshots: negotiator@0.6.4: {} - negotiator@1.1.0: - dependencies: - content-type: 2.1.0 + negotiator@1.0.0: {} node-abi@3.96.0: dependencies: @@ -25943,14 +25911,14 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 - open@11.0.4: + open@11.0.0: dependencies: - default-browser: 5.5.1 + default-browser: 5.5.0 define-lazy-prop: 3.0.0 is-in-ssh: 1.0.0 is-inside-container: 1.0.0 - powershell-utils: 0.2.1 - wsl-utils: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 openai@6.40.0(ws@8.21.0)(zod@4.4.3): optionalDependencies: @@ -26154,8 +26122,6 @@ snapshots: powershell-utils@0.1.0: {} - powershell-utils@0.2.1: {} - preact@10.29.7: {} prebuild-install@7.1.3: @@ -26220,7 +26186,7 @@ snapshots: '@types/node': 22.20.0 long: 5.3.2 - proxy-addr@2.0.8: + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 @@ -26382,7 +26348,7 @@ snapshots: rimraf@2.6.3: dependencies: - glob: 7.2.0 + glob: 7.2.3 roarr@2.15.4: dependencies: @@ -27033,7 +26999,7 @@ snapshots: type-is@2.1.0: dependencies: content-type: 2.0.0 - media-typer: 1.1.1 + media-typer: 1.1.0 mime-types: 3.0.2 typebox@1.3.7: {} @@ -27492,7 +27458,7 @@ snapshots: ws@8.21.0: {} - wsl-utils@1.0.0: + wsl-utils@0.3.1: dependencies: is-wsl: 3.1.1 powershell-utils: 0.1.0 From 68e43b173e89079cdda2940677cb42ffe011515e Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Tue, 22 Sep 2026 01:55:03 +0800 Subject: [PATCH 017/192] fix(native-command): open Windows paths through the shell's resolution The settings sheet's Open configuration file action opened nothing on Windows when the user's default lived only in the Explorer per-user record (FileExts\\\\UserChoiceLatest), which Explorer honors and the association resolver inside the host process does not. Invoke-Item also exits 0 when it finds no application, so the Remote answered { opened: true } and the sheet rendered no error. Windows opens now hand the path to explorer.exe as one argv element - the resolution a double-click performs - and revealNativePath shares that invocation rule. The shared helper accepts Explorer's delegate exit 1 for an open and a reveal alike; every other failure and every cancellation still rejects. The PowerShell command string and powershellLiteral are gone: the path crosses the process boundary as argv, with no escaping step. Fixes #4428 --- ...ws-open-through-shell-resolution.i18n.yaml | 6 +++ ...2-windows-open-through-shell-resolution.md | 41 ++++++++++++++ ...indows-open-through-shell-resolution.zh.md | 41 ++++++++++++++ ...-25-promote-open-anywhere-plugin.i18n.yaml | 4 +- ...2026-08-25-promote-open-anywhere-plugin.md | 2 +- ...6-08-25-promote-open-anywhere-plugin.zh.md | 2 +- packages/host/open-in-app/src/resolver.ts | 4 +- .../host/open-in-app/tests/resolver.spec.ts | 6 +-- packages/util/native-command/README.i18n.yaml | 4 +- packages/util/native-command/README.md | 2 +- packages/util/native-command/README.zh.md | 2 +- .../util/native-command/src/path-opener.ts | 53 ++++++++++++------- .../native-command/tests/path-opener.spec.ts | 53 +++++++++++-------- 13 files changed, 165 insertions(+), 55 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md create mode 100644 .agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.i18n.yaml new file mode 100644 index 0000000000..2cf3e341c8 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md +2026-09-22-windows-open-through-shell-resolution.md: d4bc1f1ff9dfbff4528403bcd276058845837998 +2026-09-22-windows-open-through-shell-resolution.zh.md: 29518901624402c4ebe7fd925eb3e7fcae1ec204 diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md new file mode 100644 index 0000000000..d4bc1f1ff9 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md @@ -0,0 +1,41 @@ +# Agent Note: Windows opens use the shell's own application resolution + +Status: implemented + +English | [中文](2026-09-22-windows-open-through-shell-resolution.zh.md) + +## Problem + +The Windows desktop client's settings sheet offers **Open configuration file**, which prepares the active profile's `cordis.patch.yml` and hands the path to the native text-document opener (issue #4428). On the reporting machine the gesture opened nothing and reported nothing. The opener ran `powershell.exe -NoProfile -Command "Invoke-Item -LiteralPath ''"`, so an association resolved inside the host process decided the outcome, and `Invoke-Item` exits 0 when that resolution finds no application. + +That machine records the `.yml` default only in `HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.yml\UserChoiceLatest` (progid `VSCode.yml`), with no `UserChoice` record and no `HKCU\Software\Classes\.yml` default. Explorer opens VS Code on a double-click of the same file. Run from the host process, `Invoke-Item`, `cmd /c start`, the `Shell.Application` default verb, and `AssocQueryString` all reported no application for that extension, and `cmd /c start` displayed the shell's own application chooser instead of opening one. `Invoke-Item` still exited 0, so `SettingsController.openSettingsDocument` answered `{ opened: true }` and the sheet rendered no error. + +## Decision + +`openWindowsPath` hands the path to `explorer.exe` as one argv element, and `revealNativePath` reaches the same shell through the shared `runExplorer` helper. Explorer performs the default-application resolution a double-click performs, so the shell's answer — including a default recorded only in the newer per-user record — selects the application, and a machine with no handler at all gets Explorer's own "How do you want to open this file?" chooser instead of silence. + +`runExplorer` holds the one Explorer invocation rule for both operations: exit code 1 is the handoff Explorer returns after passing the request to the desktop process already running, and it resolves the caller's promise; every other failure and every cancellation still rejects. The helper replaced the PowerShell command string, and with it `powershellLiteral` and its single-quote doubling: a path now crosses the process boundary as an argv element, so no escaping step exists to get wrong. + +Windows still names no browser for HTML and SVG, so `openInBrowser` continues to decline the platform and the default, association, and text-editor intents all reach the same Explorer handoff. + +## Alternatives considered + +**Keep `Invoke-Item`.** Rejected. It asks a different resolver than the shell does, so a user whose default Explorer honors can still get a silent no-op, and its zero exit code removes the error the sheet would otherwise render. + +**Resolve the default inside the host process.** Rejected. Reading `UserChoiceLatest`, then `UserChoice`, then the classes default, and launching the winning progid's `shell\open\command` reimplements an order Windows owns, including the `UserChoice` hash Windows validates; the shell's own answer is the definition of the application a double-click uses. + +**`cmd /c start `.** Rejected. It needs a command shell for the same resolution Explorer already performs, and it reported no application on the machine that reproduced the defect. + +**Fall back to a fixed editor when no application resolves.** Rejected. A Windows installation always ships a usable editor, but choosing it in the opener would override the user's association and turn a missing association into a silently wrong application. + +## Consequences + +The settings sheet's open gesture and the artifact and workspace opens share one Windows behavior: the application a double-click would launch. A machine whose recorded default Explorer honors but `AssocQueryString` does not — the reported state — now opens the configured editor. + +The opener can no longer distinguish "the shell opened it" from "the shell declined": Explorer reports 0 or 1 for both, so the Remote still answers `{ opened: true }`, and a machine with no handler shows the shell's chooser as its only feedback. Nothing here verifies that a window appeared. + +Directories take the same path. The rejected alternative recorded in [the open-in-app note](../feature/2026-08-25-promote-open-anywhere-plugin.md) was a detached, credential-scrubbed `explorer.exe ` spawn, which is not what this handoff does: the runner inherits the host environment and creates no detached process. + +## Verification + +`path-opener.spec.ts` pins the argv Explorer receives for both intents, the accepted delegate exit code 1 for an open and a reveal, the preserved failure and cancellation, and the absence of any escaping step. `resolver.spec.ts` pins the open-in-app route's Windows command. Native desktop verification stays on Windows, per the package's test policy; file and directory opens were observed on Windows 11 build 26340, where the associated application started and a folder window appeared for each target. diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md new file mode 100644 index 0000000000..2951890162 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md @@ -0,0 +1,41 @@ +# Agent Note: Windows 打开走 shell 自身的默认应用解析 + +Status: implemented + +[English](2026-09-22-windows-open-through-shell-resolution.md) | 中文 + +## 问题 + +Windows 桌面客户端设置页的「打开配置文件」会准备当前 profile 的 `cordis.patch.yml`,并把该路径交给原生文本编辑器打开器(issue #4428)。在报告者机器上,这个动作既没有打开窗口,也没有任何提示。打开器执行的是 `powershell.exe -NoProfile -Command "Invoke-Item -LiteralPath ''"`,因此由宿主进程内的关联解析决定结果,而 `Invoke-Item` 在解析不到应用时同样以退出码 0 结束。 + +该机器的 `.yml` 默认值只记录在 `HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.yml\UserChoiceLatest`(progid 为 `VSCode.yml`),既没有 `UserChoice` 记录,也没有 `HKCU\Software\Classes\.yml` 默认值。对同一个文件双击,资源管理器会打开 VS Code。而在宿主进程中执行时,`Invoke-Item`、`cmd /c start`、`Shell.Application` 的默认动词和 `AssocQueryString` 都报告该扩展名没有应用,`cmd /c start` 则弹出 shell 自己的应用选择框而不是打开某个应用。`Invoke-Item` 仍然返回 0,于是 `SettingsController.openSettingsDocument` 回答 `{ opened: true }`,设置页不渲染任何错误。 + +## 决策 + +`openWindowsPath` 将路径作为**一个** argv 元素交给 `explorer.exe`,`revealNativePath` 经共享的 `runExplorer` 帮助函数走到同一个 shell。资源管理器执行的是双击所执行的默认应用解析,因此由 shell 的答案选择应用——包括只记录在较新的按用户记录里的默认值;而完全没有处理程序的机器会得到资源管理器自己的「你要如何打开这个文件?」选择框,而不是毫无反应。 + +`runExplorer` 为两种操作持有唯一一条 Explorer 调用规则:退出码 1 是 Explorer 把请求转交给已在运行的桌面进程后返回的交接码,它照常兑现调用方的 Promise;其余失败和取消仍然报错。该帮助函数取代了 PowerShell 命令字符串,同时删掉了 `powershellLiteral` 及其单引号翻倍逻辑:路径现在以 argv 元素跨进程边界,不存在可能写错的转义步骤。 + +Windows 对 HTML 与 SVG 仍然无法命名浏览器,因此 `openInBrowser` 继续拒绝该平台,默认意图、关联意图与文本编辑器意图都走同一个 Explorer 交接。 + +## 考虑过的替代方案 + +**继续使用 `Invoke-Item`。** 已否决。它询问的解析器与 shell 不同,因此即使某个用户的默认值能被资源管理器识别,仍会得到静默的无效操作;而且它的零退出码正好抹掉了设置页本应渲染的错误。 + +**在宿主进程内自行解析默认值。** 已否决。依次读取 `UserChoiceLatest`、`UserChoice` 和 classes 默认值,再启动胜出 progid 的 `shell\open\command`,等于重新实现一套 Windows 自己拥有的顺序,其中还包括 Windows 会校验的 `UserChoice` 哈希;而双击所用应用的定义就是 shell 自己的答案。 + +**使用 `cmd /c start `。** 已否决。它为资源管理器本已执行的同一套解析引入一个命令 shell,并且在复现该缺陷的机器上同样报告没有应用。 + +**解析不到应用时回退到固定编辑器。** 已否决。Windows 安装总是带有可用的编辑器,但在打开器里选择它等于覆盖用户的关联,并把「缺少关联」变成「静默地打开错误应用」。 + +## 后果 + +设置页的打开动作与产物、工作区的打开动作共享同一个 Windows 行为:双击会启动的那个应用。对于「资源管理器认、`AssocQueryString` 不认」这一被报告的状态,现在会打开已配置的编辑器。 + +打开器再也无法区分「shell 打开了」与「shell 拒绝了」:两种情况 Explorer 都返回 0 或 1,因此 Remote 依旧回答 `{ opened: true }`,完全没有处理程序的机器只能看到 shell 的选择框。此处不校验窗口是否真的出现。 + +目录走同一条路径。[open-in-app 记录](../feature/2026-08-25-promote-open-anywhere-plugin.zh.md)中被否决的方案是 detached 且经过凭据清洗的 `explorer.exe ` 派生进程,那不是本交接的做法:运行器继承宿主环境,也不创建 detached 进程。 + +## 验证 + +`path-opener.spec.ts` 固定两种意图交给 Explorer 的 argv、打开与选中都可接受的交接退出码 1、仍然报错的普通失败与取消,以及不存在任何转义步骤。`resolver.spec.ts` 固定 open-in-app 路由在 Windows 上的命令。按该包测试策略,原生桌面验证留在 Windows;在 Windows 11 build 26340 上已观察到文件与目录打开分别启动了关联应用和文件夹窗口。 diff --git a/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.i18n.yaml b/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.i18n.yaml index 2f5a36785b..a806e17e4e 100644 --- a/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md -2026-08-25-promote-open-anywhere-plugin.md: 83888cb548046cd8e023cd2b7c87f123cda0ed0f -2026-08-25-promote-open-anywhere-plugin.zh.md: 4ef476ce7043d2dcee05dec9f604849741ce5e96 +2026-08-25-promote-open-anywhere-plugin.md: e5a435d24c2400b68fad8cccb094b176ad83f590 +2026-08-25-promote-open-anywhere-plugin.zh.md: 108091f3e7c2736063d67bc6b2d7fe0c039b35db diff --git a/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md b/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md index 83888cb548..e5a435d24c 100644 --- a/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md +++ b/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md @@ -21,7 +21,7 @@ The feature's first-party owners are `@deepseek-ai/dsh-host-open-in-app` at `pac - **Resolution sources are platform-honest and cheap.** macOS checks the known application directories (`/Applications`, `~/Applications`) for the entry's bundle spellings and launches `open -a `; Xcode follows `xcode-select -p`. Windows reads `App Paths`, then Uninstall records kept only when they prove an executable on disk, then known paths and the newest versioned install directory where an application uses one — one batched `reg.exe query` per root per pass. GitHub Desktop resolves its versioned executable together with the packaged `cli.js` and invokes the supported `github open ` behavior without a command shell. CLI names resolve in-process through `ctx.subprocess.resolveExecutable()` (PATH/PATHEXT stat, no shell, no `which`/`where.exe` children); Linux GUI entries whose CLI is off PATH fall back to their XDG desktop entry's verified `TryExec`/`Exec`, while `xdg-open` is offered only when the host announces a display server. The remaining host commands (`xcode-select`, `reg.exe`, icon extraction) run through `@deepseek-ai/dsh-native-command` (argv, never a shell). - **Three independent deadlines replace the single `commandTimeoutMs`**: `probeTimeoutMs` (resolution commands), `iconTimeoutMs` (icon extraction commands), and `launchWatchMs` (the early-failure watch window), so tuning one operation never changes another's response time — the shipped bundle keeps conservative 10 s command deadlines (timeouts are failure bounds, not latency budgets) with a 1 s watch window, which is what bounds how long the open route and the button's busy dress hold a successful launch. Launches spawn detached with a credential-scrubbed environment (`scrubbedParentEnv` from `dsh-subprocess`) plus explicit adapter entries; Windows GUI launchers remain visible unless an adapter explicitly hides its CLI process because that process opens the GUI separately. A launcher still running when the watch window closes counts as launched and is never killed or awaited (kitty and the JetBrains IDEs stay in the foreground for their window lifetime). - **Icons extract on every platform with a host source.** macOS converts the resolved bundle's `.icns` to a 128px PNG (`plutil` + `sips`); Windows extracts the resolved executable's associated icon as a 32px PNG through a generated PowerShell script run with positional `-File` args (no command-line path parsing); Linux follows the spec's desktop entry `Icon=` into the hicolor theme and pixmaps directories (PNG or SVG, filesystem only). Any failure answers 404 and the browser keeps its generic glyph. -- **The launch catalog is a data table with per-platform entries** (`OPEN_IN_APP_CATALOG`): each application id declares, per platform (`darwin`/`win32`/`linux`), a chain of launcher sources tried in order — `fixed` (ships with the OS), `app` (known-directory bundle spellings), `xcode` (`xcode-select -p`), `cli` (in-process PATH resolution), `file` (first existing candidate under `${VAR}`/`~/` expansion), `scan` (newest versioned install directory), `app-paths` and `install-record` (the Windows registry tiers), `github-desktop` (the versioned executable plus packaged CLI), and `desktop` (Linux XDG desktop entries) — plus a launcher argv template (a `{path}` token carries the directory in place, otherwise it is appended), optional environment and Windows visibility policy, and an optional fallback launcher (Xcode's `open -a ` behind `xed`). The whitelist follows Codex's "Open In" target list: editors and IDEs (VS Code, VS Code Insiders, Cursor, Windsurf, Zed, Sublime Text, Xcode, Android Studio, seven JetBrains IDEs), the promoted plugin's Git GUIs, terminals (Terminal, iTerm2, Ghostty, Warp, kitty, Windows Terminal, Git Bash, GNOME Terminal, Konsole), and per-platform file managers. File managers and platform terminals are separate ids (`finder`/`explorer`/`filemanager`) rather than one id with per-platform labels, because labels are static browser dictionary entries and only one of them probes as available per host. The file-manager entries launch through `dsh-native-command`'s path opener itself (`shell-open`, the OS shell's open verb with the full parent environment, not a detached scrubbed spawn), because a direct `explorer.exe ` spawn does not reliably raise a window. Closed unions end in `assertNever`. +- **The launch catalog is a data table with per-platform entries** (`OPEN_IN_APP_CATALOG`): each application id declares, per platform (`darwin`/`win32`/`linux`), a chain of launcher sources tried in order — `fixed` (ships with the OS), `app` (known-directory bundle spellings), `xcode` (`xcode-select -p`), `cli` (in-process PATH resolution), `file` (first existing candidate under `${VAR}`/`~/` expansion), `scan` (newest versioned install directory), `app-paths` and `install-record` (the Windows registry tiers), `github-desktop` (the versioned executable plus packaged CLI), and `desktop` (Linux XDG desktop entries) — plus a launcher argv template (a `{path}` token carries the directory in place, otherwise it is appended), optional environment and Windows visibility policy, and an optional fallback launcher (Xcode's `open -a ` behind `xed`). The whitelist follows Codex's "Open In" target list: editors and IDEs (VS Code, VS Code Insiders, Cursor, Windsurf, Zed, Sublime Text, Xcode, Android Studio, seven JetBrains IDEs), the promoted plugin's Git GUIs, terminals (Terminal, iTerm2, Ghostty, Warp, kitty, Windows Terminal, Git Bash, GNOME Terminal, Konsole), and per-platform file managers. File managers and platform terminals are separate ids (`finder`/`explorer`/`filemanager`) rather than one id with per-platform labels, because labels are static browser dictionary entries and only one of them probes as available per host. The file-manager entries launch through `dsh-native-command`'s path opener itself (`shell-open`, which hands the directory to the OS shell with the full parent environment, not a detached scrubbed spawn), because a detached, credential-scrubbed `explorer.exe ` spawn does not reliably raise a window; the opener's own `explorer.exe` handoff does ([Windows open resolution](../bug-fix/2026-09-22-windows-open-through-shell-resolution.md)). Closed unions end in `assertNever`. - **Every route runs behind the composition connection service's trust fence** (`requestRejection`: the Host/Origin fence defeating DNS rebinding and cross-site calls, plus browser authentication), the same guard the API gateway applies to its WebSocket upgrade; the mechanism's one home is the `src/index.ts` module comment. On top of that fence the open route validates the body at the wire: an `application/json` media type (exact essence, not a substring match), a 64 KiB bound with a drained 413, string field types, only probed-available catalog ids, and an absolute path naming an existing directory. - **The DSH-version gate is deleted.** It existed because the plugin rode release-to-release against an interface it did not own; a first-party package is versioned with the repository, so the gate, its `sessionStorage`/`localStorage` trust ledger, and the argv-walking version probe have no referent. - **The last choice persists through `createSnapshotStore(..., { persist })`** (`dsh.open-in-app.choice`), replacing hand-rolled `localStorage` access. A fresh store has no platform-specific choice; the component uses the first available host entry until the user chooses one. diff --git a/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md b/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md index 4ef476ce70..108091f3e7 100644 --- a/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md +++ b/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md @@ -21,7 +21,7 @@ Status: implemented - **解析来源按平台务实且廉价。** macOS 在已知应用目录(`/Applications`、`~/Applications`)查条目的 bundle 拼写,启动 `open -a <解析出的 bundle>`;Xcode 跟随 `xcode-select -p`。Windows 依次读 `App Paths`、只在能证明磁盘可执行文件时采用的 Uninstall 记录、已知路径,以及采用版本化安装目录的应用中最新的目录——每趟每根一条批量 `reg.exe query`。GitHub Desktop 会同时解析版本化可执行文件与随包提供的 `cli.js`,不经命令 shell 调用受支持的 `github open ` 行为。CLI 名称经 `ctx.subprocess.resolveExecutable()` 进程内解析(PATH/PATHEXT stat,无 shell、无 `which`/`where.exe` 子进程);CLI 不在 PATH 上的 Linux GUI 条目回退到其 XDG desktop 条目验证过的 `TryExec`/`Exec`,且只有主机声明了 display server 时才提供 `xdg-open`。其余主机命令(`xcode-select`、`reg.exe`、图标提取)经 `@deepseek-ai/dsh-native-command`(argv,绝不走 shell)执行。 - **三个独立期限取代单一 `commandTimeoutMs`**:`probeTimeoutMs`(解析命令)、`iconTimeoutMs`(图标提取命令)、`launchWatchMs`(早期失败看护窗口),调整一种操作的超时不再改变其他操作的响应时间——随发行 bundle 保守地保留 10 秒命令期限(超时是失败上界而非延迟预算),看护窗口 1 秒,它才是约束 open 路由与按钮忙碌态挂起一次成功启动时长的量。启动以清理过凭据的环境(`dsh-subprocess` 的 `scrubbedParentEnv`)叠加适配器显式环境后 detached 派生;Windows GUI launcher 默认保持可见,只有负责另行打开 GUI 的 CLI 适配器会显式隐藏自己的进程。看护窗口关闭时仍在运行的启动器计为已启动,绝不会被杀死或等待(kitty 与 JetBrains IDE 在整个窗口生命周期内保持前台)。 - **有主机来源的平台都提取图标。** macOS 把解析出的 bundle 的 `.icns` 转 128px PNG(`plutil` + `sips`);Windows 用生成的 PowerShell 脚本以位置式 `-File` 参数(路径不经命令行解析)提取解析出的可执行文件的关联图标为 32px PNG;Linux 沿 spec 的 desktop 条目 `Icon=` 查 hicolor 主题与 pixmaps 目录(PNG 或 SVG,纯文件系统)。任何失败应答 404,浏览器保持通用占位图形。 -- **启动目录是按平台声明条目的数据表**(`OPEN_IN_APP_CATALOG`):每个应用 id 按平台(`darwin`/`win32`/`linux`)声明一条按序尝试的启动器来源链——`fixed`(随系统内置)、`app`(已知目录的 bundle 拼写)、`xcode`(`xcode-select -p`)、`cli`(进程内 PATH 解析)、`file`(`${VAR}`/`~/` 展开后第一个存在的候选文件)、`scan`(带版本号安装目录取最新)、`app-paths` 与 `install-record`(Windows 注册表两层)、`github-desktop`(版本化可执行文件与随包 CLI)、`desktop`(Linux XDG desktop 条目)——加启动器 argv 模板(`{path}` token 原位携带目录,否则目录追加在末尾)、可选环境与 Windows 可见性策略,以及可选回退启动器(`xed` 之后的 `open -a `)。白名单对齐 Codex 的 "Open In" 目标列表:编辑器与 IDE(VS Code、VS Code Insiders、Cursor、Windsurf、Zed、Sublime Text、Xcode、Android Studio、七个 JetBrains IDE)、转正插件原有的 Git GUI、终端(Terminal、iTerm2、Ghostty、Warp、kitty、Windows Terminal、Git Bash、GNOME Terminal、Konsole)与各平台文件管理器。文件管理器与平台终端使用独立 id(`finder`/`explorer`/`filemanager`),而非一个 id 配平台标签,因为标签是静态浏览器词典条目,且每台主机只会探测到其中一个。文件管理器条目直接经 `dsh-native-command` 的路径打开器启动(`shell-open`,OS shell 的 open verb,携带完整父环境,而非 detached 的清理环境 spawn),因为直接 spawn `explorer.exe ` 不能可靠地弹出窗口。封闭 union 以 `assertNever` 收尾。 +- **启动目录是按平台声明条目的数据表**(`OPEN_IN_APP_CATALOG`):每个应用 id 按平台(`darwin`/`win32`/`linux`)声明一条按序尝试的启动器来源链——`fixed`(随系统内置)、`app`(已知目录的 bundle 拼写)、`xcode`(`xcode-select -p`)、`cli`(进程内 PATH 解析)、`file`(`${VAR}`/`~/` 展开后第一个存在的候选文件)、`scan`(带版本号安装目录取最新)、`app-paths` 与 `install-record`(Windows 注册表两层)、`github-desktop`(版本化可执行文件与随包 CLI)、`desktop`(Linux XDG desktop 条目)——加启动器 argv 模板(`{path}` token 原位携带目录,否则目录追加在末尾)、可选环境与 Windows 可见性策略,以及可选回退启动器(`xed` 之后的 `open -a `)。白名单对齐 Codex 的 "Open In" 目标列表:编辑器与 IDE(VS Code、VS Code Insiders、Cursor、Windsurf、Zed、Sublime Text、Xcode、Android Studio、七个 JetBrains IDE)、转正插件原有的 Git GUI、终端(Terminal、iTerm2、Ghostty、Warp、kitty、Windows Terminal、Git Bash、GNOME Terminal、Konsole)与各平台文件管理器。文件管理器与平台终端使用独立 id(`finder`/`explorer`/`filemanager`),而非一个 id 配平台标签,因为标签是静态浏览器词典条目,且每台主机只会探测到其中一个。文件管理器条目直接经 `dsh-native-command` 的路径打开器启动(`shell-open`,把目录交给 OS shell 并携带完整父环境,而非 detached 的清理环境 spawn),因为 detached 且经过凭据清洗的 `explorer.exe ` 派生进程不能可靠地弹出窗口;打开器自身的 `explorer.exe` 交接可以([Windows 打开解析](../bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md))。封闭 union 以 `assertNever` 收尾。 - **所有路由都运行在组合 connection 服务的信任栅栏之后**(`requestRejection`:挫败 DNS rebinding 与跨站调用的 Host/Origin 栅栏,加上浏览器认证),与 API gateway 施加在其 WebSocket upgrade 上的守卫相同;机制的唯一出处是 `src/index.ts` 的模块注释。在该栅栏之上,open 路由在 wire 边界校验请求体:`application/json` 媒体类型(精确 essence,而非子串匹配)、以排空后 413 的方式把 body 限制在 64 KiB、校验字段类型、只接受探测为可用的目录 id,并要求指向现存目录的绝对路径。 - **删除了 DSH 版本门禁。** 它存在是因为插件逐版本骑乘一个它不拥有的接口;第一方包与仓库同版本发布,门禁、它的 `sessionStorage`/`localStorage` 信任台账和 argv 遍历版本探测都失去了所指。 - **上次选择经 `createSnapshotStore(..., { persist })` 持久化**(`dsh.open-in-app.choice`),替代手写 `localStorage` 访问。新存储没有平台特定的初始选择;用户首次选择前,组件使用主机提供的第一个可用条目。 diff --git a/packages/host/open-in-app/src/resolver.ts b/packages/host/open-in-app/src/resolver.ts index 62c6f8e4bc..970118e22a 100644 --- a/packages/host/open-in-app/src/resolver.ts +++ b/packages/host/open-in-app/src/resolver.ts @@ -680,8 +680,8 @@ function isMissingExecutable(error: unknown): boolean { * Open one directory through the OS shell's open verb under the launch watch * window: the opener command completing inside the window decides the * outcome, and an opener still running when it closes counts as launched and - * keeps running (a cold `powershell.exe` start can outlive the window; its - * late settlement is swallowed because the request already answered). + * keeps running (a cold shell opener can outlive the window; its late + * settlement is swallowed because the request already answered). */ function runShellOpen( path: string, watchMs: number, internals: ResolvedInternals, diff --git a/packages/host/open-in-app/tests/resolver.spec.ts b/packages/host/open-in-app/tests/resolver.spec.ts index 7ac003b055..cf4b8c79f8 100644 --- a/packages/host/open-in-app/tests/resolver.spec.ts +++ b/packages/host/open-in-app/tests/resolver.spec.ts @@ -581,15 +581,15 @@ describe('launchResolved', () => { }, }), )).resolves.toBe('launched') - // The opener is the shipped Invoke-Item channel; the detached spawner never runs. + // The opener is the shipped Explorer channel; the detached spawner never runs. expect(spawns).toEqual([]) expect(commands).toEqual([ - ['powershell.exe', '-NoProfile', '-Command', "Invoke-Item -LiteralPath 'C:\\w\\dir'"], + ['explorer.exe', 'C:\\w\\dir'], ]) }) it('counts a shell-open opener that outlives the watch window as launched, and a fast failure as failed', async () => { - // A cold powershell start can outlive the window: still-running counts launched. + // A cold shell opener can outlive the window: still-running counts launched. await expect(launchResolved( { launch: { kind: 'shell-open' } }, '/w/dir', 25, bare({ platform: 'darwin', run: () => new Promise(() => {}) }), diff --git a/packages/util/native-command/README.i18n.yaml b/packages/util/native-command/README.i18n.yaml index bcd08be397..5010c1e803 100644 --- a/packages/util/native-command/README.i18n.yaml +++ b/packages/util/native-command/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/native-command/README.md -README.md: 23df6d784aae1a5a911b556a3d70bae7971bd566 -README.zh.md: e8548777191d1433188712c42797d4a902314f64 +README.md: cd090d69ae790ae6a1ccbe70f4a767246c3b4981 +README.zh.md: 17ff391fd29cef4909f415505a73aaca3b901f14 diff --git a/packages/util/native-command/README.md b/packages/util/native-command/README.md index 23df6d784a..cd090d69ae 100644 --- a/packages/util/native-command/README.md +++ b/packages/util/native-command/README.md @@ -45,7 +45,7 @@ The `NativeCommandRunner` type is the injectable command boundary for host integ ### Opening a Host path -`openNativePath(path, signal)` hands a path to the default application and prefers the named default browser for HTML and SVG where the platform can identify one. `openNativeAssociatedPath(path, signal)` always uses the file-type association, without a browser override. `openNativeTextFile(path, signal)` selects text-editor intent; on macOS it uses `open -t`. WSL paths are translated with `wslpath -w` before the Windows desktop receives them. `canOpenNativePath()` reports whether the current Host plausibly has a desktop target. +`openNativePath(path, signal)` hands a path to the default application and prefers the named default browser for HTML and SVG where the platform can identify one. `openNativeAssociatedPath(path, signal)` always uses the file-type association, without a browser override. `openNativeTextFile(path, signal)` selects text-editor intent; on macOS it uses `open -t`. Windows hands every intent to Explorer, so the shell's own default-application resolution — the one a double-click uses — picks the application. WSL paths are translated with `wslpath -w` before the Windows desktop receives them. `canOpenNativePath()` reports whether the current Host plausibly has a desktop target. `revealNativePath(path, signal)` selects the file in Finder or Explorer, including WSL path translation, and opens its parent directory through `xdg-open` on desktop Linux. `nativeFileManager()` identifies that action for Host-derived UI labels; desktop availability remains a separate `canOpenNativePath()` check. Callers must authorize the absolute file path before invoking either operation. Platform dispatch is covered by injected-runner tests; native desktop verification belongs to the corresponding platform. Explorer receives an encoded file URI as a separate argument. Its exit code 1 is accepted as a delegated handoff; cancellation, missing executables, and other exit codes still reject. This acknowledgement does not prove that a desktop window selected the file. diff --git a/packages/util/native-command/README.zh.md b/packages/util/native-command/README.zh.md index e854877719..17ff391fd2 100644 --- a/packages/util/native-command/README.zh.md +++ b/packages/util/native-command/README.zh.md @@ -45,7 +45,7 @@ const { stdout, stderr } = await runNativeCommand('osascript', ['-e', script], s ### 打开 Host 路径 -`openNativePath(path, signal)` 将路径交给默认应用;平台能够确定默认浏览器时,HTML 与 SVG 会优先交给该浏览器。`openNativeAssociatedPath(path, signal)` 始终使用文件类型关联,不覆盖为浏览器。`openNativeTextFile(path, signal)` 选择文本编辑器意图;macOS 使用 `open -t`。WSL 路径先通过 `wslpath -w` 转换,再交给 Windows 桌面。`canOpenNativePath()` 报告当前 Host 是否可能具备桌面目标。 +`openNativePath(path, signal)` 将路径交给默认应用;平台能够确定默认浏览器时,HTML 与 SVG 会优先交给该浏览器。`openNativeAssociatedPath(path, signal)` 始终使用文件类型关联,不覆盖为浏览器。`openNativeTextFile(path, signal)` 选择文本编辑器意图;macOS 使用 `open -t`。Windows 将每种意图都交给 Explorer,由 shell 自身的默认应用解析(即双击所走的那条)选择应用。WSL 路径先通过 `wslpath -w` 转换,再交给 Windows 桌面。`canOpenNativePath()` 报告当前 Host 是否可能具备桌面目标。 `revealNativePath(path, signal)` 在 Finder 或文件资源管理器中选中文件,包含 WSL 路径转换;在桌面 Linux 上通过 `xdg-open` 打开上层目录。`nativeFileManager()` 标识该操作,供 UI 根据 Host 选择文案;桌面是否可用仍由独立的 `canOpenNativePath()` 检查决定。调用方必须先授权绝对文件路径,再执行操作。平台分派由注入运行器的测试覆盖;原生桌面验证由对应平台负责。 Explorer 接收独立参数中的编码文件 URI。退出码 1 按已转交请求处理;取消、找不到可执行文件和其他退出码仍然报错。该确认不能证明桌面窗口已选中文件。 diff --git a/packages/util/native-command/src/path-opener.ts b/packages/util/native-command/src/path-opener.ts index 618efa31b1..a84da5cb65 100644 --- a/packages/util/native-command/src/path-opener.ts +++ b/packages/util/native-command/src/path-opener.ts @@ -5,7 +5,10 @@ * The default intent prefers the default browser for documents it renders when * the platform can name one, then falls back to the default application. WSL * translates every path for the Windows desktop instead of assuming a Linux - * GUI. The text-editor intent never consults the browser. + * GUI. The text-editor intent never consults the browser. Windows hands every + * intent to Explorer: the shell's own default-application resolution, the one + * a double-click uses, selects the application, while a process that resolves + * the association itself reads a narrower record and reports none. * @module @deepseek-ai/dsh-native-command/path-opener */ @@ -82,11 +85,6 @@ async function openInBrowser( /** Native path-open intent; macOS distinguishes text editing from file association. */ type PathOpenIntent = 'default' | 'association' | 'text-editor' -/** PowerShell single-quoted literal (doubles embedded quotes). */ -function powershellLiteral(path: string): string { - return `'${path.replace(/'/g, "''")}'` -} - /** Whether one environment marker is set to a non-empty value. */ function present(value: string | undefined): boolean { return value !== undefined && value !== '' @@ -99,13 +97,36 @@ function isWsl(internals: PathOpenerInternals): boolean { return (internals.osRelease ?? osRelease()).toLowerCase().includes('microsoft') } -/** Open one Windows-resolvable path through its registered desktop application. */ +/** + * Hand one target to Explorer, accepting its delegated-handoff exit code. + * + * Explorer exits 1 after handing the request to the desktop process already + * running, so exit 1 means the shell took it. Every other failure still + * rejects, and cancellation wins over a delegate's exit 1. + * @param args - Explorer argv: the target path alone to open it, or `/select,` to reveal it. + * @param signal - caller lifetime; abort terminates the command. + * @param run - shell-free command runner. + * @throws The runner's failure unless it is Explorer's delegate exit 1. + */ +async function runExplorer(args: readonly string[], signal: AbortSignal, run: PathOpenerRunner): Promise { + try { + await run('explorer.exe', args, signal) + } catch (error: unknown) { + signal.throwIfAborted() + // Explorer can exit 1 after delegating to the existing desktop process. + if (!(error instanceof Error) || !('code' in error) || error.code !== 1) throw error + } +} + +/** + * Open one Windows-resolvable path through Explorer, the shell that owns the + * default-application resolution a double-click uses. + * @param path - Windows-resolvable path; passed as one argv element, never a command string. + * @param signal - caller lifetime; abort terminates the command. + * @param run - shell-free command runner. + */ async function openWindowsPath(path: string, signal: AbortSignal, run: PathOpenerRunner): Promise { - await run('powershell.exe', [ - '-NoProfile', - '-Command', - `Invoke-Item -LiteralPath ${powershellLiteral(path)}`, - ], signal) + await runExplorer([path], signal, run) } /** Translate a WSL path before handing it to the Windows desktop. */ @@ -259,13 +280,7 @@ export async function revealNativePath( } // Explorer parses commas itself; a file URI preserves commas and whitespace in the path. const target = pathToFileURL(windowsPath, { windows: true }).href.replaceAll(',', '%2C') - try { - await run('explorer.exe', ['/select,', target], signal) - } catch (error) { - signal.throwIfAborted() - // Explorer can exit 1 after delegating to the existing desktop process. - if (!(error instanceof Error) || !('code' in error) || error.code !== 1) throw error - } + await runExplorer(['/select,', target], signal, run) return } if (manager === 'directory') { diff --git a/packages/util/native-command/tests/path-opener.spec.ts b/packages/util/native-command/tests/path-opener.spec.ts index c6f862f20d..e5821345e5 100644 --- a/packages/util/native-command/tests/path-opener.spec.ts +++ b/packages/util/native-command/tests/path-opener.spec.ts @@ -56,15 +56,7 @@ describe('native path opener', () => { }) expect(run.mock.calls).toEqual([ ['wslpath', ['-w', '/home/test user/settings.yaml'], requestSignal], - [ - 'powershell.exe', - [ - '-NoProfile', - '-Command', - "Invoke-Item -LiteralPath '\\\\wsl.localhost\\Ubuntu\\home\\test user\\settings.yaml'", - ], - requestSignal, - ], + ['explorer.exe', ['\\\\wsl.localhost\\Ubuntu\\home\\test user\\settings.yaml'], requestSignal], ]) }) @@ -88,12 +80,14 @@ describe('native path opener', () => { expect(run).toHaveBeenCalledOnce() }) - it('opens with Windows Invoke-Item and escapes single quotes', async () => { + it('hands Windows one argv path with no command string to escape', async () => { const run = vi.fn(async () => ({ stdout: '', stderr: '' })) - await openNativePath("C:\\work\\o'reilly.txt", signal(), { platform: 'win32', run }) + // Quotes, commas, a percent, and an ampersand reach Explorer as a single + // argument: there is no PowerShell literal and no shell to interpret them. + await openNativePath("C:\\work\\o'reilly, & 100%.txt", signal(), { platform: 'win32', run }) expect(run).toHaveBeenCalledWith( - 'powershell.exe', - ['-NoProfile', '-Command', "Invoke-Item -LiteralPath 'C:\\work\\o''reilly.txt'"], + 'explorer.exe', + ["C:\\work\\o'reilly, & 100%.txt"], expect.any(AbortSignal), ) }) @@ -102,8 +96,8 @@ describe('native path opener', () => { const run = vi.fn(async () => ({ stdout: '', stderr: '' })) await openNativeTextFile('C:\\work\\settings.yaml', signal(), { platform: 'win32', run }) expect(run).toHaveBeenCalledWith( - 'powershell.exe', - ['-NoProfile', '-Command', "Invoke-Item -LiteralPath 'C:\\work\\settings.yaml'"], + 'explorer.exe', + ['C:\\work\\settings.yaml'], expect.any(AbortSignal), ) }) @@ -128,7 +122,7 @@ describe('native path opener', () => { osRelease: '6.8.0-generic', env: {}, run, }) const expected = process.platform === 'win32' - ? 'powershell.exe' + ? 'explorer.exe' : process.platform === 'linux' ? 'xdg-open' : 'open' @@ -260,7 +254,7 @@ describe('browser-renderable documents', () => { platform: 'win32', run: async (command, args) => { win.push([command, ...args]); return { stdout: '', stderr: '' } }, }) - expect(win[0]?.[0]).toBe('powershell.exe') + expect(win[0]?.[0]).toBe('explorer.exe') }) it('hands browser-renderable WSL paths to the Windows desktop', async () => { @@ -279,12 +273,7 @@ describe('browser-renderable documents', () => { }) expect(calls).toEqual([ ['wslpath', '-w', '/home/test/page.html'], - [ - 'powershell.exe', - '-NoProfile', - '-Command', - "Invoke-Item -LiteralPath 'C:\\workspace\\page.html'", - ], + ['explorer.exe', 'C:\\workspace\\page.html'], ]) }) }) @@ -405,6 +394,24 @@ it('preserves cancellation even when Explorer returns delegate exit 1', async () await expect(revealNativePath('C:\\file.txt', abort.signal, { platform: 'win32', run })).rejects.toBe(reason) }) +it.each(['win32', 'linux'] as const)('accepts the Explorer delegate exit 1 when opening on %s', async (platform) => { + // The settings sheet's open gesture must survive the same handoff reveal does. + const run = vi.fn(async (command) => { + if (command === 'wslpath') return { stdout: 'C:\\work\\settings.yaml', stderr: '' } + throw Object.assign(new Error('delegated'), { code: 1 }) + }) + await expect(openNativePath( + platform === 'win32' ? 'C:\\work\\settings.yaml' : '/mnt/c/work/settings.yaml', signal(), + { platform, env: { WSL_DISTRO_NAME: 'Ubuntu' }, run }, + )).resolves.toBeUndefined() +}) + +it('preserves a non-delegate Explorer failure when opening', async () => { + const failure = Object.assign(new Error('launch failed'), { code: 2 }) + const run = vi.fn().mockRejectedValue(failure) + await expect(openNativePath('C:\\file.txt', signal(), { platform: 'win32', run })).rejects.toBe(failure) +}) + it.each([ ['C:\\my files\\报告,#%.txt', 'file:///C:/my%20files/%E6%8A%A5%E5%91%8A%2C%23%25.txt'], ['\\\\server\\share\\a,b.txt', 'file://server/share/a%2Cb.txt'], From 44900be005c938e89781e7f7f1e3b959c487c7b6 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Tue, 22 Sep 2026 02:07:32 +0800 Subject: [PATCH 018/192] docs(native-command): name explorer.exe in the Windows open note The Agent Note title said 'the shell's own application resolution', which names a layer instead of the command the opener runs. --- ...2026-09-22-windows-open-through-shell-resolution.i18n.yaml | 4 ++-- .../2026-09-22-windows-open-through-shell-resolution.md | 2 +- .../2026-09-22-windows-open-through-shell-resolution.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.i18n.yaml index 2cf3e341c8..952b69869a 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md -2026-09-22-windows-open-through-shell-resolution.md: d4bc1f1ff9dfbff4528403bcd276058845837998 -2026-09-22-windows-open-through-shell-resolution.zh.md: 29518901624402c4ebe7fd925eb3e7fcae1ec204 +2026-09-22-windows-open-through-shell-resolution.md: 1b353f29fe7c170974e2244fe0218e7e9bdf6921 +2026-09-22-windows-open-through-shell-resolution.zh.md: 8421209fe3a0d4ac1ade867716d89b9d3e69078e diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md index d4bc1f1ff9..1b353f29fe 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md +++ b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md @@ -1,4 +1,4 @@ -# Agent Note: Windows opens use the shell's own application resolution +# Agent Note: Windows opens go through explorer.exe Status: implemented diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md index 2951890162..8421209fe3 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md @@ -1,4 +1,4 @@ -# Agent Note: Windows 打开走 shell 自身的默认应用解析 +# Agent Note: Windows 打开经 explorer.exe 完成 Status: implemented From d08aac9db27ce1d0e35298ff409262656e5fa000 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 22 Sep 2026 10:46:06 +0800 Subject: [PATCH 019/192] fix(agent-team): read current projections through standard hooks --- .../client-ui-agent-team/README.i18n.yaml | 4 +-- .../client-ui-agent-team/README.md | 4 +-- .../client-ui-agent-team/README.zh.md | 4 +-- .../src/client/TeamAction.tsx | 10 ++++--- .../tests/team-action.client.spec.tsx | 29 +++++++++++++++++-- 5 files changed, 39 insertions(+), 12 deletions(-) diff --git a/packages/experimental/client-ui-agent-team/README.i18n.yaml b/packages/experimental/client-ui-agent-team/README.i18n.yaml index 77f7ef28a4..c61dc57ea4 100644 --- a/packages/experimental/client-ui-agent-team/README.i18n.yaml +++ b/packages/experimental/client-ui-agent-team/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/client-ui-agent-team/README.md -README.md: d08b19f19849a1da517a980adf427bdd6fddcd76 -README.zh.md: 91137d2d527c87200fe6720079d639695ac7033e +README.md: 23617b1f3f7da511f1a1f5fa588450d0c484c94f +README.zh.md: f119df4fbd62c2a1c7ff365fdf53faa90fe3e423 diff --git a/packages/experimental/client-ui-agent-team/README.md b/packages/experimental/client-ui-agent-team/README.md index d08b19f198..23617b1f3f 100644 --- a/packages/experimental/client-ui-agent-team/README.md +++ b/packages/experimental/client-ui-agent-team/README.md @@ -29,7 +29,7 @@ Enable this package through [`@deepseek-ai/dsh-experimental-agent-team-profile`] ### Inspect and navigate the roster -The trigger shows the teammate count, and the panel shows the roster and task board of the Lead Session's `agentTeam` projection, so a task created by an agent or a teammate reaching `active` appears while the panel is open. Opening the panel requests the Lead's projection baseline when the shared store has none; a failed read shows the failure with a retry control, and a reconnect reloads it. Roster rows show durable names and a status: `failed` and `provisioning` come from the durable member phase, and `running` or `inactive` comes from the member Session's live status. A model appears when the member Session's `modelSelection` projection records a durable selection or request. Provisioning and running members use the shared ongoing loader, inactive members use idle, and failed members use error. Selecting a healthy teammate opens the ordinary `{ parentSessionId, childSessionId, mode: 'continuable' }` address from its Lead and roster identity without refreshing or checking the parent catalog. The Host validates the parent, child, and mode when history opens. History and later human prompts continue through the stable addressed-subagent conversation path; this package adds no Team-specific address field. +The trigger shows the teammate count, and the panel shows the roster and task board of the Lead Session's `agentTeam` projection, so a task created by an agent or a teammate reaching `active` appears while the panel is open. Opening the panel requests the Lead's explicit projection baseline once per connection, skipping a successful prior read; a failed read shows the failure with a retry control, and a reconnect reloads it. Roster rows show durable names and a status: `failed` and `provisioning` come from the durable member phase, and `running` or `inactive` comes from the member Session's live status. A model appears when the member Session's `modelSelection` projection records a durable selection or request. Provisioning and running members use the shared ongoing loader, inactive members use idle, and failed members use error. Selecting a healthy teammate opens the ordinary `{ parentSessionId, childSessionId, mode: 'continuable' }` address from its Lead and roster identity without refreshing or checking the parent catalog. The Host validates the parent, child, and mode when history opens. History and later human prompts continue through the stable addressed-subagent conversation path; this package adds no Team-specific address field. ### Inspect the task board @@ -47,7 +47,7 @@ The read-only task board shows task identity, owner, blockers, readiness, adviso The Client export registers its locale dictionaries and one conversation-header slot through Cordis effects; it mounts no Remote namespace. Disposing the plugin fiber removes both registrations. -The panel renders outside the conversation container and stays within the viewport. Opening moves focus into the panel; Escape or Close returns focus to its trigger. Clicking outside or moving focus outside the panel and trigger closes it without moving focus back. The component derives every row from the `useSessions`, `useSessionStatus`, and `useSession` seats: the Lead identity comes from the current Session's subagent address, the Team view from `projectionsBySession[lead].values.agentTeam`, member activity from Session status with the list summary as fallback, and the model from `projectionsBySession[member].values.modelSelection.next`. Its only injected callbacks read the Lead's projection baseline and open a teammate. Switching conversations closes the panel and clears a navigation failure. +The panel renders outside the conversation container and stays within the viewport. Opening moves focus into the panel; Escape or Close returns focus to its trigger. Clicking outside or moving focus outside the panel and trigger closes it without moving focus back. The component reads the current Session's Team through `useProjection('agentTeam')` and its model through `useProjection('modelSelection')`. In a teammate conversation, `useSession` supplies the Lead identity and `useSessions` supplies that other Session's Team projection. Other members' models also come from the shared Session values; member activity comes from `useSessionStatus` with the list summary as fallback. Its only injected callbacks read the Lead's projection baseline and open a teammate. Switching conversations closes the panel and clears a navigation failure. | File | Role | |---|---| diff --git a/packages/experimental/client-ui-agent-team/README.zh.md b/packages/experimental/client-ui-agent-team/README.zh.md index 91137d2d52..f119df4fbd 100644 --- a/packages/experimental/client-ui-agent-team/README.zh.md +++ b/packages/experimental/client-ui-agent-team/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 检查并导航 roster -触发按钮显示 teammate 数量,panel 显示 Lead Session `agentTeam` 投影中的 roster 与任务板,因此 agent 创建的任务或进入 `active` 的 teammate 会在 panel 打开期间直接出现。打开 panel 时,若共享 store 尚无 Lead 的投影基线则请求一次;读取失败会显示失败原因与重试控件,重连后会重新加载。Roster row 展示持久 name 与状态:`failed` 与 `provisioning` 来自持久成员 phase,`running` 或 `inactive` 来自成员 Session 的实时状态。当成员 Session 的 `modelSelection` 投影记录了持久选择或请求时显示 model。provisioning 和 running 成员使用共享 ongoing loading,inactive 成员使用 idle 灰点,failed 成员使用 error 红点。选择健康 teammate 时,系统直接根据其 Lead 与 roster 身份打开普通的 `{ parentSessionId, childSessionId, mode: 'continuable' }` address,不刷新或检查 parent catalog。Host 在打开历史时校验 parent、child 与 mode。History 与后续人类提示词继续使用稳定 addressed-subagent 会话路径;本包不会添加 Team 专用 address 字段。 +触发按钮显示 teammate 数量,panel 显示 Lead Session `agentTeam` 投影中的 roster 与任务板,因此 agent 创建的任务或进入 `active` 的 teammate 会在 panel 打开期间直接出现。打开 panel 时,每次连接请求一次 Lead 的显式投影基线,之前读取成功则跳过;读取失败会显示失败原因与重试控件,重连后会重新加载。Roster row 展示持久 name 与状态:`failed` 与 `provisioning` 来自持久成员 phase,`running` 或 `inactive` 来自成员 Session 的实时状态。当成员 Session 的 `modelSelection` 投影记录了持久选择或请求时显示 model。provisioning 和 running 成员使用共享 ongoing loading,inactive 成员使用 idle 灰点,failed 成员使用 error 红点。选择健康 teammate 时,系统直接根据其 Lead 与 roster 身份打开普通的 `{ parentSessionId, childSessionId, mode: 'continuable' }` address,不刷新或检查 parent catalog。Host 在打开历史时校验 parent、child 与 mode。History 与后续人类提示词继续使用稳定 addressed-subagent 会话路径;本包不会添加 Team 专用 address 字段。 ### 查看任务板 @@ -47,7 +47,7 @@ kind: "package-reference" Client export 通过 Cordis effect 注册 locale dictionary 与一个 conversation-header slot;它不挂载任何 Remote namespace。Dispose plugin fiber 会移除这两项 registration。 -面板渲染在会话容器外,并保持在视口范围内。打开时焦点移入面板;按 Escape 或选择关闭按钮时,焦点返回触发按钮。点击外部或将焦点移出面板与触发按钮时,面板关闭,但不会将焦点移回。组件从 `useSessions`、`useSessionStatus` 与 `useSession` 座位派生每一行:Lead 身份来自当前 Session 的 subagent address,Team 视图来自 `projectionsBySession[lead].values.agentTeam`,成员活动来自 Session 状态并以列表摘要为后备,model 来自 `projectionsBySession[member].values.modelSelection.next`。它仅有的注入回调是读取 Lead 的投影基线与打开 teammate。切换会话会关闭面板并清除导航失败。 +面板渲染在会话容器外,并保持在视口范围内。打开时焦点移入面板;按 Escape 或选择关闭按钮时,焦点返回触发按钮。点击外部或将焦点移出面板与触发按钮时,面板关闭,但不会将焦点移回。组件通过 `useProjection('agentTeam')` 读取当前 Session 的 Team,通过 `useProjection('modelSelection')` 读取其 model。在 teammate 会话中,`useSession` 提供 Lead 身份,`useSessions` 提供该另一个 Session 的 Team 投影。其他成员的 model 也来自共享 Session 值;成员活动来自 `useSessionStatus`,并以列表摘要为后备。它仅有的注入回调是读取 Lead 的投影基线与打开 teammate。切换会话会关闭面板并清除导航失败。 | 文件 | 职责 | |---|---| diff --git a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx index c9d678b050..c6661bed06 100644 --- a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx +++ b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx @@ -17,7 +17,7 @@ import css from './TeamAction.module.css' /** Business actions injected by the browser plugin. */ export interface TeamActionInjected { - /** Read the Lead Session's projection baseline when the shared store has none, or retry a failed read. */ + /** Request the Lead's projection baseline once per connection, or retry a failed read. */ loadProjections: (leadSessionId: SessionId) => void openTeammate: (sessionId: SessionId, member: TeamMemberProjection) => void } @@ -73,7 +73,7 @@ function taskDotState(task: TeamTask): StateDotState { /** Render the Team roster and read-only task board from the Lead Session's `agentTeam` projection. */ export function TeamAction({ - sessionId, useSession, useSessions, useSessionStatus, loadProjections, openTeammate, t, + sessionId, useSession, useProjection, useSessions, useSessionStatus, loadProjections, openTeammate, t, }: TeamActionProps) { const [open, setOpen] = useState(false) const [error, setError] = useState(null) @@ -87,11 +87,13 @@ export function TeamAction({ useDismissOnOutsidePointer(rootRef, open, setOpen, panelRef) const leadSessionId = useSession(snapshot => snapshot.subagent?.address.parentSessionId) ?? sessionId + const currentTeam = useProjection('agentTeam') + const currentModel = useProjection('modelSelection', value => value?.next?.model) const projections = useSessions(state => state.projectionsBySession) const summaries = useSessions(state => state.byId) const statuses = useSessionStatus(snapshot => snapshot) const projection = projections[leadSessionId] - const team = projection?.values.agentTeam + const team = leadSessionId === sessionId ? currentTeam : projection?.values.agentTeam const readError = team === undefined && projection?.state === 'error' ? projection.error : null useEffect(() => { @@ -118,7 +120,7 @@ export function TeamAction({ return (statuses.get(member.id)?.running ?? summaries[member.id]?.running) === true ? 'running' : 'inactive' } const memberModel = (member: TeamMemberProjection): string | undefined => - projections[member.id]?.values.modelSelection?.next?.model + member.id === sessionId ? currentModel : projections[member.id]?.values.modelSelection?.next?.model const teammates = team?.members.filter(member => member.role === 'teammate') ?? [] diff --git a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx index a8f8713a5e..f150de9021 100644 --- a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx +++ b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx @@ -6,7 +6,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { TeamMemberProjection, TeamProjection, TeamTaskId, TeamTaskView as TeamTask, } from '@deepseek-ai/dsh-experimental-agent-team/client' -import type { SessionListState, SessionSnapshot, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionListState, SessionSnapshot, SessionSummary, UseProjection } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { bindSnapshotSelector, makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' @@ -77,11 +77,18 @@ function bench(options: { promptAttempted: false, awaitingFirstTurn: false, }) + const useSessions = bindSnapshotSelector(sessions) const injected: TeamActionInjected = { loadProjections: vi.fn(), openTeammate: vi.fn() } const props: TeamActionProps = { sessionId, useSession: bindSnapshotSelector(session), - useSessions: bindSnapshotSelector(sessions), + useProjection: ((key: string, select?: (value: unknown) => unknown) => { + const value = useSessions(state => state.projectionsBySession[sessionId]?.values[ + key as keyof SessionListState['projectionsBySession'][SessionId]['values'] + ]) + return select === undefined ? value : select(value) + }) as UseProjection, + useSessions, useSessionStatus: bindSnapshotSelector(statuses), ...injected, t: makeTranslate(zh, commonZh), @@ -128,6 +135,24 @@ describe('TeamAction', () => { expect(screen.getByRole('button', { name: /Agent Team/u }).textContent).toContain('2') }) + it('reads the current Team and model through the standard projection hook', () => { + const b = bench() + const projected = createSnapshotStore({ ...team, tasks: [{ ...task, subject: 'Scoped Team task' }] }) + const useTeam = bindSnapshotSelector(projected) + const useProjection = ((key: string, select?: (value: unknown) => unknown) => { + const current = useTeam(value => value) + const value = key === 'agentTeam' ? current : { next: { model: 'scoped-model' } } + return select === undefined ? value : select(value) + }) as UseProjection + render() + openPanel() + expect(screen.getByText('Scoped Team task')).toBeTruthy() + expect(screen.queryByText('Implement runtime')).toBeNull() + expect(screen.getByRole('button', { name: /lead.*scoped-model/u })).toBeTruthy() + act(() => { projected.set({ ...team, tasks: [{ ...task, subject: 'Updated scoped task' }] }) }) + expect(screen.getByText('Updated scoped task')).toBeTruthy() + }) + it('overlays live Session status and the durable model selection on roster rows', () => { const statuses: SessionStatusSnapshot = new Map([[WORKER, { running: true, pendingInteraction: undefined, completionUnread: false }]]) const b = bench({ From 2426d398b5cf23d0e13598580bb6e5fe2cfd292d Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:38:30 -0700 Subject: [PATCH 020/192] fix(ui-agent-preset): restore the read-only configuration viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The declarative-preset refactor (#4569) removed the registry's read Remote together with the YAML editor, and with it the 查看配置 card action that opened a preset's composition read-only. #4850 brought the Creator entry back and had to leave 查看配置 out because `agentPresets/read` was gone. Give the registry a read-only `read` Remote that renders a declaration's child plugin list from the in-memory definition in the Loader's own entry-list dialect, so `!!js` conditions read as declared, and put the card action back at the right end of each card footer: an icon that opens the YAML in a read-only dialog titled after the preset. Nothing accepts YAML back, and a broken preset stays readable because its diagnostic points into that YAML. Fixes #4895 --- apps/web/tests/agent-preset-authoring.e2e.ts | 27 ++++++- .../section.expected.md | 4 ++ docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 6 ++ docs/subsystems/core.zh.md | 6 ++ .../client/ui-agent-preset/README.i18n.yaml | 4 +- packages/client/ui-agent-preset/README.md | 10 +-- packages/client/ui-agent-preset/README.zh.md | 10 +-- .../src/client/AgentPresetSection.module.css | 71 +++++++++++++++++++ .../src/client/AgentPresetSection.tsx | 38 +++++++--- .../ui-agent-preset/src/client/index.ts | 7 +- .../ui-agent-preset/src/client/locales.ts | 3 + .../src/client/section-store.ts | 35 +++++++-- .../tests/apply.client.spec.ts | 9 ++- .../tests/section-store.client.spec.ts | 22 +++++- .../tests/section.client.spec.tsx | 26 ++++++- .../extensions/tool-cordis/src/api-catalog.ts | 10 +++ .../agent-preset-registry/README.i18n.yaml | 4 +- .../preset/agent-preset-registry/README.md | 2 +- .../preset/agent-preset-registry/README.zh.md | 2 +- .../preset/agent-preset-registry/package.json | 5 +- .../preset/agent-preset-registry/src/index.ts | 23 +++++- .../preset/agent-preset-registry/src/types.ts | 12 ++++ .../tests/registry.spec.ts | 19 ++++- pnpm-lock.yaml | 6 ++ 25 files changed, 322 insertions(+), 43 deletions(-) diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts index 4986424158..3a6058efa6 100644 --- a/apps/web/tests/agent-preset-authoring.e2e.ts +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -1,4 +1,4 @@ -/** The preset settings page only selects a preset; creating one starts a Creator-mode task. */ +/** The preset settings page selects a preset and shows what it declares; creating one starts a Creator-mode task. */ import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page } from 'playwright' @@ -27,13 +27,13 @@ describe('web e2e: preset roster guidance', () => { }, 120_000) afterAll(async () => { await browser?.close(); await scaffold?.close() }) - it('shows the shipped roster with mode help and no editing actions', async () => { + it('shows the shipped roster with mode help and a read-only view, and no editing actions', async () => { onTestFailed(() => saveFailureShot(page, 'preset-roster-section')) await expect.poll(() => page.locator('[data-agent-preset-id]').count()).toBe(4) const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(join(EXPECTED, 'section.expected.md'), snapshot, mode) expect(snapshot).toContain('让 Agent 帮我创建预设模式') - expect(snapshot).not.toContain('查看配置') + expect(snapshot).toContain('查看配置: 标准模式') expect(snapshot).not.toContain('复制预设') expect(snapshot).not.toContain('编辑插件') expect(snapshot).not.toContain('打开目录') @@ -55,6 +55,27 @@ describe('web e2e: preset roster guidance', () => { expect(await settings.getByRole('button', { name: '新任务默认: 标准模式', exact: true }).getAttribute('aria-pressed')).toBe('true') }) + it('views a shipped composition read-only', async () => { + onTestFailed(() => saveFailureShot(page, 'preset-roster-view')) + const settings = page.getByRole('dialog', { name: '设置' }) + await settings.getByRole('button', { name: '查看配置: PTC 模式', exact: true }).click() + const viewer = page.getByRole('dialog', { name: '查看配置 · PTC 模式', exact: true }) + await viewer.waitFor({ timeout: 10_000 }) + // The real shipped declaration, not a golden: the viewer shows whatever + // the deployment ships, and this lane only asserts it is shown read-only + // in the Loader's own dialect. + const shown = await viewer.locator('pre').textContent() + expect(shown).toContain("- id: persona\n name: '@deepseek-ai/dsh-persona'\n") + expect(shown).toContain('- id: workflow-ptc\n') + expect(shown).toContain("disabled: !!js process.platform === 'win32'\n") + expect(shown).not.toContain('__jsExpr') + expect(await viewer.getByRole('textbox').count()).toBe(0) + // The header X and the footer button share the 关闭 name; the footer one is last. + await viewer.getByRole('button', { name: '关闭', exact: true }).last().click() + await viewer.waitFor({ state: 'detached', timeout: 10_000 }) + expect(await settings.getByRole('button', { name: '新任务默认: 标准模式', exact: true }).getAttribute('aria-pressed')).toBe('true') + }) + it('starts a Creator-mode task from the section entry', async () => { onTestFailed(() => saveFailureShot(page, 'preset-roster-creator')) // The entry stages the self-referential preset and lands a new task on it, diff --git a/apps/web/tests/expected/agent-preset-authoring/section.expected.md b/apps/web/tests/expected/agent-preset-authoring/section.expected.md index 7bccb7e4c2..11f454119f 100644 --- a/apps/web/tests/expected/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/section.expected.md @@ -21,6 +21,7 @@ - text: 处理代码、文件和资料,适合大多数任务。Agent 会按需使用检索、编辑和终端等工具。 - 'button "模式说明: 标准模式"': 模式说明 - 'button "如何使用: 标准模式"': 如何使用 + - 'button "查看配置: 标准模式"': 查看配置 - listitem: - 'button "设为新任务默认: PTC 模式"': - text: PTC 模式 内置 @@ -28,6 +29,7 @@ - text: 包含标准模式的所有能力,更适合批量调用工具,并对结果进行筛选、整理、去重、统计或汇总的任务。 - 'button "模式说明: PTC 模式"': 模式说明 - 'button "如何使用: PTC 模式"': 如何使用 + - 'button "查看配置: PTC 模式"': 查看配置 - listitem: - 'button "设为新任务默认: 极简模式"': - text: 极简模式 内置 @@ -35,6 +37,7 @@ - text: Agent 仅使用终端工具完成任务,适合测试和对比其基础表现。 - 'button "模式说明: 极简模式"': 模式说明 - 'button "如何使用: 极简模式"': 如何使用 + - 'button "查看配置: 极简模式"': 查看配置 - listitem: - 'button "设为新任务默认: 创造模式"': - text: 创造模式 内置 @@ -42,5 +45,6 @@ - text: 用对话定制 DSH:让 Agent 编写插件,添加新功能或界面;也能组合工具和提示词,创建自己的模式。 - 'button "模式说明: 创造模式"': 模式说明 - 'button "如何使用: 创造模式"': 如何使用 + - 'button "查看配置: 创造模式"': 查看配置 - heading "自定义" [level=3] - button "让 Agent 帮我创建预设模式" diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 29fbf9d0ae..51b124f552 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/core.md -core.md: aa9af309c03ebedae353139c4017d19f2607dd7d -core.zh.md: b18ff5bba1c0309e9b9dcdf1f3e97adb5895caf6 +core.md: 64344148708b29fc186a2e33ef187025c4bc5300 +core.zh.md: 0b85519b916490d6692a31f59ed89527ce72fa43 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index aa9af309c0..6434414870 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -510,6 +510,12 @@ async list(): Promise */ async resolve(id?: string): Promise +/** Read one declaration's child plugin list as YAML, for viewing only. + * @param agentPreset Preset identity. + * @returns The declared composition beside its published metadata. + */ +@Remote('read') readDocument(agentPreset: string): Promise + /** Bind an unpublished Agent to the current preset revision. * @param ctx Agent context from its setup callback. * @param id Requested preset, or the default. diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index b18ff5bba1..0b85519b91 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -520,6 +520,12 @@ async list(): Promise */ async resolve(id?: string): Promise +/** Read one declaration's child plugin list as YAML, for viewing only. + * @param agentPreset Preset identity. + * @returns The declared composition beside its published metadata. + */ +@Remote('read') readDocument(agentPreset: string): Promise + /** Bind an unpublished Agent to the current preset revision. * @param ctx Agent context from its setup callback. * @param id Requested preset, or the default. diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 961b1712a0..fde3410d83 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-agent-preset/README.md -README.md: 176957e32695debf6a8aedd98368ec0d985d9182 -README.zh.md: a9496780ce5ea48ae95420d11d9f182134518b93 +README.md: 3f50449078f66643025fe235ee45ce63689d65b7 +README.zh.md: 8753520ecc97f06eeea0d52ea4e782a7e76c38e8 diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 176957e326..3f50449078 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -1,5 +1,5 @@ --- -description: "Choose Agent presets and the new-task default in Web, and read what each mode does. Authoring is guided to Creator mode." +description: "Choose Agent presets and the new-task default in Web, read what each mode does and what it declares. Authoring is guided to Creator mode." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Choose Agent presets and the new-task default in Web, and read what each mode does. Authoring is guided to Creator mode. +Choose Agent presets and the new-task default in Web, read what each mode does and what it declares. Authoring is guided to Creator mode. ## Table of Contents @@ -25,7 +25,7 @@ Choose Agent presets and the new-task default in Web, and read what each mode do ## Use this package -Settings shows the built-in and custom card groups with default highlighting and card-body selection; a group without presets is omitted, except the custom group, which keeps its Creator entry on screen. The page edits nothing: that entry starts a Creator-mode task that authors or overrides a preset as a bundle, offered while the `cordis` preset is on the roster and a conversation flow exists. +Settings shows the built-in and custom card groups with default highlighting and card-body selection; a group without presets is omitted, except the custom group, which keeps its Creator entry on screen. Every card offers “View configuration”, which opens the preset's declared plugin list as read-only YAML in the Loader's own dialect (`!!js` conditions included); a failed preset stays readable because its diagnostic points into that YAML. The page edits nothing: the Creator entry starts a Creator-mode task that authors or overrides a preset as a bundle, offered while the `cordis` preset is on the roster and a conversation flow exists. The “Choose a mode for new tasks” switch controls whether the saved user default is active. Hiding selection uses the deployment default; showing it restores the user preference. Choosing a healthy default also synchronizes the blank session on the current new-task surface. Creator starts a new task using the `cordis` preset. The new-session picker additionally requires Developer tools in General Settings. @@ -37,7 +37,7 @@ Known shipped presets offer mode details and usage examples in a read-only dialo
Implementation internals — click to expand -`agentPresets/list` supplies the roster and the chooser policy; default and visibility changes write the `agent-presets` settings namespace. The picker, blank-session synchronization and read-only session label use recorded preset identities. Connection resets and settings updates refresh the roster. +`agentPresets/list` supplies the roster and the chooser policy, and `agentPresets/read` one declaration's YAML for the viewer; default and visibility changes write the `agent-presets` settings namespace. The picker, blank-session synchronization and read-only session label use recorded preset identities. Connection resets and settings updates refresh the roster.
@@ -61,7 +61,7 @@ Selection changes affect only later tasks; existing plugins and prompts remain u -- Web creates and edits no preset: a bundle installed through Creator mode declares a new preset or overrides a shipped one by row id, replacing its complete child list. +- Web creates and edits no preset: the configuration viewer is read-only, and a bundle installed through Creator mode declares a new preset or overrides a shipped one by row id, replacing its complete child list. ### Dev Note diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index a9496780ce..8753520ecc 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -1,5 +1,5 @@ --- -description: "在 Web 中选择 Agent preset 和新任务默认值,并查看各模式的说明。创建与修改引导至创造模式。" +description: "在 Web 中选择 Agent preset 和新任务默认值,查看各模式的说明与声明的配置。创建与修改引导至创造模式。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -在 Web 中选择 Agent preset 和新任务默认值,并查看各模式的说明。创建与修改引导至创造模式。 +在 Web 中选择 Agent preset 和新任务默认值,查看各模式的说明与声明的配置。创建与修改引导至创造模式。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用此包 -设置页显示内置与自定义卡片分组、默认项高亮和点击卡片选择;没有 preset 的分组不显示,但自定义分组会保留其创造入口。本页不编辑任何内容:该入口启动一个创造模式任务,以 bundle 形式创建或覆盖 preset;当 `cordis` preset 在列表中且存在会话流程时提供。 +设置页显示内置与自定义卡片分组、默认项高亮和点击卡片选择;没有 preset 的分组不显示,但自定义分组会保留其创造入口。每张卡片都提供「查看配置」,以只读 YAML 打开该 preset 声明的插件列表,使用 Loader 自己的方言(含 `!!js` 条件);加载失败的 preset 同样可读,因为其诊断信息正指向这份 YAML。本页不编辑任何内容:创造入口启动一个创造模式任务,以 bundle 形式创建或覆盖 preset;当 `cordis` preset 在列表中且存在会话流程时提供。 「新任务可选择模式」开关控制保存的用户默认值是否生效。关闭时使用部署默认值,重新开启则恢复用户偏好。选择健康定义作为默认值也会同步当前新任务页面的空白会话。Creator 入口开启一个使用 `cordis` preset 的新任务。新会话选择器还要求通用设置开启开发者工具。 @@ -37,7 +37,7 @@ kind: "package-reference"
实现细节 — 点击展开 -`agentPresets/list` 提供列表和选择器策略;默认值与可见性的修改写入 `agent-presets` settings 命名空间。选择器、空白会话同步和只读会话标签使用记录的 preset 标识。连接重置和设置更新会刷新列表。 +`agentPresets/list` 提供列表和选择器策略,`agentPresets/read` 为查看器提供一条声明的 YAML;默认值与可见性的修改写入 `agent-presets` settings 命名空间。选择器、空白会话同步和只读会话标签使用记录的 preset 标识。连接重置和设置更新会刷新列表。
@@ -61,7 +61,7 @@ kind: "package-reference" -- Web 不创建也不编辑 preset:通过创造模式安装的 bundle 声明新 preset,或按行 id 覆盖内置 preset,覆盖会替换整个子插件列表。 +- Web 不创建也不编辑 preset:配置查看器是只读的;通过创造模式安装的 bundle 声明新 preset,或按行 id 覆盖内置 preset,覆盖会替换整个子插件列表。 ### 开发备注 diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css index 33827b02c4..f801fe90cd 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css @@ -317,6 +317,77 @@ color: var(--dsw-alias-label-primary); } +/* Icon-only actions: the label rides `data-tip` so the row stays quiet until + someone reaches for it. */ +.iconButton { + position: relative; + appearance: none; + border: 0; + border-radius: 7px; + padding: 6px; + background: none; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; + display: inline-flex; + align-items: center; +} + +.iconButton:hover { + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); +} + +.iconButton:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: -1px; +} + +.iconButton::after { + content: attr(data-tip); + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + padding: 3px 8px; + border-radius: 6px; + background: var(--dsw-alias-label-primary); + color: var(--dsw-alias-bg-layer-3); + font-size: 11px; + line-height: 17px; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity .12s; +} + +.iconButton:hover::after, +.iconButton:focus-visible::after { + opacity: 1; +} + +.dialog { + width: min(720px, 100%); +} + +/* A declared composition can be long; the dialog scrolls it rather than grow. */ +.viewerCode { + margin: 0; + padding: 12px; + max-height: min(60vh, 560px); + overflow: auto; + border: 0.5px solid var(--dsw-alias-border-l4); + border-radius: 10px; + background: var(--dsw-alias-bg-layer-2); + color: var(--dsw-alias-label-secondary); + font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 12.5px; + line-height: 1.5; + white-space: pre; + tab-size: 2; + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); +} + .error { margin: 0; font-size: 12px; diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx index 6650be7d9e..c7e6ee3dc4 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -1,7 +1,9 @@ -/** Preset selection settings: the roster, its default, mode help and the Creator-mode entry. */ +/** Preset selection settings: the roster, its default, mode help, a read-only view of each composition, and the Creator-mode entry. */ import type { ReactNode } from 'react' import { useEffect, useLayoutEffect, useRef, useState } from 'react' -import { Button, IconPlusOutlineRegular, Switch, Tag, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import { + Button, IconBrowseOutlineRegular, IconPlusOutlineRegular, Modal, Switch, Tag, Tooltip, +} from '@deepseek-ai/dsh-client-ui-primitives' import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { AgentPresetSectionState } from './section-store.ts' @@ -19,6 +21,10 @@ export interface AgentPresetSectionInjected { /** Stage the `cordis` preset and start a Creator-mode task; absent without a conversation flow. */ startCreatorDraft?: () => void load: () => Promise + /** Open one preset's declared composition in the read-only viewer. */ + view: (id: string) => Promise + /** Close the read-only viewer. */ + closeView: () => void makeDefault: (id: string) => Promise setPickerVisible: (visible: boolean) => Promise } @@ -51,12 +57,13 @@ function CardDescription({ text }: { text: string }): ReactNode { ) } -/** Render the roster with its default, mode help, and the guidance to Creator mode. +/** Render the roster with its default, mode help, composition viewer, and the guidance to Creator mode. * @param props Settings actions, snapshot hooks and localized text. * @returns The preset settings section. */ export function AgentPresetSection({ - useAgentPresetSection, load, makeDefault, setPickerVisible, startCreatorDraft, close: closeSettings, useDeveloperTools, t, + useAgentPresetSection, load, view, closeView, makeDefault, setPickerVisible, startCreatorDraft, + close: closeSettings, useDeveloperTools, t, }: AgentPresetSectionProps) { const state = useAgentPresetSection(value => value) const developerTools = useDeveloperTools(enabled => enabled) @@ -65,6 +72,9 @@ export function AgentPresetSection({ page: PresetGuidePage } | null>(null) useEffect(() => { void load() }, [load]) + const viewed = state.view + const viewedRow = viewed === null ? undefined : state.rows.find(row => row.id === viewed.id) + const viewedTitle = viewed === null ? '' : viewedRow === undefined ? viewed.title : presetDisplayText(viewedRow, t).name // Creator mode authors presets in conversation; it needs the flow to land a // session in and the self-referential preset on the roster. const creator = startCreatorDraft !== undefined && state.rows.some(row => row.id === 'cordis') ? startCreatorDraft : undefined @@ -138,14 +148,21 @@ export function AgentPresetSection({ {row.broken === undefined ? null : {row.broken}} - {help === undefined ? null :
-
+
+ {help === undefined ? null :
-
-
} +
} + {/* Reading the declaration is the one thing this page offers + beyond choosing: a broken preset's YAML is also where its + diagnostic points, so the viewer stays available for it. */} + +
})} } @@ -153,5 +170,10 @@ export function AgentPresetSection({
})} {guide === null ? null : { setGuide(null) }} />} + {t('close')}}> + {viewed === null ? null :
{viewed.content}
} +
} diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 9121d5b07d..a253ac3888 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -2,7 +2,8 @@ * Agent-preset surface plugin, browser half — three surfaces over one roster: * a chip on the new-session screen for the session about to start, a * read-only label in the session header, and a settings section that lists - * the roster (selection, the new-task default, and the way into Creator mode). + * the roster (selection, the new-task default, a read-only view of each + * declared composition, and the way into Creator mode). * * A running session keeps the composition it began with (the host refuses to * adopt an existing session under a different preset). That is what splits @@ -49,7 +50,7 @@ export type { AgentPresetLabelInjected, AgentPresetLabelProps } from './AgentPre export type { AgentPresetSeatInjected, AgentPresetSeatProps } from './AgentPresetSeat.tsx' export type { AgentPresetSectionInjected, AgentPresetSectionProps } from './AgentPresetSection.tsx' export type { AgentPresetSeatState } from './seat-store.ts' -export type { AgentPresetSectionState } from './section-store.ts' +export type { AgentPresetSectionState, PresetView } from './section-store.ts' export type { AgentPresetOption, AgentPresetSettingsState } from './settings-store.ts' export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.ts' @@ -199,6 +200,8 @@ export function apply(ctx: ClientContext): void { const sectionInjected = (): AgentPresetSectionInjected => ({ hooks: { agentPresetSection: section.store, developerTools: ctx.configForms.developerTools.enabled }, load: () => section.load(), + view: (id: string) => section.view(id), + closeView: () => { section.closeView() }, ...creatorDraft === undefined ? {} : { startCreatorDraft: creatorDraft }, makeDefault: (id: string) => section.makeDefault(id, captureBlankSessionSync()), setPickerVisible: (showPicker: boolean) => section.setPickerVisible(showPicker, captureBlankSessionSync()), diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts index 4137600704..0577e07443 100644 --- a/packages/client/ui-agent-preset/src/client/locales.ts +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -12,6 +12,7 @@ export type AgentPresetSettingsKey = | 'nav' | 'sectionIntro' | 'setDefault' + | 'view' | 'presetStandardName' | 'presetStandardDescription' | 'presetPtcName' @@ -44,6 +45,7 @@ export const en: Record = { nav: 'Agent presets', setDefault: 'Set as new task default', + view: 'View configuration', presetStandardName: 'Standard mode', presetStandardDescription: @@ -89,6 +91,7 @@ export const zh: Record = { nav: 'Agent 预设', setDefault: '设为新任务默认', + view: '查看配置', presetStandardName: '标准模式', presetStandardDescription: '处理代码、文件和资料,适合大多数任务。Agent 会按需使用检索、编辑和终端等工具。', diff --git a/packages/client/ui-agent-preset/src/client/section-store.ts b/packages/client/ui-agent-preset/src/client/section-store.ts index 334a134d87..9f4a8ecc97 100644 --- a/packages/client/ui-agent-preset/src/client/section-store.ts +++ b/packages/client/ui-agent-preset/src/client/section-store.ts @@ -1,10 +1,19 @@ -/** Preset roster and selection policy for the settings section. */ +/** Preset roster, selection policy and the read-only composition viewer for the settings section. */ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { AgentPresetRow } from '@deepseek-ai/dsh-agent-preset-registry/types' import { writeDefaultPreset, writeModeSelectionEnabled } from './settings-store.ts' +/** The read-only composition viewer over one preset. */ +export interface PresetView { + /** The preset being read. */ + id: string + /** Display name the preset published, or its id. */ + title: string + /** The declared child plugin list as YAML. */ + content: string +} /** Settings page state. */ export interface AgentPresetSectionState { status: 'idle' | 'loading' | 'ready' | 'error' @@ -12,13 +21,15 @@ export interface AgentPresetSectionState { showPicker: boolean policySaving: boolean rows: readonly AgentPresetRow[] + /** The open viewer, or null. */ + view: PresetView | null } -const INITIAL: AgentPresetSectionState = { status: 'idle', error: null, showPicker: true, policySaving: false, rows: [] } +const INITIAL: AgentPresetSectionState = { status: 'idle', error: null, showPicker: true, policySaving: false, rows: [], view: null } const message = (error: unknown): string => error instanceof Error ? error.message : String(error) -/** Loads the roster and writes the default and chooser policy. */ +/** Loads the roster, writes the default and chooser policy, and reads one composition at a time. */ export class AgentPresetSectionController { - /** Observable roster and selection state. */ + /** Observable roster, selection and viewer state. */ readonly store: SnapshotStore = createSnapshotStore(INITIAL) private loading: Promise | undefined constructor(private readonly ctx: Context) {} @@ -39,6 +50,22 @@ export class AgentPresetSectionController { } catch (error) { this.set({ status: 'error', error: message(error) }) } } + /** Open one preset's declared composition in the viewer. + * @param id Preset to read. + * @returns Once the read settles; a failure lands in `error` and leaves the viewer closed. + */ + async view(id: string): Promise { + this.set({ error: null }) + try { + const result = await this.ctx.remote.agentPresets.read(id) + if (!result.ok) throw new Error(result.error.message) + const { name, content } = result.value + this.set({ view: { id, title: name ?? id, content } }) + } catch (error) { this.set({ error: message(error) }) } + } + /** Close the viewer. */ + closeView(): void { this.set({ view: null }) } + /** Set the default and synchronize the current blank task when supplied. * @param id Selected default. * @param sync Blank-session synchronization callback. diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index 23e75612d0..ed6e9698a5 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -131,9 +131,9 @@ async function bench(options: { } return Promise.resolve(ROSTER) }, - read: () => Promise.resolve({ + read: (id: string) => Promise.resolve({ ok: true as const, - value: { agentPreset: 'standard', content: '', revision: 'rev' }, + value: { agentPreset: id, content: `# ${id}\n[]\n` }, }), save: (id: string) => { calls.push(`save:${id}`) @@ -350,6 +350,11 @@ describe('ui-agent-preset apply', () => { await section.makeDefault('standard') expect(section.hooks.agentPresetSection.getSnapshot().rows) .toEqual([{ id: 'standard', isDefault: true }]) + await section.view('standard') + expect(section.hooks.agentPresetSection.getSnapshot().view) + .toEqual({ id: 'standard', title: 'standard', content: '# standard\n[]\n' }) + section.closeView() + expect(section.hooks.agentPresetSection.getSnapshot().view).toBeNull() }) it('refreshes a showing surface when its namespace changes, and ignores others', async () => { diff --git a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts index 1a708fd4ec..2441ea606a 100644 --- a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts @@ -4,6 +4,7 @@ import { AgentPresetSectionController } from '../src/client/section-store.ts' function fixture() { const remote = { agentPresets: { list: vi.fn(async () => ({ ok: true as const, value: { presets: [{ id: 'standard', isDefault: true }], modeSelectionEnabled: true } })), + read: vi.fn(async (id: string) => ({ ok: true as const, value: { agentPreset: id, name: 'Standard', content: '- name: fs\n' } })), }, settings: { update: vi.fn(async () => ({ ok: true as const, value: {} })) } } const controller = new AgentPresetSectionController({ remote } as never) return { remote, controller, state: () => controller.store.getSnapshot() } @@ -14,7 +15,7 @@ describe('the preset roster', () => { const { controller, remote, state } = fixture() await Promise.all([controller.load(), controller.load()]) expect(remote.agentPresets.list).toHaveBeenCalledOnce() - expect(state()).toMatchObject({ status: 'ready', rows: [{ id: 'standard', isDefault: true }], showPicker: true }) + expect(state()).toMatchObject({ status: 'ready', rows: [{ id: 'standard', isDefault: true }], showPicker: true, view: null }) remote.agentPresets.list.mockRejectedValueOnce(new Error('offline')) await controller.load() expect(state()).toMatchObject({ status: 'error', error: 'offline' }) @@ -26,6 +27,25 @@ describe('the preset roster', () => { expect(state().error).toBe('gone') }) + it('opens one declared composition for reading and keeps a failed read out of the viewer', async () => { + const { controller, remote, state } = fixture() + await controller.view('standard') + expect(remote.agentPresets.read).toHaveBeenCalledWith('standard') + expect(state().view).toEqual({ id: 'standard', title: 'Standard', content: '- name: fs\n' }) + controller.closeView() + expect(state().view).toBeNull() + remote.agentPresets.read.mockResolvedValueOnce({ ok: true, value: { agentPreset: 'mine', content: '[]\n' } } as never) + await controller.view('mine') + expect(state().view).toEqual({ id: 'mine', title: 'mine', content: '[]\n' }) + controller.closeView() + remote.agentPresets.read.mockResolvedValueOnce({ ok: false, error: { message: 'Unknown agent preset: gone' } } as never) + await controller.view('gone') + expect(state()).toMatchObject({ view: null, error: 'Unknown agent preset: gone' }) + remote.agentPresets.read.mockRejectedValueOnce(new Error('offline')) + await controller.view('standard') + expect(state()).toMatchObject({ view: null, error: 'offline' }) + }) + it('keeps default selection and blank-session synchronization on their existing settings path', async () => { const { controller, remote, state } = fixture() const sync = vi.fn(async () => undefined) diff --git a/packages/client/ui-agent-preset/tests/section.client.spec.tsx b/packages/client/ui-agent-preset/tests/section.client.spec.tsx index 9add87de92..44461a0cbe 100644 --- a/packages/client/ui-agent-preset/tests/section.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/section.client.spec.tsx @@ -13,8 +13,9 @@ function unusedHook(): never { } function view(partial: Partial = {}, startCreatorDraft?: () => void, developerTools = true) { const store = createSnapshotStore({ status: 'ready', error: null, - showPicker: true, policySaving: false, rows: [{ id: 'standard', isDefault: true }, { id: 'mine', name: 'Mine', isDefault: false }], ...partial }) - const actions = { load: vi.fn(async () => {}), makeDefault: vi.fn(async () => {}), + showPicker: true, policySaving: false, rows: [{ id: 'standard', isDefault: true }, { id: 'mine', name: 'Mine', isDefault: false }], + view: null, ...partial }) + const actions = { load: vi.fn(async () => {}), view: vi.fn(async () => {}), closeView: vi.fn(), makeDefault: vi.fn(async () => {}), setPickerVisible: vi.fn(async () => {}), close: vi.fn() } const props: AgentPresetSectionProps = { ...actions, ...(startCreatorDraft === undefined ? {} : { startCreatorDraft }), @@ -87,6 +88,27 @@ it('disables the Creator entry when mode selection is hidden', () => { fireEvent.click(button) expect(launch).not.toHaveBeenCalled() }) +it('reads a declared composition read-only from every card, broken ones included', () => { + const actions = view({ rows: [{ id: 'standard', isDefault: true }, { id: 'broken', isDefault: false, broken: 'Missing plugin' }] }) + expect(screen.queryByRole('dialog')).toBeNull() + fireEvent.click(within(rowFor('standard')).getByRole('button', { name: `${en.view}: ${en.presetStandardName}` })) + expect(actions.view).toHaveBeenCalledWith('standard') + fireEvent.click(within(rowFor('broken')).getByRole('button', { name: `${en.view}: broken` })) + expect(actions.view).toHaveBeenLastCalledWith('broken') + expect(actions.makeDefault).not.toHaveBeenCalled() +}) +it('shows the open composition under the preset display name, without copy, and closes it from the footer', () => { + const actions = view({ view: { id: 'standard', title: 'standard', content: '- id: tool-fs\n name: fs\n' } }) + const dialog = screen.getByRole('dialog', { name: `${en.view} · ${en.presetStandardName}` }) + expect(dialog.querySelector('pre')?.textContent).toBe('- id: tool-fs\n name: fs\n') + expect(within(dialog).queryByRole('textbox')).toBeNull() + expect(dialog.querySelectorAll('p')).toHaveLength(0) + fireEvent.click(within(dialog).getAllByRole('button', { name: en.close }).at(-1)!) + expect(actions.closeView).toHaveBeenCalledOnce() + cleanup() + view({ view: { id: 'gone', title: 'Gone', content: '[]\n' } }) + expect(screen.getByRole('dialog', { name: `${en.view} · Gone` })).toBeTruthy() +}) it('shows roster errors while the policy switch stays usable', () => { const actions = view({ error: 'Roster stale', rows: [{ id: 'broken', isDefault: false, broken: 'Missing plugin' }] }) expect(screen.getAllByRole('alert').map(node => node.textContent)).toEqual(['Roster stale', 'Missing plugin']) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index f8edccaf67..9abb48eb32 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -159,6 +159,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'id', description: 'Explicit preset or the current default.' }], returns: 'Current metadata, including failure when activation failed.', }, + { + signature: '@Remote(\'read\') readDocument(agentPreset: string): Promise', + description: 'Read one declaration\'s child plugin list as YAML, for viewing only.', + parameters: [{ name: 'agentPreset', description: 'Preset identity.' }], + returns: 'The declared composition beside its published metadata.', + }, { signature: 'async mount(ctx: Context, id?: string): Promise', description: 'Bind an unpublished Agent to the current preset revision.', @@ -4182,6 +4188,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentPresetCompositionRow', declaration: 'export interface AgentPresetCompositionRow {\n readonly entryId: string | null;\n readonly moduleName: string;\n readonly enabled: CompositionRowEnablement;\n readonly condition?: string;\n readonly fiberState?: FiberState;\n}', }, + { + name: 'AgentPresetDocument', + declaration: 'export interface AgentPresetDocument {\n readonly agentPreset: string;\n readonly content: string;\n readonly name?: string;\n readonly description?: string;\n}', + }, { name: 'AgentPresetRoster', declaration: 'export interface AgentPresetRoster {\n readonly presets: readonly AgentPresetRow[];\n readonly modeSelectionEnabled: boolean;\n}', diff --git a/packages/preset/agent-preset-registry/README.i18n.yaml b/packages/preset/agent-preset-registry/README.i18n.yaml index 93f84a50f9..a7792a0e26 100644 --- a/packages/preset/agent-preset-registry/README.i18n.yaml +++ b/packages/preset/agent-preset-registry/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/preset/agent-preset-registry/README.md -README.md: 7a1ee3e733fc54194515dbf093e53af0c9698e40 -README.zh.md: 11206a3f9237cbca237220a81fd98ec5eea1083e +README.md: 44b01be1ceea5936668f091fb410137f3a139a88 +README.zh.md: a0c75fa50ba781ffc394e04502c922684beda387 diff --git a/packages/preset/agent-preset-registry/README.md b/packages/preset/agent-preset-registry/README.md index 7a1ee3e733..44b01be1ce 100644 --- a/packages/preset/agent-preset-registry/README.md +++ b/packages/preset/agent-preset-registry/README.md @@ -45,7 +45,7 @@ Choose an Agent’s tools, prompt sections and skills through declarative preset The Web definitions come from the `dsh-web-app` bundle. Definitions are ordinary plugin rows; the registry neither scans directories nor accepts preset paths. The `selectedDefault` and `modeSelectionEnabled` volatile fields of the `agent-preset-registry` entry retain the user default and the chooser visibility; hiding the chooser uses the deployment `default`. -The registry writes no declarations. A new preset or an override of a shipped one is a bundle patch: an `insert` of a `@deepseek-ai/dsh-agent-preset` row, or a patch keyed by that row’s id, installed into the profile with `plugin_manager`; Creator mode authors such bundles in conversation. +The registry writes no declarations. The `read` Remote renders one declaration’s child list back as entry-list YAML (`!!js` conditions included) so a client can show what a preset composes; nothing accepts YAML back. A new preset or an override of a shipped one is a bundle patch: an `insert` of a `@deepseek-ai/dsh-agent-preset` row, or a patch keyed by that row’s id, installed into the profile with `plugin_manager`; Creator mode authors such bundles in conversation. ## Understand the implementation diff --git a/packages/preset/agent-preset-registry/README.zh.md b/packages/preset/agent-preset-registry/README.zh.md index 11206a3f92..a0c75fa50b 100644 --- a/packages/preset/agent-preset-registry/README.zh.md +++ b/packages/preset/agent-preset-registry/README.zh.md @@ -45,7 +45,7 @@ kind: "package-reference" Web 内置定义来自 `dsh-web-app` bundle。定义使用普通插件行;注册表不扫描目录,也不接受 preset 路径。`agent-preset-registry` 条目的 volatile 字段 `selectedDefault` 与 `modeSelectionEnabled` 保留用户默认值和选择器可见性;隐藏选择器时使用部署 `default`。 -注册表不写入任何声明。新建 preset 或覆盖内置 preset 都是 bundle 补丁:插入一行 `@deepseek-ai/dsh-agent-preset`,或按该行 id 写覆盖补丁,再用 `plugin_manager` 安装到 profile;创造模式在对话中编写这类 bundle。 +注册表不写入任何声明。`read` Remote 把一条声明的子插件列表按 entry-list YAML 方言(含 `!!js` 条件)渲染回来,供客户端展示 preset 的组成;没有任何接口接受 YAML 写回。新建 preset 或覆盖内置 preset 都是 bundle 补丁:插入一行 `@deepseek-ai/dsh-agent-preset`,或按该行 id 写覆盖补丁,再用 `plugin_manager` 安装到 profile;创造模式在对话中编写这类 bundle。 ## 理解实现 diff --git a/packages/preset/agent-preset-registry/package.json b/packages/preset/agent-preset-registry/package.json index 71b0d8865a..f442537386 100644 --- a/packages/preset/agent-preset-registry/package.json +++ b/packages/preset/agent-preset-registry/package.json @@ -53,6 +53,7 @@ ], "license": "MIT", "peerDependencies": { + "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -67,6 +68,7 @@ }, "dependencies": { "@deepseek-ai/schemastery": "workspace:^", + "js-yaml": "^4.2.0", "zod": "^4.4.3" }, "devDependencies": { @@ -91,6 +93,7 @@ "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/dsh-agent-preset": "workspace:^" + "@deepseek-ai/dsh-agent-preset": "workspace:^", + "@types/js-yaml": "^4.0.9" } } diff --git a/packages/preset/agent-preset-registry/src/index.ts b/packages/preset/agent-preset-registry/src/index.ts index d8f54dddc3..f1366ff0d9 100644 --- a/packages/preset/agent-preset-registry/src/index.ts +++ b/packages/preset/agent-preset-registry/src/index.ts @@ -3,11 +3,13 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope' +import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' +import { dump } from 'js-yaml' import type { Agent } from '@deepseek-ai/dsh-agent' // Type-only: the optional `settings` service this registry keeps off the generated pages. import type {} from '@deepseek-ai/dsh-settings' import type {} from '@deepseek-ai/dsh-tools' -import type { AgentPresetRoster } from './types.ts' +import type { AgentPresetDocument, AgentPresetRoster } from './types.ts' import { entryListProblem, type PresetDefinition } from './definition.ts' import type { AgentPreset, Config } from './preset.ts' import { agentPresetProjectionDefinition } from './session.ts' @@ -191,6 +193,25 @@ export class AgentPresetRegistry extends TypertRemoteService { return { id: wanted, ...(broken === undefined ? {} : { broken }) } } + /** Read one declaration's child plugin list as YAML, for viewing only. + * @param agentPreset Preset identity. + * @returns The declared composition beside its published metadata. + */ + @Remote('read') + readDocument(agentPreset: string): Promise { + const record = this.definitions.get(agentPreset) + if (record === undefined) { + return Promise.reject(new RemoteError('agent-preset/not-found', `Unknown agent preset: ${agentPreset}`, + { agentPreset, available: [...this.definitions.keys()] })) + } + const { id, name, description, plugins } = record.config + // The Loader's own dialect, so `!!js` conditions read as declared rather than as expression objects. + const content = dump(plugins, { schema: entryListSchema, noRefs: true, lineWidth: -1 }) + return Promise.resolve({ + agentPreset: id, content, ...(name === undefined ? {} : { name }), ...(description === undefined ? {} : { description }), + }) + } + private async retain(id?: string): Promise { const wanted = id ?? this.defaultId while (true) { diff --git a/packages/preset/agent-preset-registry/src/types.ts b/packages/preset/agent-preset-registry/src/types.ts index 4a73dc09b9..eba2d3dce3 100644 --- a/packages/preset/agent-preset-registry/src/types.ts +++ b/packages/preset/agent-preset-registry/src/types.ts @@ -26,6 +26,18 @@ export interface AgentPresetRoster { readonly modeSelectionEnabled: boolean } +/** One preset's declared composition, rendered for reading. */ +export interface AgentPresetDocument { + /** The preset the composition belongs to. */ + readonly agentPreset: string + /** The declared child plugin list as entry-list YAML, `!!js` expressions included. */ + readonly content: string + /** Display name the preset published. */ + readonly name?: string + /** One sentence on what this preset is for. */ + readonly description?: string +} + declare module '@deepseek-ai/dsh-typert-protocol' { interface RemoteErrorDetailsMap { /** No declaration supplies the requested identity. */ diff --git a/packages/preset/agent-preset-registry/tests/registry.spec.ts b/packages/preset/agent-preset-registry/tests/registry.spec.ts index 778fa2f834..a5481db4be 100644 --- a/packages/preset/agent-preset-registry/tests/registry.spec.ts +++ b/packages/preset/agent-preset-registry/tests/registry.spec.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { createScope } from '@deepseek-ai/dsh-scope' import { assembleContextFor } from '@deepseek-ai/dsh-agent' import { entryListProblem, livePresetMounts } from '../src/index.ts' -import { currentKey, harness, declare, contribution, agentOn, liveRegistries } from './harness.ts' +import { currentKey, harness, declare, contribution, agentOn, liveRegistries, plugin } from './harness.ts' import { omitsGeneratedPage } from '../../../settings/settings/tests/live-config.ts' import Loader from '@deepseek-ai/cordis-plugin-loader' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' @@ -121,6 +121,23 @@ describe('declarative preset revisions', () => { expect.objectContaining({ id: 'empty', rows: [expect.objectContaining({ moduleName: 'missing', enabled: false })] }), ])) }) + + it('renders a declaration as entry-list YAML for reading, conditions included', async () => { + const ctx = await setup() + await declare(ctx, { + ...contribution('standard'), name: 'Standard', description: 'General', + plugins: [ + { id: 'contribute', name: plugin('contribute'), config: { tool: 'standard' } }, + { id: 'windows-only', name: 'missing', disabled: { __jsExpr: "process.platform !== 'win32'" } }, + ], + }) + const document = await ctx.agentPresets.readDocument('standard') + expect(document).toMatchObject({ agentPreset: 'standard', name: 'Standard', description: 'General' }) + expect(document.content.startsWith('- id: contribute\n name: ')).toBe(true) + expect(document.content).toContain("\n config:\n tool: standard\n- id: windows-only\n name: missing\n disabled: !!js process.platform !== 'win32'\n") + expect(document.content).not.toContain('__jsExpr') + await expect(ctx.agentPresets.readDocument('absent')).rejects.toThrow('Unknown agent preset: absent') + }) }) it('retains a retired revision for an in-flight cold read', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4bb1a3a0da..475d32fe58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9409,6 +9409,9 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + js-yaml: + specifier: ^4.2.0 + version: 4.3.1 zod: specifier: ^4.4.3 version: 4.4.3 @@ -9479,6 +9482,9 @@ importers: '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../interaction/user-approval + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 packages/preset/persona: dependencies: From 95611fbc635039ab1f62fe7b6bf3090081fe401a Mon Sep 17 00:00:00 2001 From: mektpoy Date: Tue, 22 Sep 2026 12:24:53 +0800 Subject: [PATCH 021/192] fix(native-command): encode the Explorer target so a comma stays in the path --- ...ws-open-through-shell-resolution.i18n.yaml | 4 +-- ...2-windows-open-through-shell-resolution.md | 8 +++--- ...indows-open-through-shell-resolution.zh.md | 8 +++--- .../host/open-in-app/tests/resolver.spec.ts | 2 +- packages/util/native-command/README.i18n.yaml | 4 +-- packages/util/native-command/README.md | 4 +-- packages/util/native-command/README.zh.md | 4 +-- .../util/native-command/src/path-opener.ts | 25 ++++++++++++++----- .../native-command/tests/path-opener.spec.ts | 25 +++++++++++++------ 9 files changed, 54 insertions(+), 30 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.i18n.yaml index 952b69869a..793d1c261f 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md -2026-09-22-windows-open-through-shell-resolution.md: 1b353f29fe7c170974e2244fe0218e7e9bdf6921 -2026-09-22-windows-open-through-shell-resolution.zh.md: 8421209fe3a0d4ac1ade867716d89b9d3e69078e +2026-09-22-windows-open-through-shell-resolution.md: 359d4163a06cc39b84f423940a22a1bf862dfbc8 +2026-09-22-windows-open-through-shell-resolution.zh.md: 755f9beacf8a635cccd63131acde5a6d072ee077 diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md index 1b353f29fe..359d4163a0 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md +++ b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md @@ -12,9 +12,9 @@ That machine records the `.yml` default only in `HKCU\Software\Microsoft\Windows ## Decision -`openWindowsPath` hands the path to `explorer.exe` as one argv element, and `revealNativePath` reaches the same shell through the shared `runExplorer` helper. Explorer performs the default-application resolution a double-click performs, so the shell's answer — including a default recorded only in the newer per-user record — selects the application, and a machine with no handler at all gets Explorer's own "How do you want to open this file?" chooser instead of silence. +`openWindowsPath` hands the path to `explorer.exe` as one argv element, and `revealNativePath` reaches the same shell through the shared `runExplorer` helper. Both encode the target as a file URI with its commas percent-encoded, because Explorer parses its own command line and splits it at commas — a raw path containing one opens a different target without reporting it. Explorer performs the default-application resolution a double-click performs, so the shell's answer — including a default recorded only in the newer per-user record — selects the application, and a machine with no handler at all gets Explorer's own "How do you want to open this file?" chooser instead of silence. -`runExplorer` holds the one Explorer invocation rule for both operations: exit code 1 is the handoff Explorer returns after passing the request to the desktop process already running, and it resolves the caller's promise; every other failure and every cancellation still rejects. The helper replaced the PowerShell command string, and with it `powershellLiteral` and its single-quote doubling: a path now crosses the process boundary as an argv element, so no escaping step exists to get wrong. +`runExplorer` holds the one Explorer invocation rule for both operations: exit code 1 is the handoff Explorer returns after passing the request to the desktop process already running, and it resolves the caller's promise; every other failure and every cancellation still rejects. The helper replaced the PowerShell command string, and with it `powershellLiteral` and its single-quote doubling: a path crosses the process boundary as an argv element, which removes the shell-layer escaping rather than the encoding Explorer's own parser requires. `explorerTarget` owns that one encoding step for both intents. Windows still names no browser for HTML and SVG, so `openInBrowser` continues to decline the platform and the default, association, and text-editor intents all reach the same Explorer handoff. @@ -34,8 +34,8 @@ The settings sheet's open gesture and the artifact and workspace opens share one The opener can no longer distinguish "the shell opened it" from "the shell declined": Explorer reports 0 or 1 for both, so the Remote still answers `{ opened: true }`, and a machine with no handler shows the shell's chooser as its only feedback. Nothing here verifies that a window appeared. -Directories take the same path. The rejected alternative recorded in [the open-in-app note](../feature/2026-08-25-promote-open-anywhere-plugin.md) was a detached, credential-scrubbed `explorer.exe ` spawn, which is not what this handoff does: the runner inherits the host environment and creates no detached process. +Directories take the same path. The rejected alternative recorded in [the open-in-app note](../feature/2026-08-25-promote-open-anywhere-plugin.md) was a detached, credential-scrubbed `explorer.exe ` spawn, which is not what this handoff does: the `explorer.exe` child inherits the host environment and no detached process is created, while the application or folder window Explorer delegates to starts from the desktop session's environment rather than the host's. ## Verification -`path-opener.spec.ts` pins the argv Explorer receives for both intents, the accepted delegate exit code 1 for an open and a reveal, the preserved failure and cancellation, and the absence of any escaping step. `resolver.spec.ts` pins the open-in-app route's Windows command. Native desktop verification stays on Windows, per the package's test policy; file and directory opens were observed on Windows 11 build 26340, where the associated application started and a folder window appeared for each target. +`path-opener.spec.ts` pins the encoded file URI Explorer receives for both intents, the accepted delegate exit code 1 for an open and a reveal, and the preserved failure and cancellation. `resolver.spec.ts` pins the open-in-app route's Windows command. Native desktop verification stays on Windows, per the package's test policy: on Windows 11 ARM64 build 26200 a probe association recorded the argument each handoff passed, and the opener's own commands opened the associated application for a plain file and for a file whose name contains a comma, while the same comma-bearing file handed over as a raw path opened the user's Documents folder instead — the shell split the path at the comma and reported nothing. A directory whose name contains a comma opened as a window rooted at that directory. Every one of these handoffs, successful ones included, exits 1. diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md index 8421209fe3..755f9beacf 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md @@ -12,9 +12,9 @@ Windows 桌面客户端设置页的「打开配置文件」会准备当前 profi ## 决策 -`openWindowsPath` 将路径作为**一个** argv 元素交给 `explorer.exe`,`revealNativePath` 经共享的 `runExplorer` 帮助函数走到同一个 shell。资源管理器执行的是双击所执行的默认应用解析,因此由 shell 的答案选择应用——包括只记录在较新的按用户记录里的默认值;而完全没有处理程序的机器会得到资源管理器自己的「你要如何打开这个文件?」选择框,而不是毫无反应。 +`openWindowsPath` 将路径作为**一个** argv 元素交给 `explorer.exe`,`revealNativePath` 经共享的 `runExplorer` 帮助函数走到同一个 shell。两者都把目标编码成文件 URI 并对逗号做百分号编码,因为资源管理器自己解析命令行并以逗号分段——含逗号的原始路径会打开另一个目标且不作任何提示。资源管理器执行的是双击所执行的默认应用解析,因此由 shell 的答案选择应用——包括只记录在较新的按用户记录里的默认值;而完全没有处理程序的机器会得到资源管理器自己的「你要如何打开这个文件?」选择框,而不是毫无反应。 -`runExplorer` 为两种操作持有唯一一条 Explorer 调用规则:退出码 1 是 Explorer 把请求转交给已在运行的桌面进程后返回的交接码,它照常兑现调用方的 Promise;其余失败和取消仍然报错。该帮助函数取代了 PowerShell 命令字符串,同时删掉了 `powershellLiteral` 及其单引号翻倍逻辑:路径现在以 argv 元素跨进程边界,不存在可能写错的转义步骤。 +`runExplorer` 为两种操作持有唯一一条 Explorer 调用规则:退出码 1 是 Explorer 把请求转交给已在运行的桌面进程后返回的交接码,它照常兑现调用方的 Promise;其余失败和取消仍然报错。该帮助函数取代了 PowerShell 命令字符串,同时删掉了 `powershellLiteral` 及其单引号翻倍逻辑:路径以 argv 元素跨进程边界,去掉的是 shell 层转义,而不是资源管理器自身解析所需的编码;该编码由 `explorerTarget` 为两种意图统一持有。 Windows 对 HTML 与 SVG 仍然无法命名浏览器,因此 `openInBrowser` 继续拒绝该平台,默认意图、关联意图与文本编辑器意图都走同一个 Explorer 交接。 @@ -34,8 +34,8 @@ Windows 对 HTML 与 SVG 仍然无法命名浏览器,因此 `openInBrowser` 打开器再也无法区分「shell 打开了」与「shell 拒绝了」:两种情况 Explorer 都返回 0 或 1,因此 Remote 依旧回答 `{ opened: true }`,完全没有处理程序的机器只能看到 shell 的选择框。此处不校验窗口是否真的出现。 -目录走同一条路径。[open-in-app 记录](../feature/2026-08-25-promote-open-anywhere-plugin.zh.md)中被否决的方案是 detached 且经过凭据清洗的 `explorer.exe ` 派生进程,那不是本交接的做法:运行器继承宿主环境,也不创建 detached 进程。 +目录走同一条路径。[open-in-app 记录](../feature/2026-08-25-promote-open-anywhere-plugin.zh.md)中被否决的方案是 detached 且经过凭据清洗的 `explorer.exe ` 派生进程,那不是本交接的做法:`explorer.exe` 子进程继承宿主环境,也不创建 detached 进程;而它转交给桌面会话启动的应用或文件夹窗口,继承的是桌面会话环境而不是宿主进程环境。 ## 验证 -`path-opener.spec.ts` 固定两种意图交给 Explorer 的 argv、打开与选中都可接受的交接退出码 1、仍然报错的普通失败与取消,以及不存在任何转义步骤。`resolver.spec.ts` 固定 open-in-app 路由在 Windows 上的命令。按该包测试策略,原生桌面验证留在 Windows;在 Windows 11 build 26340 上已观察到文件与目录打开分别启动了关联应用和文件夹窗口。 +`path-opener.spec.ts` 固定两种意图交给 Explorer 的编码文件 URI、打开与选中都可接受的交接退出码 1,以及仍然报错的普通失败与取消。`resolver.spec.ts` 固定 open-in-app 路由在 Windows 上的命令。按该包测试策略,原生桌面验证留在 Windows:在 Windows 11 ARM64 build 26200 上,一个探针关联记录了每次交接实际收到的参数——打开器自身的命令对普通文件、以及文件名含逗号的文件都启动了关联应用;而同一个含逗号文件若以原始路径交接,则会打开用户的「文档」文件夹,shell 在逗号处分段且不作任何提示。名称含逗号的目录会以该目录为根打开窗口。上述每一次交接(包括成功的那些)退出码都是 1。 diff --git a/packages/host/open-in-app/tests/resolver.spec.ts b/packages/host/open-in-app/tests/resolver.spec.ts index cf4b8c79f8..867dec7f7c 100644 --- a/packages/host/open-in-app/tests/resolver.spec.ts +++ b/packages/host/open-in-app/tests/resolver.spec.ts @@ -584,7 +584,7 @@ describe('launchResolved', () => { // The opener is the shipped Explorer channel; the detached spawner never runs. expect(spawns).toEqual([]) expect(commands).toEqual([ - ['explorer.exe', 'C:\\w\\dir'], + ['explorer.exe', 'file:///C:/w/dir'], ]) }) diff --git a/packages/util/native-command/README.i18n.yaml b/packages/util/native-command/README.i18n.yaml index 5010c1e803..0e2bf74b61 100644 --- a/packages/util/native-command/README.i18n.yaml +++ b/packages/util/native-command/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/native-command/README.md -README.md: cd090d69ae790ae6a1ccbe70f4a767246c3b4981 -README.zh.md: 17ff391fd29cef4909f415505a73aaca3b901f14 +README.md: b158cbf3153962af14ed99c07f7527f4bdb86f89 +README.zh.md: f02a37480934122dbf569983105b43b9962b894c diff --git a/packages/util/native-command/README.md b/packages/util/native-command/README.md index cd090d69ae..b158cbf315 100644 --- a/packages/util/native-command/README.md +++ b/packages/util/native-command/README.md @@ -45,9 +45,9 @@ The `NativeCommandRunner` type is the injectable command boundary for host integ ### Opening a Host path -`openNativePath(path, signal)` hands a path to the default application and prefers the named default browser for HTML and SVG where the platform can identify one. `openNativeAssociatedPath(path, signal)` always uses the file-type association, without a browser override. `openNativeTextFile(path, signal)` selects text-editor intent; on macOS it uses `open -t`. Windows hands every intent to Explorer, so the shell's own default-application resolution — the one a double-click uses — picks the application. WSL paths are translated with `wslpath -w` before the Windows desktop receives them. `canOpenNativePath()` reports whether the current Host plausibly has a desktop target. +`openNativePath(path, signal)` hands a path to the default application and prefers the named default browser for HTML and SVG where the platform can identify one. `openNativeAssociatedPath(path, signal)` always uses the file-type association, without a browser override. `openNativeTextFile(path, signal)` selects text-editor intent; on macOS it uses `open -t`. Windows hands every intent to Explorer, so the shell's own default-application resolution — the one a double-click uses — picks the application; Explorer receives the target as an encoded file URI and its exit code 1 counts as a delegated handoff, while cancellation, missing executables, and other exit codes still reject. WSL paths are translated with `wslpath -w` before the Windows desktop receives them. `canOpenNativePath()` reports whether the current Host plausibly has a desktop target. -`revealNativePath(path, signal)` selects the file in Finder or Explorer, including WSL path translation, and opens its parent directory through `xdg-open` on desktop Linux. `nativeFileManager()` identifies that action for Host-derived UI labels; desktop availability remains a separate `canOpenNativePath()` check. Callers must authorize the absolute file path before invoking either operation. Platform dispatch is covered by injected-runner tests; native desktop verification belongs to the corresponding platform. Explorer receives an encoded file URI as a separate argument. Its exit code 1 is accepted as a delegated handoff; cancellation, missing executables, and other exit codes still reject. This acknowledgement does not prove that a desktop window selected the file. +`revealNativePath(path, signal)` selects the file in Finder or Explorer, including WSL path translation, and opens its parent directory through `xdg-open` on desktop Linux. `nativeFileManager()` identifies that action for Host-derived UI labels; desktop availability remains a separate `canOpenNativePath()` check. Callers must authorize the absolute file path before invoking either operation. Platform dispatch is covered by injected-runner tests; native desktop verification belongs to the corresponding platform. Explorer receives an encoded file URI as a separate argument. Its exit code 1 is accepted as a delegated handoff; cancellation, missing executables, and other exit codes still reject. This acknowledgement does not prove that an application opened the file or that a window selected it. `nativeFileApplications(path, signal)` returns registered applications, localized names, icons, and the current default. macOS 12 and later use LaunchServices; Windows uses Shell association handlers; Linux uses GIO with shared XDG desktop-entry and icon lookup. On macOS, copies sharing a bundle identifier and display name (staged self-updates, per-version installs) collapse to the system default, else the highest version; deliberate side-by-side installs keep distinct display names and both entries. `openNativeFileApplication(path, application, signal)` revalidates the handler against the complete registered list, so copies collapsed out of the display list stay openable, and never changes the system default. Windows delegates invocation to the Shell, and Linux delegates argument expansion to `gio launch`. WSL translates the path and uses the Windows adapter. Callers authorize the local file path. Native integration tests use private Windows file associations and Linux XDG roots on their respective platforms. diff --git a/packages/util/native-command/README.zh.md b/packages/util/native-command/README.zh.md index 17ff391fd2..f02a374809 100644 --- a/packages/util/native-command/README.zh.md +++ b/packages/util/native-command/README.zh.md @@ -45,9 +45,9 @@ const { stdout, stderr } = await runNativeCommand('osascript', ['-e', script], s ### 打开 Host 路径 -`openNativePath(path, signal)` 将路径交给默认应用;平台能够确定默认浏览器时,HTML 与 SVG 会优先交给该浏览器。`openNativeAssociatedPath(path, signal)` 始终使用文件类型关联,不覆盖为浏览器。`openNativeTextFile(path, signal)` 选择文本编辑器意图;macOS 使用 `open -t`。Windows 将每种意图都交给 Explorer,由 shell 自身的默认应用解析(即双击所走的那条)选择应用。WSL 路径先通过 `wslpath -w` 转换,再交给 Windows 桌面。`canOpenNativePath()` 报告当前 Host 是否可能具备桌面目标。 +`openNativePath(path, signal)` 将路径交给默认应用;平台能够确定默认浏览器时,HTML 与 SVG 会优先交给该浏览器。`openNativeAssociatedPath(path, signal)` 始终使用文件类型关联,不覆盖为浏览器。`openNativeTextFile(path, signal)` 选择文本编辑器意图;macOS 使用 `open -t`。Windows 将每种意图都交给 Explorer,由 shell 自身的默认应用解析(即双击所走的那条)选择应用;Explorer 收到的是编码后的文件 URI,其退出码 1 按已转交请求处理,取消、找不到可执行文件和其他退出码仍然报错。WSL 路径先通过 `wslpath -w` 转换,再交给 Windows 桌面。`canOpenNativePath()` 报告当前 Host 是否可能具备桌面目标。 -`revealNativePath(path, signal)` 在 Finder 或文件资源管理器中选中文件,包含 WSL 路径转换;在桌面 Linux 上通过 `xdg-open` 打开上层目录。`nativeFileManager()` 标识该操作,供 UI 根据 Host 选择文案;桌面是否可用仍由独立的 `canOpenNativePath()` 检查决定。调用方必须先授权绝对文件路径,再执行操作。平台分派由注入运行器的测试覆盖;原生桌面验证由对应平台负责。 Explorer 接收独立参数中的编码文件 URI。退出码 1 按已转交请求处理;取消、找不到可执行文件和其他退出码仍然报错。该确认不能证明桌面窗口已选中文件。 +`revealNativePath(path, signal)` 在 Finder 或文件资源管理器中选中文件,包含 WSL 路径转换;在桌面 Linux 上通过 `xdg-open` 打开上层目录。`nativeFileManager()` 标识该操作,供 UI 根据 Host 选择文案;桌面是否可用仍由独立的 `canOpenNativePath()` 检查决定。调用方必须先授权绝对文件路径,再执行操作。平台分派由注入运行器的测试覆盖;原生桌面验证由对应平台负责。 Explorer 接收独立参数中的编码文件 URI。退出码 1 按已转交请求处理;取消、找不到可执行文件和其他退出码仍然报错。该确认不能证明应用已打开文件,也不能证明桌面窗口已选中文件。 `nativeFileApplications(path, signal)` 返回关联应用、本地化名称、图标和当前默认项。macOS 12 及以上版本使用 LaunchServices,Windows 使用 Shell 关联处理器,Linux 使用 GIO,并共用 XDG 桌面文件和图标读取逻辑。macOS 上 bundle 标识符与显示名都相同的多份拷贝(自更新暂存副本、按版本安装的拷贝)合并为系统默认项,否则保留最高版本;显示名不同的并存安装两项都保留。`openNativeFileApplication(path, application, signal)` 按完整的当前注册列表重新验证关联应用,展示列表中被合并掉的拷贝仍可打开,且不修改系统默认值。Windows 交给 Shell 启动应用,Linux 交给 `gio launch` 展开参数。WSL 转换路径后使用 Windows 适配器。调用方负责验证本地文件路径。原生集成测试分别在相应平台使用独立的 Windows 文件关联和 Linux XDG 目录。 diff --git a/packages/util/native-command/src/path-opener.ts b/packages/util/native-command/src/path-opener.ts index a84da5cb65..e4341f8567 100644 --- a/packages/util/native-command/src/path-opener.ts +++ b/packages/util/native-command/src/path-opener.ts @@ -97,13 +97,28 @@ function isWsl(internals: PathOpenerInternals): boolean { return (internals.osRelease ?? osRelease()).toLowerCase().includes('microsoft') } +/** + * Encode one Windows path as the target Explorer can receive intact. + * + * Explorer parses its own command line and treats commas as field separators, + * so a path handed over raw loses everything after its first comma and the + * shell opens a different target without reporting it. A file URI has `,` + * percent-encoded and keeps whitespace, and Explorer resolves it to the shell + * item the path names. + * @param windowsPath - path already translated for the Windows desktop. + * @returns the target for an open, or the object of a `/select,` reveal. + */ +function explorerTarget(windowsPath: string): string { + return pathToFileURL(windowsPath, { windows: true }).href.replaceAll(',', '%2C') +} + /** * Hand one target to Explorer, accepting its delegated-handoff exit code. * * Explorer exits 1 after handing the request to the desktop process already * running, so exit 1 means the shell took it. Every other failure still * rejects, and cancellation wins over a delegate's exit 1. - * @param args - Explorer argv: the target path alone to open it, or `/select,` to reveal it. + * @param args - Explorer argv: the encoded target alone to open it, or `/select,` to reveal it. * @param signal - caller lifetime; abort terminates the command. * @param run - shell-free command runner. * @throws The runner's failure unless it is Explorer's delegate exit 1. @@ -121,12 +136,12 @@ async function runExplorer(args: readonly string[], signal: AbortSignal, run: Pa /** * Open one Windows-resolvable path through Explorer, the shell that owns the * default-application resolution a double-click uses. - * @param path - Windows-resolvable path; passed as one argv element, never a command string. + * @param path - Windows-resolvable path; Explorer receives its encoded file URI as one argv element, never a command string. * @param signal - caller lifetime; abort terminates the command. * @param run - shell-free command runner. */ async function openWindowsPath(path: string, signal: AbortSignal, run: PathOpenerRunner): Promise { - await runExplorer([path], signal, run) + await runExplorer([explorerTarget(path)], signal, run) } /** Translate a WSL path before handing it to the Windows desktop. */ @@ -278,9 +293,7 @@ export async function revealNativePath( windowsPath = translated.stdout.replace(/[\r\n]+$/, '') if (windowsPath === '') throw new Error('wslpath returned no Windows path') } - // Explorer parses commas itself; a file URI preserves commas and whitespace in the path. - const target = pathToFileURL(windowsPath, { windows: true }).href.replaceAll(',', '%2C') - await runExplorer(['/select,', target], signal, run) + await runExplorer(['/select,', explorerTarget(windowsPath)], signal, run) return } if (manager === 'directory') { diff --git a/packages/util/native-command/tests/path-opener.spec.ts b/packages/util/native-command/tests/path-opener.spec.ts index e5821345e5..9be8ae01e8 100644 --- a/packages/util/native-command/tests/path-opener.spec.ts +++ b/packages/util/native-command/tests/path-opener.spec.ts @@ -56,7 +56,7 @@ describe('native path opener', () => { }) expect(run.mock.calls).toEqual([ ['wslpath', ['-w', '/home/test user/settings.yaml'], requestSignal], - ['explorer.exe', ['\\\\wsl.localhost\\Ubuntu\\home\\test user\\settings.yaml'], requestSignal], + ['explorer.exe', ['file://wsl.localhost/Ubuntu/home/test%20user/settings.yaml'], requestSignal], ]) }) @@ -80,14 +80,25 @@ describe('native path opener', () => { expect(run).toHaveBeenCalledOnce() }) - it('hands Windows one argv path with no command string to escape', async () => { + it('encodes the target Explorer parses so a comma stays inside the path', async () => { const run = vi.fn(async () => ({ stdout: '', stderr: '' })) - // Quotes, commas, a percent, and an ampersand reach Explorer as a single - // argument: there is no PowerShell literal and no shell to interpret them. + // Explorer parses its own command line and splits it at commas, so the path + // crosses as a file URI with the comma percent-encoded. The apostrophe, + // ampersand, space, and percent need no escaping: no shell interprets them. await openNativePath("C:\\work\\o'reilly, & 100%.txt", signal(), { platform: 'win32', run }) expect(run).toHaveBeenCalledWith( 'explorer.exe', - ["C:\\work\\o'reilly, & 100%.txt"], + ["file:///C:/work/o'reilly%2C%20&%20100%25.txt"], + expect.any(AbortSignal), + ) + }) + + it('encodes a directory target the same way it encodes a file target', async () => { + const run = vi.fn(async () => ({ stdout: '', stderr: '' })) + await openNativeAssociatedPath('C:\\work\\dir,comma', signal(), { platform: 'win32', run }) + expect(run).toHaveBeenCalledWith( + 'explorer.exe', + ['file:///C:/work/dir%2Ccomma'], expect.any(AbortSignal), ) }) @@ -97,7 +108,7 @@ describe('native path opener', () => { await openNativeTextFile('C:\\work\\settings.yaml', signal(), { platform: 'win32', run }) expect(run).toHaveBeenCalledWith( 'explorer.exe', - ['C:\\work\\settings.yaml'], + ['file:///C:/work/settings.yaml'], expect.any(AbortSignal), ) }) @@ -273,7 +284,7 @@ describe('browser-renderable documents', () => { }) expect(calls).toEqual([ ['wslpath', '-w', '/home/test/page.html'], - ['explorer.exe', 'C:\\workspace\\page.html'], + ['explorer.exe', 'file:///C:/workspace/page.html'], ]) }) }) From 843e1b97af5c9eb016623aa41e0896ea129ddacc Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 22 Sep 2026 14:27:00 +0800 Subject: [PATCH 022/192] fix(agent-team): scope projection reads and address review feedback --- .../feature/2026-08-05-agent-teams.i18n.yaml | 4 +- .../feature/2026-08-05-agent-teams.md | 6 +- .../feature/2026-08-05-agent-teams.zh.md | 6 +- docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 4 +- docs/capability-seams.zh.md | 4 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 20 +-- docs/persistence-catalog.zh.md | 20 +-- docs/persistence-schema.json | 12 +- docs/subsystems/agent-team.i18n.yaml | 4 +- docs/subsystems/agent-team.md | 61 ++++++-- docs/subsystems/agent-team.zh.md | 61 ++++++-- .../experimental/agent-team/README.i18n.yaml | 4 +- packages/experimental/agent-team/README.md | 7 +- packages/experimental/agent-team/README.zh.md | 7 +- packages/experimental/agent-team/package.json | 20 +-- .../experimental/agent-team/src/client.ts | 1 - packages/experimental/agent-team/src/index.ts | 19 +-- .../experimental/agent-team/src/projection.ts | 2 + packages/experimental/agent-team/src/types.ts | 6 - .../agent-team/tests/built-lib.e2e.ts | 10 +- .../agent-team/tests/team.spec.ts | 18 --- .../experimental/agent-team/tsconfig.json | 1 - .../client-ui-agent-team/README.i18n.yaml | 4 +- .../client-ui-agent-team/README.md | 6 +- .../client-ui-agent-team/README.zh.md | 6 +- .../src/client/TeamAction.tsx | 147 ++++++++++++------ .../src/client/locales.ts | 2 + .../client-ui-agent-team/src/client/mount.ts | 4 +- .../tests/team-action.client.spec.tsx | 117 +++++++++++++- .../extensions/tool-cordis/src/api-catalog.ts | 10 -- pnpm-lock.yaml | 3 - scripts/gen-cordis-catalog.ts | 3 +- scripts/gen-doc-graphs.ts | 4 +- scripts/type-equiv.manifest.json | 15 ++ 39 files changed, 413 insertions(+), 221 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml index f2228c4045..5a98fcfa4d 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-agent-teams.md -2026-08-05-agent-teams.md: 29b53ee5057c44b9ca4d959cf865e4b989b4070e -2026-08-05-agent-teams.zh.md: e41812e25b41c96cf92cc2ee876ebc0d6c566bcd +2026-08-05-agent-teams.md: 073b0ac745e5bb171987ff11b4bfba0a50a8087a +2026-08-05-agent-teams.zh.md: 0fbb477429d10fbfbcbfb5437e977f597f3ce4c2 diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md index 29b53ee505..073b0ac745 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md @@ -56,7 +56,11 @@ Worktree isolation is not a harness runtime behavior. A deployment or prompt may ## Web projection -The Web panel reads the Lead Session's `agentTeam` wire projection instead of polling a Remote read, because the projection registry already broadcasts one Host-wide frame per changed client view to every connected browser, and the subagent catalog panel proves that path needs no refresh control. The wire view carries durable facts only: roster rows with their durable phase, non-deleted task views with the same owner-name, readiness, and write-overlap derivation the task board returns to agents, and the projection `failure` beside the last valid state so a rejected persisted record is visible instead of appearing as a stale but healthy board. Live turn activity and the model stay out of the view because they are process-local; the browser overlays Session status and the member Session's `modelSelection` projection, and shows no model before a durable selection or request exists. Publication requires the projection `apply` to return a new state object per applied Team event and to replace only the touched collection, so mailbox-only events keep the previous view reference and publish nothing. The `agentTeams/view` Remote method remains for callers that need live availability and the configured model. +The Web panel reads the Lead Session's `agentTeam` wire projection because the existing projection stream keeps browser state current without a separate Team read API. Current-session values use the standard `useProjection` hook; a teammate conversation reads its Lead through the shared cross-Session store. + +The wire view carries durable roster phases, non-deleted task views with shared readiness and overlap derivation, and `failure` beside the last valid state. Live activity comes from Session status. Model selection has its own durable projection; opening the panel requests other active members' baselines without opening their conversations, and no model is shown before a durable selection or request exists. + +Publication requires `apply` to return a new state object per applied Team event and replace only the touched collection. Mailbox-only events retain the view reference and publish nothing. The existing carrier sends each changed complete view to every connected browser; the package README records that cost. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md index e41812e25b..0fbb477429 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md @@ -56,7 +56,11 @@ Worktree isolation 不是 harness runtime 行为。deployment 或 prompt 可以 ## Web projection -Web panel 读取 Lead Session 的 `agentTeam` wire projection 而不是轮询 Remote 读取,因为 projection registry 已经向每个已连接浏览器广播每次客户端视图变化的一帧 Host 级 frame,而 subagent catalog panel 证明这条路径不需要刷新控件。wire 视图只承载持久事实:带持久 phase 的 roster row、与任务板返回给 agent 相同的 owner 名称、就绪状态与写入范围重叠派生的未删除任务视图,以及与最后有效状态并列的 projection `failure`,使被拒绝的持久记录可见而不是表现为陈旧但健康的任务板。实时轮次活动与模型不进入视图,因为它们是进程本地的;浏览器叠加 Session 状态与成员 Session 的 `modelSelection` projection,并在持久选择或请求存在之前不显示模型。发布要求 projection `apply` 对每个已应用的 Team 事件返回新的 state 对象并只替换被触及的集合,因此仅邮箱事件保留上一个视图引用且不发布。`agentTeams/view` Remote method 保留给需要实时可用性与配置模型的调用方。 +Web panel 读取 Lead Session 的 `agentTeam` 传输投影,因为现有投影流能持续更新浏览器状态,无需独立的 Team 读取 API。当前会话的值使用标准 `useProjection` 钩子;teammate 会话通过共享的跨 Session store 读取其 Lead。 + +传输视图携带持久 roster phase、使用共享就绪与重叠派生逻辑的未删除任务视图,以及最后有效状态旁的 `failure`。实时活动来自 Session 状态。模型选择有自己的持久投影;打开 panel 时请求其他 active 成员的基线,无需打开它们的会话,在持久选择或请求出现前不显示模型。 + +发布要求 `apply` 对每个已应用的 Team 事件返回新的 state 对象,并只替换被触及的集合。仅邮箱的事件保留视图引用,不发布 frame。现有传输层将每个变化后的完整视图发送给所有已连接浏览器;包 README 记录了这项开销。 ## Alternatives considered diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 6e360392ad..581ef4950d 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: aac4813e5c375af311a36c10820909f85641ca4e -capability-seams.zh.md: f0e584f92658efb7cb34601aeb88494aa8085293 +capability-seams.md: b5d763a019ed4a67e975928410a20e7e5464619e +capability-seams.zh.md: 2e183905bbb949e3cca88aa6da3469bcd73c0bcb diff --git a/docs/capability-seams.md b/docs/capability-seams.md index aac4813e5c..b5d763a019 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -230,7 +230,6 @@ flowchart LR pkg_experimental_agent_team["experimental-agent-team"] svc_agentTeams["ctx.agentTeams
Agent Teams coordination domain"] pkg_experimental_tool_agent_team["experimental-tool-agent-team"] - pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"] pkg_inspector["inspector"] svc_inspector["ctx.inspector
Cross-realm runtime inspection"] pkg_jobs["jobs"] @@ -416,7 +415,6 @@ flowchart LR svc_agentDefaultModel --> pkg_headless svc_agentLoop --> pkg_base svc_agentLoop --> pkg_sdk_minimal - svc_agentTeams --> pkg_experimental_client_ui_agent_team svc_agentTeams --> pkg_experimental_tool_agent_team svc_agents --> pkg_acp svc_agents --> pkg_agent_loop @@ -638,7 +636,7 @@ flowchart LR | `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; there is no model-facing compact tool. | | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | | `ctx.speechToText` | `seam` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text) | - | Routes explicit recognizers; the browser uses the authenticated Remote and keeps transcripts in the draft until submission. | -| `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | Owns the implicit-root roster, durable peer mailbox, shared task DAG, continuable-child lifecycle, and generated Team Remote methods; tool-agent-team contributes model controls and client-ui-agent-team mounts the browser contribution. | +| `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | - | Owns the implicit-root roster, durable peer mailbox, shared task DAG, and continuable-child lifecycle; tool-agent-team contributes model controls. | | `ctx.inspector` | `core` | `inspector` | - | - | - | Owns the Worker-hosted CDP target and the transport-independent Host and Client observation and Cordis-tree query API. | | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`api-job-controller`](../packages/api/job-controller) | - | Producers (background bash/pwsh, PTY sends, and subagent delegations) register running work; record-declaring jobs additionally stream raw output for non-consuming observers; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index f0e584f926..2e183905bb 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -232,7 +232,6 @@ flowchart LR pkg_experimental_agent_team["experimental-agent-team"] svc_agentTeams["ctx.agentTeams
Agent Teams coordination domain"] pkg_experimental_tool_agent_team["experimental-tool-agent-team"] - pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"] pkg_inspector["inspector"] svc_inspector["ctx.inspector
Cross-realm runtime inspection"] pkg_jobs["jobs"] @@ -418,7 +417,6 @@ flowchart LR svc_agentDefaultModel --> pkg_headless svc_agentLoop --> pkg_base svc_agentLoop --> pkg_sdk_minimal - svc_agentTeams --> pkg_experimental_client_ui_agent_team svc_agentTeams --> pkg_experimental_tool_agent_team svc_agents --> pkg_acp svc_agents --> pkg_agent_loop @@ -640,7 +638,7 @@ flowchart LR | `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 基础后端消费步骤后的压力事件和请求错误恢复事件;不存在面向模型的压缩工具。 | | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | 提供方实现传输;该服务还负责可选的、基于 Activation 的延续编排,tool-subagent 选择一次性或可延续委派,tool-subagent-control 传递后续消息,而 tool-ralph 要求一条全新的结构化输出路由。 | | `ctx.speechToText` | `seam` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text) | - | 路由显式选择的识别器;浏览器使用带认证的 Remote,并在提交前将转写保留在草稿中。 | -| `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | 负责隐式 Root roster、持久 peer mailbox、共享任务 DAG、continuable child 生命周期与生成式 Team Remote method;tool-agent-team 提供模型控制工具,client-ui-agent-team 挂载浏览器 contribution。 | +| `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | - | 负责隐式 Root roster、持久 peer mailbox、共享任务 DAG 与 continuable child 生命周期;tool-agent-team 提供模型控制工具。 | | `ctx.inspector` | `core` | `inspector` | - | - | - | 负责 Worker 托管的 CDP target,以及独立于传输的 Host 和 Client observation 与 Cordis tree query API。 | | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`api-job-controller`](../packages/api/job-controller) | - | 生产方(后台 bash/pwsh、PTY 发送和 subagent 委派)登记正在运行的工作;声明 record 的 job 还为非消费观察者流式提供原始输出;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 64f34ae432..0dd1410723 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 5075b3ee2937d9fc4ad9c6bc6e67ecd6f7e17cad -config-catalog.zh.md: 9a464c285e1388c41348ce09925e84b7fe1d7324 +config-catalog.md: dd83fe4253ee42f0179c216583df76234c588049 +config-catalog.zh.md: 8bf242b33fc473db6e9cf0645ad908baf24d86a8 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5075b3ee29..dd83fe4253 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -742,7 +742,7 @@ export interface Config { } ``` -Source: [`packages/experimental/agent-team/src/types.ts:161`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:155`](../packages/experimental/agent-team/src/types.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 9a464c285e..8bf242b33f 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -744,7 +744,7 @@ export interface Config { } ``` -来源: [`packages/experimental/agent-team/src/types.ts:161`](../packages/experimental/agent-team/src/types.ts) +来源: [`packages/experimental/agent-team/src/types.ts:155`](../packages/experimental/agent-team/src/types.ts) diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index d55c8e1360..daebfcaf5e 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: 8f07cc9512433704a63f30c5ae1aee9b08ade44e -persistence-catalog.zh.md: 4fd589d3243057104bc36cfd5c5acb66f30cd059 +persistence-catalog.md: 7f7fe1521a5fa23b8fce28fba6d15be586feec6c +persistence-catalog.zh.md: 7e6b90d79d07745bcff2aab50bc02c2147f9cda4 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 8f07cc9512..7f7fe1521a 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -975,7 +975,7 @@ Source: [`packages/core/session/src/types.ts:330`](../packages/core/session/src/ Types: [TeamId](subsystems/agent-team.md) · [TeamMemberSnapshot](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:241`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:235`](../packages/experimental/agent-team/src/types.ts) @@ -993,7 +993,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:241`](../packages/experi Types: [TeamId](subsystems/agent-team.md) · [TeamMessageId](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:247`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:241`](../packages/experimental/agent-team/src/types.ts) @@ -1006,7 +1006,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:247`](../packages/experi Types: [TeamId](subsystems/agent-team.md) · [TeamMessageSnapshot](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:245`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:239`](../packages/experimental/agent-team/src/types.ts) @@ -1019,7 +1019,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:245`](../packages/experi Types: [TeamId](subsystems/agent-team.md) · [TeamTaskSnapshot](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:243`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:237`](../packages/experimental/agent-team/src/types.ts) ### `todo/*` @@ -4713,7 +4713,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:47`](../packages/experi SHA-256: `2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e` -Sources: [`packages/experimental/agent-team/src/types.ts:137`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:131`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4733,7 +4733,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:137`](../packages/exper SHA-256: `2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0` -Sources: [`packages/experimental/agent-team/src/types.ts:146`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:140`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7373,7 +7373,7 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:61`](../packages/web SHA-256: `b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1` -Sources: [`packages/experimental/agent-team/src/types.ts:241`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:235`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7401,7 +7401,7 @@ Sources: [`packages/core/session/src/types.ts:330`](../packages/core/session/src SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403` -Sources: [`packages/experimental/agent-team/src/types.ts:245`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:239`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7415,7 +7415,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:245`](../packages/exper SHA-256: `0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1` -Sources: [`packages/experimental/agent-team/src/types.ts:247`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:241`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7561,7 +7561,7 @@ Sources: [`packages/compaction/compaction-image-offload/src/projection.ts:25`](. SHA-256: `a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c` -Sources: [`packages/experimental/agent-team/src/types.ts:243`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:237`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 4fd589d324..7e6b90d79d 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -977,7 +977,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMemberSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:241`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:235`](../packages/experimental/agent-team/src/types.ts) @@ -995,7 +995,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMessageId](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:247`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:241`](../packages/experimental/agent-team/src/types.ts) @@ -1008,7 +1008,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMessageSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:245`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:239`](../packages/experimental/agent-team/src/types.ts) @@ -1021,7 +1021,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamTaskSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:243`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:237`](../packages/experimental/agent-team/src/types.ts) ### `todo/*` @@ -4715,7 +4715,7 @@ SHA-256: `6598cd6da253948d6b207c6374a45335804c79b576866d6215649c2d1bc85e25` SHA-256: `2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e` -来源:[`packages/experimental/agent-team/src/types.ts:137`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:131`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4735,7 +4735,7 @@ SHA-256: `2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e` SHA-256: `2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0` -来源:[`packages/experimental/agent-team/src/types.ts:146`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:140`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7375,7 +7375,7 @@ SHA-256: `930a6567a10bb62ddd157bd8abdf4b182810c8c3b49d91309b3b009a5fed9731` SHA-256: `b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1` -来源:[`packages/experimental/agent-team/src/types.ts:241`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:235`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7403,7 +7403,7 @@ SHA-256: `6bc8e51124d0874bc89e1d0ceff381a9c3cd0fc240667d69f841ce4f60f65fe5` SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403` -来源:[`packages/experimental/agent-team/src/types.ts:245`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:239`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7417,7 +7417,7 @@ SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403` SHA-256: `0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1` -来源:[`packages/experimental/agent-team/src/types.ts:247`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:241`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7563,7 +7563,7 @@ SHA-256: `477614bca595a9e7adb5a1870be446b9a8684f3ba8950259d6289f2d3b74f028` SHA-256: `a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c` -来源:[`packages/experimental/agent-team/src/types.ts:243`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:237`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index e309173b34..acbf7f3344 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -22575,7 +22575,7 @@ }, "names": [], "sources": [ - "packages/experimental/agent-team/src/types.ts:247" + "packages/experimental/agent-team/src/types.ts:241" ] }, { @@ -31770,7 +31770,7 @@ "packages/experimental/agent-team/src/types.ts#TeamMessageSource" ], "sources": [ - "packages/experimental/agent-team/src/types.ts:146" + "packages/experimental/agent-team/src/types.ts:140" ] }, { @@ -33739,7 +33739,7 @@ "packages/experimental/agent-team/src/types.ts#TeamMessageSnapshot" ], "sources": [ - "packages/experimental/agent-team/src/types.ts:137" + "packages/experimental/agent-team/src/types.ts:131" ] }, { @@ -69181,7 +69181,7 @@ }, "names": [], "sources": [ - "packages/experimental/agent-team/src/types.ts:243" + "packages/experimental/agent-team/src/types.ts:237" ] }, { @@ -71163,7 +71163,7 @@ }, "names": [], "sources": [ - "packages/experimental/agent-team/src/types.ts:241" + "packages/experimental/agent-team/src/types.ts:235" ] }, { @@ -83593,7 +83593,7 @@ }, "names": [], "sources": [ - "packages/experimental/agent-team/src/types.ts:245" + "packages/experimental/agent-team/src/types.ts:239" ] }, { diff --git a/docs/subsystems/agent-team.i18n.yaml b/docs/subsystems/agent-team.i18n.yaml index 6a04e1377d..4551177af7 100644 --- a/docs/subsystems/agent-team.i18n.yaml +++ b/docs/subsystems/agent-team.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/agent-team.md -agent-team.md: 2f49e164645e6b66bd0da4946c477a09c412ddba -agent-team.zh.md: 24d7d73fdace11ad5e00eb3fb4c90a4abde33810 +agent-team.md: dc86afa2d682996eed0d11be42f91736e3675ae5 +agent-team.zh.md: 1ed2164ffbb7cf35d7e8d508e7cde2ca774e7496 diff --git a/docs/subsystems/agent-team.md b/docs/subsystems/agent-team.md index 2f49e16464..dc86afa2d6 100644 --- a/docs/subsystems/agent-team.md +++ b/docs/subsystems/agent-team.md @@ -2,7 +2,7 @@ English | [中文](agent-team.zh.md) -Types shared by the experimental implicit-root Team domain, model tools, and host adapters. The [Agent Teams Agent Note](../../.agents/notes/implemented/feature/2026-08-05-agent-teams.md) owns identity, mailbox, task, and shared-checkout decisions; this page records the literal durable forms from [`packages/experimental/agent-team/src/types.ts`](../../packages/experimental/agent-team/src/types.ts). +Types shared by the experimental implicit-root Team domain, model tools, and host adapters. The [Agent Teams Agent Note](../../.agents/notes/implemented/feature/2026-08-05-agent-teams.md) owns identity, mailbox, task, and shared-checkout decisions; this page records the durable and client-visible forms from [`packages/experimental/agent-team/src/types.ts`](../../packages/experimental/agent-team/src/types.ts). ## Identity and roster @@ -73,9 +73,59 @@ interface TeamTaskSnapshot { `pending` is unstarted or released, `in_progress` carries an owner, `completed` satisfies blockers, and `deleted` is a retained tombstone. Views add owner name, readiness, and write-scope overlap warnings without changing the durable snapshot. + + +## Web projection + +The Lead Session publishes `SessionProjectionMap.agentTeam` with durable roster rows and non-deleted task views. `failure` reports a rejected persisted record beside the last valid state. Member activity comes from Session status; model labels come from each member's `modelSelection` projection. + +```ts type-equiv +/** One durable roster row published through the `agentTeam` Session projection. */ +interface TeamMemberProjection { + readonly id: SessionId + readonly name: string + readonly role: 'lead' | 'teammate' + /** Durable lifecycle; the Lead row is always `active`. Turn activity comes from Session status. */ + readonly phase: TeamMemberPhase + readonly description?: string + readonly provider?: string + readonly context?: 'fresh' | 'fork' + readonly error?: string +} +``` + +```ts type-equiv +/** Runtime-enriched task view returned to tools and hosts. */ +interface TeamTaskView { + readonly id: TeamTaskId + readonly revision: number + readonly subject: string + readonly description: string + readonly status: TeamTaskStatus + readonly blockedBy: TeamTaskId[] + readonly writeScopes: string[] + readonly ownerName?: string + readonly ready: boolean + readonly writeScopeWarnings: string[] +} +``` + +```ts type-equiv +/** + * Durable Team state published to browser clients through the Lead Session's + * `agentTeam` projection. `failure` names the first rejected persisted Team + * record; members and tasks then stay at the last valid state. + */ +interface TeamProjection { + readonly members: TeamMemberProjection[] + readonly tasks: TeamTaskView[] + readonly failure?: string +} +``` + ## Replay -`foldTeam()` replays one root Session into the roster, task board, and queued-minus-delivered mailbox that every Team operation reads. It selects records by `TeamId`, so events inherited by an ordinary fork retain the ancestor id and never enter the new root's state. Session event `seq` and `time` remain the ordering and timing record; Team snapshots do not duplicate them. Roster and task reads reach callers as views; pending mail stays internal to delivery and recovery. The package [README](../../packages/experimental/agent-team/README.md) owns operation, authorization, recovery, and limit behavior. +The `agentTeam` Session projection replays one root Session into the roster, task board, and queued-minus-delivered mailbox that every Team operation reads. It selects records by `TeamId`, so events inherited by an ordinary fork retain the ancestor id and never enter the new root's state. Session event `seq` and `time` remain the ordering and timing record; Team snapshots do not duplicate them. Roster and task reads reach callers as views; pending mail stays internal to delivery and recovery. The package [README](../../packages/experimental/agent-team/README.md) owns operation, authorization, recovery, and limit behavior. @@ -176,13 +226,6 @@ interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'ina * @returns Team membership, or undefined for non-Team subagents and stale identities. */ tryMembership(agent: Agent): TeamMembership | undefined - -/** - * Read the current roster and non-deleted task board through the generated Remote API. - * @param agent - exact live Team member used as the authority credential. - * @returns detached current roster and task views. - */ -@Remote('view') remoteView(agent: Agent): TeamView ``` Types: [Agent](core.md) diff --git a/docs/subsystems/agent-team.zh.md b/docs/subsystems/agent-team.zh.md index 24d7d73fda..1ed2164ffb 100644 --- a/docs/subsystems/agent-team.zh.md +++ b/docs/subsystems/agent-team.zh.md @@ -2,7 +2,7 @@ [English](agent-team.md) | 中文 -实验性隐式 Root Team 领域、模型工具与宿主适配器共享的类型。[Agent Teams Agent Note](../../.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md)负责身份、mailbox、task 与共享 checkout 决策;本页记录 [`packages/experimental/agent-team/src/types.ts`](../../packages/experimental/agent-team/src/types.ts) 中的字面持久形式。 +实验性隐式 Root Team 领域、模型工具与宿主适配器共享的类型。[Agent Teams Agent Note](../../.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md)负责身份、mailbox、task 与共享 checkout 决策;本页记录 [`packages/experimental/agent-team/src/types.ts`](../../packages/experimental/agent-team/src/types.ts) 中的持久与客户端可见形式。 ## 身份与 roster @@ -73,9 +73,59 @@ interface TeamTaskSnapshot { `pending` 表示尚未开始或已经释放,`in_progress` 携带 owner,`completed` 满足 blocker,`deleted` 是保留的 tombstone。view 会添加 owner name、readiness 和 write-scope 重叠警告,但不会改变持久快照。 + + +## Web 投影 + +Lead Session 通过 `SessionProjectionMap.agentTeam` 发布持久 roster 行与未删除任务视图。`failure` 在最后有效状态旁报告被拒绝的持久记录。成员活动来自 Session 状态;模型标签来自各成员的 `modelSelection` 投影。 + +```ts type-equiv +/** One durable roster row published through the `agentTeam` Session projection. */ +interface TeamMemberProjection { + readonly id: SessionId + readonly name: string + readonly role: 'lead' | 'teammate' + /** Durable lifecycle; the Lead row is always `active`. Turn activity comes from Session status. */ + readonly phase: TeamMemberPhase + readonly description?: string + readonly provider?: string + readonly context?: 'fresh' | 'fork' + readonly error?: string +} +``` + +```ts type-equiv +/** Runtime-enriched task view returned to tools and hosts. */ +interface TeamTaskView { + readonly id: TeamTaskId + readonly revision: number + readonly subject: string + readonly description: string + readonly status: TeamTaskStatus + readonly blockedBy: TeamTaskId[] + readonly writeScopes: string[] + readonly ownerName?: string + readonly ready: boolean + readonly writeScopeWarnings: string[] +} +``` + +```ts type-equiv +/** + * Durable Team state published to browser clients through the Lead Session's + * `agentTeam` projection. `failure` names the first rejected persisted Team + * record; members and tasks then stay at the last valid state. + */ +interface TeamProjection { + readonly members: TeamMemberProjection[] + readonly tasks: TeamTaskView[] + readonly failure?: string +} +``` + ## 回放 -`foldTeam()` 把一个 Root Session 回放成每个 Team 操作所读取的 roster、任务板与 queued-minus-delivered mailbox。它按 `TeamId` 选取记录,因此普通 fork 继承的 event 保留 ancestor id,绝不会进入新 Root 的状态。Session event 的 `seq` 与 `time` 继续负责顺序和时间记录,Team snapshot 不再重复保存它们。roster 与 task 读取以 view 形式到达调用方,而 pending 邮件仅供投递与恢复内部使用。包 [README](../../packages/experimental/agent-team/README.zh.md)负责 operation、authorization、recovery 和限制行为。 +`agentTeam` Session 投影把一个 Root Session 回放成每个 Team 操作所读取的 roster、任务板与 queued-minus-delivered mailbox。它按 `TeamId` 选取记录,因此普通 fork 继承的 event 保留 ancestor id,绝不会进入新 Root 的状态。Session event 的 `seq` 与 `time` 继续负责顺序和时间记录,Team snapshot 不再重复保存它们。roster 与 task 读取以 view 形式到达调用方,而 pending 邮件仅供投递与恢复内部使用。包 [README](../../packages/experimental/agent-team/README.zh.md)负责 operation、authorization、recovery 和限制行为。 @@ -176,13 +226,6 @@ interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'ina * @returns Team membership, or undefined for non-Team subagents and stale identities. */ tryMembership(agent: Agent): TeamMembership | undefined - -/** - * Read the current roster and non-deleted task board through the generated Remote API. - * @param agent - exact live Team member used as the authority credential. - * @returns detached current roster and task views. - */ -@Remote('view') remoteView(agent: Agent): TeamView ``` Types: [Agent](core.zh.md) diff --git a/packages/experimental/agent-team/README.i18n.yaml b/packages/experimental/agent-team/README.i18n.yaml index 00326bcff4..4b7b8f5ec1 100644 --- a/packages/experimental/agent-team/README.i18n.yaml +++ b/packages/experimental/agent-team/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/agent-team/README.md -README.md: cc2a7a546715d88c5fc7bacf103fbde2e51befc5 -README.zh.md: 321200cf7395277933b00b62f370e2b14629720d +README.md: 9c0a351cf6017bb026b864c643a2ebeaa19ccbdc +README.zh.md: 44a3ade10100d7d44d9ed9735cedfc75684fa2ae diff --git a/packages/experimental/agent-team/README.md b/packages/experimental/agent-team/README.md index cc2a7a5467..9c0a351cf6 100644 --- a/packages/experimental/agent-team/README.md +++ b/packages/experimental/agent-team/README.md @@ -172,9 +172,11 @@ Read these pages when the package-level contract is not enough. They move from t -### Browser projection and Remote +### Browser projection -The `agentTeam` Session projection publishes a durable client view of the Lead Session: the roster with each member's durable phase, the non-deleted task board with owner names, readiness, and write-scope overlap warnings, and `failure` when a persisted Team record was rejected, after which the roster and tasks stay at the last valid state. Every applied Team event replaces the projection state object and only the collection it touched, so the projection registry publishes one Host-wide frame per roster or task change and none for mailbox-only changes. Live turn activity and the selected model are not part of the view; the Web UI overlays them from Session status and the member Session's `modelSelection` projection. `TeamService` also exposes the read-only `agentTeams/view` Remote method, which adds live availability and the configured model; task creation and updates belong to Team agents through the service and model tools. The `./remote` export supplies the generated Client contribution, and `./client` exports the browser-safe roster, task, and projection types. +The `agentTeam` Session projection publishes the Lead Session's durable roster, non-deleted task views, and any `failure` beside the last valid state. Its `apply` replaces only the touched collection; mailbox-only changes retain the client view reference and produce no frame. The [subsystem reference](../../../docs/subsystems/agent-team.md#web-projection) defines the wire types. + +The Web UI reads current-session values through the standard projection hook and overlays activity from Session status. The current Session supplies its baseline through conversation loading; other active members' baselines are loaded for durable model selection without opening their conversations. Task creation and updates belong to Team agents through the service and model tools. The `./client` export supplies browser-safe roster, task, and projection types. ## Model Experience @@ -199,6 +201,7 @@ Peer messages append after the target's reusable history prefix. Cold resume reu These limits describe what a team cannot do yet or what needs special operational care. They are current package constraints, not a comparison with other coordination mechanisms. +- **Whole-view broadcasts** — each roster or task change sends the complete roster and non-deleted task board, including descriptions, to every connected browser, even when it is viewing another Session. - **Experimental prototype with no stability promise** — the package is public, but its contracts can change freely while it incubates. - **One process and one shared checkout** — members share cwd and observe edits immediately; this package provides no worktree, remote member, merge, or filesystem lock. - **Advisory write scopes** — Bash, formatters, code generators, and direct external writers can bypass filesystem version checks; Leads must coordinate ownership and review the final diff. diff --git a/packages/experimental/agent-team/README.zh.md b/packages/experimental/agent-team/README.zh.md index 321200cf73..44a3ade101 100644 --- a/packages/experimental/agent-team/README.zh.md +++ b/packages/experimental/agent-team/README.zh.md @@ -172,9 +172,11 @@ dispose 会关闭准入、中止并等待已获准的创建与 mailbox dispatch -### 浏览器投影与 Remote +### 浏览器投影 -`agentTeam` Session 投影发布 Lead Session 的持久客户端视图:带每个成员持久 phase 的 roster、带 owner 名称、就绪状态与写入范围重叠警告的未删除任务板,以及在某条持久 Team 记录被拒绝时的 `failure`,此后 roster 与任务停留在最后有效状态。每个已应用的 Team 事件都会替换投影 state 对象,且只替换它触及的集合,因此投影注册表对每次 roster 或任务变化发布一帧 Host 级 frame,对仅邮箱的变化不发布。实时轮次活动与所选模型不在视图内;Web UI 从 Session 状态和成员 Session 的 `modelSelection` 投影叠加它们。`TeamService` 同时公开只读的 `agentTeams/view` Remote method,它额外提供实时可用性与配置的模型;任务创建与更新由 Team agent 通过服务和模型工具执行。`./remote` 导出生成的 Client contribution,`./client` 导出可供浏览器使用的 roster、任务与投影类型。 +`agentTeam` Session 投影发布 Lead Session 的持久 roster、未删除任务视图,以及最后有效状态旁的 `failure`。其 `apply` 只替换被触及的集合;仅邮箱的变化保留客户端视图引用,不产生 frame。[子系统参考](../../../docs/subsystems/agent-team.zh.md#web-projection) 定义传输类型。 + +Web UI 通过标准投影钩子读取当前会话的值,并从 Session 状态叠加活动信息。当前 Session 通过会话加载提供基线;其他 active 成员的基线用于读取持久模型选择,无需打开它们的会话。任务创建与更新由 Team agent 通过服务和模型工具完成。`./client` 导出可供浏览器使用的 roster、任务与投影类型。 ## 模型体验 @@ -199,6 +201,7 @@ Peer 消息追加在 target 可复用历史前缀之后。冷恢复会先复用 这些限制说明一支团队目前不能做什么、或哪些方面需要特别的运维关注。它们是当前包约束,不是与其他协作机制的对比。 +- **完整视图广播** — 每次 roster 或任务变化都会把完整 roster 和未删除任务板(含描述)发给所有已连接浏览器,即使它正在查看其他 Session。 - **实验原型,无稳定性承诺**——本包公开发布,但孵化期间约定仍可自由变更。 - **单进程、共享 checkout**——成员共享 cwd,修改立即可见;本包不提供 worktree、远端成员、merge 或文件锁。 - **write scope 仅作提示**——Bash、formatter、代码生成器与直接外部写入可以绕过文件版本检查;Lead 必须协调 owner 并检查最终 diff。 diff --git a/packages/experimental/agent-team/package.json b/packages/experimental/agent-team/package.json index db127f9f7e..453b011def 100644 --- a/packages/experimental/agent-team/package.json +++ b/packages/experimental/agent-team/package.json @@ -30,14 +30,6 @@ "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" }, - "./typert": { - "types": "./lib/typert.host.d.ts", - "default": "./lib/typert.host.js" - }, - "./remote": { - "types": "./lib/typert.remote-client.d.ts", - "default": "./lib/typert.remote-client.js" - }, "./locale/*.json": "./locale/*.json", "./src/*": "./src/*", "./package.json": "./package.json" @@ -47,11 +39,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/typert.host.js", - "lib/typert.host.d.ts", - "lib/typert.remote-client.js", - "lib/typert.remote-client.d.ts" + "lib/types/**/*.d.ts" ], "license": "MIT", "dependencies": { @@ -67,8 +55,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^" + "@deepseek-ai/dsh-subagent": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -84,7 +71,6 @@ "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-fork-in-process": "workspace:^", - "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^" + "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^" } } diff --git a/packages/experimental/agent-team/src/client.ts b/packages/experimental/agent-team/src/client.ts index fc5e7e66e5..c56076fa7c 100644 --- a/packages/experimental/agent-team/src/client.ts +++ b/packages/experimental/agent-team/src/client.ts @@ -8,5 +8,4 @@ export type { TeamTaskId, TeamTaskStatus, TeamTaskView, - TeamView, } from './types.ts' diff --git a/packages/experimental/agent-team/src/index.ts b/packages/experimental/agent-team/src/index.ts index 03ddbc7a48..96a79bea16 100644 --- a/packages/experimental/agent-team/src/index.ts +++ b/packages/experimental/agent-team/src/index.ts @@ -1,10 +1,9 @@ /** Agent Teams service façade over roster, mailbox, task, and runtime lifecycle owners. */ -import { Context } from '@deepseek-ai/cordis' +import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-session-persistence' -import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { TeamActivity } from './activity.ts' import { errorMessage, TeamError } from './error.ts' import { TeamJournal } from './journal.ts' @@ -24,7 +23,6 @@ import type { SpawnTeammateResult, TeamMemberView, TeamTaskView, - TeamView, TeamWaitResult, UpdateTeamTaskRequest, } from './types.ts' @@ -55,7 +53,7 @@ function positiveLimit(name: string, value: number): number { } /** Agent Teams service backed by the exact live Lead Session log. */ -export class TeamService extends TypertRemoteService { +export class TeamService extends Service { static inject = ['agents', 'sessions', 'sessionPersistence', 'sessionProjections', 'subagents'] static Config: z = z.object({ @@ -233,19 +231,6 @@ export class TeamService extends TypertRemoteService { return this.roster.tryMembership(agent) } - /** - * Read the current roster and non-deleted task board through the generated Remote API. - * @param agent - exact live Team member used as the authority credential. - * @returns detached current roster and task views. - */ - @Remote('view') - remoteView(agent: Agent): TeamView { - return { - members: this.listMembers(agent), - tasks: this.listTasks(agent), - } - } - /** Queue one contained recovery pass after publication has unwound. */ private scheduleRecovery(agent: Agent): void { queueMicrotask(() => { diff --git a/packages/experimental/agent-team/src/projection.ts b/packages/experimental/agent-team/src/projection.ts index 13c83d4175..956b3d257f 100644 --- a/packages/experimental/agent-team/src/projection.ts +++ b/packages/experimental/agent-team/src/projection.ts @@ -369,6 +369,8 @@ function buildTeamProjection(state: TeamProjectionState): TeamProjection { /** * Durable client view of one Team state. Mailbox-only state changes reuse the * previous view reference, so the live drive publishes nothing for them. + * A failure is terminal: later events retain the failed state reference and + * do not republish its view. * @param state - current Team state. * @returns the roster and non-deleted task board, plus any projection failure. */ diff --git a/packages/experimental/agent-team/src/types.ts b/packages/experimental/agent-team/src/types.ts index 10e82ff32d..0b51530535 100644 --- a/packages/experimental/agent-team/src/types.ts +++ b/packages/experimental/agent-team/src/types.ts @@ -96,12 +96,6 @@ export interface TeamTaskView { readonly writeScopeWarnings: string[] } -/** Point-in-time roster and task-board projection returned to browser clients. */ -export interface TeamView { - readonly members: TeamMemberView[] - readonly tasks: TeamTaskView[] -} - /** One durable roster row published through the `agentTeam` Session projection. */ export interface TeamMemberProjection { readonly id: SessionId diff --git a/packages/experimental/agent-team/tests/built-lib.e2e.ts b/packages/experimental/agent-team/tests/built-lib.e2e.ts index 4e2d762fd6..897ba36e4a 100644 --- a/packages/experimental/agent-team/tests/built-lib.e2e.ts +++ b/packages/experimental/agent-team/tests/built-lib.e2e.ts @@ -13,21 +13,17 @@ const artifactUrl = (path: string): string => pathToFileURL(artifact(path)).href const requiredArtifacts = [ 'packages/experimental/agent-team/lib/index.js', - 'packages/experimental/agent-team/lib/typert.remote-client.js', ].every(path => existsSync(artifact(path))) describe.skipIf(!requiredArtifacts)('Agent Teams built LIB service', () => { - it('loads the Host service and its generated browser contribution under plain Node', async () => { + it('loads the Host service under plain Node', async () => { const urls = { host: artifactUrl('packages/experimental/agent-team/lib/index.js'), - remote: artifactUrl('packages/experimental/agent-team/lib/typert.remote-client.js'), } const script = ` const host = await import(${JSON.stringify(urls.host)}) - const remote = await import(${JSON.stringify(urls.remote)}) console.log(JSON.stringify({ className: host.default.name, - methods: remote.default.descriptors.map(descriptor => descriptor.id), })) ` @@ -35,13 +31,9 @@ describe.skipIf(!requiredArtifacts)('Agent Teams built LIB service', () => { expect(result.exitCode, `stderr:\n${result.stderr}`).toBe(0) const output = JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '{}') as { className: string - methods: string[] } expect(output).toEqual({ className: 'TeamService', - methods: [ - '@deepseek-ai/dsh-experimental-agent-team#agentTeams/view', - ], }) }) }) diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts index 15c227b303..55b6b5efe3 100644 --- a/packages/experimental/agent-team/tests/team.spec.ts +++ b/packages/experimental/agent-team/tests/team.spec.ts @@ -873,24 +873,6 @@ describe('Team shared task DAG', () => { }) }) -describe('Team Remote API', () => { - it('reads tasks created and updated by Team agents', async () => { - const { ctx, lead } = await setup([]) - expect(ctx.agentTeams.typertRemote).toMatchObject({ serviceKey: 'agentTeams', namespace: 'agentTeams' }) - expect(ctx.agentTeams.remoteView(lead)).toEqual({ - members: [expect.objectContaining({ name: 'lead', role: 'lead', status: 'inactive' })], - tasks: [], - }) - const created = await ctx.agentTeams.createTask(lead, { - subject: 'Agent task', description: 'Created by the Team Lead', - }) - const updated = await ctx.agentTeams.updateTask(lead, { - taskId: created.id, expectedRevision: 1, action: 'claim', - }) - expect(ctx.agentTeams.remoteView(lead).tasks).toEqual([updated]) - }) -}) - describe('Team mailbox and waiting', () => { it('steers a message addressed to the Lead and checkpoints its receipt', async () => { const { ctx, lead } = await setup(['hang']) diff --git a/packages/experimental/agent-team/tsconfig.json b/packages/experimental/agent-team/tsconfig.json index 862b4b5836..669a476e44 100644 --- a/packages/experimental/agent-team/tsconfig.json +++ b/packages/experimental/agent-team/tsconfig.json @@ -13,7 +13,6 @@ { "path": "../../llm/llm" }, { "path": "../../core/session" }, { "path": "../../core/agent" }, - { "path": "../../typert/protocol" }, { "path": "../../subagent/subagent" }, { "path": "../../session/session-persistence" }, { "path": "../../session/session-projection" }, diff --git a/packages/experimental/client-ui-agent-team/README.i18n.yaml b/packages/experimental/client-ui-agent-team/README.i18n.yaml index c61dc57ea4..455eea79d0 100644 --- a/packages/experimental/client-ui-agent-team/README.i18n.yaml +++ b/packages/experimental/client-ui-agent-team/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/client-ui-agent-team/README.md -README.md: 23617b1f3f7da511f1a1f5fa588450d0c484c94f -README.zh.md: f119df4fbd62c2a1c7ff365fdf53faa90fe3e423 +README.md: 5244cb6e9e02a106fb52e6ac882b9b550a180af5 +README.zh.md: 35031d9410f5c38edb3e0d9f3a914c2368487724 diff --git a/packages/experimental/client-ui-agent-team/README.md b/packages/experimental/client-ui-agent-team/README.md index 23617b1f3f..5244cb6e9e 100644 --- a/packages/experimental/client-ui-agent-team/README.md +++ b/packages/experimental/client-ui-agent-team/README.md @@ -29,7 +29,7 @@ Enable this package through [`@deepseek-ai/dsh-experimental-agent-team-profile`] ### Inspect and navigate the roster -The trigger shows the teammate count, and the panel shows the roster and task board of the Lead Session's `agentTeam` projection, so a task created by an agent or a teammate reaching `active` appears while the panel is open. Opening the panel requests the Lead's explicit projection baseline once per connection, skipping a successful prior read; a failed read shows the failure with a retry control, and a reconnect reloads it. Roster rows show durable names and a status: `failed` and `provisioning` come from the durable member phase, and `running` or `inactive` comes from the member Session's live status. A model appears when the member Session's `modelSelection` projection records a durable selection or request. Provisioning and running members use the shared ongoing loader, inactive members use idle, and failed members use error. Selecting a healthy teammate opens the ordinary `{ parentSessionId, childSessionId, mode: 'continuable' }` address from its Lead and roster identity without refreshing or checking the parent catalog. The Host validates the parent, child, and mode when history opens. History and later human prompts continue through the stable addressed-subagent conversation path; this package adds no Team-specific address field. +The trigger shows the teammate count, and the panel shows the roster and task board of the Lead Session's `agentTeam` projection, so a task created by an agent or a teammate reaching `active` appears while the panel is open. The current Session supplies its Team baseline through normal conversation loading. In a teammate conversation, opening the panel requests the Lead's projection baseline once per connection, skipping a successful prior read; a failed parent read shows a retry control alongside any last valid Team, and a reconnect reloads it. Current Session loading failures follow the conversation lifecycle and appear in the panel without a separate projection retry. A successful read without the Team capability shows an unavailable notice. The panel also requests active members' projection baselines outside the current Session and Lead, including persisted Sessions that have not been opened, and loads new members when they become active. Roster rows show durable names and a status: `failed` and `provisioning` come from the durable member phase, and `running` or `inactive` comes from the member Session's live status. A model appears when the member Session's `modelSelection` projection records a durable selection or request. Provisioning and running members use the shared ongoing loader, inactive members use idle, and failed members use error. Selecting a healthy teammate opens the ordinary `{ parentSessionId, childSessionId, mode: 'continuable' }` address from its Lead and roster identity without refreshing or checking the parent catalog. The Host validates the parent, child, and mode when history opens. History and later human prompts continue through the stable addressed-subagent conversation path; this package adds no Team-specific address field. ### Inspect the task board @@ -47,7 +47,7 @@ The read-only task board shows task identity, owner, blockers, readiness, adviso The Client export registers its locale dictionaries and one conversation-header slot through Cordis effects; it mounts no Remote namespace. Disposing the plugin fiber removes both registrations. -The panel renders outside the conversation container and stays within the viewport. Opening moves focus into the panel; Escape or Close returns focus to its trigger. Clicking outside or moving focus outside the panel and trigger closes it without moving focus back. The component reads the current Session's Team through `useProjection('agentTeam')` and its model through `useProjection('modelSelection')`. In a teammate conversation, `useSession` supplies the Lead identity and `useSessions` supplies that other Session's Team projection. Other members' models also come from the shared Session values; member activity comes from `useSessionStatus` with the list summary as fallback. Its only injected callbacks read the Lead's projection baseline and open a teammate. Switching conversations closes the panel and clears a navigation failure. +The panel renders outside the conversation container and stays within the viewport. Opening moves focus into the panel; Escape or Close returns focus to its trigger. Clicking outside or moving focus outside the panel and trigger closes it without moving focus back. The component reads the current Session's Team through `useProjection('agentTeam')` and its model through `useProjection('modelSelection')`. In a teammate conversation, `useSession` supplies the Lead identity, and separate `useSessions` selectors read the parent's Team value, baseline-read state, and error. Each roster row selects its own model and running state; the panel subscribes to neither the complete projection collection nor the complete summary or status collection. Its only injected callbacks read a Session's projection baseline and open a teammate. Switching conversations closes the panel and clears a navigation failure. | File | Role | |---|---| @@ -84,7 +84,7 @@ No direct effect; the Team tools and ordinary conversation submission own any la - **No mailbox timeline** — the projection view carries roster and tasks only; peer messages are not shown. -- **Model before the first request** — a member without a durable model selection or request shows no model, and a teammate Session that is not loaded on the Host shows no model or activity until its projections arrive. +- **Model before the first request** — a member without a durable model selection or request shows no model. Inactive persisted members can supply their model through the explicit projection read; live activity still requires a running Host agent. - **Ordinary child continuation** — a human message sent after navigation uses the stable addressed-subagent prompt path, not the Team peer mailbox. - **No lifecycle or workspace controls** — the panel cannot spawn, rename, delete, or interrupt teammates, and write scopes remain advisory metadata. diff --git a/packages/experimental/client-ui-agent-team/README.zh.md b/packages/experimental/client-ui-agent-team/README.zh.md index f119df4fbd..35031d9410 100644 --- a/packages/experimental/client-ui-agent-team/README.zh.md +++ b/packages/experimental/client-ui-agent-team/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 检查并导航 roster -触发按钮显示 teammate 数量,panel 显示 Lead Session `agentTeam` 投影中的 roster 与任务板,因此 agent 创建的任务或进入 `active` 的 teammate 会在 panel 打开期间直接出现。打开 panel 时,每次连接请求一次 Lead 的显式投影基线,之前读取成功则跳过;读取失败会显示失败原因与重试控件,重连后会重新加载。Roster row 展示持久 name 与状态:`failed` 与 `provisioning` 来自持久成员 phase,`running` 或 `inactive` 来自成员 Session 的实时状态。当成员 Session 的 `modelSelection` 投影记录了持久选择或请求时显示 model。provisioning 和 running 成员使用共享 ongoing loading,inactive 成员使用 idle 灰点,failed 成员使用 error 红点。选择健康 teammate 时,系统直接根据其 Lead 与 roster 身份打开普通的 `{ parentSessionId, childSessionId, mode: 'continuable' }` address,不刷新或检查 parent catalog。Host 在打开历史时校验 parent、child 与 mode。History 与后续人类提示词继续使用稳定 addressed-subagent 会话路径;本包不会添加 Team 专用 address 字段。 +触发按钮显示 teammate 数量,panel 显示 Lead Session `agentTeam` 投影中的 roster 与任务板,因此 agent 创建的任务或进入 `active` 的 teammate 会在 panel 打开期间直接出现。当前 Session 通过普通会话加载提供 Team 基线。在 teammate 会话中,打开 panel 时每次连接请求一次 Lead 的投影基线,之前读取成功则跳过;父会话读取失败会在最后有效 Team 旁显示重试控件,重连后会重新加载。当前 Session 的加载失败沿用会话生命周期,并显示在 panel 中,不提供单独的投影重试。读取成功但缺少 Team 能力时显示不可用提示。panel 还会请求当前 Session 与 Lead 之外 active 成员的投影基线,包括尚未打开的持久 Session,并在新成员进入 active 时加载它。Roster row 展示持久 name 与状态:`failed` 与 `provisioning` 来自持久成员 phase,`running` 或 `inactive` 来自成员 Session 的实时状态。当成员 Session 的 `modelSelection` 投影记录了持久选择或请求时显示 model。provisioning 和 running 成员使用共享 ongoing loading,inactive 成员使用 idle 灰点,failed 成员使用 error 红点。选择健康 teammate 时,系统直接根据其 Lead 与 roster 身份打开普通的 `{ parentSessionId, childSessionId, mode: 'continuable' }` address,不刷新或检查 parent catalog。Host 在打开历史时校验 parent、child 与 mode。History 与后续人类提示词继续使用稳定 addressed-subagent 会话路径;本包不会添加 Team 专用 address 字段。 ### 查看任务板 @@ -47,7 +47,7 @@ kind: "package-reference" Client export 通过 Cordis effect 注册 locale dictionary 与一个 conversation-header slot;它不挂载任何 Remote namespace。Dispose plugin fiber 会移除这两项 registration。 -面板渲染在会话容器外,并保持在视口范围内。打开时焦点移入面板;按 Escape 或选择关闭按钮时,焦点返回触发按钮。点击外部或将焦点移出面板与触发按钮时,面板关闭,但不会将焦点移回。组件通过 `useProjection('agentTeam')` 读取当前 Session 的 Team,通过 `useProjection('modelSelection')` 读取其 model。在 teammate 会话中,`useSession` 提供 Lead 身份,`useSessions` 提供该另一个 Session 的 Team 投影。其他成员的 model 也来自共享 Session 值;成员活动来自 `useSessionStatus`,并以列表摘要为后备。它仅有的注入回调是读取 Lead 的投影基线与打开 teammate。切换会话会关闭面板并清除导航失败。 +面板渲染在会话容器外,并保持在视口范围内。打开时焦点移入面板;按 Escape 或选择关闭按钮时,焦点返回触发按钮。点击外部或将焦点移出面板与触发按钮时,面板关闭,但不会将焦点移回。组件通过 `useProjection('agentTeam')` 读取当前 Session 的 Team,通过 `useProjection('modelSelection')` 读取其 model。在 teammate 会话中,`useSession` 提供 Lead 身份,独立的 `useSessions` selector 分别读取父会话的 Team 值、基线读取状态和错误。每个 roster row 只选择自己的 model 和运行状态;panel 不订阅完整投影集合,也不订阅完整摘要或状态集合。它仅有的注入回调是读取 Session 的投影基线与打开 teammate。切换会话会关闭面板并清除导航失败。 | 文件 | 职责 | |---|---| @@ -84,7 +84,7 @@ Client export 通过 Cordis effect 注册 locale dictionary 与一个 conversati - **没有 mailbox timeline**——投影视图只承载 roster 与任务;不显示 peer 消息。 -- **首次请求前没有 model**——没有持久模型选择或请求的成员不显示 model;未在 Host 加载的 teammate Session 在其投影到达前不显示 model 与活动。 +- **首次请求前的模型** — 没有持久模型选择或请求的成员不显示模型。未运行的持久成员可通过显式投影读取提供模型;实时活动仍需要正在运行的 Host agent。 - **普通 child continuation**——导航后发送的人类消息使用稳定 addressed-subagent 提示词路径,而不是 Team peer mailbox。 - **没有 lifecycle 或 workspace control**——panel 不能 spawn、rename、delete 或 interrupt teammate,write scope 仍只是提示性 metadata。 diff --git a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx index c6661bed06..2eb15582c1 100644 --- a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx +++ b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx @@ -17,8 +17,8 @@ import css from './TeamAction.module.css' /** Business actions injected by the browser plugin. */ export interface TeamActionInjected { - /** Request the Lead's projection baseline once per connection, or retry a failed read. */ - loadProjections: (leadSessionId: SessionId) => void + /** Request another Session's projection baseline once per connection, or retry a failed read. */ + loadProjections: (sessionId: SessionId) => void openTeammate: (sessionId: SessionId, member: TeamMemberProjection) => void } @@ -71,6 +71,52 @@ function taskDotState(task: TeamTask): StateDotState { } } +type TeamMemberRowProps = Pick & { + member: TeamMemberProjection + onError: (message: string) => void +} + +function TeamMemberRow({ + member, sessionId, useProjection, useSessions, useSessionStatus, openTeammate, onError, t, +}: TeamMemberRowProps) { + const currentModel = useProjection('modelSelection', value => + member.id === sessionId ? value?.next?.model : undefined) + const otherModel = useSessions(state => member.id === sessionId + ? undefined + : state.projectionsBySession[member.id]?.values.modelSelection?.next?.model) + const running = useSessionStatus(state => state.get(member.id)?.running) + const summaryRunning = useSessions(state => state.byId[member.id]?.running) + const status: MemberStatus = member.phase === 'active' + ? (running ?? summaryRunning) === true ? 'running' : 'inactive' + : member.phase + const model = member.id === sessionId ? currentModel : otherModel + + return ( + + ) +} + /** Render the Team roster and read-only task board from the Lead Session's `agentTeam` projection. */ export function TeamAction({ sessionId, useSession, useProjection, useSessions, useSessionStatus, loadProjections, openTeammate, t, @@ -87,14 +133,25 @@ export function TeamAction({ useDismissOnOutsidePointer(rootRef, open, setOpen, panelRef) const leadSessionId = useSession(snapshot => snapshot.subagent?.address.parentSessionId) ?? sessionId + const isLead = leadSessionId === sessionId const currentTeam = useProjection('agentTeam') - const currentModel = useProjection('modelSelection', value => value?.next?.model) - const projections = useSessions(state => state.projectionsBySession) - const summaries = useSessions(state => state.byId) - const statuses = useSessionStatus(snapshot => snapshot) - const projection = projections[leadSessionId] - const team = leadSessionId === sessionId ? currentTeam : projection?.values.agentTeam - const readError = team === undefined && projection?.state === 'error' ? projection.error : null + const openState = useSession(snapshot => snapshot.openState) + const openError = useSession(snapshot => snapshot.openError) + const parentTeam = useSessions(state => isLead + ? undefined + : state.projectionsBySession[leadSessionId]?.values.agentTeam) + const parentState = useSessions(state => isLead + ? undefined + : state.projectionsBySession[leadSessionId]?.state) + const parentError = useSessions(state => isLead + ? null + : state.projectionsBySession[leadSessionId]?.error ?? null) + const team = isLead ? currentTeam : parentTeam + const readError = isLead + ? openState === 'error' ? openError : null + : parentState === 'error' ? parentError : null + const ready = isLead ? openState === 'open' : parentState === 'ready' + const members = team?.members useEffect(() => { setOpen(false) @@ -102,8 +159,15 @@ export function TeamAction({ }, [sessionId]) useEffect(() => { - if (open) loadProjections(leadSessionId) - }, [open, leadSessionId, loadProjections]) + if (open && !isLead) loadProjections(leadSessionId) + }, [open, isLead, leadSessionId, loadProjections]) + + useEffect(() => { + if (!open) return + for (const member of members ?? []) { + if (member.id !== sessionId && member.id !== leadSessionId && member.phase === 'active') loadProjections(member.id) + } + }, [open, sessionId, leadSessionId, members, loadProjections]) useLayoutEffect(() => { if (open && positioned) panelRef.current?.focus() @@ -114,14 +178,6 @@ export function TeamAction({ triggerRef.current?.focus() } - const memberStatus = (member: TeamMemberProjection): MemberStatus => { - if (member.phase === 'failed') return 'failed' - if (member.phase === 'provisioning') return 'provisioning' - return (statuses.get(member.id)?.running ?? summaries[member.id]?.running) === true ? 'running' : 'inactive' - } - const memberModel = (member: TeamMemberProjection): string | undefined => - member.id === sessionId ? currentModel : projections[member.id]?.values.modelSelection?.next?.model - const teammates = team?.members.filter(member => member.role === 'teammate') ?? [] return ( @@ -171,13 +227,18 @@ export function TeamAction({
{failureText(readError)} - + {!isLead && ( + + )}
)} {team === undefined && readError === null && ( -
{t('loading')}
+
+ + {t(ready ? 'unavailable' : 'loading')} +
)} {team !== undefined && ( <> @@ -187,33 +248,19 @@ export function TeamAction({

{t('roster')}

- {team.members.map((member) => { - const status = memberStatus(member) - const model = memberModel(member) - return ( - - ) - })} + {team.members.map(member => ( + + ))}
diff --git a/packages/experimental/client-ui-agent-team/src/client/locales.ts b/packages/experimental/client-ui-agent-team/src/client/locales.ts index 8d3c45d513..c3365d4268 100644 --- a/packages/experimental/client-ui-agent-team/src/client/locales.ts +++ b/packages/experimental/client-ui-agent-team/src/client/locales.ts @@ -9,6 +9,7 @@ export const zh = { close: '关闭', retry: '重试读取 Team', loading: '正在加载 Team…', + unavailable: 'Team 暂不可用', failure: 'Team 持久记录无效:{message}', empty: '还没有共享任务', roster: '成员', @@ -39,6 +40,7 @@ export const en = { close: 'Close', retry: 'Retry reading Team', loading: 'Loading Team…', + unavailable: 'Team is unavailable', failure: 'Invalid persisted Team record: {message}', empty: 'No shared tasks yet', roster: 'Members', diff --git a/packages/experimental/client-ui-agent-team/src/client/mount.ts b/packages/experimental/client-ui-agent-team/src/client/mount.ts index d7e9083307..1e2dfbdab3 100644 --- a/packages/experimental/client-ui-agent-team/src/client/mount.ts +++ b/packages/experimental/client-ui-agent-team/src/client/mount.ts @@ -36,8 +36,8 @@ export function registerAgentTeamUi(ctx: ClientContext): void { } const actions: TeamActionInjected = { - loadProjections(leadId: SessionId): void { - void sessions.refreshProjections(leadId) + loadProjections(sessionId: SessionId): void { + void sessions.refreshProjections(sessionId) }, openTeammate(sessionId: SessionId, member: TeamMemberProjection): void { if (member.role !== 'teammate') return diff --git a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx index f150de9021..9949212f5e 100644 --- a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx +++ b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx @@ -1,5 +1,6 @@ // @vitest-environment jsdom +import { Profiler } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -48,6 +49,7 @@ function bench(options: { projections?: Projections sessionId?: SessionId parentSessionId?: SessionId + openState?: SessionSnapshot['openState'] statuses?: SessionStatusSnapshot running?: Record } = {}) { @@ -67,7 +69,7 @@ function bench(options: { ? null : { address: { parentSessionId: options.parentSessionId, childSessionId: sessionId, mode: 'continuable' } }, removed: false, - openState: 'open', + openState: options.openState ?? 'open', openError: null, hasMore: false, loadingOlder: false, @@ -123,7 +125,7 @@ describe('TeamAction', () => { openPanel() expect(await screen.findByText('Implement runtime')).toBeTruthy() expect(screen.getByText('write scopes overlap with task-2')).toBeTruthy() - expect(b.injected.loadProjections).toHaveBeenCalledWith(SESSION) + expect(b.injected.loadProjections).not.toHaveBeenCalledWith(SESSION) expect(screen.queryByRole('button', { name: /刷新|Refresh/u })).toBeNull() setProjection(b.sessions, SESSION, { @@ -195,8 +197,8 @@ describe('TeamAction', () => { expect(b.injected.openTeammate).toHaveBeenCalledWith(WORKER, worker) }) - it('shows loading until the projection arrives and a retryable read failure', () => { - const b = bench({ projections: {} }) + it('loads the parent Team and retries failed parent baseline reads', () => { + const b = bench({ sessionId: WORKER, parentSessionId: SESSION, projections: {} }) render() openPanel() expect(screen.getByRole('status').textContent).toBe(zh.loading) @@ -215,6 +217,109 @@ describe('TeamAction', () => { expect(screen.queryByRole('alert')).toBeNull() }) + it('uses the current Session opening state without requesting its projection baseline', () => { + const b = bench({ projections: {}, openState: 'loading' }) + render() + openPanel() + expect(screen.getByRole('status').textContent).toBe(zh.loading) + expect(b.injected.loadProjections).not.toHaveBeenCalled() + + act(() => { + b.session.update((draft) => { + draft.openState = 'error' + draft.openError = new RemoteError('gateway/internal', 'history unavailable', {}) + }) + }) + expect(screen.getByRole('alert').textContent).toBe('history unavailable (gateway/internal)') + expect(screen.queryByRole('button', { name: zh.retry })).toBeNull() + expect(b.injected.loadProjections).not.toHaveBeenCalled() + + act(() => { + b.session.update((draft) => { + draft.openState = 'open' + draft.openError = null + }) + }) + expect(screen.getByRole('status').textContent).toBe(zh.unavailable) + setProjection(b.sessions, SESSION, team) + expect(screen.getByText('Implement runtime')).toBeTruthy() + expect(b.injected.loadProjections).not.toHaveBeenCalledWith(SESSION) + }) + + it.each([false, true])('ignores unrelated Session updates (teammate page: %s)', (addressed) => { + const b = bench(addressed ? { sessionId: WORKER, parentSessionId: SESSION } : {}) + const onRender = vi.fn() + render() + openPanel() + onRender.mockClear() + + act(() => { + const current = b.sessions.getSnapshot() + const projectionsBySession = Object.fromEntries(Object.entries(current.projectionsBySession) + .map(([id, snapshot]) => [id, { ...snapshot }])) + b.sessions.set({ + ...current, + byId: { ...current.byId, unrelated: summary('unrelated' as SessionId, true) }, + projectionsBySession: { + ...projectionsBySession, + unrelated: { state: 'ready', error: null, values: { agentTeam: { members: [], tasks: [] } } }, + }, + }) + b.statuses.set(new Map([['unrelated' as SessionId, { running: true, pendingInteraction: undefined, completionUnread: false }]])) + }) + expect(onRender).not.toHaveBeenCalled() + + setProjectionSnapshot(b.sessions, WORKER, { + state: 'ready', error: null, + values: { modelSelection: { lastUsed: null, next: { provider: 'p', model: 'updated-worker-model' } } }, + }) + expect(screen.getByRole('button', { name: /worker.*updated-worker-model/u })).toBeTruthy() + setProjection(b.sessions, SESSION, { ...team, tasks: [{ ...task, subject: 'Updated parent task' }] }) + expect(screen.getByText('Updated parent task')).toBeTruthy() + expect(b.injected.loadProjections).not.toHaveBeenCalledWith(addressed ? WORKER : SESSION) + }) + + it('shows capability absence after a successful read instead of loading forever', () => { + const b = bench({ projections: { [SESSION]: { state: 'ready', error: null, values: {} } } }) + render() + openPanel() + expect(screen.queryByText(zh.loading)).toBeNull() + expect(screen.getByRole('status').textContent).toBe('Team 暂不可用') + }) + + it('keeps the last parent Team visible beside a retryable baseline failure', () => { + const b = bench({ sessionId: WORKER, parentSessionId: SESSION }) + render() + openPanel() + setProjectionSnapshot(b.sessions, SESSION, { + state: 'error', error: new RemoteError('gateway/internal', 'offline', {}), values: { agentTeam: team }, + }) + expect(screen.getByText('Implement runtime')).toBeTruthy() + expect(screen.getByRole('alert').textContent).toBe('offline (gateway/internal)') + fireEvent.click(screen.getByRole('button', { name: zh.retry })) + expect(b.injected.loadProjections).toHaveBeenLastCalledWith(SESSION) + }) + + it('loads active members models without opening their conversations', () => { + const nextMember = { ...worker, id: 'new-worker' as SessionId, name: 'new-worker', phase: 'provisioning' as const } + const failed = { ...worker, id: 'failed-worker' as SessionId, name: 'failed-worker', phase: 'failed' as const } + const b = bench() + render() + expect(b.injected.loadProjections).not.toHaveBeenCalled() + openPanel() + expect(b.injected.loadProjections).toHaveBeenCalledWith(WORKER) + setProjectionSnapshot(b.sessions, WORKER, { + state: 'ready', error: null, + values: { modelSelection: { lastUsed: null, next: { provider: 'p', model: 'cold-worker-model' } } }, + }) + expect(screen.getByRole('button', { name: /worker.*cold-worker-model/u })).toBeTruthy() + setProjection(b.sessions, SESSION, { ...team, members: [...team.members, nextMember, failed] }) + expect(b.injected.loadProjections).not.toHaveBeenCalledWith(nextMember.id) + expect(b.injected.loadProjections).not.toHaveBeenCalledWith(failed.id) + setProjection(b.sessions, SESSION, { ...team, members: [...team.members, { ...nextMember, phase: 'active' }] }) + expect(b.injected.loadProjections).toHaveBeenCalledWith(nextMember.id) + }) + it('surfaces a Team projection failure beside the last valid state', () => { const b = bench({ projections: { [SESSION]: { state: 'ready', error: null, values: { agentTeam: { ...team, failure: 'revision is not contiguous' } } } }, @@ -284,13 +389,13 @@ describe('TeamAction', () => { fireEvent.click(screen.getByRole('button', { name: /^worker/u })) expect(screen.getByRole('alert')).toBeTruthy() - const next = bench({ sessionId: 'next-lead' as SessionId, projections: {} }) + const next = bench({ sessionId: 'next-lead' as SessionId, projections: {}, openState: 'loading' }) rendered.rerender() expect(screen.queryByRole('dialog')).toBeNull() openPanel() expect(screen.queryByRole('alert')).toBeNull() expect(screen.getByRole('status').textContent).toBe(zh.loading) - expect(next.injected.loadProjections).toHaveBeenCalledWith('next-lead') + expect(next.injected.loadProjections).not.toHaveBeenCalled() }) it('keeps panel interactions open and dismisses on outside pointer or Escape', () => { diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 930a7c6b56..cf77809950 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -376,12 +376,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'agent', description: 'candidate exact live Agent.' }], returns: 'Team membership, or undefined for non-Team subagents and stale identities.', }, - { - signature: '@Remote(\'view\') remoteView(agent: Agent): TeamView', - description: 'Read the current roster and non-deleted task board through the generated Remote API.', - parameters: [{ name: 'agent', description: 'exact live Team member used as the authority credential.' }], - returns: 'detached current roster and task views.', - }, ], }, { @@ -7031,10 +7025,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TeamTaskView', declaration: 'export interface TeamTaskView {\n readonly id: TeamTaskId;\n readonly revision: number;\n readonly subject: string;\n readonly description: string;\n readonly status: TeamTaskStatus;\n readonly blockedBy: TeamTaskId[];\n readonly writeScopes: string[];\n readonly ownerName?: string;\n readonly ready: boolean;\n readonly writeScopeWarnings: string[];\n}', }, - { - name: 'TeamView', - declaration: 'export interface TeamView {\n readonly members: TeamMemberView[];\n readonly tasks: TeamTaskView[];\n}', - }, { name: 'TeamWaitResult', declaration: 'export interface TeamWaitResult {\n readonly timedOut: boolean;\n}', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb69cfc475..da098d5cc6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6972,9 +6972,6 @@ importers: '@deepseek-ai/dsh-subagent-spawn-in-process': specifier: workspace:^ version: link:../../subagent/subagent-spawn-in-process - '@deepseek-ai/dsh-typert-protocol': - specifier: workspace:^ - version: link:../../typert/protocol packages/experimental/agent-team-profile: dependencies: diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 1caa45f192..d5530c9d26 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -649,7 +649,8 @@ export const LINK_MAP: Readonly> = { TeamMembership: 'agent-team.md', TeamTaskId: 'agent-team.md', TeamTaskView: 'agent-team.md', - TeamView: 'agent-team.md', + TeamProjection: 'agent-team.md', + TeamMemberProjection: 'agent-team.md', TeamWaitResult: 'agent-team.md', UpdateTeamTaskRequest: 'agent-team.md', TokenMeasurement: 'token-meter.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index ebc7dcd277..5cf56de8d0 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -708,8 +708,8 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'experimental-agent-team', title: 'Agent Teams coordination domain', mode: 'core', - consumers: ['experimental-tool-agent-team', 'experimental-client-ui-agent-team'], - note: 'Owns the implicit-root roster, durable peer mailbox, shared task DAG, continuable-child lifecycle, and generated Team Remote methods; tool-agent-team contributes model controls and client-ui-agent-team mounts the browser contribution.', + consumers: ['experimental-tool-agent-team'], + note: 'Owns the implicit-root roster, durable peer mailbox, shared task DAG, and continuable-child lifecycle; tool-agent-team contributes model controls.', }, { key: 'inspector', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 13b995afc4..48d3b928d7 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -337,6 +337,21 @@ "symbol": "ScheduleView", "source": "packages/schedule/schedule/src/types.ts" }, + { + "doc": "docs/subsystems/agent-team.md", + "symbol": "TeamMemberProjection", + "source": "packages/experimental/agent-team/src/types.ts" + }, + { + "doc": "docs/subsystems/agent-team.md", + "symbol": "TeamTaskView", + "source": "packages/experimental/agent-team/src/types.ts" + }, + { + "doc": "docs/subsystems/agent-team.md", + "symbol": "TeamProjection", + "source": "packages/experimental/agent-team/src/types.ts" + }, { "doc": "docs/subsystems/agent-team.md", "symbol": "TeamMemberSnapshot", From 7befda1147b9511e6daa8effc72d51257d8a0cfe Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 22 Sep 2026 14:32:01 +0800 Subject: [PATCH 023/192] test(agent-team): release fixtures and respect snapshot types --- .../agent-team/tests/built-lib.e2e.ts | 2 +- .../experimental/agent-team/tests/team.spec.ts | 7 ++++++- .../tests/team-action.client.spec.tsx | 16 +++++++--------- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/experimental/agent-team/tests/built-lib.e2e.ts b/packages/experimental/agent-team/tests/built-lib.e2e.ts index 897ba36e4a..d171624d80 100644 --- a/packages/experimental/agent-team/tests/built-lib.e2e.ts +++ b/packages/experimental/agent-team/tests/built-lib.e2e.ts @@ -1,4 +1,4 @@ -/** Plain-Node smoke for the built Agent Teams service and Remote contribution. */ +/** Plain-Node smoke for the built Agent Teams service. */ import { execFile } from 'node:child_process' import { existsSync } from 'node:fs' diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts index 55b6b5efe3..af939b0108 100644 --- a/packages/experimental/agent-team/tests/team.spec.ts +++ b/packages/experimental/agent-team/tests/team.spec.ts @@ -22,9 +22,11 @@ import { TestSessionQuery } from './test-session-query.ts' const SIGNAL = new AbortController().signal const roots: string[] = [] +const contexts: Context[] = [] -afterEach(() => { +afterEach(async () => { vi.useRealTimers() + for (const ctx of contexts.splice(0).reverse()) await ctx.fiber.dispose() for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) @@ -60,6 +62,7 @@ async function setup( config: ConstructorParameters[1] = {}, ) { const ctx = new Context() + contexts.push(ctx) await mountAgentLoopTestDependencies(ctx) const storageRoot = mkdtempSync(join(tmpdir(), 'dsh-team-')) roots.push(storageRoot) @@ -167,6 +170,7 @@ describe('Team identity and provisioning', () => { it('supports direct-constructor defaults and recovers roots that already exist', async () => { const ctx = new Context() + contexts.push(ctx) await mountAgentLoopTestDependencies(ctx) const storageRoot = mkdtempSync(join(tmpdir(), 'dsh-team-direct-')) roots.push(storageRoot) @@ -1346,6 +1350,7 @@ describe('Team mailbox and waiting', () => { it('waits for one change, supports cancellation, times out, and releases waiters on HMR disposal', async () => { const ctx = new Context() + contexts.push(ctx) await mountAgentLoopTestDependencies(ctx) const storageRoot = mkdtempSync(join(tmpdir(), 'dsh-team-wait-')) roots.push(storageRoot) diff --git a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx index 9949212f5e..c1c588995a 100644 --- a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx +++ b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx @@ -225,9 +225,10 @@ describe('TeamAction', () => { expect(b.injected.loadProjections).not.toHaveBeenCalled() act(() => { - b.session.update((draft) => { - draft.openState = 'error' - draft.openError = new RemoteError('gateway/internal', 'history unavailable', {}) + b.session.set({ + ...b.session.getSnapshot(), + openState: 'error', + openError: new RemoteError('gateway/internal', 'history unavailable', {}), }) }) expect(screen.getByRole('alert').textContent).toBe('history unavailable (gateway/internal)') @@ -235,10 +236,7 @@ describe('TeamAction', () => { expect(b.injected.loadProjections).not.toHaveBeenCalled() act(() => { - b.session.update((draft) => { - draft.openState = 'open' - draft.openError = null - }) + b.session.set({ ...b.session.getSnapshot(), openState: 'open', openError: null }) }) expect(screen.getByRole('status').textContent).toBe(zh.unavailable) setProjection(b.sessions, SESSION, team) @@ -259,10 +257,10 @@ describe('TeamAction', () => { .map(([id, snapshot]) => [id, { ...snapshot }])) b.sessions.set({ ...current, - byId: { ...current.byId, unrelated: summary('unrelated' as SessionId, true) }, + byId: { ...current.byId, ['unrelated' as SessionId]: summary('unrelated' as SessionId, true) }, projectionsBySession: { ...projectionsBySession, - unrelated: { state: 'ready', error: null, values: { agentTeam: { members: [], tasks: [] } } }, + ['unrelated' as SessionId]: { state: 'ready', error: null, values: { agentTeam: { members: [], tasks: [] } } }, }, }) b.statuses.set(new Map([['unrelated' as SessionId, { running: true, pendingInteraction: undefined, completionUnread: false }]])) From db4bfb4c9665b3f0e5e4ac10eb23a6d1eac53b04 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 22 Sep 2026 14:37:05 +0800 Subject: [PATCH 024/192] docs(agent-team): refresh generated module dependencies --- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 3 +-- docs/module-graph.zh.md | 3 +-- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 68b1e636b1..a1c8765cd8 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: ce9a4256c71e79193a359549cefd89cd37e5fa22 -module-graph.zh.md: 557415a750798c039547b9824c6fb8fa882f2c44 +module-graph.md: ef70bdf4a002d764a1fca6ffd5e67163e93027aa +module-graph.zh.md: 729b76c88e6d5f3dc1c2abcea0c5852d1b39348f diff --git a/docs/module-graph.md b/docs/module-graph.md index ce9a4256c7..ef70bdf4a0 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1249,7 +1249,6 @@ flowchart TD pkg_experimental_agent_team --> pkg_session_persistence pkg_experimental_agent_team --> pkg_session_projection pkg_experimental_agent_team --> pkg_subagent - pkg_experimental_agent_team --> pkg_typert_protocol pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session pkg_sdk_protocol --> pkg_subagent @@ -1638,7 +1637,7 @@ flowchart TD | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | -| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | +| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 557415a750..729b76c88e 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1251,7 +1251,6 @@ flowchart TD pkg_experimental_agent_team --> pkg_session_persistence pkg_experimental_agent_team --> pkg_session_projection pkg_experimental_agent_team --> pkg_subagent - pkg_experimental_agent_team --> pkg_typert_protocol pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session pkg_sdk_protocol --> pkg_subagent @@ -1640,7 +1639,7 @@ flowchart TD | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | -| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | +| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | From f21098e9d32372ebe86605f81e392d4abecd88d7 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:41:33 +0800 Subject: [PATCH 025/192] fix(account): expire rejected credentials and hide signed-out provider --- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 + apps/desktop/README.zh.md | 2 + apps/desktop/src/account-backend.ts | 4 ++ apps/desktop/tests/account-backend.spec.ts | 7 +++ apps/desktop/tests/welcome-startup.spec.ts | 24 +++++++- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- .../tests/browser-plugin.client.spec.ts | 21 +++++++ .../ui-settings-account/README.i18n.yaml | 4 +- packages/client/ui-settings-account/README.md | 2 + .../client/ui-settings-account/README.zh.md | 2 + .../src/client/AccountMenu.tsx | 8 ++- .../ui-settings-account/src/client/locales.ts | 2 + .../tests/account.client.spec.tsx | 11 ++++ .../tests/expected/expired-en.txt | 1 + .../tests/expected/expired-zh.txt | 1 + .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 2 +- .../client/ui-settings-models/README.zh.md | 2 +- .../ui-settings-models/src/client/store.ts | 2 +- .../tests/store.client.spec.ts | 26 ++++++++- .../README.i18n.yaml | 4 +- .../deepseek-account-platform/README.md | 2 + .../deepseek-account-platform/README.zh.md | 2 + .../deepseek-account-platform/src/details.ts | 8 ++- .../deepseek-account-platform/src/index.ts | 37 +++++++++++-- .../deepseek-account-platform/src/protocol.ts | 9 +++ .../tests/account.spec.ts | 55 +++++++++++++++++++ .../deepseek-account/README.i18n.yaml | 4 +- .../credentials/deepseek-account/README.md | 2 + .../credentials/deepseek-account/README.zh.md | 2 + .../credentials/deepseek-account/src/types.ts | 2 + .../extensions/tool-cordis/src/api-catalog.ts | 2 +- 35 files changed, 236 insertions(+), 32 deletions(-) create mode 100644 packages/client/ui-settings-account/tests/expected/expired-en.txt create mode 100644 packages/client/ui-settings-account/tests/expected/expired-zh.txt diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index d6d13bc0fb..1bf1514fdd 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: 6107698a58cbbe76215d9f23607dabfb3031f8fd -README.zh.md: d85acc25c3bfdebf8929d74cba10e36ca7e8c64a +README.md: 5b9805dab3ee15bc32bdae9a62233ba2c64cadb0 +README.zh.md: bb3500dc834bc2d00f79984b4733715197c190e8 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 6107698a58..5b9805dab3 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -14,6 +14,8 @@ Embedded Platform documents use a dedicated non-persistent WebContentsView sessi Desktop Host Platform API requests use the same `x-client-platform` mapping as update-policy requests. The account provider owns [API-only header configuration](../../packages/credentials/deepseek-account-platform/README.md#use-this-package). +A server-expired account credential returns to Welcome when no official API key is configured; an available API key keeps the workspace open. Explicit sign-out follows the same rule. + ## Key technical decisions The original artwork lives in `resources/icon.png` and `resources/icon.svg`; platform adaptations retain the whale and gradients in `resources/icon-windows.*` and `resources/icon-macos.*`. Export each platform SVG as a transparent 1024×1024 PNG. Electron-builder generates the multi-size ICO for the Windows application, installer, and uninstaller ([Windows icon requirements](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction)). The installation pages use matching artwork in both themes; the uninstaller's welcome and finish pages share `installer/assets/uninstaller-sidebar.png`, converted to a 164×314 BMP during preparation. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index d85acc25c3..bb3500dc83 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -14,6 +14,8 @@ Creator 和 Web Plugin Manager 在 Electron Node 模式下使用 Desktop 内置 Desktop Host 的 Platform API 请求与更新策略请求使用相同的 `x-client-platform` 映射。账号 provider 管理[仅 API 使用的请求头配置](../../packages/credentials/deepseek-account-platform/README.zh.md#use-this-package)。 +账号凭据被服务端判定失效后,未配置官方 API key 时返回 Welcome;有可用 API key 时保持工作区打开。主动退出登录遵循相同规则。 + ## 关键技术决策 设计师原稿位于 `resources/icon.png` 和 `resources/icon.svg`;平台适配保留鲸鱼与渐变,分别位于 `resources/icon-windows.*` 和 `resources/icon-macos.*`。将各平台 SVG 导出为透明的 1024×1024 PNG。electron-builder 为 Windows 应用、安装程序和卸载程序生成多尺寸 ICO([Windows 图标要求](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction))。安装页面在两种主题下使用匹配的图案;卸载程序的欢迎和完成页共用 `installer/assets/uninstaller-sidebar.png`,准备阶段将其转换为 164×314 BMP。 diff --git a/apps/desktop/src/account-backend.ts b/apps/desktop/src/account-backend.ts index 0db791f631..7b60e55db7 100644 --- a/apps/desktop/src/account-backend.ts +++ b/apps/desktop/src/account-backend.ts @@ -20,6 +20,9 @@ export function accountView(value: unknown): AccountView { } validateBrowserDestination(value.links.usageUrl) validateBrowserDestination(value.links.topUpUrl) + if ('signOutReason' in value && value.signOutReason !== 'expired') { + throw new Error('desktop account: invalid sign-out reason') + } const attempt = value.attempt if (attempt !== null && (typeof attempt !== 'object' || !('id' in attempt) || typeof attempt.id !== 'string' || !('phase' in attempt) || !['initializing', 'waiting-browser', 'exchanging', 'committing', 'succeeded', 'cancelled', 'expired', 'failed'].includes(String(attempt.phase)) @@ -34,6 +37,7 @@ export function accountView(value: unknown): AccountView { const parsed = value as AccountView return { status: parsed.status, + ...parsed.signOutReason === undefined ? {} : { signOutReason: parsed.signOutReason }, links: { usageUrl: parsed.links.usageUrl, topUpUrl: parsed.links.topUpUrl }, attempt: parsed.attempt === null ? null : { id: parsed.attempt.id, phase: parsed.attempt.phase, diff --git a/apps/desktop/tests/account-backend.spec.ts b/apps/desktop/tests/account-backend.spec.ts index 9c25ae1026..11217454ac 100644 --- a/apps/desktop/tests/account-backend.spec.ts +++ b/apps/desktop/tests/account-backend.spec.ts @@ -24,3 +24,10 @@ it('uses account Remote commands without returning additional wire fields', asyn expect(await backend.start('en')).toEqual({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null }) expect(requests).toEqual([{ namespace: 'account', method: 'startSignIn', args: { locale: 'en', callbackOrigin: 'http://127.0.0.1:1234', loginSource: 'desktop' } }]) }) + +it('preserves the safe expiration reason and rejects unknown reasons', () => { + const state = { status: 'signed-out', attempt: null, signOutReason: 'expired', + links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' } } + expect(accountView(state)).toEqual(state) + expect(() => accountView({ ...state, signOutReason: 'unexpected' })).toThrow('invalid sign-out reason') +}) diff --git a/apps/desktop/tests/welcome-startup.spec.ts b/apps/desktop/tests/welcome-startup.spec.ts index 5e12c279d6..5d29254c2f 100644 --- a/apps/desktop/tests/welcome-startup.spec.ts +++ b/apps/desktop/tests/welcome-startup.spec.ts @@ -13,6 +13,7 @@ const state = vi.hoisted(() => ({ beforeRead: vi.fn(async () => {}), beforeWelcome: vi.fn(async () => {}), copy: vi.fn(), + accountListener: undefined as ((value: AccountView) => void) | undefined, accountState: vi.fn<() => Promise>().mockResolvedValue({ status: 'signed-out', attempt: null, links: { usageUrl: '', topUpUrl: '' }, }), @@ -24,6 +25,7 @@ const state = vi.hoisted(() => ({ closeWelcome: vi.fn(), welcomeLocale: undefined as DesktopLocale | undefined, preference: 'zh', + hasApiKey: false, handlers: new Map unknown>(), listeners: new Map void>(), contents: undefined as { mainFrame: { url: string } } | undefined, @@ -87,7 +89,9 @@ vi.mock('../src/host-process.ts', () => ({ start = state.startHost stop = state.stopHost fetch() { - return Promise.resolve(Response.json({ loggedIn: false, hasApiKey: false, writable: true, localePreference: state.preference })) + return Promise.resolve(Response.json({ + loggedIn: false, hasApiKey: state.hasApiKey, writable: true, localePreference: state.preference, + })) } }, })) @@ -95,10 +99,13 @@ vi.mock('../src/welcome-backend.ts', () => ({ connectDesktopWelcome: async () => ({ read: async () => { await state.beforeRead() - return { loggedIn: false, hasApiKey: false, writable: true, localePreference: state.preference } + return { loggedIn: false, hasApiKey: state.hasApiKey, writable: true, localePreference: state.preference } }, save: async () => ({ ok: true }), - account: { watch: () => () => {}, state: state.accountState }, + account: { + watch: (listener: (value: AccountView) => void) => { state.accountListener = listener; return () => {} }, + state: state.accountState, + }, }), })) vi.mock('node:fs/promises', async importOriginal => ({ @@ -201,4 +208,15 @@ it('starts the Host for welcome onboarding and opens the workspace on skip witho changed(event, 'en') expect(state.menu).toHaveBeenCalledTimes(initialMenus + 1) expect(await bootstrap(event)).toEqual({ languages: ['en-US'], preference: 'en' }) + const welcomeCount = state.beforeWelcome.mock.calls.length + state.hasApiKey = true + state.accountListener!({ ...account, status: 'credential-stored', attempt: null }) + state.accountListener!({ ...account, status: 'signed-out', attempt: null, signOutReason: 'expired' }) + await vi.advanceTimersByTimeAsync(0) + expect(state.beforeWelcome).toHaveBeenCalledTimes(welcomeCount) + state.hasApiKey = false + state.accountListener!({ ...account, status: 'credential-stored', attempt: null }) + state.accountListener!({ ...account, status: 'signed-out', attempt: null, signOutReason: 'expired' }) + await vi.waitFor(() => { expect(state.beforeWelcome).toHaveBeenCalledTimes(welcomeCount + 1) }) + }) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 3b89b5ced5..99b74a71ee 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 57e2c00c98ab2cf09757d78e93fff042156918b1 -config-catalog.zh.md: f197659787958d902a3069ac889b442dcad46d6b +config-catalog.md: 982333d8b645530f0e7a51440b1cfc9f74c86a1b +config-catalog.zh.md: bf195a16ebbc9e09eb8d6e5b79bb2671ee966b77 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 57e2c00c98..982333d8b6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -639,7 +639,7 @@ export interface Config { } ``` -Source: [`packages/credentials/deepseek-account-platform/src/index.ts:23`](../packages/credentials/deepseek-account-platform/src/index.ts) +Source: [`packages/credentials/deepseek-account-platform/src/index.ts:24`](../packages/credentials/deepseek-account-platform/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index f197659787..bf195a16eb 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -641,7 +641,7 @@ export interface Config { } ``` -来源: [`packages/credentials/deepseek-account-platform/src/index.ts:23`](../packages/credentials/deepseek-account-platform/src/index.ts) +来源: [`packages/credentials/deepseek-account-platform/src/index.ts:24`](../packages/credentials/deepseek-account-platform/src/index.ts) diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index ffd02ee21b..eca46c5e64 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -298,6 +298,27 @@ describe('ui-model-selection dual entry', () => { } }) + it('removes account models from the picker after sign-out', async () => { + const b = await bench('en') + try { + b.setGroups([{ ...GROUPS[0]!, id: 'deepseek-account', name: 'DeepSeek Account' }, ...GROUPS]) + b.remote.emit('llm/adapters-updated', []) + b.mint('s1') + const before = await b.popup().options(projection('s1'), new AbortController().signal) + expect(before.some(option => option.detail?.includes('DeepSeek Account'))).toBe(true) + b.setGroups(GROUPS) + b.remote.emit('credentials/record-updated', ['deepseek-account-platform']) + await vi.waitFor(() => { + expect(b.ctx.modelDirectories.directoryFor(sid('s1')).store.getSnapshot().groups).toEqual(GROUPS) + }) + const after = await b.popup().options(projection('s1'), new AbortController().signal) + expect(after.some(option => option.detail?.includes('DeepSeek Account'))).toBe(false) + expect(after.length).toBeGreaterThan(0) + } finally { + await b.ctx.fiber.dispose() + } + }) + it('both entries share one directory instance per session, isolated across sessions', async () => { const b = await bench() b.mint('a') diff --git a/packages/client/ui-settings-account/README.i18n.yaml b/packages/client/ui-settings-account/README.i18n.yaml index a3c8fbdf6f..eb121fc25e 100644 --- a/packages/client/ui-settings-account/README.i18n.yaml +++ b/packages/client/ui-settings-account/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-account/README.md -README.md: 615d2ef3466fc748d7b8c65c65c356afdd4f3cf3 -README.zh.md: 39d8a94b2e8d98896b13b8c76f7e5ff6390b5d0d +README.md: 54f4461573de456afc29fadabb28d29528199e74 +README.zh.md: 31fb4e76c97fb65aa278bf8c354e6dab169764a4 diff --git a/packages/client/ui-settings-account/README.md b/packages/client/ui-settings-account/README.md index 615d2ef346..54f4461573 100644 --- a/packages/client/ui-settings-account/README.md +++ b/packages/client/ui-settings-account/README.md @@ -7,6 +7,8 @@ kind: "package-reference" English | [中文](README.zh.md) +Server-expired account credentials show a localized sign-in reminder toast and clear the displayed account details. + ## Summary The Account settings section displays DeepSeek login state and offers browser sign-in and cancellation; the sidebar account menu provides Platform sign-out. diff --git a/packages/client/ui-settings-account/README.zh.md b/packages/client/ui-settings-account/README.zh.md index 39d8a94b2e..31fb4e76c9 100644 --- a/packages/client/ui-settings-account/README.zh.md +++ b/packages/client/ui-settings-account/README.zh.md @@ -7,6 +7,8 @@ kind: "package-reference" [English](README.md) | 中文 +账号凭据被服务端判定失效后,显示本地化的重新登录提示 toast,并清空显示的账号详情。 + ## 概述 设置中的账号页面显示 DeepSeek 登录状态,并提供浏览器登录和取消;侧边栏账号菜单提供 Platform 退出登录。 diff --git a/packages/client/ui-settings-account/src/client/AccountMenu.tsx b/packages/client/ui-settings-account/src/client/AccountMenu.tsx index 7570a13190..6a1476175a 100644 --- a/packages/client/ui-settings-account/src/client/AccountMenu.tsx +++ b/packages/client/ui-settings-account/src/client/AccountMenu.tsx @@ -1,6 +1,6 @@ /** Sidebar account launcher and locally authoritative sign-out action. */ -import { useState } from 'react' -import { Menu, IconPaperPlaneOutlineMedium, IconSettingsOutlineMedium, IconUserOutlineMedium } from '@deepseek-ai/dsh-client-ui-primitives' +import { useEffect, useState } from 'react' +import { Toast, Menu, IconPaperPlaneOutlineMedium, IconSettingsOutlineMedium, IconUserOutlineMedium } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { AccountSectionInjected } from './AccountSection.tsx' import { SignOutDialog } from './SignOutDialog.tsx' @@ -21,6 +21,9 @@ export function AccountMenu({ }: AccountMenuProps) { const account = useAccount(state => state) const signedIn = account.view?.status === 'credential-stored' + const expired = account.view?.status === 'signed-out' && account.view.signOutReason === 'expired' + const [expiryNotice, setExpiryNotice] = useState(false) + useEffect(() => { setExpiryNotice(expired) }, [expired]) const profile = account.details?.profile const label = profile === undefined ? null : profile.status === 'ready' ? profile.value.name ?? profile.value.contact ?? t('signedIn') : t('signedIn') @@ -36,6 +39,7 @@ export function AccountMenu({ finally { setBusy(false) } } return
+ {expiryNotice && { setExpiryNotice(false) }} />} { setOpen(value => !value) }}> diff --git a/packages/client/ui-settings-account/src/client/locales.ts b/packages/client/ui-settings-account/src/client/locales.ts index e3a07f3f8f..fad00a6775 100644 --- a/packages/client/ui-settings-account/src/client/locales.ts +++ b/packages/client/ui-settings-account/src/client/locales.ts @@ -1,5 +1,6 @@ /** Account settings copy, owned by the account feature. */ export const en = { + sessionExpired: 'You have signed out of your account, please log in again.', close: 'Close', addApiKey: 'Add API Key', retry: 'Sign in again', loginTitle: 'Start creating', loginDescription: 'Sign in to create, edit, and share your design projects. Everything is saved locally.', browserTitle: 'Waiting for sign in', browserPrompt: 'Page did not open automatically? ', copyLink: 'Copy sign-in link', copiedLink: 'Link copied', copyFailed: 'Copy failed', @@ -27,6 +28,7 @@ export const en = { export type AccountKey = keyof typeof en /** Chinese account settings copy. */ export const zh: Record = { + sessionExpired: '登录信息已失效,请重新登录', close: '关闭', addApiKey: '添加 API Key', retry: '重新登录', loginTitle: '开始你的创作', loginDescription: '登录后即可创建、编辑和分享你的设计项目,所有内容在本地保存。', browserTitle: '等待登录', browserPrompt: '没有自动打开新页面?', copyLink: '复制登录链接', copiedLink: '链接已复制', copyFailed: '复制失败', diff --git a/packages/client/ui-settings-account/tests/account.client.spec.tsx b/packages/client/ui-settings-account/tests/account.client.spec.tsx index dbf81ece0e..3cfb77c5fc 100644 --- a/packages/client/ui-settings-account/tests/account.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/account.client.spec.tsx @@ -243,3 +243,14 @@ it('opens more account information externally without invoking the embedded Plat fireEvent.click(link) expect(platform.open).not.toHaveBeenCalled() }) + +it.each([en, zh])('shows a localized toast when the account credential expires', async (copy) => { + const operations = mount({ status: 'signed-out', attempt: null, signOutReason: 'expired' }, copy) + cleanup() + const { AccountMenu } = await import('../src/client/AccountMenu.tsx') + render( selector(operations.hooks.account.getSnapshot())} wide openOnboarding={() => {}} openSettings={() => {}} + t={key => key in copy ? copy[key as AccountKey] : key} />) + expect(screen.getByRole('alert').textContent).toContain(copy.sessionExpired) + await expect(`${screen.getByRole('alert').textContent}\n`).toMatchFileSnapshot(`./expected/expired-${copy === en ? 'en' : 'zh'}.txt`) +}) diff --git a/packages/client/ui-settings-account/tests/expected/expired-en.txt b/packages/client/ui-settings-account/tests/expected/expired-en.txt new file mode 100644 index 0000000000..4187486bfe --- /dev/null +++ b/packages/client/ui-settings-account/tests/expected/expired-en.txt @@ -0,0 +1 @@ +You have signed out of your account, please log in again. diff --git a/packages/client/ui-settings-account/tests/expected/expired-zh.txt b/packages/client/ui-settings-account/tests/expected/expired-zh.txt new file mode 100644 index 0000000000..7995f21137 --- /dev/null +++ b/packages/client/ui-settings-account/tests/expected/expired-zh.txt @@ -0,0 +1 @@ +登录信息已失效,请重新登录 diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 3147eeab54..3854ee9364 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-models/README.md -README.md: 289a0ec0b3603590dddcab848a79235ca4579bcf -README.zh.md: 10c61a464d48933a692a265b5d5b0812bf79cdbb +README.md: 99b090dcdda887e75b9e6bee6eb73495d9235af1 +README.zh.md: e89be1ab2959daf2758209c376ea6468d7953ed7 diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 289a0ec0b3..99b090dcdd 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -59,7 +59,7 @@ After the versioned notice step completes, the DeepSeek step projects first-run The section declares two seats for plugins distributed outside this repository, typed in [`src/client/slot-contract.ts`](src/client/slot-contract.ts) and exported from `./client`. `settings.models.provider-card` (keyed) renders inside every card that shows a directory row — a saved row's card, its first-run setup posture, and the add-provider draft — dispatched with `entryKey = settingsNs` and owner props carrying the row's `ConfigurableProviderView`, its configured state, and its confirmed api-key credential state, so one registration under an adapter family's namespace receives every card of that family, hand-declared routes included; the hand-declared draft card has no directory row yet and dispatches nothing until saved. `settings.models.footer` (list) renders after the rows and the add controls. A registrant activates through `ctx.slots.inject` with a type-only import of this package's `/client` entry; without registrants both seats render nothing. -The Models page includes **DeepSeek Account** (`deepseek-account`, **DeepSeek 账号** in Chinese). Its editor exposes the shared DeepSeek model catalog without API-key or base-URL inputs. Account availability follows the stored account credential. The first successful API-key setup initializes the default from its own provider; later setup preserves the user’s saved model choice. +The Models page includes **DeepSeek Account** (`deepseek-account`, **DeepSeek 账号** in Chinese). Its editor exposes the shared DeepSeek model catalog without API-key or base-URL inputs. The account row is hidden when its available model catalog is empty, including before sign-in and after sign-out; it returns when account models become available. The first successful API-key setup initializes the default from its own provider; later setup preserves the user’s saved model choice. ----- diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 10c61a464d..e89be1ab29 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -59,7 +59,7 @@ Host 配置 `credentialOnboarding` 默认为 `true`。Electron preload 标记会 本分区为仓库外分发的插件声明两个席位,类型定义在 [`src/client/slot-contract.ts`](src/client/slot-contract.ts) 并从 `./client` 导出。`settings.models.provider-card`(keyed)渲染在每张展示目录行的卡片内部——已保存行的卡片、其首次运行 setup 形态、以及「添加提供方」草稿卡——以 `entryKey = settingsNs` 分发,owner props 携带该行的 `ConfigurableProviderView`、其 configured 状态与已确认的 api-key 凭据状态,因此以某适配器家族的 namespace 注册一次即可收到该家族的全部卡片,含手工声明的路由;手工声明的草稿卡尚无目录行,保存之前不分发。`settings.models.footer`(list)渲染在行列表与新增控件之后。注册方通过 `ctx.slots.inject` 激活,并以 type-only import 引入本包 `/client` 入口;没有注册方时两个席位均不渲染任何内容。 -Models 页面包含 **DeepSeek 账号**(`deepseek-account`,英文为 **DeepSeek Account**)。其编辑器展示共享的 DeepSeek 模型目录,不提供 API Key 或 Base URL 输入框。账号可用性取决于已保存的账号凭据。首次成功配置 API Key 时,从对应提供方初始化默认模型;后续配置保留用户已保存的模型选择。 +Models 页面包含 **DeepSeek 账号**(`deepseek-account`,英文为 **DeepSeek Account**)。其编辑器展示共享的 DeepSeek 模型目录,不提供 API Key 或 Base URL 输入框。账号可用模型目录为空时隐藏账号行,包括登录前和退登后;账号模型恢复可用时重新显示。首次成功配置 API Key 时,从对应提供方初始化默认模型;后续配置保留用户已保存的模型选择。 ----- diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index f693b5960d..34a20107e3 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -240,7 +240,7 @@ export class ModelsSettingsStore { s.error = null s.credentialError = credentialError s.writable = writable - s.rows = rows.map((row) => { + s.rows = rows.filter(row => row.entry.provider !== 'deepseek-account' || row.accountAvailable === true).map((row) => { if (row.entry.provider === 'deepseek-account') return row const named = row.apiKeyEnv === undefined ? undefined : credentials[row.apiKeyEnv] const derived = row.apiKeyEnv !== undefined ? undefined : credentials[deriveKeyRef(row.entry.provider)] diff --git a/packages/client/ui-settings-models/tests/store.client.spec.ts b/packages/client/ui-settings-models/tests/store.client.spec.ts index 5a56062da4..85b2347122 100644 --- a/packages/client/ui-settings-models/tests/store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/store.client.spec.ts @@ -350,8 +350,28 @@ it.each([false, true])('uses account availability without asking for an API key: }] }) }) const store = new ModelsSettingsStore(ctx, settingsSchema, mirror) await store.load() - const row = store.store.getSnapshot().rows[0]! - expect(row).toMatchObject({ accountAvailable, apiKeyEnv: undefined, credential: undefined }) - expect(providerUsable(row)).toBe(accountAvailable) + const rows = store.store.getSnapshot().rows + expect(rows).toHaveLength(accountAvailable ? 1 : 0) + if (accountAvailable) { + expect(rows[0]).toMatchObject({ accountAvailable: true, apiKeyEnv: undefined, credential: undefined }) + expect(providerUsable(rows[0]!)).toBe(true) + } expect(seenRefs).toEqual([]) }) + +it('removes the account row after sign-out and restores it after sign-in', async () => { + const overrides = { accountAvailable: true, providers: async () => ok({ providers: [{ + provider: 'deepseek-account', displayName: 'DeepSeek Account', settingsNs: 'llm-deepseek', settingsPath: [], active: true, + }, ...DIRECTORY] }) } + const { ctx, mirror } = api(overrides) + const store = new ModelsSettingsStore(ctx, settingsSchema, mirror) + await store.load() + expect(store.store.getSnapshot().rows[0]?.entry.provider).toBe('deepseek-account') + overrides.accountAvailable = false + await store.load() + expect(store.store.getSnapshot().rows.map(row => row.entry.provider)).not.toContain('deepseek-account') + expect(store.store.getSnapshot().rows).toHaveLength(DIRECTORY.length) + overrides.accountAvailable = true + await store.load() + expect(store.store.getSnapshot().rows[0]?.entry.provider).toBe('deepseek-account') +}) diff --git a/packages/credentials/deepseek-account-platform/README.i18n.yaml b/packages/credentials/deepseek-account-platform/README.i18n.yaml index b896927357..236da44ed1 100644 --- a/packages/credentials/deepseek-account-platform/README.i18n.yaml +++ b/packages/credentials/deepseek-account-platform/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/credentials/deepseek-account-platform/README.md -README.md: 31172b1749428decebed41fac99e3fc34a70cf6d -README.zh.md: bdff2428021b7452865cb10d6774fff618c3b111 +README.md: 16cb23d805b15e7c2c0d3f6a0b71f52a35626f7a +README.zh.md: 63c2ec09f0ccae7ee7e3f6388974c5244676e939 diff --git a/packages/credentials/deepseek-account-platform/README.md b/packages/credentials/deepseek-account-platform/README.md index 31172b1749..16cb23d805 100644 --- a/packages/credentials/deepseek-account-platform/README.md +++ b/packages/credentials/deepseek-account-platform/README.md @@ -13,6 +13,8 @@ getPlatformSession exports the stored grant only when its issuer matches platfor `desktopPlatform` defaults to `null`. The Desktop profile supplies `darwin` or `win32`; Host authorization, profile, balance, and logout requests then carry `x-client-platform: desktop-mac` or `desktop-win`, overriding that header in deployment configuration. Embedded Platform document and API requests receive the same platform header alongside their deployment headers, only at the configured origin. Other profiles omit the automatic header. +Profile and balance HTTP 401 responses clear the rejected local grant and publish `signOutReason: expired`. Concurrent responses share one removal; responses from an invalidated credential generation cannot clear its replacement. Other HTTP failures retain the grant. + ## Summary Sign in through the system browser and keep the account credential in the existing local credential store. Local cancellation prevents late callbacks and exchange responses from signing the user in. diff --git a/packages/credentials/deepseek-account-platform/README.zh.md b/packages/credentials/deepseek-account-platform/README.zh.md index bdff242802..63c2ec09f0 100644 --- a/packages/credentials/deepseek-account-platform/README.zh.md +++ b/packages/credentials/deepseek-account-platform/README.zh.md @@ -13,6 +13,8 @@ getPlatformSession 仅在已存授权的 issuer 与 platformOrigin 一致时导 `desktopPlatform` 默认为 `null`。Desktop profile 提供 `darwin` 或 `win32`,使 Host 的授权、资料、余额和退登请求携带 `x-client-platform: desktop-mac` 或 `desktop-win`,覆盖部署配置中的同名请求头。内嵌 Platform 的文档与 API 请求仅向配置来源发送相同的平台请求头,同时保留其他部署请求头;其他 profile 不自动添加平台请求头。 +资料和余额接口返回 HTTP 401 时清除被拒绝的本地凭据,并发布 `signOutReason: expired`。并发响应共用一次清除操作;已失效凭据代次的响应不能清除替换后的凭据。其他 HTTP 错误保留凭据。 + ## 概述 通过系统浏览器登录,并将账号凭证保存在现有本地凭证存储中。本地取消会阻止迟到的回调和兑换响应使用户登录。 diff --git a/packages/credentials/deepseek-account-platform/src/details.ts b/packages/credentials/deepseek-account-platform/src/details.ts index d77606bd7b..a7847d0ca8 100644 --- a/packages/credentials/deepseek-account-platform/src/details.ts +++ b/packages/credentials/deepseek-account-platform/src/details.ts @@ -1,7 +1,7 @@ /** Platform Web profile and wallet queries projected for account UI consumers. */ import { z } from 'zod' import type { AccountDetails, AccountProfile, AccountUserId } from '@deepseek-ai/dsh-deepseek-account/types' -import { PlatformAuthError, requestAccount } from './protocol.ts' +import { AccountUnauthorizedError, PlatformAuthError, requestAccount } from './protocol.ts' const user = z.object({ id: z.string().nullish(), @@ -48,10 +48,14 @@ const queries: { [K in keyof AccountDetails]: { * @param signal - request and credential lifetime. * @param headers - validated deployment headers for the configured origin. * @returns sanitized query outcome; failure never becomes a zero balance. + * @throws AccountUnauthorizedError when Platform rejects the stored token with HTTP 401. */ export async function readAccountDetail(field: K, origin: string, token: string, signal: AbortSignal, headers: Record): Promise { const query = queries[field] try { return query.parse(await requestAccount(origin, query.path, token, signal, headers)) } - catch { return { status: 'failed' } } + catch (error) { + if (error instanceof AccountUnauthorizedError) throw error + return { status: 'failed' } + } } diff --git a/packages/credentials/deepseek-account-platform/src/index.ts b/packages/credentials/deepseek-account-platform/src/index.ts index 835b79d7da..c41ec18100 100644 --- a/packages/credentials/deepseek-account-platform/src/index.ts +++ b/packages/credentials/deepseek-account-platform/src/index.ts @@ -10,6 +10,7 @@ import { z } from 'zod' import { DeepSeekAccount, desktopClientHeaders, mergePlatformCookies, type PlatformSession, type AccountDetails, type AccountView, type SignInAttemptId, type SignInAttemptView } from '@deepseek-ai/dsh-deepseek-account' import { credentialKey } from '@deepseek-ai/dsh-credentials' import type { AuthorizationSession } from '@deepseek-ai/dsh-authorization' +import { AccountUnauthorizedError } from './protocol.ts' import { profile, readAccountDetail } from './details.ts' import { revokeAccount, type LogoutRetryPolicy } from './logout.ts' import { PlatformAuthError, platformHeaders, platformOrigin, browserUrl, requestPlatform, initialization, exchange, loginOrigin } from './protocol.ts' @@ -98,6 +99,7 @@ export class PlatformAccount extends DeepSeekAccount { private detailsLifetime = new AbortController() private closed = false private removing: Promise | undefined + private signOutReason: 'expired' | undefined /** @param ctx - Host with authorization and credentials services. @param config - deployment options. */ constructor(ctx: Context, config: Config = {}) { @@ -170,6 +172,7 @@ export class PlatformAccount extends DeepSeekAccount { throw new PlatformAuthError('storage') } return { + ...(record === undefined && this.signOutReason !== undefined ? { signOutReason: this.signOutReason } : {}), status: record === undefined ? 'signed-out' : 'credential-stored', attempt: this.attempt?.view ?? null, links: { usageUrl: new URL('/usage', this.origin).href, topUpUrl: new URL('/top_up', this.origin).href }, } @@ -200,10 +203,34 @@ export class PlatformAccount extends DeepSeekAccount { delete this.attempt.initialProfile return initial as AccountDetails[K] } - const details = await readAccountDetail(field, this.origin, parsed.data.token, - AbortSignal.any([lifetime.signal, AbortSignal.timeout(this.requestTimeout)]), - { ...this.accountRequestHeaders, ...this.clientHeaders }) - return this.detailsLifetime !== lifetime ? null : details + try { + const details = await readAccountDetail(field, this.origin, parsed.data.token, + AbortSignal.any([lifetime.signal, AbortSignal.timeout(this.requestTimeout)]), + { ...this.accountRequestHeaders, ...this.clientHeaders }) + return this.detailsLifetime !== lifetime ? null : details + } catch (error) { + if (!(error instanceof AccountUnauthorizedError)) throw error + if (this.detailsLifetime === lifetime) await this.expireCredential(parsed.data.token, lifetime) + return null + } + } + + private async expireCredential(token: string, lifetime: AbortController): Promise { + this.removing ??= (async () => { + if (this.attempt !== undefined) await this.cancelSignIn(this.attempt.view.id) + const record = await this.ctx.credentials.readRecord(KEY) + if (this.closed || this.detailsLifetime !== lifetime || record?.kind !== 'grant') return this.getState() + const current = grant.parse(record.payload) + if (current.token !== token || current.issuer !== this.origin) return this.getState() + this.signOutReason = 'expired' + try { await this.ctx.credentials.deleteRecord(KEY) } + catch (error) { this.signOutReason = undefined; throw error } + this.attempt = undefined + this.ctx.emit('deepseek-account/signed-out') + this.changed() + return this.getState() + })().finally(() => { this.removing = undefined }) + await this.removing } override async getPlatformSession(): Promise { @@ -241,6 +268,7 @@ export class PlatformAccount extends DeepSeekAccount { override async startSignIn(locale: string, callbackOrigin: string, loginSource: 'web' | 'desktop'): Promise { if (this.removing !== undefined) await this.removing + this.signOutReason = undefined const origin = loginOrigin(callbackOrigin) if (this.closed) throw new PlatformAuthError('protocol') if (this.attempt !== undefined && ['initializing', 'waiting-browser', 'exchanging', 'committing'].includes(this.attempt.view.phase)) { @@ -299,6 +327,7 @@ export class PlatformAccount extends DeepSeekAccount { } override signOut(): Promise { + this.signOutReason = undefined this.removing ??= (async () => { if (this.closed) throw new PlatformAuthError('protocol') if (this.attempt !== undefined) await this.cancelSignIn(this.attempt.view.id) diff --git a/packages/credentials/deepseek-account-platform/src/protocol.ts b/packages/credentials/deepseek-account-platform/src/protocol.ts index c437f67ac3..092518c4ad 100644 --- a/packages/credentials/deepseek-account-platform/src/protocol.ts +++ b/packages/credentials/deepseek-account-platform/src/protocol.ts @@ -7,6 +7,11 @@ export class PlatformAuthError extends Error { constructor(readonly code: 'network' | 'protocol' | 'expired' | 'storage') { super(`account: ${code}`) } } +/** An authenticated Platform request was rejected with HTTP 401. */ +export class AccountUnauthorizedError extends PlatformAuthError { + constructor() { super('expired') } +} + /** * Accept HTTPS platform endpoints, or explicitly configured loopback development HTTP. * @param value - configured origin. @@ -132,6 +137,10 @@ async function platformRequest(url: string, init: RequestInit, signal: AbortSign throw new PlatformAuthError('network') } console.info('[deepseek-account] response', { path, status: response.status }) + if (response.status === 401 && new Headers(init.headers).has('x-dsh-auth-token')) { + await response.body?.cancel() + throw new AccountUnauthorizedError() + } if (!response.ok || response.body === null) { await response.body?.cancel() throw new PlatformAuthError('network') diff --git a/packages/credentials/deepseek-account-platform/tests/account.spec.ts b/packages/credentials/deepseek-account-platform/tests/account.spec.ts index d04dc498ce..e690a5ccfc 100644 --- a/packages/credentials/deepseek-account-platform/tests/account.spec.ts +++ b/packages/credentials/deepseek-account-platform/tests/account.spec.ts @@ -45,6 +45,7 @@ async function fixture( let origin = '' let detailsHold = false let balanceHold = false + let detailStatus = 200 let profileFailed = false let summaryFailed = false let logoutFailed = false @@ -75,8 +76,10 @@ async function fixture( } if (req.method === 'GET') { detailRequests.push({ path: req.url!, authorization: req.headers['x-dsh-auth-token'] as string | undefined }) + const status = detailStatus detailsStarted.resolve(undefined) if (detailsHold || (balanceHold && req.url === '/api/v0/users/get_user_summary')) await release.promise + if (status !== 200) { res.writeHead(status).end(); return } const value = req.url === '/auth-api/v0/users/current' ? { id: 'test-user', token: 'never-copy-response-token', ...contact, id_profile: { name: 'Test Account', picture: null } } @@ -151,6 +154,7 @@ async function fixture( ctx, account, home, origin, callbackOrigin, wait, receivedHeaders, logoutHeaders, logoutCount: () => logoutCount, holdLogout: () => { logoutHold = true }, failLogout: (failed: boolean) => { logoutFailed = failed }, detailRequests, detailsStarted, + detailStatus: (status: number) => { detailStatus = status }, failProfile: (failed: boolean) => { profileFailed = failed }, holdBalance: () => { balanceHold = true }, holdDetails: () => { detailsHold = true }, failSummary: () => { summaryFailed = true }, @@ -801,3 +805,54 @@ it.each([undefined, [{ currency: 'EUR', balance: '1' }], [{ currency: 'CNY', bal expect(await f.account.getBalance()).toEqual({ status: 'failed' }) }, ) + +it.each(['profile', 'balance'] as const)('clears the rejected account grant after a %s HTTP 401', async (field) => { + const f = await fixture() + await storeAccount(f) + let signedOut = 0 + f.ctx.on('deepseek-account/signed-out', () => { signedOut++ }) + f.detailStatus(401) + expect(await (field === 'profile' ? f.account.getProfile() : f.account.getBalance())).toBeNull() + expect(await f.account.getState()).toMatchObject({ status: 'signed-out', signOutReason: 'expired', attempt: null }) + expect(await f.ctx.credentials.readRecord(credentialKey('deepseek-account-platform', 'default'))).toBeUndefined() + expect(signedOut).toBe(1) +}) + +it('coalesces simultaneous unauthorized profile and balance responses', async () => { + const f = await fixture() + await storeAccount(f) + let signedOut = 0 + f.ctx.on('deepseek-account/signed-out', () => { signedOut++ }) + f.detailStatus(401) + await Promise.all([f.account.getProfile(), f.account.getBalance()]) + expect(signedOut).toBe(1) + expect(await f.account.getState()).toMatchObject({ status: 'signed-out', signOutReason: 'expired' }) +}) + +it.each([403, 500])('retains the account grant after HTTP %s', async (status) => { + const f = await fixture() + await storeAccount(f) + f.detailStatus(status) + expect(await f.account.getBalance()).toEqual({ status: 'failed' }) + expect(await f.account.getState()).toMatchObject({ status: 'credential-stored' }) +}) + +it('does not remove a replacement grant when an older request is rejected', async () => { + const f = await fixture() + await storeAccount(f) + f.detailStatus(401) + f.holdDetails() + const pending = f.account.getBalance() + try { + await f.detailsStarted.promise + await f.ctx.credentials.modifyRecord(credentialKey('deepseek-account-platform', 'default'), () => Promise.resolve({ + kind: 'grant', payload: { version: 1, issuer: f.origin, token: 'replacement-grant' }, + })) + } finally { + f.release.resolve(undefined) + } + expect(await pending).toBeNull() + expect(await f.account.getState()).toMatchObject({ status: 'credential-stored' }) + expect(await f.ctx.credentials.readRecord(credentialKey('deepseek-account-platform', 'default'))) + .toMatchObject({ kind: 'grant', payload: { token: 'replacement-grant' } }) +}) diff --git a/packages/credentials/deepseek-account/README.i18n.yaml b/packages/credentials/deepseek-account/README.i18n.yaml index 4841809a90..c2ae5249f5 100644 --- a/packages/credentials/deepseek-account/README.i18n.yaml +++ b/packages/credentials/deepseek-account/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/credentials/deepseek-account/README.md -README.md: 52bc8e15ee72282b79c0b8823b56476650cdf728 -README.zh.md: 427b4a22aaf1ed503d41e90d0db600d220ef931e +README.md: bfebbc1ca9fa7cdfedbfbef7b6ca5f696043752c +README.zh.md: dd1fe65c17a89a73132380d663743601dd0ec3d5 diff --git a/packages/credentials/deepseek-account/README.md b/packages/credentials/deepseek-account/README.md index 52bc8e15ee..bfebbc1ca9 100644 --- a/packages/credentials/deepseek-account/README.md +++ b/packages/credentials/deepseek-account/README.md @@ -11,6 +11,8 @@ getPlatformSession returns a Host-only origin/token snapshot for native Platform `desktopClientHeaders` maps the native `darwin` and `win32` platforms to the shared Desktop account and update-policy request header; `null` adds no header. +`AccountView.signOutReason` identifies a server-expired credential separately from explicit sign-out; it is absent while signed in. + ## Summary Account consumers read stored login state, start or cancel a browser login, and sign out without editing API keys. Host model consumers resolve account credentials only for the provider-configured inference origin. diff --git a/packages/credentials/deepseek-account/README.zh.md b/packages/credentials/deepseek-account/README.zh.md index 427b4a22aa..dd1fe65c17 100644 --- a/packages/credentials/deepseek-account/README.zh.md +++ b/packages/credentials/deepseek-account/README.zh.md @@ -11,6 +11,8 @@ getPlatformSession 为原生 Platform 内嵌提供仅限 Host 的 origin/token `desktopClientHeaders` 将原生平台 `darwin` 和 `win32` 映射为 Desktop 账号与更新策略请求共用的请求头;`null` 不添加请求头。 +`AccountView.signOutReason` 区分服务端判定凭据失效和主动退登;登录状态下不携带该字段。 + ## 概述 账号使用方可读取本地登录状态、发起或取消浏览器登录,并在保留 API Key 的情况下退出。Host 模型使用方仅能为提供者配置的推理来源解析账号凭证。 diff --git a/packages/credentials/deepseek-account/src/types.ts b/packages/credentials/deepseek-account/src/types.ts index f59c959567..7bc5f624f9 100644 --- a/packages/credentials/deepseek-account/src/types.ts +++ b/packages/credentials/deepseek-account/src/types.ts @@ -21,6 +21,8 @@ export interface AccountLinks { /** Stored-account presence is not a claim that the server has validated its token. */ export interface AccountView { readonly status: 'signed-out' | 'credential-stored' + /** Server rejection cleared the local credential. */ + readonly signOutReason?: 'expired' readonly links: AccountLinks readonly attempt: SignInAttemptView | null } diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index f05c4ed4f6..53b23dd9f8 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4130,7 +4130,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'AccountView', - declaration: 'export interface AccountView {\n readonly status: \'signed-out\' | \'credential-stored\';\n readonly links: AccountLinks;\n readonly attempt: SignInAttemptView | null;\n}', + declaration: 'export interface AccountView {\n readonly status: \'signed-out\' | \'credential-stored\';\n readonly signOutReason?: \'expired\';\n readonly links: AccountLinks;\n readonly attempt: SignInAttemptView | null;\n}', }, { name: 'AccountWallet', From bc8e8005fc4d17a2ec4046c7103256ac43878c00 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:19:29 +0800 Subject: [PATCH 026/192] docs: refresh module graph for account provider dependencies --- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 32 +++++++++++++++++--------------- docs/module-graph.zh.md | 32 +++++++++++++++++--------------- 3 files changed, 36 insertions(+), 32 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 6ae367c64a..19bba03247 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: bfe3e5faa7168b03848ac76d2dd2584d4aa31f5e -module-graph.zh.md: bfa0f83e14cbcf89599fa207ec919195128db49e +module-graph.md: 7ccaa6d9eb060fadf75f581c6837cb7d06bd9e61 +module-graph.zh.md: 0e2ff4f1ea5780f7adc11693d7a97c39b37b0c3b diff --git a/docs/module-graph.md b/docs/module-graph.md index bfe3e5faa7..7ccaa6d9eb 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -460,8 +460,6 @@ flowchart TD pkg_web_search_exa --> pkg_web pkg_web_search_perplexity --> pkg_launch_environment pkg_web_search_perplexity --> pkg_web - pkg_api_account_controller --> pkg_deepseek_account - pkg_api_account_controller --> pkg_typert_protocol pkg_api_remotes --> pkg_scope pkg_api_terminal_controller --> pkg_subprocess pkg_attachment_local --> pkg_attachment @@ -546,17 +544,6 @@ flowchart TD pkg_workspace --> pkg_storage pkg_workspace --> pkg_storage_domain pkg_workspace --> pkg_typert_protocol - pkg_llm_deepseek --> pkg_anonymous_user_id - pkg_llm_deepseek --> pkg_atomic_write - pkg_llm_deepseek --> pkg_attachment - pkg_llm_deepseek --> pkg_credentials - pkg_llm_deepseek --> pkg_deepseek_account - pkg_llm_deepseek --> pkg_deepseek_llm_api_extensions - pkg_llm_deepseek --> pkg_fs - pkg_llm_deepseek --> pkg_home_paths - pkg_llm_deepseek --> pkg_launch_environment - pkg_llm_deepseek --> pkg_llm - pkg_llm_deepseek --> pkg_timeout pkg_llm_pi_ai --> pkg_attachment pkg_llm_pi_ai --> pkg_authorization pkg_llm_pi_ai --> pkg_credentials @@ -609,6 +596,18 @@ flowchart TD pkg_pwsh_local --> pkg_shell pkg_pwsh_local --> pkg_subprocess pkg_pwsh_local --> pkg_timeout + pkg_llm_deepseek --> pkg_agent + pkg_llm_deepseek --> pkg_anonymous_user_id + pkg_llm_deepseek --> pkg_atomic_write + pkg_llm_deepseek --> pkg_attachment + pkg_llm_deepseek --> pkg_credentials + pkg_llm_deepseek --> pkg_deepseek_account + pkg_llm_deepseek --> pkg_deepseek_llm_api_extensions + pkg_llm_deepseek --> pkg_fs + pkg_llm_deepseek --> pkg_home_paths + pkg_llm_deepseek --> pkg_launch_environment + pkg_llm_deepseek --> pkg_llm + pkg_llm_deepseek --> pkg_timeout pkg_llm_retry --> pkg_agent pkg_llm_retry --> pkg_brand pkg_llm_retry --> pkg_invariants @@ -631,6 +630,9 @@ flowchart TD pkg_web_search_deepseek --> pkg_launch_environment pkg_web_search_deepseek --> pkg_session pkg_web_search_deepseek --> pkg_web + pkg_api_account_controller --> pkg_agent + pkg_api_account_controller --> pkg_deepseek_account + pkg_api_account_controller --> pkg_typert_protocol pkg_file_reference --> pkg_agent pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants @@ -1475,7 +1477,6 @@ flowchart TD | [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`http-proxy`](../packages/util/http-proxy), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | -| [`api-account-controller`](../packages/api/account-controller) | `api` | [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) | | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | | [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | @@ -1515,7 +1516,6 @@ flowchart TD | [`session-turn-outline`](../packages/session/session-turn-outline) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`shell`](../packages/shell/shell) | `shell` | [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values), [`workspace`](../packages/workspace/workspace) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) | @@ -1530,10 +1530,12 @@ flowchart TD | [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`bash-local`](../packages/shell/bash-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`agent`](../packages/core/agent), [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) | | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) | +| [`api-account-controller`](../packages/api/account-controller) | `api` | [`agent`](../packages/core/agent), [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index bfa0f83e14..0e2ff4f1ea 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -462,8 +462,6 @@ flowchart TD pkg_web_search_exa --> pkg_web pkg_web_search_perplexity --> pkg_launch_environment pkg_web_search_perplexity --> pkg_web - pkg_api_account_controller --> pkg_deepseek_account - pkg_api_account_controller --> pkg_typert_protocol pkg_api_remotes --> pkg_scope pkg_api_terminal_controller --> pkg_subprocess pkg_attachment_local --> pkg_attachment @@ -548,17 +546,6 @@ flowchart TD pkg_workspace --> pkg_storage pkg_workspace --> pkg_storage_domain pkg_workspace --> pkg_typert_protocol - pkg_llm_deepseek --> pkg_anonymous_user_id - pkg_llm_deepseek --> pkg_atomic_write - pkg_llm_deepseek --> pkg_attachment - pkg_llm_deepseek --> pkg_credentials - pkg_llm_deepseek --> pkg_deepseek_account - pkg_llm_deepseek --> pkg_deepseek_llm_api_extensions - pkg_llm_deepseek --> pkg_fs - pkg_llm_deepseek --> pkg_home_paths - pkg_llm_deepseek --> pkg_launch_environment - pkg_llm_deepseek --> pkg_llm - pkg_llm_deepseek --> pkg_timeout pkg_llm_pi_ai --> pkg_attachment pkg_llm_pi_ai --> pkg_authorization pkg_llm_pi_ai --> pkg_credentials @@ -611,6 +598,18 @@ flowchart TD pkg_pwsh_local --> pkg_shell pkg_pwsh_local --> pkg_subprocess pkg_pwsh_local --> pkg_timeout + pkg_llm_deepseek --> pkg_agent + pkg_llm_deepseek --> pkg_anonymous_user_id + pkg_llm_deepseek --> pkg_atomic_write + pkg_llm_deepseek --> pkg_attachment + pkg_llm_deepseek --> pkg_credentials + pkg_llm_deepseek --> pkg_deepseek_account + pkg_llm_deepseek --> pkg_deepseek_llm_api_extensions + pkg_llm_deepseek --> pkg_fs + pkg_llm_deepseek --> pkg_home_paths + pkg_llm_deepseek --> pkg_launch_environment + pkg_llm_deepseek --> pkg_llm + pkg_llm_deepseek --> pkg_timeout pkg_llm_retry --> pkg_agent pkg_llm_retry --> pkg_brand pkg_llm_retry --> pkg_invariants @@ -633,6 +632,9 @@ flowchart TD pkg_web_search_deepseek --> pkg_launch_environment pkg_web_search_deepseek --> pkg_session pkg_web_search_deepseek --> pkg_web + pkg_api_account_controller --> pkg_agent + pkg_api_account_controller --> pkg_deepseek_account + pkg_api_account_controller --> pkg_typert_protocol pkg_file_reference --> pkg_agent pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants @@ -1477,7 +1479,6 @@ flowchart TD | [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`http-proxy`](../packages/util/http-proxy), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | -| [`api-account-controller`](../packages/api/account-controller) | `api` | [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) | | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | | [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | @@ -1517,7 +1518,6 @@ flowchart TD | [`session-turn-outline`](../packages/session/session-turn-outline) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`shell`](../packages/shell/shell) | `shell` | [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values), [`workspace`](../packages/workspace/workspace) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) | @@ -1532,10 +1532,12 @@ flowchart TD | [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`bash-local`](../packages/shell/bash-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`agent`](../packages/core/agent), [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) | | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) | +| [`api-account-controller`](../packages/api/account-controller) | `api` | [`agent`](../packages/core/agent), [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) | From 43f107d164ce69a24c98f65b8835ce43982f7647 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:27:38 +0800 Subject: [PATCH 027/192] fix(account): expire grants rejected by Platform response code --- .../deepseek-account-platform/README.i18n.yaml | 4 ++-- .../deepseek-account-platform/README.md | 2 +- .../deepseek-account-platform/README.zh.md | 2 +- .../deepseek-account-platform/src/details.ts | 2 +- .../deepseek-account-platform/src/index.ts | 2 +- .../deepseek-account-platform/src/protocol.ts | 9 +++++++-- .../tests/account.spec.ts | 15 +++++++++++++++ .../tests/authorization-response.spec.ts | 18 ++++++++++++++++++ 8 files changed, 46 insertions(+), 8 deletions(-) create mode 100644 packages/credentials/deepseek-account-platform/tests/authorization-response.spec.ts diff --git a/packages/credentials/deepseek-account-platform/README.i18n.yaml b/packages/credentials/deepseek-account-platform/README.i18n.yaml index 4a5e95d0b8..1e0eb5ef40 100644 --- a/packages/credentials/deepseek-account-platform/README.i18n.yaml +++ b/packages/credentials/deepseek-account-platform/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/credentials/deepseek-account-platform/README.md -README.md: 204689db355a988daa91f645c338421c50429193 -README.zh.md: 6ae778df799b6fff8e01bf46e284a537819b9412 +README.md: a7e9ef87ce59a01cef4a20b2d544a986d26a7d73 +README.zh.md: f64964af053f4ab601cdda3b90f4c15ffc360650 diff --git a/packages/credentials/deepseek-account-platform/README.md b/packages/credentials/deepseek-account-platform/README.md index 204689db35..a7e9ef87ce 100644 --- a/packages/credentials/deepseek-account-platform/README.md +++ b/packages/credentials/deepseek-account-platform/README.md @@ -13,7 +13,7 @@ getPlatformSession exports the stored grant only when its issuer matches platfor `desktopPlatform` defaults to `null`. Every profile then sends `x-client-platform: web` on Host authorization, profile, balance, and logout requests; the Desktop profile supplies `darwin` or `win32`, replacing it with `x-client-platform: desktop-mac` or `desktop-win`. The provider owns that header, so deployment configuration cannot override it. Embedded Platform document and API requests receive the same platform header alongside their deployment headers, only at the configured origin. -Profile and balance HTTP 401 responses clear the rejected local grant and publish `signOutReason: expired`. Concurrent responses share one removal; responses from an invalidated credential generation cannot clear its replacement. Other HTTP failures retain the grant. +Profile and balance HTTP 401 responses or top-level response code `40003` (invalid authorization) clear the rejected local grant and publish `signOutReason: expired`. Concurrent responses share one removal; responses from an invalidated credential generation cannot clear its replacement. Other HTTP failures retain the grant. ## Summary diff --git a/packages/credentials/deepseek-account-platform/README.zh.md b/packages/credentials/deepseek-account-platform/README.zh.md index 6ae778df79..f64964af05 100644 --- a/packages/credentials/deepseek-account-platform/README.zh.md +++ b/packages/credentials/deepseek-account-platform/README.zh.md @@ -13,7 +13,7 @@ getPlatformSession 仅在已存授权的 issuer 与 platformOrigin 一致时导 `desktopPlatform` 默认为 `null`。此时所有 profile 的 Host 授权、资料、余额和退登请求都携带 `x-client-platform: web`;Desktop profile 提供 `darwin` 或 `win32`,改为携带 `x-client-platform: desktop-mac` 或 `desktop-win`。该请求头由 provider 拥有,部署配置无法覆盖。内嵌 Platform 的文档与 API 请求仅向配置来源发送相同的平台请求头,同时保留其他部署请求头。 -资料和余额接口返回 HTTP 401 时清除被拒绝的本地凭据,并发布 `signOutReason: expired`。并发响应共用一次清除操作;已失效凭据代次的响应不能清除替换后的凭据。其他 HTTP 错误保留凭据。 +资料和余额接口返回 HTTP 401 或顶层响应码 `40003`(鉴权失效)时清除被拒绝的本地凭据,并发布 `signOutReason: expired`。并发响应共用一次清除操作;已失效凭据代次的响应不能清除替换后的凭据。其他 HTTP 错误保留凭据。 ## 概述 diff --git a/packages/credentials/deepseek-account-platform/src/details.ts b/packages/credentials/deepseek-account-platform/src/details.ts index a7847d0ca8..a3638116b4 100644 --- a/packages/credentials/deepseek-account-platform/src/details.ts +++ b/packages/credentials/deepseek-account-platform/src/details.ts @@ -48,7 +48,7 @@ const queries: { [K in keyof AccountDetails]: { * @param signal - request and credential lifetime. * @param headers - validated deployment headers for the configured origin. * @returns sanitized query outcome; failure never becomes a zero balance. - * @throws AccountUnauthorizedError when Platform rejects the stored token with HTTP 401. + * @throws AccountUnauthorizedError when Platform rejects the stored token with HTTP 401 or response code 40003. */ export async function readAccountDetail(field: K, origin: string, token: string, signal: AbortSignal, headers: Record): Promise { diff --git a/packages/credentials/deepseek-account-platform/src/index.ts b/packages/credentials/deepseek-account-platform/src/index.ts index d0cc6f5d21..06896b701a 100644 --- a/packages/credentials/deepseek-account-platform/src/index.ts +++ b/packages/credentials/deepseek-account-platform/src/index.ts @@ -202,7 +202,7 @@ export class PlatformAccount extends DeepSeekAccount { { ...this.accountRequestHeaders, ...this.clientHeaders }) return this.detailsLifetime !== lifetime ? null : details } catch (_unauthorized) { - // readAccountDetail exposes only authenticated HTTP 401 failures. + // readAccountDetail exposes only authenticated credential rejection failures. if (this.detailsLifetime === lifetime) await this.expireCredential(stored.token, lifetime) return null } diff --git a/packages/credentials/deepseek-account-platform/src/protocol.ts b/packages/credentials/deepseek-account-platform/src/protocol.ts index 092518c4ad..c8e65107d8 100644 --- a/packages/credentials/deepseek-account-platform/src/protocol.ts +++ b/packages/credentials/deepseek-account-platform/src/protocol.ts @@ -7,7 +7,7 @@ export class PlatformAuthError extends Error { constructor(readonly code: 'network' | 'protocol' | 'expired' | 'storage') { super(`account: ${code}`) } } -/** An authenticated Platform request was rejected with HTTP 401. */ +/** An authenticated Platform request was rejected with HTTP 401 or code 40003. */ export class AccountUnauthorizedError extends PlatformAuthError { constructor() { super('expired') } } @@ -162,6 +162,10 @@ async function platformRequest(url: string, init: RequestInit, signal: AbortSign } stage = 'parse-json' const payload: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8')) + const authorization = z.object({ code: z.literal(40003) }).safeParse(payload) + if (authorization.success && new Headers(init.headers).has('x-dsh-auth-token')) { + throw new AccountUnauthorizedError() + } const codes = z.object({ code: z.number().int(), data: z.object({ biz_code: z.number().int() }).optional() }).safeParse(payload) if (codes.success) console.info('[deepseek-account] response codes', { path, code: codes.data.code, bizCode: codes.data.data?.biz_code, @@ -182,7 +186,8 @@ async function platformRequest(url: string, init: RequestInit, signal: AbortSign } return parsed.data.data.biz_data } catch (error) { - console.info('[deepseek-account] response rejected', { path, stage, errorCode: 'protocol' }) + console.info('[deepseek-account] response rejected', { path, stage, + errorCode: error instanceof PlatformAuthError ? error.code : 'protocol' }) if (error instanceof PlatformAuthError) throw error throw new PlatformAuthError('protocol') } finally { diff --git a/packages/credentials/deepseek-account-platform/tests/account.spec.ts b/packages/credentials/deepseek-account-platform/tests/account.spec.ts index a6e90657bd..805c8ed46d 100644 --- a/packages/credentials/deepseek-account-platform/tests/account.spec.ts +++ b/packages/credentials/deepseek-account-platform/tests/account.spec.ts @@ -49,6 +49,7 @@ async function fixture( let origin = '' let detailsHold = false let balanceHold = false + let detailCode = 0 let detailStatus = 200 let profileFailed = false let summaryFailed = false @@ -84,6 +85,7 @@ async function fixture( detailsStarted.resolve(undefined) if (detailsHold || (balanceHold && req.url === '/api/v0/users/get_user_summary')) await release.promise if (status !== 200) { res.writeHead(status).end(); return } + if (detailCode !== 0) { res.end(JSON.stringify({ code: detailCode, data: null })); return } const value = req.url === '/auth-api/v0/users/current' ? { id: 'test-user', token: 'never-copy-response-token', ...contact, id_profile: { name: 'Test Account', picture: null } } @@ -159,6 +161,7 @@ async function fixture( ctx, account, home, origin, callbackOrigin, wait, receivedHeaders, logoutHeaders, logoutCount: () => logoutCount, holdLogout: () => { logoutHold = true }, failLogout: (failed: boolean) => { logoutFailed = failed }, detailRequests, detailsStarted, + detailCode: (code: number) => { detailCode = code }, detailStatus: (status: number) => { detailStatus = status }, failProfile: (failed: boolean) => { profileFailed = failed }, holdBalance: () => { balanceHold = true }, @@ -883,6 +886,18 @@ it.each(['profile', 'balance'] as const)('clears the rejected account grant afte expect(signedOut).toBe(1) }) +it.each(['profile', 'balance'] as const)('clears the rejected account grant after a %s HTTP 200 with code 40003', async (field) => { + const f = await fixture() + await storeAccount(f) + let signedOut = 0 + f.ctx.on('deepseek-account/signed-out', () => { signedOut++ }) + f.detailCode(40003) + expect(await (field === 'profile' ? f.account.getProfile() : f.account.getBalance())).toBeNull() + expect(await f.account.getState()).toMatchObject({ status: 'signed-out', signOutReason: 'expired', attempt: null }) + expect(await f.ctx.credentials.readRecord(credentialKey('deepseek-account-platform', 'default'))).toBeUndefined() + expect(signedOut).toBe(1) +}) + it('coalesces simultaneous unauthorized profile and balance responses', async () => { const f = await fixture() await storeAccount(f) diff --git a/packages/credentials/deepseek-account-platform/tests/authorization-response.spec.ts b/packages/credentials/deepseek-account-platform/tests/authorization-response.spec.ts new file mode 100644 index 0000000000..f28fd25928 --- /dev/null +++ b/packages/credentials/deepseek-account-platform/tests/authorization-response.spec.ts @@ -0,0 +1,18 @@ +import { expect, it, vi } from 'vitest' +import { AccountUnauthorizedError, requestAccount, requestPlatform } from '../src/protocol.ts' + +it.each([null, undefined])('recognizes invalid authorization without requiring business data (%s)', async (data) => { + const fetcher = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ code: 40003, data }))) + try { + await expect(requestAccount('https://platform.deepseek.com', '/auth-api/v0/users/current', + 'fixture-token', new AbortController().signal, {})).rejects.toBeInstanceOf(AccountUnauthorizedError) + } finally { fetcher.mockRestore() } +}) + +it('does not classify an unauthenticated exchange failure as a rejected stored credential', async () => { + const fetcher = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ code: 40003, data: null }))) + try { + await expect(requestPlatform('https://platform.deepseek.com', 'auth_exchange', {}, + new AbortController().signal, {})).rejects.toThrow('account: protocol') + } finally { fetcher.mockRestore() } +}) From 749f05e9fa57680810603271b2c142f952ef6912 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 22 Sep 2026 15:38:32 +0800 Subject: [PATCH 028/192] fix(agent-team): refresh projections when opening the panel --- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 +- packages/api/session-controller/README.zh.md | 2 +- .../src/client/contract/sessions.ts | 13 ++++- .../session-controller/src/client/index.ts | 2 +- .../src/client/sessions/manager.ts | 18 ++++-- .../src/client/sessions/service.ts | 9 +-- .../tests/manager.client.spec.ts | 57 +++++++++++++++++++ .../tests/sessions-service.client.spec.ts | 11 ++++ .../experimental/agent-team/README.i18n.yaml | 4 +- packages/experimental/agent-team/README.md | 2 +- packages/experimental/agent-team/README.zh.md | 2 +- .../client-ui-agent-team/README.i18n.yaml | 4 +- .../client-ui-agent-team/README.md | 6 +- .../client-ui-agent-team/README.zh.md | 6 +- .../src/client/TeamAction.tsx | 34 ++++------- .../client-ui-agent-team/src/client/mount.ts | 4 +- .../tests/browser-plugin.client.spec.ts | 11 ++-- .../tests/team-action.client.spec.tsx | 52 +++++++++-------- .../src/client/api-catalog.ts | 12 ++-- 20 files changed, 169 insertions(+), 86 deletions(-) diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index a6a1d20012..31847df767 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/session-controller/README.md -README.md: e12ad7623eefc73c94e99cc3078e0ae78d6a969a -README.zh.md: 5f4ceec9a8e8303cad6651dbbe7af27fc07bccec +README.md: e00acab6862e66f1c1f34ce81f0249513c9bf5c8 +README.zh.md: b2ad89a8664d54f70d2364aeaf3b1c7d3b3f5bff diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index e12ad7623e..e00acab686 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -39,7 +39,7 @@ Client list refreshes retain unchanged row objects and reuse the items array whe Background-job rows and observation streams belong to [`dsh-api-job-controller`](../job-controller/README.md); the control stream carries projections only. -Projection reads retain loading and failure state independently of their values. Reconnect cancels reads from the previous connection and reloads previously requested projections; live membership updates arrive through the control stream. Parent availability comes from Host summaries and remains unknown until a summary or a successful list baseline establishes it. Address lookup resolves projected children without selecting them or creating scopes. +Projection reads retain loading and failure state independently of their values. `refreshProjections` reuses a successful baseline within one connection by default. Passing `{ force: true }` requests a fresh baseline after any running read; concurrent forced calls during that read share one follow-up. Session removal, reconnect, and disposal cancel queued follow-ups. Reconnect cancels reads from the previous connection and reloads previously requested projections; live membership updates arrive through the control stream. Parent availability comes from Host summaries and remains unknown until a summary or a successful list baseline establishes it. Address lookup resolves projected children without selecting them or creating scopes. Accepted prompts and observed running keep their Client display conversion across list refresh, reconnect, and Session-object replacement. The Manager retains these observations by Session id; they do not establish durable history. Drafts, projection stores, and sidebar display rules retain their existing behavior. The [blank rollback decision](../../../.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md) defines retention and late-response handling. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 5f4ceec9a8..b2ad89a866 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -39,7 +39,7 @@ Client 列表刷新保留未变化的行对象,并在顺序和值均相同时 后台 job 的名册行与观测流属于 [`dsh-api-job-controller`](../job-controller/README.zh.md);控制流只承载投影。 -投影读取独立于其值保留加载与失败状态。重连会取消上一连接的读取,并重新加载已请求的投影;实时成员更新通过 control stream 到达。父 Agent 可用性来自 Host 摘要,在收到对应摘要或成功的列表 baseline 前保持未知。地址查找直接解析投影中的子项,不会选择它们或创建 scope。 +投影读取独立于其值保留加载与失败状态。`refreshProjections` 默认复用同一连接内已成功读取的基线。传入 `{ force: true }` 会在当前读取结束后请求新基线;当前读取期间并发的强制刷新调用共用一次后续读取。会话移除、重连和释放会取消排队的后续读取。重连会取消上一连接的读取,并重新加载已请求的投影;实时成员更新通过 control stream 到达。父 Agent 可用性来自 Host 摘要,在收到对应摘要或成功的列表 baseline 前保持未知。地址查找直接解析投影中的子项,不会选择它们或创建 scope。 已受理的提示词与已观察到的运行,会使客户端展示转换跨列表刷新、重连及 Session 对象替换保留。Manager 按 Session id 保留这些观察;它们不证明持久历史已经产生。草稿、投影存储和侧边栏展示规则保留原有行为。[blank 回退修复决策](../../../.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md) 定义保留期限及迟到响应处理。 diff --git a/packages/api/session-controller/src/client/contract/sessions.ts b/packages/api/session-controller/src/client/contract/sessions.ts index 8f016ab711..cbe0ba8e0d 100644 --- a/packages/api/session-controller/src/client/contract/sessions.ts +++ b/packages/api/session-controller/src/client/contract/sessions.ts @@ -21,6 +21,12 @@ export type { AgentContext } from '../scope.ts' /** Known Session identity or durable direct-parent subagent address; an address owns no lifetime. */ export type SessionTarget = SessionId | SubagentAddress +/** Cache policy for an explicit Session projection read. */ +export interface SessionProjectionRefreshOptions { + /** Read again after any current request; concurrent forced reads share that follow-up. */ + readonly force?: boolean +} + /** One independent use of an exact Client generation, without Host Agent ownership. */ export interface SessionReference extends Disposable { readonly sessionId: SessionId @@ -96,11 +102,12 @@ export interface ISessions { subagentAddress(id: SessionId): SubagentAddress | undefined /** - * Load all Session projections once per connection; retry an unsuccessful initial read. + * Load all Session projections once per connection; retry unsuccessful reads. * @param sessionId - Session to inspect without opening its conversation. - * @returns completion of the current or newly started refresh. + * @param options - force a fresh read even after a successful baseline; a running read finishes first. + * @returns completion of the requested read; removal, reconnect, or disposal cancels a queued refresh. */ - refreshProjections(sessionId: SessionId): Promise + refreshProjections(sessionId: SessionId, options?: SessionProjectionRefreshOptions): Promise /** * Refresh the Host-authoritative Session list. diff --git a/packages/api/session-controller/src/client/index.ts b/packages/api/session-controller/src/client/index.ts index b9600f36a6..dcd44580ca 100644 --- a/packages/api/session-controller/src/client/index.ts +++ b/packages/api/session-controller/src/client/index.ts @@ -50,7 +50,7 @@ export type { SubmissionHandle, } from './contract/session.ts' export type { - ISessions, SessionReference, SessionRetainInfo, SessionRetainOptions, SessionTarget, + ISessions, SessionProjectionRefreshOptions, SessionReference, SessionRetainInfo, SessionRetainOptions, SessionTarget, } from './contract/sessions.ts' export { MutableSessionEventSource } from './contract/events.ts' export type { diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index 50af09df13..f1650011a8 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -25,7 +25,7 @@ import { Notifier } from './notifier.ts' import { ProjectionValueStore } from './projection-store.ts' import { Session } from './session.ts' import type { SessionRemotes } from './remotes.ts' -import type { SessionTarget } from '../contract/sessions.ts' +import type { SessionProjectionRefreshOptions, SessionTarget } from '../contract/sessions.ts' function sessionSeqCursor(value: number): SessionSeqCursor { return value === -1 ? -1 : SessionSeq(value) @@ -68,6 +68,7 @@ export interface SessionProjectionSnapshot { interface ProjectionInflight { readonly promise: Promise readonly controller: AbortController + refreshAfter?: Promise } type ProjectionLoad = Omit @@ -334,12 +335,19 @@ export class SessionManager { /** * Load a complete projection baseline once per connection; retry unsuccessful reads. * @param sessionId - Session to inspect without opening its conversation. - * @returns completion of the current or newly started read. + * @param options - force a fresh read after any current request, including a cached successful baseline. + * @returns completion of the requested read; aborting the current request also cancels its queued refresh. */ - refreshProjections(sessionId: SessionId): Promise { + refreshProjections(sessionId: SessionId, options?: SessionProjectionRefreshOptions): Promise { const existing = this.projectionInflight.get(sessionId) - if (existing !== undefined) return existing.promise - if (this.projectionLoads.get(sessionId)?.state === 'ready') return Promise.resolve() + if (existing !== undefined) { + if (!options?.force) return existing.refreshAfter ?? existing.promise + // A running read may predate plugin activation and omit its new projection. + return existing.refreshAfter ??= existing.promise.then(() => { + if (!existing.controller.signal.aborted) return this.refreshProjections(sessionId, { force: true }) + }) + } + if (!options?.force && this.projectionLoads.get(sessionId)?.state === 'ready') return Promise.resolve() const controller = new AbortController() const store = this.projectionStore(sessionId) const initialValues = store.values() diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index 48d8f6a533..040835cc53 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -13,7 +13,7 @@ import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protoc import type { SessionEventSource } from '../contract/events.ts' import type { SessionFace } from '../contract/session.ts' import type { - AgentContext, ISessions, SessionReference, SessionRetainInfo, SessionRetainOptions, SessionTarget, + AgentContext, ISessions, SessionProjectionRefreshOptions, SessionReference, SessionRetainInfo, SessionRetainOptions, SessionTarget, } from '../contract/sessions.ts' import type { SessionReferenceSource } from '../index.ts' import { createScope, scopeIdentityOf, scopeOf as scopeTagOf } from '../scope.ts' @@ -339,11 +339,12 @@ export class ClientSessions implements ISessions { } /** - * Load all Session projections once per connection; retry an unsuccessful initial read. + * Load all Session projections using the requested cache policy. * @param sessionId - Session to inspect without opening its conversation. + * @param options - optional fresh read after any current request. */ - refreshProjections(sessionId: SessionId): Promise { - return this.manager.refreshProjections(sessionId) + refreshProjections(sessionId: SessionId, options?: SessionProjectionRefreshOptions): Promise { + return this.manager.refreshProjections(sessionId, options) } /** diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index 4d91e0b77f..3ac31520b2 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -927,6 +927,63 @@ describe('subagent catalogs', () => { expect(remote.session.projections.mock.calls.map(([request]) => request)).toHaveLength(1) }) + it('refreshes a successful baseline to discover a newly available projection', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + remote.session.projections.mockResolvedValueOnce(ok({ asOfSeq: 0, values: {} })) + await manager.refreshProjections(S1) + remote.session.projections.mockResolvedValueOnce(ok({ asOfSeq: 0, values: { title: 'new capability' } })) + await manager.refreshProjections(S1, { force: true }) + expect(manager.getListSnapshot().projectionsBySession[S1]?.values.title).toBe('new capability') + await manager.refreshProjections(S1) + expect(remote.session.projections).toHaveBeenCalledTimes(2) + }) + + it('coalesces forced reads behind an older request without regressing newer frames', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + const old = Promise.withResolvers>>() + const fresh = Promise.withResolvers>>() + remote.session.projections.mockImplementationOnce(() => old.promise) + remote.session.projections.mockImplementationOnce(() => fresh.promise) + const initial = manager.refreshProjections(S1) + const refresh = manager.refreshProjections(S1, { force: true }) + expect(manager.refreshProjections(S1, { force: true })).toBe(refresh) + expect(manager.refreshProjections(S1)).toBe(refresh) + expect(remote.session.projections).toHaveBeenCalledOnce() + old.resolve(ok({ asOfSeq: 0, values: {} })) + await initial + expect(remote.session.projections).toHaveBeenCalledTimes(2) + manager.handleControlFrame({ type: 'projection', sessionId: S1, key: 'title', seq: 2, value: 'live title' }) + fresh.resolve(ok({ asOfSeq: 1, values: { title: 'older title', subagentCatalog: [] } })) + await refresh + expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({ + state: 'ready', values: { title: 'live title', subagentCatalog: [] }, + }) + }) + + it.for(['remove', 'reconnect', 'dispose'] as const)( + 'cancels a queued forced read on %s', + async (action, { mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + const old = Promise.withResolvers>>() + remote.session.projections.mockImplementationOnce(() => old.promise) + remote.session.projections.mockResolvedValue(ok({ asOfSeq: 0, values: { title: 'new connection' } })) + const initial = manager.refreshProjections(S1) + const refresh = manager.refreshProjections(S1, { force: true }) + let disposal: Promise | undefined + if (action === 'remove') manager.handleSessionRemoved(S1) + else if (action === 'reconnect') manager.handleConnected() + else disposal = manager.dispose() + old.resolve(ok({ asOfSeq: 0, values: { title: 'old connection' } })) + await Promise.all([initial, refresh, disposal]) + expect(remote.session.projections).toHaveBeenCalledTimes(action === 'reconnect' ? 2 : 1) + expect(manager.getListSnapshot().projectionsBySession[S1]?.values.title) + .toBe(action === 'reconnect' ? 'new connection' : undefined) + }, + ) + it('uses current Session status rather than the time an initial catalog read started', async ({ mock, remote }) => { const response = Promise.withResolvers>>() remote.session.projections.mockImplementation(() => response.promise) diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts index d4061def11..97af4bbea9 100644 --- a/packages/api/session-controller/tests/sessions-service.client.spec.ts +++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts @@ -719,6 +719,17 @@ describe('borrow-only bindings', () => { }) describe('catalog-addressed navigation', () => { + it('refreshes cached projection baselines through the public Session service', async ({ bench }) => { + const b = bench() + b.mock.remote.session.projections.mockResolvedValueOnce(ok({ asOfSeq: 0, values: {} })) + await b.svc.refreshProjections(sid('root')) + b.mock.remote.session.projections.mockResolvedValueOnce(ok({ asOfSeq: 0, values: { title: 'new capability' } })) + await b.svc.refreshProjections(sid('root'), { force: true }) + await b.svc.refreshProjections(sid('root')) + expect(b.svc.list.getSnapshot().projectionsBySession[sid('root')]?.values.title).toBe('new capability') + expect(b.mock.remote.session.projections).toHaveBeenCalledTimes(2) + }) + it('retains a projected child independently of its parent and shares its history generation', async ({ bench }) => { const b = bench() await feedList(b, [{ id: 'root' }]) diff --git a/packages/experimental/agent-team/README.i18n.yaml b/packages/experimental/agent-team/README.i18n.yaml index 4b7b8f5ec1..6a0bef282f 100644 --- a/packages/experimental/agent-team/README.i18n.yaml +++ b/packages/experimental/agent-team/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/agent-team/README.md -README.md: 9c0a351cf6017bb026b864c643a2ebeaa19ccbdc -README.zh.md: 44a3ade10100d7d44d9ed9735cedfc75684fa2ae +README.md: 79fc15fa369ec4615d3a3531de7c1fd54aa41ee7 +README.zh.md: 8e1905ded14b8cb803298c470085b6ac2a426fd4 diff --git a/packages/experimental/agent-team/README.md b/packages/experimental/agent-team/README.md index 9c0a351cf6..79fc15fa36 100644 --- a/packages/experimental/agent-team/README.md +++ b/packages/experimental/agent-team/README.md @@ -176,7 +176,7 @@ Read these pages when the package-level contract is not enough. They move from t The `agentTeam` Session projection publishes the Lead Session's durable roster, non-deleted task views, and any `failure` beside the last valid state. Its `apply` replaces only the touched collection; mailbox-only changes retain the client view reference and produce no frame. The [subsystem reference](../../../docs/subsystems/agent-team.md#web-projection) defines the wire types. -The Web UI reads current-session values through the standard projection hook and overlays activity from Session status. The current Session supplies its baseline through conversation loading; other active members' baselines are loaded for durable model selection without opening their conversations. Task creation and updates belong to Team agents through the service and model tools. The `./client` export supplies browser-safe roster, task, and projection types. +The Web UI reads current-session values through the standard projection hook and overlays activity from Session status. Opening the panel refreshes the Lead baseline; other active members' baselines are loaded for durable model selection without opening their conversations. Task creation and updates belong to Team agents through the service and model tools. The `./client` export supplies browser-safe roster, task, and projection types. ## Model Experience diff --git a/packages/experimental/agent-team/README.zh.md b/packages/experimental/agent-team/README.zh.md index 44a3ade101..8e1905ded1 100644 --- a/packages/experimental/agent-team/README.zh.md +++ b/packages/experimental/agent-team/README.zh.md @@ -176,7 +176,7 @@ dispose 会关闭准入、中止并等待已获准的创建与 mailbox dispatch `agentTeam` Session 投影发布 Lead Session 的持久 roster、未删除任务视图,以及最后有效状态旁的 `failure`。其 `apply` 只替换被触及的集合;仅邮箱的变化保留客户端视图引用,不产生 frame。[子系统参考](../../../docs/subsystems/agent-team.zh.md#web-projection) 定义传输类型。 -Web UI 通过标准投影钩子读取当前会话的值,并从 Session 状态叠加活动信息。当前 Session 通过会话加载提供基线;其他 active 成员的基线用于读取持久模型选择,无需打开它们的会话。任务创建与更新由 Team agent 通过服务和模型工具完成。`./client` 导出可供浏览器使用的 roster、任务与投影类型。 +Web UI 通过标准投影钩子读取当前会话的值,并从 Session 状态叠加活动信息。打开 panel 时会刷新 Lead 基线;其他 active 成员的基线用于读取持久模型选择,无需打开它们的会话。任务创建与更新由 Team agent 通过服务和模型工具完成。`./client` 导出可供浏览器使用的 roster、任务与投影类型。 ## 模型体验 diff --git a/packages/experimental/client-ui-agent-team/README.i18n.yaml b/packages/experimental/client-ui-agent-team/README.i18n.yaml index 455eea79d0..0affc2f3f3 100644 --- a/packages/experimental/client-ui-agent-team/README.i18n.yaml +++ b/packages/experimental/client-ui-agent-team/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/client-ui-agent-team/README.md -README.md: 5244cb6e9e02a106fb52e6ac882b9b550a180af5 -README.zh.md: 35031d9410f5c38edb3e0d9f3a914c2368487724 +README.md: 8a94b57035d3ed56c2b88b91020eff659384fabe +README.zh.md: cbea68e5aa91ccc27bbcc9f3a89beed2bc2d5580 diff --git a/packages/experimental/client-ui-agent-team/README.md b/packages/experimental/client-ui-agent-team/README.md index 5244cb6e9e..8a94b57035 100644 --- a/packages/experimental/client-ui-agent-team/README.md +++ b/packages/experimental/client-ui-agent-team/README.md @@ -29,7 +29,7 @@ Enable this package through [`@deepseek-ai/dsh-experimental-agent-team-profile`] ### Inspect and navigate the roster -The trigger shows the teammate count, and the panel shows the roster and task board of the Lead Session's `agentTeam` projection, so a task created by an agent or a teammate reaching `active` appears while the panel is open. The current Session supplies its Team baseline through normal conversation loading. In a teammate conversation, opening the panel requests the Lead's projection baseline once per connection, skipping a successful prior read; a failed parent read shows a retry control alongside any last valid Team, and a reconnect reloads it. Current Session loading failures follow the conversation lifecycle and appear in the panel without a separate projection retry. A successful read without the Team capability shows an unavailable notice. The panel also requests active members' projection baselines outside the current Session and Lead, including persisted Sessions that have not been opened, and loads new members when they become active. Roster rows show durable names and a status: `failed` and `provisioning` come from the durable member phase, and `running` or `inactive` comes from the member Session's live status. A model appears when the member Session's `modelSelection` projection records a durable selection or request. Provisioning and running members use the shared ongoing loader, inactive members use idle, and failed members use error. Selecting a healthy teammate opens the ordinary `{ parentSessionId, childSessionId, mode: 'continuable' }` address from its Lead and roster identity without refreshing or checking the parent catalog. The Host validates the parent, child, and mode when history opens. History and later human prompts continue through the stable addressed-subagent conversation path; this package adds no Team-specific address field. +The trigger shows the teammate count, and the panel shows the roster and task board of the Lead Session's `agentTeam` projection, so a task created by an agent or a teammate reaching `active` appears while the panel is open. Opening the panel refreshes the Lead's projection baseline, including when the Team plugin was enabled after the conversation opened. A read already in progress finishes before this refresh starts. Failed reads show a retry control alongside any last valid Team; a reconnect reloads previously requested projections. A successful read without the Team capability shows an unavailable notice. The panel also requests active members' projection baselines outside the current Session and Lead, including persisted Sessions that have not been opened, and loads new members when they become active. Roster rows show durable names and a status: `failed` and `provisioning` come from the durable member phase, and `running` or `inactive` comes from the member Session's live status. A model appears when the member Session's `modelSelection` projection records a durable selection or request. Provisioning and running members use the shared ongoing loader, inactive members use idle, and failed members use error. Selecting a healthy teammate opens the ordinary `{ parentSessionId, childSessionId, mode: 'continuable' }` address from its Lead and roster identity without refreshing or checking the parent catalog. The Host validates the parent, child, and mode when history opens. History and later human prompts continue through the stable addressed-subagent conversation path; this package adds no Team-specific address field. ### Inspect the task board @@ -47,7 +47,7 @@ The read-only task board shows task identity, owner, blockers, readiness, adviso The Client export registers its locale dictionaries and one conversation-header slot through Cordis effects; it mounts no Remote namespace. Disposing the plugin fiber removes both registrations. -The panel renders outside the conversation container and stays within the viewport. Opening moves focus into the panel; Escape or Close returns focus to its trigger. Clicking outside or moving focus outside the panel and trigger closes it without moving focus back. The component reads the current Session's Team through `useProjection('agentTeam')` and its model through `useProjection('modelSelection')`. In a teammate conversation, `useSession` supplies the Lead identity, and separate `useSessions` selectors read the parent's Team value, baseline-read state, and error. Each roster row selects its own model and running state; the panel subscribes to neither the complete projection collection nor the complete summary or status collection. Its only injected callbacks read a Session's projection baseline and open a teammate. Switching conversations closes the panel and clears a navigation failure. +The panel renders outside the conversation container and stays within the viewport. Opening moves focus into the panel; Escape or Close returns focus to its trigger. Clicking outside or moving focus outside the panel and trigger closes it without moving focus back. The component reads the current Session's Team through `useProjection('agentTeam')` and its model through `useProjection('modelSelection')`. In a teammate conversation, `useSession` supplies the Lead identity and `useSessions` selects the parent's Team value. Separate `useSessions` selectors read the Lead's baseline-read state and error in either conversation. Each roster row selects its own model and running state; the panel subscribes to neither the complete projection collection nor the complete summary or status collection. Its only injected callbacks read a Session's projection baseline and open a teammate. Switching conversations closes the panel and clears a navigation failure. | File | Role | |---|---| @@ -64,7 +64,7 @@ The panel renders outside the conversation container and stays within the viewpo ## Further Exploration - [Agent Teams bundle](../agent-team-profile/README.md) — the published opt-in bundle that mounts this Client plugin. -- [Agent Teams service](../agent-team/README.md) — authoritative roster, task, projection, and Remote behavior. +- [Agent Teams service](../agent-team/README.md) — authoritative roster, task, and projection behavior. - [Conversation UI](../../client/ui-conversation/README.md) — the stable header slot and addressed-subagent navigation surface. - [Experimental packages](../README.md) — incubation status and publication policy. diff --git a/packages/experimental/client-ui-agent-team/README.zh.md b/packages/experimental/client-ui-agent-team/README.zh.md index 35031d9410..cbea68e5aa 100644 --- a/packages/experimental/client-ui-agent-team/README.zh.md +++ b/packages/experimental/client-ui-agent-team/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 检查并导航 roster -触发按钮显示 teammate 数量,panel 显示 Lead Session `agentTeam` 投影中的 roster 与任务板,因此 agent 创建的任务或进入 `active` 的 teammate 会在 panel 打开期间直接出现。当前 Session 通过普通会话加载提供 Team 基线。在 teammate 会话中,打开 panel 时每次连接请求一次 Lead 的投影基线,之前读取成功则跳过;父会话读取失败会在最后有效 Team 旁显示重试控件,重连后会重新加载。当前 Session 的加载失败沿用会话生命周期,并显示在 panel 中,不提供单独的投影重试。读取成功但缺少 Team 能力时显示不可用提示。panel 还会请求当前 Session 与 Lead 之外 active 成员的投影基线,包括尚未打开的持久 Session,并在新成员进入 active 时加载它。Roster row 展示持久 name 与状态:`failed` 与 `provisioning` 来自持久成员 phase,`running` 或 `inactive` 来自成员 Session 的实时状态。当成员 Session 的 `modelSelection` 投影记录了持久选择或请求时显示 model。provisioning 和 running 成员使用共享 ongoing loading,inactive 成员使用 idle 灰点,failed 成员使用 error 红点。选择健康 teammate 时,系统直接根据其 Lead 与 roster 身份打开普通的 `{ parentSessionId, childSessionId, mode: 'continuable' }` address,不刷新或检查 parent catalog。Host 在打开历史时校验 parent、child 与 mode。History 与后续人类提示词继续使用稳定 addressed-subagent 会话路径;本包不会添加 Team 专用 address 字段。 +触发按钮显示 teammate 数量,panel 显示 Lead Session `agentTeam` 投影中的 roster 与任务板,因此 agent 创建的任务或进入 `active` 的 teammate 会在 panel 打开期间直接出现。打开 panel 时会刷新 Lead 的投影基线,包括在会话打开后才启用 Team 插件的情况。若已有读取正在进行,本次刷新会在其结束后开始。读取失败时,最后有效 Team 旁会显示重试控件;重连会重新加载已请求的投影。读取成功但缺少 Team 能力时显示不可用提示。panel 还会请求当前 Session 与 Lead 之外 active 成员的投影基线,包括尚未打开的持久 Session,并在新成员进入 active 时加载它。Roster row 展示持久 name 与状态:`failed` 与 `provisioning` 来自持久成员 phase,`running` 或 `inactive` 来自成员 Session 的实时状态。当成员 Session 的 `modelSelection` 投影记录了持久选择或请求时显示 model。provisioning 和 running 成员使用共享 ongoing loading,inactive 成员使用 idle 灰点,failed 成员使用 error 红点。选择健康 teammate 时,系统直接根据其 Lead 与 roster 身份打开普通的 `{ parentSessionId, childSessionId, mode: 'continuable' }` address,不刷新或检查 parent catalog。Host 在打开历史时校验 parent、child 与 mode。History 与后续人类提示词继续使用稳定 addressed-subagent 会话路径;本包不会添加 Team 专用 address 字段。 ### 查看任务板 @@ -47,7 +47,7 @@ kind: "package-reference" Client export 通过 Cordis effect 注册 locale dictionary 与一个 conversation-header slot;它不挂载任何 Remote namespace。Dispose plugin fiber 会移除这两项 registration。 -面板渲染在会话容器外,并保持在视口范围内。打开时焦点移入面板;按 Escape 或选择关闭按钮时,焦点返回触发按钮。点击外部或将焦点移出面板与触发按钮时,面板关闭,但不会将焦点移回。组件通过 `useProjection('agentTeam')` 读取当前 Session 的 Team,通过 `useProjection('modelSelection')` 读取其 model。在 teammate 会话中,`useSession` 提供 Lead 身份,独立的 `useSessions` selector 分别读取父会话的 Team 值、基线读取状态和错误。每个 roster row 只选择自己的 model 和运行状态;panel 不订阅完整投影集合,也不订阅完整摘要或状态集合。它仅有的注入回调是读取 Session 的投影基线与打开 teammate。切换会话会关闭面板并清除导航失败。 +面板渲染在会话容器外,并保持在视口范围内。打开时焦点移入面板;按 Escape 或选择关闭按钮时,焦点返回触发按钮。点击外部或将焦点移出面板与触发按钮时,面板关闭,但不会将焦点移回。组件通过 `useProjection('agentTeam')` 读取当前 Session 的 Team,通过 `useProjection('modelSelection')` 读取其 model。在 teammate 会话中,`useSession` 提供 Lead 身份,`useSessions` 选择父会话的 Team 值。两种会话都通过独立的 `useSessions` selector 读取 Lead 的基线读取状态和错误。每个 roster row 只选择自己的 model 和运行状态;panel 不订阅完整投影集合,也不订阅完整摘要或状态集合。它仅有的注入回调是读取 Session 的投影基线与打开 teammate。切换会话会关闭面板并清除导航失败。 | 文件 | 职责 | |---|---| @@ -64,7 +64,7 @@ Client export 通过 Cordis effect 注册 locale dictionary 与一个 conversati ## 进一步探索 - [Agent Teams bundle](../agent-team-profile/README.zh.md)——挂载本 Client plugin 的公开 opt-in bundle。 -- [Agent Teams service](../agent-team/README.zh.md)——权威 roster、task、投影与 Remote 行为。 +- [Agent Teams service](../agent-team/README.zh.md)——权威 roster、task 与投影行为。 - [会话 UI](../../client/ui-conversation/README.zh.md)——稳定 header slot 与 addressed-subagent 导航表层。 - [实验性包](../README.zh.md)——孵化状态与发布规则。 diff --git a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx index 2eb15582c1..cd9ba1ad75 100644 --- a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx +++ b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx @@ -5,7 +5,7 @@ import type { TeamMemberProjection, TeamTaskView as TeamTask, } from '@deepseek-ai/dsh-experimental-agent-team/client' -import type {} from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionProjectionRefreshOptions } from '@deepseek-ai/dsh-api-session-controller/client' import { IconCloseOutlineRegular, IconRefreshOutlineRegular, IconUserOutlineRegular, StateDot, useAnchoredPosition, useDismissOnOutsidePointer, type StateDotState, @@ -17,8 +17,8 @@ import css from './TeamAction.module.css' /** Business actions injected by the browser plugin. */ export interface TeamActionInjected { - /** Request another Session's projection baseline once per connection, or retry a failed read. */ - loadProjections: (sessionId: SessionId) => void + /** Read a Session's baseline, optionally refreshing after any existing read. */ + loadProjections: (sessionId: SessionId, options?: SessionProjectionRefreshOptions) => void openTeammate: (sessionId: SessionId, member: TeamMemberProjection) => void } @@ -135,22 +135,14 @@ export function TeamAction({ const leadSessionId = useSession(snapshot => snapshot.subagent?.address.parentSessionId) ?? sessionId const isLead = leadSessionId === sessionId const currentTeam = useProjection('agentTeam') - const openState = useSession(snapshot => snapshot.openState) - const openError = useSession(snapshot => snapshot.openError) const parentTeam = useSessions(state => isLead ? undefined : state.projectionsBySession[leadSessionId]?.values.agentTeam) - const parentState = useSessions(state => isLead - ? undefined - : state.projectionsBySession[leadSessionId]?.state) - const parentError = useSessions(state => isLead - ? null - : state.projectionsBySession[leadSessionId]?.error ?? null) + const readState = useSessions(state => state.projectionsBySession[leadSessionId]?.state) + const projectionError = useSessions(state => state.projectionsBySession[leadSessionId]?.error ?? null) const team = isLead ? currentTeam : parentTeam - const readError = isLead - ? openState === 'error' ? openError : null - : parentState === 'error' ? parentError : null - const ready = isLead ? openState === 'open' : parentState === 'ready' + const readError = readState === 'error' ? projectionError : null + const ready = readState === 'ready' const members = team?.members useEffect(() => { @@ -159,8 +151,8 @@ export function TeamAction({ }, [sessionId]) useEffect(() => { - if (open && !isLead) loadProjections(leadSessionId) - }, [open, isLead, leadSessionId, loadProjections]) + if (open) loadProjections(leadSessionId, { force: true }) + }, [open, leadSessionId, loadProjections]) useEffect(() => { if (!open) return @@ -227,11 +219,9 @@ export function TeamAction({
{failureText(readError)} - {!isLead && ( - - )} +
)} {team === undefined && readError === null && ( diff --git a/packages/experimental/client-ui-agent-team/src/client/mount.ts b/packages/experimental/client-ui-agent-team/src/client/mount.ts index 1e2dfbdab3..2f945e4b12 100644 --- a/packages/experimental/client-ui-agent-team/src/client/mount.ts +++ b/packages/experimental/client-ui-agent-team/src/client/mount.ts @@ -36,8 +36,8 @@ export function registerAgentTeamUi(ctx: ClientContext): void { } const actions: TeamActionInjected = { - loadProjections(sessionId: SessionId): void { - void sessions.refreshProjections(sessionId) + loadProjections(sessionId, options): void { + void sessions.refreshProjections(sessionId, options) }, openTeammate(sessionId: SessionId, member: TeamMemberProjection): void { if (member.role !== 'teammate') return diff --git a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts index d63394fd64..7bf446d97c 100644 --- a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts +++ b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts @@ -1,5 +1,6 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' +import type { SessionProjectionRefreshOptions } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' @@ -29,8 +30,8 @@ async function bench(options: { addressed?: boolean } = {}) { }, }) } } : undefined, - refreshProjections: (id: SessionId) => { - navigation.push(['refresh', id]) + refreshProjections: (id: SessionId, options?: SessionProjectionRefreshOptions) => { + navigation.push(['refresh', id, options]) return Promise.resolve() }, retainInfo: (id: SessionId) => ({ @@ -88,10 +89,10 @@ describe('ui-team browser plugin', () => { const t = b.ctx.locale.bind('agent-team') expect(t('trigger')).toBe('Agent Team') - b.actions().loadProjections(SESSION) - expect(b.navigation).toEqual([['refresh', SESSION]]) + b.actions().loadProjections(SESSION, { force: true }) + expect(b.navigation).toEqual([['refresh', SESSION, { force: true }]]) b.actions().openTeammate(SESSION, LEAD) - expect(b.navigation).toEqual([['refresh', SESSION]]) + expect(b.navigation).toEqual([['refresh', SESSION, { force: true }]]) await b.fiber.dispose() expect(b.entry()).toBeUndefined() diff --git a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx index c1c588995a..0f71861236 100644 --- a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx +++ b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx @@ -125,7 +125,7 @@ describe('TeamAction', () => { openPanel() expect(await screen.findByText('Implement runtime')).toBeTruthy() expect(screen.getByText('write scopes overlap with task-2')).toBeTruthy() - expect(b.injected.loadProjections).not.toHaveBeenCalledWith(SESSION) + expect(b.injected.loadProjections).toHaveBeenCalledWith(SESSION, { force: true }) expect(screen.queryByRole('button', { name: /刷新|Refresh/u })).toBeNull() setProjection(b.sessions, SESSION, { @@ -192,7 +192,7 @@ describe('TeamAction', () => { render() openPanel() expect(screen.getByText('Implement runtime')).toBeTruthy() - expect(b.injected.loadProjections).toHaveBeenCalledWith(SESSION) + expect(b.injected.loadProjections).toHaveBeenCalledWith(SESSION, { force: true }) fireEvent.click(screen.getByRole('button', { name: /^worker/u })) expect(b.injected.openTeammate).toHaveBeenCalledWith(WORKER, worker) }) @@ -217,31 +217,34 @@ describe('TeamAction', () => { expect(screen.queryByRole('alert')).toBeNull() }) - it('uses the current Session opening state without requesting its projection baseline', () => { - const b = bench({ projections: {}, openState: 'loading' }) + it('refreshes an already-open Lead on every panel opening and retries failed reads', () => { + const b = bench({ projections: {}, openState: 'open' }) render() + expect(b.injected.loadProjections).not.toHaveBeenCalled() openPanel() expect(screen.getByRole('status').textContent).toBe(zh.loading) - expect(b.injected.loadProjections).not.toHaveBeenCalled() + expect(b.injected.loadProjections).toHaveBeenCalledExactlyOnceWith(SESSION, { force: true }) - act(() => { - b.session.set({ - ...b.session.getSnapshot(), - openState: 'error', - openError: new RemoteError('gateway/internal', 'history unavailable', {}), - }) + setProjectionSnapshot(b.sessions, SESSION, { + state: 'error', error: new RemoteError('gateway/internal', 'offline', {}), values: {}, }) - expect(screen.getByRole('alert').textContent).toBe('history unavailable (gateway/internal)') - expect(screen.queryByRole('button', { name: zh.retry })).toBeNull() - expect(b.injected.loadProjections).not.toHaveBeenCalled() + expect(screen.getByRole('alert').textContent).toBe('offline (gateway/internal)') + fireEvent.click(screen.getByRole('button', { name: zh.retry })) + expect(b.injected.loadProjections).toHaveBeenCalledTimes(2) + expect(b.injected.loadProjections).toHaveBeenLastCalledWith(SESSION, { force: true }) - act(() => { - b.session.set({ ...b.session.getSnapshot(), openState: 'open', openError: null }) - }) - expect(screen.getByRole('status').textContent).toBe(zh.unavailable) - setProjection(b.sessions, SESSION, team) - expect(screen.getByText('Implement runtime')).toBeTruthy() - expect(b.injected.loadProjections).not.toHaveBeenCalledWith(SESSION) + setProjectionSnapshot(b.sessions, SESSION, { state: 'loading', error: null, values: {} }) + expect(screen.getByRole('status').textContent).toBe(zh.loading) + setProjection(b.sessions, SESSION, { members: [lead], tasks: [] }) + expect(screen.getByText(zh.empty)).toBeTruthy() + expect(screen.queryByRole('alert')).toBeNull() + expect(b.injected.loadProjections).toHaveBeenCalledTimes(2) + + fireEvent.click(screen.getByRole('button', { name: zh.close })) + expect(b.injected.loadProjections).toHaveBeenCalledTimes(2) + openPanel() + expect(b.injected.loadProjections).toHaveBeenCalledTimes(3) + expect(b.injected.loadProjections).toHaveBeenLastCalledWith(SESSION, { force: true }) }) it.each([false, true])('ignores unrelated Session updates (teammate page: %s)', (addressed) => { @@ -274,7 +277,8 @@ describe('TeamAction', () => { expect(screen.getByRole('button', { name: /worker.*updated-worker-model/u })).toBeTruthy() setProjection(b.sessions, SESSION, { ...team, tasks: [{ ...task, subject: 'Updated parent task' }] }) expect(screen.getByText('Updated parent task')).toBeTruthy() - expect(b.injected.loadProjections).not.toHaveBeenCalledWith(addressed ? WORKER : SESSION) + expect(b.injected.loadProjections).toHaveBeenCalledWith(SESSION, { force: true }) + expect(b.injected.loadProjections).not.toHaveBeenCalledWith(WORKER, { force: true }) }) it('shows capability absence after a successful read instead of loading forever', () => { @@ -295,7 +299,7 @@ describe('TeamAction', () => { expect(screen.getByText('Implement runtime')).toBeTruthy() expect(screen.getByRole('alert').textContent).toBe('offline (gateway/internal)') fireEvent.click(screen.getByRole('button', { name: zh.retry })) - expect(b.injected.loadProjections).toHaveBeenLastCalledWith(SESSION) + expect(b.injected.loadProjections).toHaveBeenLastCalledWith(SESSION, { force: true }) }) it('loads active members models without opening their conversations', () => { @@ -393,7 +397,7 @@ describe('TeamAction', () => { openPanel() expect(screen.queryByRole('alert')).toBeNull() expect(screen.getByRole('status').textContent).toBe(zh.loading) - expect(next.injected.loadProjections).not.toHaveBeenCalled() + expect(next.injected.loadProjections).toHaveBeenCalledWith('next-lead', { force: true }) }) it('keeps panel interactions open and dismisses on outside pointer or Escape', () => { diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 8217db928c..6d51b921c0 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -201,10 +201,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'a stable read-only source across same-id generations, with zero counts when none is live.', }, { - signature: 'refreshProjections(sessionId: SessionId): Promise', - description: 'Load all Session projections once per connection; retry an unsuccessful initial read.', - parameters: [{ name: 'sessionId', description: 'Session to inspect without opening its conversation.' }], - returns: 'completion of the current or newly started refresh.', + signature: 'refreshProjections(sessionId: SessionId, options?: SessionProjectionRefreshOptions): Promise', + description: 'Load all Session projections once per connection; retry unsuccessful reads.', + parameters: [{ name: 'sessionId', description: 'Session to inspect without opening its conversation.' }, { name: 'options', description: 'force a fresh read even after a successful baseline; a running read finishes first.' }], + returns: 'completion of the requested read; removal, reconnect, or disposal cancels a queued refresh.', }, { signature: 'search( query: string, signal: AbortSignal, ): Promise>', @@ -871,6 +871,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionMaybeStandardProps', declaration: 'export interface SessionMaybeStandardProps {\n}', }, + { + name: 'SessionProjectionRefreshOptions', + declaration: 'export interface SessionProjectionRefreshOptions {\n readonly force?: boolean;\n}', + }, { name: 'SessionProviderComponent', declaration: 'export type SessionProviderComponent = (props: SessionAreaProps) => ReactNode;', From 37d3a31e295bb93e4bf29e46b1b962c147075f95 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:44:26 +0800 Subject: [PATCH 029/192] fix(account): sign out on rejected inference tokens --- .../tests/platform-session.spec.ts | 1 + docs/subsystems/credentials.i18n.yaml | 4 +- docs/subsystems/credentials.md | 7 ++ docs/subsystems/credentials.zh.md | 7 ++ .../README.i18n.yaml | 4 +- .../deepseek-account-platform/README.md | 2 +- .../deepseek-account-platform/README.zh.md | 2 +- .../deepseek-account-platform/src/index.ts | 7 ++ .../tests/account.spec.ts | 36 ++++++ .../deepseek-account/README.i18n.yaml | 4 +- .../credentials/deepseek-account/README.md | 2 + .../credentials/deepseek-account/README.zh.md | 2 + .../credentials/deepseek-account/src/index.ts | 6 + .../extensions/tool-cordis/src/api-catalog.ts | 6 + packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/README.zh.md | 2 +- packages/llm/llm-deepseek/src/adapter.ts | 119 ++++++++++-------- packages/llm/llm-deepseek/src/index.ts | 1 + packages/llm/llm-deepseek/src/transport.ts | 3 +- packages/llm/llm-deepseek/src/types.ts | 2 + .../llm/llm-deepseek/tests/adapter.spec.ts | 55 ++++++++ 22 files changed, 210 insertions(+), 68 deletions(-) diff --git a/apps/desktop-host/tests/platform-session.spec.ts b/apps/desktop-host/tests/platform-session.spec.ts index 3fee3c2d5e..7be4154979 100644 --- a/apps/desktop-host/tests/platform-session.spec.ts +++ b/apps/desktop-host/tests/platform-session.spec.ts @@ -20,6 +20,7 @@ class Account extends DeepSeekAccount { override async startSignIn() { return state } override async cancelSignIn() { return state } override async signOut() { return state } + override async rejectToken() {} override async resolveToken() { return undefined } override async getPlatformSession() { this.reading.resolve(undefined); return this.read() } override async *watch(signal: AbortSignal) { diff --git a/docs/subsystems/credentials.i18n.yaml b/docs/subsystems/credentials.i18n.yaml index 186e3f2fb6..509edf1de2 100644 --- a/docs/subsystems/credentials.i18n.yaml +++ b/docs/subsystems/credentials.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/credentials.md -credentials.md: bce883640da863b5d0cc7e5657ee2fcff9eec661 -credentials.zh.md: 58ed1e1d1df217f0ecdecc06a55e36447ad28d99 +credentials.md: f6d510d48ec92feb28cc3af8820a1e497fad2cdd +credentials.zh.md: 7040fe28cb930a0f2dce9a092124fa24e72cf767 diff --git a/docs/subsystems/credentials.md b/docs/subsystems/credentials.md index bce883640d..f6d510d48e 100644 --- a/docs/subsystems/credentials.md +++ b/docs/subsystems/credentials.md @@ -320,6 +320,13 @@ abstract watch(signal: AbortSignal): AsyncIterable */ abstract resolveToken(url: string): Promise +/** + * Remove an inference-rejected token only while it still matches the stored login. + * @param token - token captured by the rejected inference request. + * @returns after matching credentials are removed and expiry state is published. + */ +abstract rejectToken(token: string): Promise + /** * Read credentials for the configured Platform origin, bound to their issuing environment. * @returns a Host-only snapshot, or null while signed out. diff --git a/docs/subsystems/credentials.zh.md b/docs/subsystems/credentials.zh.md index 58ed1e1d1d..7040fe28cb 100644 --- a/docs/subsystems/credentials.zh.md +++ b/docs/subsystems/credentials.zh.md @@ -320,6 +320,13 @@ abstract watch(signal: AbortSignal): AsyncIterable */ abstract resolveToken(url: string): Promise +/** + * Remove an inference-rejected token only while it still matches the stored login. + * @param token - token captured by the rejected inference request. + * @returns after matching credentials are removed and expiry state is published. + */ +abstract rejectToken(token: string): Promise + /** * Read credentials for the configured Platform origin, bound to their issuing environment. * @returns a Host-only snapshot, or null while signed out. diff --git a/packages/credentials/deepseek-account-platform/README.i18n.yaml b/packages/credentials/deepseek-account-platform/README.i18n.yaml index 1e0eb5ef40..69ffd59af5 100644 --- a/packages/credentials/deepseek-account-platform/README.i18n.yaml +++ b/packages/credentials/deepseek-account-platform/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/credentials/deepseek-account-platform/README.md -README.md: a7e9ef87ce59a01cef4a20b2d544a986d26a7d73 -README.zh.md: f64964af053f4ab601cdda3b90f4c15ffc360650 +README.md: ecb4f9616bc9da0d25d5d8f70690efa983b35279 +README.zh.md: e384b982def75b07bb7290ca1bf70b1b4d144c70 diff --git a/packages/credentials/deepseek-account-platform/README.md b/packages/credentials/deepseek-account-platform/README.md index a7e9ef87ce..ecb4f9616b 100644 --- a/packages/credentials/deepseek-account-platform/README.md +++ b/packages/credentials/deepseek-account-platform/README.md @@ -13,7 +13,7 @@ getPlatformSession exports the stored grant only when its issuer matches platfor `desktopPlatform` defaults to `null`. Every profile then sends `x-client-platform: web` on Host authorization, profile, balance, and logout requests; the Desktop profile supplies `darwin` or `win32`, replacing it with `x-client-platform: desktop-mac` or `desktop-win`. The provider owns that header, so deployment configuration cannot override it. Embedded Platform document and API requests receive the same platform header alongside their deployment headers, only at the configured origin. -Profile and balance HTTP 401 responses or top-level response code `40003` (invalid authorization) clear the rejected local grant and publish `signOutReason: expired`. Concurrent responses share one removal; responses from an invalidated credential generation cannot clear its replacement. Other HTTP failures retain the grant. +Profile and balance HTTP 401 responses or top-level response code `40003` (invalid authorization) clear the rejected local grant and publish `signOutReason: expired`. Concurrent responses share one removal; responses from an invalidated credential generation cannot clear its replacement. Other HTTP failures retain the grant. Host inference consumers can report a rejected request token through `rejectToken`; removal requires that it still matches the stored login. ## Summary diff --git a/packages/credentials/deepseek-account-platform/README.zh.md b/packages/credentials/deepseek-account-platform/README.zh.md index f64964af05..e384b982de 100644 --- a/packages/credentials/deepseek-account-platform/README.zh.md +++ b/packages/credentials/deepseek-account-platform/README.zh.md @@ -13,7 +13,7 @@ getPlatformSession 仅在已存授权的 issuer 与 platformOrigin 一致时导 `desktopPlatform` 默认为 `null`。此时所有 profile 的 Host 授权、资料、余额和退登请求都携带 `x-client-platform: web`;Desktop profile 提供 `darwin` 或 `win32`,改为携带 `x-client-platform: desktop-mac` 或 `desktop-win`。该请求头由 provider 拥有,部署配置无法覆盖。内嵌 Platform 的文档与 API 请求仅向配置来源发送相同的平台请求头,同时保留其他部署请求头。 -资料和余额接口返回 HTTP 401 或顶层响应码 `40003`(鉴权失效)时清除被拒绝的本地凭据,并发布 `signOutReason: expired`。并发响应共用一次清除操作;已失效凭据代次的响应不能清除替换后的凭据。其他 HTTP 错误保留凭据。 +资料和余额接口返回 HTTP 401 或顶层响应码 `40003`(鉴权失效)时清除被拒绝的本地凭据,并发布 `signOutReason: expired`。并发响应共用一次清除操作;已失效凭据代次的响应不能清除替换后的凭据。其他 HTTP 错误保留凭据。Host 推理调用方可以通过 `rejectToken` 报告被拒绝的请求 token;仅当它仍匹配当前登录凭据时才执行清除。 ## 概述 diff --git a/packages/credentials/deepseek-account-platform/src/index.ts b/packages/credentials/deepseek-account-platform/src/index.ts index 06896b701a..3909be12e5 100644 --- a/packages/credentials/deepseek-account-platform/src/index.ts +++ b/packages/credentials/deepseek-account-platform/src/index.ts @@ -209,6 +209,13 @@ export class PlatformAccount extends DeepSeekAccount { } + override async rejectToken(token: string): Promise { + const lifetime = this.detailsLifetime + const stored = await this.readCurrentGrant(lifetime) + if (stored?.token !== token || this.detailsLifetime !== lifetime) return + await this.expireCredential(token, lifetime) + } + private async expireCredential(token: string, lifetime: AbortController): Promise { this.removing ??= (async () => { if (this.attempt !== undefined) await this.cancelSignIn(this.attempt.view.id) diff --git a/packages/credentials/deepseek-account-platform/tests/account.spec.ts b/packages/credentials/deepseek-account-platform/tests/account.spec.ts index 805c8ed46d..5d80023af5 100644 --- a/packages/credentials/deepseek-account-platform/tests/account.spec.ts +++ b/packages/credentials/deepseek-account-platform/tests/account.spec.ts @@ -1346,3 +1346,39 @@ it('ignores a 401 whose response cleanup overlaps a credential replacement', asy .toMatchObject({ kind: 'grant', payload: { token: 'new-login' } }) } finally { cleanup.mockRestore(); fetchResponse.mockRestore() } }) + +it('expires an inference-rejected token without another Platform request', async () => { + const f = await fixture() + await storeAccount(f) + const published: string[] = [] + f.ctx.on('deepseek-account/signed-out', () => { published.push('signed-out') }) + await f.account.rejectToken('test-platform-grant') + const state = await f.account.getState() + expect({ status: state.status, reason: state.signOutReason, published, detailRequests: f.detailRequests }) + .toMatchInlineSnapshot(` + { + "detailRequests": [], + "published": [ + "signed-out", + ], + "reason": "expired", + "status": "signed-out", + } + `) +}) + +it('retains a replacement login when inference rejects the previous token', async () => { + const f = await fixture() + await storeAccount(f) + await f.account.rejectToken('previous-platform-grant') + expect(await f.account.getState()).toMatchObject({ status: 'credential-stored' }) + expect(await f.ctx.credentials.readRecord(credentialKey('deepseek-account-platform', 'default'))) + .toMatchObject({ kind: 'grant', payload: { token: 'test-platform-grant' } }) +}) + +it('ignores an inference rejection while already signed out', async () => { + const f = await fixture() + await f.account.rejectToken('previous-platform-grant') + expect(await f.account.getState()).toMatchObject({ status: 'signed-out' }) + expect((await f.account.getState()).signOutReason).toBeUndefined() +}) diff --git a/packages/credentials/deepseek-account/README.i18n.yaml b/packages/credentials/deepseek-account/README.i18n.yaml index 0289b83b50..04ef86deeb 100644 --- a/packages/credentials/deepseek-account/README.i18n.yaml +++ b/packages/credentials/deepseek-account/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/credentials/deepseek-account/README.md -README.md: 85b4c9a7eca12358b425ccf7db092ae71ec98465 -README.zh.md: f29d1becdb258bfd6bc3b833d6e723f1f9e0e8f1 +README.md: eabed7ba74cc9995a46e42eb13eb238a06ec0359 +README.zh.md: 0f48d6286e8eec9d375439e052272e10f2871806 diff --git a/packages/credentials/deepseek-account/README.md b/packages/credentials/deepseek-account/README.md index 85b4c9a7ec..eabed7ba74 100644 --- a/packages/credentials/deepseek-account/README.md +++ b/packages/credentials/deepseek-account/README.md @@ -11,6 +11,8 @@ getPlatformSession returns a Host-only origin/token snapshot for native Platform `desktopClientHeaders` maps the native `darwin` and `win32` platforms to the shared Desktop account and update-policy request header; `null` adds no header. +`rejectToken` accepts a Host inference request’s rejected token and removes only the matching current login; a late rejection cannot clear a replacement credential. + `AccountView.signOutReason` identifies a server-expired credential separately from explicit sign-out; it is absent while signed in. ## Summary diff --git a/packages/credentials/deepseek-account/README.zh.md b/packages/credentials/deepseek-account/README.zh.md index f29d1becdb..0f48d6286e 100644 --- a/packages/credentials/deepseek-account/README.zh.md +++ b/packages/credentials/deepseek-account/README.zh.md @@ -11,6 +11,8 @@ getPlatformSession 为原生 Platform 内嵌提供仅限 Host 的 origin/token `desktopClientHeaders` 将原生平台 `darwin` 和 `win32` 映射为 Desktop 账号与更新策略请求共用的请求头;`null` 不添加请求头。 +`rejectToken` 接收 Host 推理请求被拒绝的 token,仅删除与它匹配的当前登录凭据;延迟返回的拒绝不能清除替换后的凭据。 + `AccountView.signOutReason` 区分服务端判定凭据失效和主动退登;登录状态下不携带该字段。 ## 概述 diff --git a/packages/credentials/deepseek-account/src/index.ts b/packages/credentials/deepseek-account/src/index.ts index 6ae86556c7..f876c749c8 100644 --- a/packages/credentials/deepseek-account/src/index.ts +++ b/packages/credentials/deepseek-account/src/index.ts @@ -75,6 +75,12 @@ export abstract class DeepSeekAccount extends Service { * @returns stored token, or undefined for other origins or a signed-out account. */ abstract resolveToken(url: string): Promise + /** + * Remove an inference-rejected token only while it still matches the stored login. + * @param token - token captured by the rejected inference request. + * @returns after matching credentials are removed and expiry state is published. + */ + abstract rejectToken(token: string): Promise /** * Read credentials for the configured Platform origin, bound to their issuing environment. * @returns a Host-only snapshot, or null while signed out. diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 5c4b0b05d8..6de43328f2 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -901,6 +901,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'url', description: 'actual request destination or API base URL.' }], returns: 'stored token, or undefined for other origins or a signed-out account.', }, + { + signature: 'abstract rejectToken(token: string): Promise', + description: 'Remove an inference-rejected token only while it still matches the stored login.', + parameters: [{ name: 'token', description: 'token captured by the rejected inference request.' }], + returns: 'after matching credentials are removed and expiry state is published.', + }, { signature: 'abstract getPlatformSession(): Promise', description: 'Read credentials for the configured Platform origin, bound to their issuing environment.', diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index d43739e1f4..765dd5a54a 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: 6a3e89788a5a23a56727fb41615898993939ef8c -README.zh.md: db364b968f33838d3f8360ec54b2cf929866be67 +README.md: 8019a8be1f0c8e1f00ae5573e350c3a7eaf1c721 +README.zh.md: 86855c49ebeb9fd3c3ca578d52eefe7b6e4c8f92 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 6a3e89788a..8019a8be1f 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -86,7 +86,7 @@ Messages sends text, thinking, tool calls, and tool results as content blocks, r `deepseek-official` resolves only its configured API-key reference. `deepseek-account` resolves only the stored grant from the [account provider](../../credentials/deepseek-account-platform/README.md), whose allowed `inferenceOrigin` defaults to `https://api.deepseek.com`. Neither route falls back to the other. Signing out removes the account grant and preserves API keys. -Messages and Files requests send account tokens as `x-dsh-auth-token` without a Bearer prefix; API keys use `x-api-key`. Neither credential mode follows redirects. +Messages and Files requests send account tokens as `x-dsh-auth-token` without a Bearer prefix; API keys use `x-api-key`. Neither credential mode follows redirects. HTTP 401 with the provider message `Authentication Fails (invalid dsh token)` becomes `ACCOUNT_TOKEN_INVALID`. On the account route, it removes the request token only if it still matches the stored login, then publishes account expiry and UI sign-out. Generic authentication errors and API-key routes do not remove account credentials. ### Streaming with thinking and images diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index db364b968f..86855c49eb 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -86,7 +86,7 @@ Messages 以内容块发送文本、思考、工具调用和工具结果,以 ` `deepseek-official` 仅解析配置的 API Key 引用。`deepseek-account` 仅解析[账号提供者](../../credentials/deepseek-account-platform/README.zh.md)保存的授权,其允许的 `inferenceOrigin` 默认为 `https://api.deepseek.com`。两条路由均不回退到另一凭证。退出登录删除账号授权,保留 API Key。 -Messages 和 Files 请求通过 `x-dsh-auth-token` 发送账号 token,不加 Bearer 前缀;API Key 使用 `x-api-key`。两种凭据模式均拒绝重定向。 +Messages 和 Files 请求通过 `x-dsh-auth-token` 发送账号 token,不加 Bearer 前缀;API Key 使用 `x-api-key`。两种凭据模式均拒绝重定向。HTTP 401 且提供方消息为 `Authentication Fails (invalid dsh token)` 时归类为 `ACCOUNT_TOKEN_INVALID`。账号路由仅在请求 token 仍匹配已存登录凭据时删除它,并发布账号过期状态和 UI 退登通知。普通鉴权错误和 API Key 路由不会删除账号凭据。 ### 带 thinking 与图片的流式调用 diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 03aa7b605b..41a2cf4a96 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -87,63 +87,72 @@ export class DeepSeekAdapter extends LlmAdapter { ) const key = await this.dependencies.resolveApiKey(connection) const accountToken = this.dependencies.accountCredential === true ? key : undefined - const files = new RequestFiles(this.files, { - baseURL: connection.baseURL, apiKey: key, accountCredential: accountToken !== undefined, - }, - connection.filePolicy, connection.filesApiTimeoutMs, signal, activity) - let inline = false - while (true) { - signal.throwIfAborted() - files.beginAttempt() - let fileIds: Awaited> | undefined - if (!inline) { - try { - fileIds = await prepareFileIds(messages, versions, files) - } catch (error) { - if (!(error instanceof FileResolutionFailure)) throw error - inline = true - continue + try { + const files = new RequestFiles(this.files, { + baseURL: connection.baseURL, apiKey: key, accountCredential: accountToken !== undefined, + }, + connection.filePolicy, connection.filesApiTimeoutMs, signal, activity) + let inline = false + while (true) { + signal.throwIfAborted() + files.beginAttempt() + let fileIds: Awaited> | undefined + if (!inline) { + try { + fileIds = await prepareFileIds(messages, versions, files) + } catch (error) { + if (!(error instanceof FileResolutionFailure)) throw error + inline = true + continue + } + } + const history = inline ? inlineImages(messages, versions, connection) : messages + const body = serialize(options, connection, history, versions, this.imageAccess, (reason) => { + this.dependencies.onReplayDegrade?.({ provider: options.provider, model: options.model, reason }) + }, fileIds) + const extensions = await prepareRequestExtensions(body as Readonly>, { + signal, + ...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) }, + ...options.purpose === undefined ? {} : { purpose: options.purpose }, + }, this.dependencies.prepareExtensions) + signal.throwIfAborted() + const response = await fetch(`${messagesApiRoot(connection.baseURL)}/messages`, { + method: 'POST', signal, body: extensions.payload, redirect: 'error', + headers: { + ...attributionHeaders(), + 'content-type': 'application/json', 'accept': 'text/event-stream', + ...accountToken === undefined ? { 'x-api-key': key } : { 'x-dsh-auth-token': accountToken }, + 'anthropic-version': '2023-06-01', + ...fileIds === undefined || fileIds.size === 0 ? {} : { 'anthropic-beta': MESSAGES_FILES_BETA }, + 'x-deepseek-harness-user-id': this.dependencies.resolveUserId(), + ...options.sessionId === undefined ? {} : { 'x-deepseek-harness-session-id': String(options.sessionId) }, + ...options.purpose === 'compaction' ? { 'x-deepseek-harness-compact': '1' } : {}, + }, + }) + if (!response.ok) { + const text = await response.text() + let raw: unknown + try { raw = JSON.parse(text) } catch (_nonJsonGatewayError) { + // HTTP status is authoritative when a gateway does not return JSON. + } + const detail = providerErrorDetail(raw) + if (await files.retry(detail)) continue + const failure = providerError(raw, response.status, response.headers) + const message = files.errorMessage(response.status, failure.message, detail) + throw new LlmError(message, failure.code, { ...failure.failure, cause: new Error(text) }) + } + await extensions.accept() + if (response.body === null) throw new LlmError('DeepSeek Messages returned no response body', 'EMPTY_RESPONSE') + yield* translate(parseSse(response.body, activity), options.model) + return + } + } catch (error) { + if (accountToken !== undefined && error instanceof LlmError && error.code === 'ACCOUNT_TOKEN_INVALID') { + try { await this.dependencies.onInvalidAccountToken?.(accountToken) } catch (_credentialRemovalFailed) { + // Credential storage failure cannot replace the inference failure. } } - const history = inline ? inlineImages(messages, versions, connection) : messages - const body = serialize(options, connection, history, versions, this.imageAccess, (reason) => { - this.dependencies.onReplayDegrade?.({ provider: options.provider, model: options.model, reason }) - }, fileIds) - const extensions = await prepareRequestExtensions(body as Readonly>, { - signal, - ...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) }, - ...options.purpose === undefined ? {} : { purpose: options.purpose }, - }, this.dependencies.prepareExtensions) - signal.throwIfAborted() - const response = await fetch(`${messagesApiRoot(connection.baseURL)}/messages`, { - method: 'POST', signal, body: extensions.payload, redirect: 'error', - headers: { - ...attributionHeaders(), - 'content-type': 'application/json', 'accept': 'text/event-stream', - ...accountToken === undefined ? { 'x-api-key': key } : { 'x-dsh-auth-token': accountToken }, - 'anthropic-version': '2023-06-01', - ...fileIds === undefined || fileIds.size === 0 ? {} : { 'anthropic-beta': MESSAGES_FILES_BETA }, - 'x-deepseek-harness-user-id': this.dependencies.resolveUserId(), - ...options.sessionId === undefined ? {} : { 'x-deepseek-harness-session-id': String(options.sessionId) }, - ...options.purpose === 'compaction' ? { 'x-deepseek-harness-compact': '1' } : {}, - }, - }) - if (!response.ok) { - const text = await response.text() - let raw: unknown - try { raw = JSON.parse(text) } catch (_nonJsonGatewayError) { - // HTTP status is authoritative when a gateway does not return JSON. - } - const detail = providerErrorDetail(raw) - if (await files.retry(detail)) continue - const failure = providerError(raw, response.status, response.headers) - const message = files.errorMessage(response.status, failure.message, detail) - throw new LlmError(message, failure.code, { ...failure.failure, cause: new Error(text) }) - } - await extensions.accept() - if (response.body === null) throw new LlmError('DeepSeek Messages returned no response body', 'EMPTY_RESPONSE') - yield* translate(parseSse(response.body, activity), options.model) - return + throw error } } } diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 366f184e03..34a2581ece 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -89,6 +89,7 @@ export function apply(ctx: Context, config: Config): void { const resolveUserId = (): AnonymousUserId => userId ??= getOrCreateAnonymousUserId() const adapter = (accountCredential: boolean): DeepSeekAdapter => new DeepSeekAdapter({ accountCredential, + ...accountCredential ? { onInvalidAccountToken: async (token: string) => { await ctx.get('deepseekAccount')?.rejectToken(token) } } : {}, options, onReplayDegrade: ({ provider, model, reason }) => { ctx.logger.warn(`llm-deepseek: unusable Messages replay state on assistant history for route "${provider}/${model}"; sending provider-neutral content (${reason})`) diff --git a/packages/llm/llm-deepseek/src/transport.ts b/packages/llm/llm-deepseek/src/transport.ts index 935e8855df..ddae610178 100644 --- a/packages/llm/llm-deepseek/src/transport.ts +++ b/packages/llm/llm-deepseek/src/transport.ts @@ -26,7 +26,8 @@ export function providerError(raw: unknown, status: number | undefined, headers? const type = typeof error.type === 'string' ? error.type : '' const detail = `${type} ${typeof error.code === 'string' ? error.code : ''} ${message}` let code: string - if (status === 401 || status === 403 || ['authentication_error', 'permission_error'].includes(type)) code = 'AUTH' + if (status === 401 && message === 'Authentication Fails (invalid dsh token)') code = 'ACCOUNT_TOKEN_INVALID' + else if (status === 401 || status === 403 || ['authentication_error', 'permission_error'].includes(type)) code = 'AUTH' else if (isQuotaExceededError(detail) || status === 402) code = 'QUOTA' else if (status === 429 || type === 'rate_limit_error') code = 'RATE_LIMIT' else if (isContextWindowExceededError(detail)) code = 'CONTEXT_WINDOW_EXCEEDED' diff --git a/packages/llm/llm-deepseek/src/types.ts b/packages/llm/llm-deepseek/src/types.ts index 25bf19636b..8beb5030d5 100644 --- a/packages/llm/llm-deepseek/src/types.ts +++ b/packages/llm/llm-deepseek/src/types.ts @@ -86,6 +86,8 @@ export interface DeepSeekConnectionOptions { export interface DeepSeekAdapterOptions { /** Report unusable native Messages replay metadata without exposing content or signatures. */ onReplayDegrade?: (detail: { provider: string; model: string; reason: string }) => void + /** Remove a server-rejected account token if it still matches the current login; failures preserve the inference error. */ + onInvalidAccountToken?: (token: string) => Promise /** Current validated connection facts; called once per operation. */ options: () => DeepSeekConnectionOptions /** diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 088985a3ad..b051aedda6 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -277,6 +277,61 @@ describe('Cordis provider composition', () => { return { ctx, http } } + it.each(['deepseek-account', 'deepseek-official'])('reports the rejected account token only for %s', async (provider) => { + const { ctx } = await boot((response) => { + response.writeHead(401, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ error: { type: 'authentication_error', message: 'Authentication Fails (invalid dsh token)' } })) + }) + const rejectToken = vi.fn(async (_token: string) => {}) + ctx.provide('deepseekAccount', { resolveToken: async (_url: string): Promise => 'fixture-token', + rejectToken: (token: string): Promise => rejectToken(token) } as DeepSeekAccount) + expect((await chunks(ctx.llm.stream(options({ provider })))).at(-1)).toMatchObject({ + type: 'finish', reason: { kind: 'error', failure: { code: 'ACCOUNT_TOKEN_INVALID' } }, + }) + expect(rejectToken.mock.calls).toEqual(provider === 'deepseek-account' ? [['fixture-token']] : []) + }) + + it('finishes the active account turn when rejected credentials publish sign-out', async () => { + const { ctx } = await boot((response) => { + response.writeHead(401) + response.end(JSON.stringify({ error: { message: 'Authentication Fails (invalid dsh token)' } })) + }) + ctx.provide('deepseekAccount', { resolveToken: async (_url: string): Promise => 'fixture-token', + rejectToken: async (_token: string): Promise => { ctx.emit('deepseek-account/signed-out') } } as DeepSeekAccount) + const agent = await ctx.agentLoop.create(SessionId('inference-account-expiry'), { provider: 'deepseek-account', model: MODEL }) + agent.followup(user('hello')) + await agent.whenIdle() + expect(agent.session.snapshotEvents().at(-1)?.data).toMatchObject({ + reason: { kind: 'aborted', reason: { kind: 'hook', reason: 'deepseek-account/signed-out' } }, + }) + }) + + it('preserves the inference error when rejected credential removal fails', async () => { + const { ctx } = await boot((response) => { + response.writeHead(401) + response.end(JSON.stringify({ error: { message: 'Authentication Fails (invalid dsh token)' } })) + }) + ctx.provide('deepseekAccount', { resolveToken: async (_url: string): Promise => 'fixture-token', + rejectToken: async (_token: string): Promise => { throw new Error('credential storage unavailable') } } as DeepSeekAccount) + expect((await chunks(ctx.llm.stream(options({ provider: 'deepseek-account' })))).at(-1)).toMatchObject({ + type: 'finish', reason: { kind: 'error', failure: { code: 'ACCOUNT_TOKEN_INVALID' } }, + }) + }) + + it('does not remove account credentials for a generic authentication error', async () => { + const { ctx } = await boot((response) => { + response.writeHead(401) + response.end(JSON.stringify({ error: { type: 'authentication_error', message: 'API key is invalid' } })) + }) + const rejectToken = vi.fn(async (_token: string) => {}) + ctx.provide('deepseekAccount', { resolveToken: async (_url: string): Promise => 'fixture-token', + rejectToken: (token: string): Promise => rejectToken(token) } as DeepSeekAccount) + expect((await chunks(ctx.llm.stream(options({ provider: 'deepseek-account' })))).at(-1)).toMatchObject({ + type: 'finish', reason: { kind: 'error', failure: { code: 'AUTH' } }, + }) + expect(rejectToken).not.toHaveBeenCalled() + }) + it.each([ { model: MODEL, inHistory: false }, { model: MODEL, inHistory: true }, From 64154d2f68f4c43e7a7c0a2752bfdc063abd4dc1 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:49:54 +0800 Subject: [PATCH 030/192] test(models): declare available fixture models and update selection flows --- apps/web/tests/deepseek-messages-settings.e2e.ts | 9 ++++----- apps/web/tests/github-ready-review.e2e.ts | 1 + apps/web/tests/onboarding-deepseek-config.e2e.ts | 5 ++--- apps/web/tests/reasoning-preview.e2e.ts | 1 + apps/web/tests/schedule-after.e2e.ts | 3 +++ apps/web/tests/shipped-composition.e2e.ts | 2 ++ apps/web/tests/streaming-fence-highlight.e2e.ts | 1 + apps/web/tests/support.ts | 6 ++++-- packages/sdk/server/tests/server.spec.ts | 5 ++++- 9 files changed, 22 insertions(+), 11 deletions(-) diff --git a/apps/web/tests/deepseek-messages-settings.e2e.ts b/apps/web/tests/deepseek-messages-settings.e2e.ts index 8e5f2a9617..93aa400f57 100644 --- a/apps/web/tests/deepseek-messages-settings.e2e.ts +++ b/apps/web/tests/deepseek-messages-settings.e2e.ts @@ -84,17 +84,16 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages sett expect(tripwire.pageErrors).toEqual([]) }, 60_000) - it('keeps a saved DeepSeek selection available after editing provider settings', async () => { + it('selects an available DeepSeek model after provider settings remove the default', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-deepseek-messages-default')) await page.keyboard.press('Escape') await scaffold.ctx.agentDefaultModel.saveSelection({ provider: 'deepseek-official', model: 'deepseek-v4-flash' }) await page.reload({ waitUntil: 'load' }) const input = page.locator('[data-composer-input]').first() - await expect.poll(() => input.isEnabled()).toBe(true) - await page.getByRole('button', { name: /^选择模型/ }).click() - await page.getByRole('menuitem', { name: /模型/ }).click() + await expect.poll(() => input.getAttribute('contenteditable')).toBe('false') + await page.getByRole('button', { name: '请选择模型', exact: true }).click() await page.getByRole('menuitemradio', { name: 'Messages Flash', exact: true }).click() - await expect.poll(() => input.isEnabled()).toBe(true) + await expect.poll(() => input.getAttribute('contenteditable')).toBe('true') await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection().provider).toBe('deepseek-official') const settings = await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8') expect(settings).toContain('provider: deepseek-official') diff --git a/apps/web/tests/github-ready-review.e2e.ts b/apps/web/tests/github-ready-review.e2e.ts index 1477e2ea25..8bca2a746a 100644 --- a/apps/web/tests/github-ready-review.e2e.ts +++ b/apps/web/tests/github-ready-review.e2e.ts @@ -35,6 +35,7 @@ const REPLY = 'Review complete: no actionable findings.' /** Deterministic model response for the webhook-created Session. */ class ReviewAdapter extends LlmAdapter { + override async listModels(provider: string) { return [{ provider, id: MODEL, name: `${provider}/${MODEL}` }] } readonly requests: GenerateOptions[] = [] override async * stream(options: GenerateOptions): AsyncIterable { diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index 30cae9db7b..d0e5ddd582 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -273,12 +273,11 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await page.keyboard.press('Escape') // A connected Workspace is what puts a live composer — and its model // trigger — on the page; the scaffold boots without one. - await connectFreshWorkspaceZh(page, scaffold.workspaceCwd, 'model-fallback-e2e') + await connectFreshWorkspaceZh(page, scaffold.workspaceCwd, 'model-fallback-e2e', false) - const modelTrigger = page.getByRole('button', { name: /^选择模型/ }) + const modelTrigger = page.getByRole('button', { name: '请选择模型', exact: true }) await modelTrigger.waitFor({ timeout: 10_000 }) await modelTrigger.click() - await page.getByRole('menuitem', { name: /模型/ }).click() expect(await page.getByText('Configured Flash', { exact: true }).count()).toBe(0) await page.getByRole('menuitemradio', { name: 'Private Preview' }).waitFor({ timeout: 10_000 }) expect(tripwire.warnings).toEqual([]) diff --git a/apps/web/tests/reasoning-preview.e2e.ts b/apps/web/tests/reasoning-preview.e2e.ts index 9571b51d1c..0d5911edde 100644 --- a/apps/web/tests/reasoning-preview.e2e.ts +++ b/apps/web/tests/reasoning-preview.e2e.ts @@ -14,6 +14,7 @@ const DELTAS = ['First paragraph', `\nDetails\n\n\n${SUMMARY}`, '\nMore detail'] const UI_EXPECTED = fileURLToPath(new URL('./expected/reasoning-preview/running.expected.md', import.meta.url)) class PausedReasoningAdapter extends LlmAdapter { + override async listModels(provider: string) { return [{ provider, id: 'paused', name: `${provider}/paused` }] } readonly stages = DELTAS.map(text => ({ text, arrived: Promise.withResolvers(), diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 5dd7bf2ccb..9ce1560f7b 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -92,6 +92,7 @@ function textResponse(text: string): StreamChunk[] { /** Deterministic model seam that turns one due reminder into ordinary assistant prose. */ class ReminderAdapter extends LlmAdapter { + override async listModels(provider: string) { return [{ provider, id: MODEL, name: `${provider}/${MODEL}` }] } readonly requests: GenerateOptions[] = [] override async * stream(options: GenerateOptions): AsyncIterable { @@ -102,6 +103,7 @@ class ReminderAdapter extends LlmAdapter { /** Deterministic model seam for one multi-record fixed-rate batch. */ class EveryReminderAdapter extends LlmAdapter { + override async listModels(provider: string) { return [{ provider, id: MODEL, name: `${provider}/${MODEL}` }] } readonly requests: GenerateOptions[] = [] override async * stream(options: GenerateOptions): AsyncIterable { @@ -137,6 +139,7 @@ function localAt(epoch: number, timeZone: string): LocalAt { /** Dynamic model seam proving request-local browser context becomes an explicit At selector. */ class BrowserZoneAtAdapter extends LlmAdapter { + override async listModels(provider: string) { return [{ provider, id: MODEL, name: `${provider}/${MODEL}` }] } readonly requests: GenerateOptions[] = [] selectedAt: LocalAt | undefined scheduledAt: string | undefined diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index d1e6ecd83a..f6727eecaa 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -94,6 +94,7 @@ function textChunks(text: string): StreamChunk[] { /** Scripted same-route main model and reviewer for the shipped Auto pipeline. */ class ShippedAutoAdapter extends LlmAdapter { + override async listModels(provider: string) { return [{ provider, id: AUTO_MODEL, name: AUTO_MODEL }] } readonly requests: GenerateOptions[] = [] constructor(private readonly targetPath: string) { @@ -196,6 +197,7 @@ function topLevelText(options: GenerateOptions): string { /** Same-route scripts for real one-shot, continuable, and cold-resumed children. */ class ShippedChildAutoAdapter extends LlmAdapter { + override async listModels(provider: string) { return [{ provider, id: AUTO_MODEL, name: AUTO_MODEL }] } readonly requests: GenerateOptions[] = [] readonly reviews: ChildReviewObservation[] = [] private readonly children = new Map() diff --git a/apps/web/tests/streaming-fence-highlight.e2e.ts b/apps/web/tests/streaming-fence-highlight.e2e.ts index 6d65cdca0d..bff5ff0f7e 100644 --- a/apps/web/tests/streaming-fence-highlight.e2e.ts +++ b/apps/web/tests/streaming-fence-highlight.e2e.ts @@ -30,6 +30,7 @@ const REPLY = `${OPEN_REPLY}\n\`\`\`` /** Deterministic model response held after each visible fence-growth frame. */ class StreamingFenceAdapter extends LlmAdapter { + override async listModels(provider: string) { return [{ provider, id: MODEL, name: `${provider}/${MODEL}` }] } private resolveFirstPaused!: () => void private resolveFirstContinuation!: () => void private resolveSecondPaused!: () => void diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 9945da66a4..83e5d25253 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -153,8 +153,9 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo * @param page - the browser page under test. * @param root - workspace parent directory. * @param name - directory created under `root` and connected. + * @param modelAvailable - require an editable composer when the selected model is available. */ -export async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace'): Promise { +export async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace', modelAvailable = true): Promise { mkdirSync(join(root, name), { recursive: true }) await page.getByRole('textbox', { name: '选择工作区' }).click() const dialog = page.getByRole('dialog', { name: '选择工作区目录' }) @@ -164,7 +165,8 @@ export async function connectFreshWorkspaceZh(page: Page, root: string, name = ' await pathInput.fill(join(root, name)) await pathInput.press('Enter') await dialog.getByRole('button', { name: '打开', exact: true }).click() - await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="描述你想要构建的内容, / 调用指令, @ 文件或对话"]') + const editable = modelAvailable ? '[contenteditable="true"]' : '' + await page.locator(`[data-composer-input]${editable}[data-placeholder="描述你想要构建的内容, / 调用指令, @ 文件或对话"]`) .waitFor({ timeout: 15_000 }) } diff --git a/packages/sdk/server/tests/server.spec.ts b/packages/sdk/server/tests/server.spec.ts index 3448c5dc87..eb74c12243 100644 --- a/packages/sdk/server/tests/server.spec.ts +++ b/packages/sdk/server/tests/server.spec.ts @@ -922,7 +922,10 @@ describe('HarnessSdkJsonRpcServer', () => { await expect(server.initialize({ cwd: storageDir, provider: 'private', model: 'new-model' })) .rejects.toThrow('no adapter registered for provider "private"') - expect(ctx.get('llm')?.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) + expect(ctx.get('llm')?.listProviders()).toEqual([ + { id: 'deepseek-official', name: 'DeepSeek' }, + { id: 'deepseek-account', name: 'DeepSeek Account' }, + ]) await server.shutdown() } finally { await ctx.fiber.dispose() From 700b636508f9692acf492a2d698549671b708dc2 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 22 Sep 2026 16:00:57 +0800 Subject: [PATCH 031/192] fix(agent-team): limit baseline loading fix to the panel --- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 +- packages/api/session-controller/README.zh.md | 2 +- .../src/client/contract/sessions.ts | 13 +---- .../session-controller/src/client/index.ts | 2 +- .../src/client/sessions/manager.ts | 18 ++---- .../src/client/sessions/service.ts | 9 ++- .../tests/manager.client.spec.ts | 57 ------------------- .../tests/sessions-service.client.spec.ts | 11 ---- .../experimental/agent-team/README.i18n.yaml | 4 +- packages/experimental/agent-team/README.md | 2 +- packages/experimental/agent-team/README.zh.md | 2 +- .../client-ui-agent-team/README.i18n.yaml | 4 +- .../client-ui-agent-team/README.md | 7 ++- .../client-ui-agent-team/README.zh.md | 7 ++- .../src/client/TeamAction.tsx | 10 ++-- .../client-ui-agent-team/src/client/mount.ts | 4 +- .../tests/browser-plugin.client.spec.ts | 11 ++-- .../tests/team-action.client.spec.tsx | 19 +++---- .../src/client/api-catalog.ts | 12 ++-- 20 files changed, 56 insertions(+), 144 deletions(-) diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 31847df767..a6a1d20012 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/session-controller/README.md -README.md: e00acab6862e66f1c1f34ce81f0249513c9bf5c8 -README.zh.md: b2ad89a8664d54f70d2364aeaf3b1c7d3b3f5bff +README.md: e12ad7623eefc73c94e99cc3078e0ae78d6a969a +README.zh.md: 5f4ceec9a8e8303cad6651dbbe7af27fc07bccec diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index e00acab686..e12ad7623e 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -39,7 +39,7 @@ Client list refreshes retain unchanged row objects and reuse the items array whe Background-job rows and observation streams belong to [`dsh-api-job-controller`](../job-controller/README.md); the control stream carries projections only. -Projection reads retain loading and failure state independently of their values. `refreshProjections` reuses a successful baseline within one connection by default. Passing `{ force: true }` requests a fresh baseline after any running read; concurrent forced calls during that read share one follow-up. Session removal, reconnect, and disposal cancel queued follow-ups. Reconnect cancels reads from the previous connection and reloads previously requested projections; live membership updates arrive through the control stream. Parent availability comes from Host summaries and remains unknown until a summary or a successful list baseline establishes it. Address lookup resolves projected children without selecting them or creating scopes. +Projection reads retain loading and failure state independently of their values. Reconnect cancels reads from the previous connection and reloads previously requested projections; live membership updates arrive through the control stream. Parent availability comes from Host summaries and remains unknown until a summary or a successful list baseline establishes it. Address lookup resolves projected children without selecting them or creating scopes. Accepted prompts and observed running keep their Client display conversion across list refresh, reconnect, and Session-object replacement. The Manager retains these observations by Session id; they do not establish durable history. Drafts, projection stores, and sidebar display rules retain their existing behavior. The [blank rollback decision](../../../.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md) defines retention and late-response handling. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index b2ad89a866..5f4ceec9a8 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -39,7 +39,7 @@ Client 列表刷新保留未变化的行对象,并在顺序和值均相同时 后台 job 的名册行与观测流属于 [`dsh-api-job-controller`](../job-controller/README.zh.md);控制流只承载投影。 -投影读取独立于其值保留加载与失败状态。`refreshProjections` 默认复用同一连接内已成功读取的基线。传入 `{ force: true }` 会在当前读取结束后请求新基线;当前读取期间并发的强制刷新调用共用一次后续读取。会话移除、重连和释放会取消排队的后续读取。重连会取消上一连接的读取,并重新加载已请求的投影;实时成员更新通过 control stream 到达。父 Agent 可用性来自 Host 摘要,在收到对应摘要或成功的列表 baseline 前保持未知。地址查找直接解析投影中的子项,不会选择它们或创建 scope。 +投影读取独立于其值保留加载与失败状态。重连会取消上一连接的读取,并重新加载已请求的投影;实时成员更新通过 control stream 到达。父 Agent 可用性来自 Host 摘要,在收到对应摘要或成功的列表 baseline 前保持未知。地址查找直接解析投影中的子项,不会选择它们或创建 scope。 已受理的提示词与已观察到的运行,会使客户端展示转换跨列表刷新、重连及 Session 对象替换保留。Manager 按 Session id 保留这些观察;它们不证明持久历史已经产生。草稿、投影存储和侧边栏展示规则保留原有行为。[blank 回退修复决策](../../../.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md) 定义保留期限及迟到响应处理。 diff --git a/packages/api/session-controller/src/client/contract/sessions.ts b/packages/api/session-controller/src/client/contract/sessions.ts index cbe0ba8e0d..8f016ab711 100644 --- a/packages/api/session-controller/src/client/contract/sessions.ts +++ b/packages/api/session-controller/src/client/contract/sessions.ts @@ -21,12 +21,6 @@ export type { AgentContext } from '../scope.ts' /** Known Session identity or durable direct-parent subagent address; an address owns no lifetime. */ export type SessionTarget = SessionId | SubagentAddress -/** Cache policy for an explicit Session projection read. */ -export interface SessionProjectionRefreshOptions { - /** Read again after any current request; concurrent forced reads share that follow-up. */ - readonly force?: boolean -} - /** One independent use of an exact Client generation, without Host Agent ownership. */ export interface SessionReference extends Disposable { readonly sessionId: SessionId @@ -102,12 +96,11 @@ export interface ISessions { subagentAddress(id: SessionId): SubagentAddress | undefined /** - * Load all Session projections once per connection; retry unsuccessful reads. + * Load all Session projections once per connection; retry an unsuccessful initial read. * @param sessionId - Session to inspect without opening its conversation. - * @param options - force a fresh read even after a successful baseline; a running read finishes first. - * @returns completion of the requested read; removal, reconnect, or disposal cancels a queued refresh. + * @returns completion of the current or newly started refresh. */ - refreshProjections(sessionId: SessionId, options?: SessionProjectionRefreshOptions): Promise + refreshProjections(sessionId: SessionId): Promise /** * Refresh the Host-authoritative Session list. diff --git a/packages/api/session-controller/src/client/index.ts b/packages/api/session-controller/src/client/index.ts index dcd44580ca..b9600f36a6 100644 --- a/packages/api/session-controller/src/client/index.ts +++ b/packages/api/session-controller/src/client/index.ts @@ -50,7 +50,7 @@ export type { SubmissionHandle, } from './contract/session.ts' export type { - ISessions, SessionProjectionRefreshOptions, SessionReference, SessionRetainInfo, SessionRetainOptions, SessionTarget, + ISessions, SessionReference, SessionRetainInfo, SessionRetainOptions, SessionTarget, } from './contract/sessions.ts' export { MutableSessionEventSource } from './contract/events.ts' export type { diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index f1650011a8..50af09df13 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -25,7 +25,7 @@ import { Notifier } from './notifier.ts' import { ProjectionValueStore } from './projection-store.ts' import { Session } from './session.ts' import type { SessionRemotes } from './remotes.ts' -import type { SessionProjectionRefreshOptions, SessionTarget } from '../contract/sessions.ts' +import type { SessionTarget } from '../contract/sessions.ts' function sessionSeqCursor(value: number): SessionSeqCursor { return value === -1 ? -1 : SessionSeq(value) @@ -68,7 +68,6 @@ export interface SessionProjectionSnapshot { interface ProjectionInflight { readonly promise: Promise readonly controller: AbortController - refreshAfter?: Promise } type ProjectionLoad = Omit @@ -335,19 +334,12 @@ export class SessionManager { /** * Load a complete projection baseline once per connection; retry unsuccessful reads. * @param sessionId - Session to inspect without opening its conversation. - * @param options - force a fresh read after any current request, including a cached successful baseline. - * @returns completion of the requested read; aborting the current request also cancels its queued refresh. + * @returns completion of the current or newly started read. */ - refreshProjections(sessionId: SessionId, options?: SessionProjectionRefreshOptions): Promise { + refreshProjections(sessionId: SessionId): Promise { const existing = this.projectionInflight.get(sessionId) - if (existing !== undefined) { - if (!options?.force) return existing.refreshAfter ?? existing.promise - // A running read may predate plugin activation and omit its new projection. - return existing.refreshAfter ??= existing.promise.then(() => { - if (!existing.controller.signal.aborted) return this.refreshProjections(sessionId, { force: true }) - }) - } - if (!options?.force && this.projectionLoads.get(sessionId)?.state === 'ready') return Promise.resolve() + if (existing !== undefined) return existing.promise + if (this.projectionLoads.get(sessionId)?.state === 'ready') return Promise.resolve() const controller = new AbortController() const store = this.projectionStore(sessionId) const initialValues = store.values() diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index 040835cc53..48d8f6a533 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -13,7 +13,7 @@ import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protoc import type { SessionEventSource } from '../contract/events.ts' import type { SessionFace } from '../contract/session.ts' import type { - AgentContext, ISessions, SessionProjectionRefreshOptions, SessionReference, SessionRetainInfo, SessionRetainOptions, SessionTarget, + AgentContext, ISessions, SessionReference, SessionRetainInfo, SessionRetainOptions, SessionTarget, } from '../contract/sessions.ts' import type { SessionReferenceSource } from '../index.ts' import { createScope, scopeIdentityOf, scopeOf as scopeTagOf } from '../scope.ts' @@ -339,12 +339,11 @@ export class ClientSessions implements ISessions { } /** - * Load all Session projections using the requested cache policy. + * Load all Session projections once per connection; retry an unsuccessful initial read. * @param sessionId - Session to inspect without opening its conversation. - * @param options - optional fresh read after any current request. */ - refreshProjections(sessionId: SessionId, options?: SessionProjectionRefreshOptions): Promise { - return this.manager.refreshProjections(sessionId, options) + refreshProjections(sessionId: SessionId): Promise { + return this.manager.refreshProjections(sessionId) } /** diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index 3ac31520b2..4d91e0b77f 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -927,63 +927,6 @@ describe('subagent catalogs', () => { expect(remote.session.projections.mock.calls.map(([request]) => request)).toHaveLength(1) }) - it('refreshes a successful baseline to discover a newly available projection', async ({ mock, remote }) => { - const manager = makeManager(mock, remote) - onTestFinished(() => manager.dispose()) - remote.session.projections.mockResolvedValueOnce(ok({ asOfSeq: 0, values: {} })) - await manager.refreshProjections(S1) - remote.session.projections.mockResolvedValueOnce(ok({ asOfSeq: 0, values: { title: 'new capability' } })) - await manager.refreshProjections(S1, { force: true }) - expect(manager.getListSnapshot().projectionsBySession[S1]?.values.title).toBe('new capability') - await manager.refreshProjections(S1) - expect(remote.session.projections).toHaveBeenCalledTimes(2) - }) - - it('coalesces forced reads behind an older request without regressing newer frames', async ({ mock, remote }) => { - const manager = makeManager(mock, remote) - onTestFinished(() => manager.dispose()) - const old = Promise.withResolvers>>() - const fresh = Promise.withResolvers>>() - remote.session.projections.mockImplementationOnce(() => old.promise) - remote.session.projections.mockImplementationOnce(() => fresh.promise) - const initial = manager.refreshProjections(S1) - const refresh = manager.refreshProjections(S1, { force: true }) - expect(manager.refreshProjections(S1, { force: true })).toBe(refresh) - expect(manager.refreshProjections(S1)).toBe(refresh) - expect(remote.session.projections).toHaveBeenCalledOnce() - old.resolve(ok({ asOfSeq: 0, values: {} })) - await initial - expect(remote.session.projections).toHaveBeenCalledTimes(2) - manager.handleControlFrame({ type: 'projection', sessionId: S1, key: 'title', seq: 2, value: 'live title' }) - fresh.resolve(ok({ asOfSeq: 1, values: { title: 'older title', subagentCatalog: [] } })) - await refresh - expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({ - state: 'ready', values: { title: 'live title', subagentCatalog: [] }, - }) - }) - - it.for(['remove', 'reconnect', 'dispose'] as const)( - 'cancels a queued forced read on %s', - async (action, { mock, remote }) => { - const manager = makeManager(mock, remote) - onTestFinished(() => manager.dispose()) - const old = Promise.withResolvers>>() - remote.session.projections.mockImplementationOnce(() => old.promise) - remote.session.projections.mockResolvedValue(ok({ asOfSeq: 0, values: { title: 'new connection' } })) - const initial = manager.refreshProjections(S1) - const refresh = manager.refreshProjections(S1, { force: true }) - let disposal: Promise | undefined - if (action === 'remove') manager.handleSessionRemoved(S1) - else if (action === 'reconnect') manager.handleConnected() - else disposal = manager.dispose() - old.resolve(ok({ asOfSeq: 0, values: { title: 'old connection' } })) - await Promise.all([initial, refresh, disposal]) - expect(remote.session.projections).toHaveBeenCalledTimes(action === 'reconnect' ? 2 : 1) - expect(manager.getListSnapshot().projectionsBySession[S1]?.values.title) - .toBe(action === 'reconnect' ? 'new connection' : undefined) - }, - ) - it('uses current Session status rather than the time an initial catalog read started', async ({ mock, remote }) => { const response = Promise.withResolvers>>() remote.session.projections.mockImplementation(() => response.promise) diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts index 97af4bbea9..d4061def11 100644 --- a/packages/api/session-controller/tests/sessions-service.client.spec.ts +++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts @@ -719,17 +719,6 @@ describe('borrow-only bindings', () => { }) describe('catalog-addressed navigation', () => { - it('refreshes cached projection baselines through the public Session service', async ({ bench }) => { - const b = bench() - b.mock.remote.session.projections.mockResolvedValueOnce(ok({ asOfSeq: 0, values: {} })) - await b.svc.refreshProjections(sid('root')) - b.mock.remote.session.projections.mockResolvedValueOnce(ok({ asOfSeq: 0, values: { title: 'new capability' } })) - await b.svc.refreshProjections(sid('root'), { force: true }) - await b.svc.refreshProjections(sid('root')) - expect(b.svc.list.getSnapshot().projectionsBySession[sid('root')]?.values.title).toBe('new capability') - expect(b.mock.remote.session.projections).toHaveBeenCalledTimes(2) - }) - it('retains a projected child independently of its parent and shares its history generation', async ({ bench }) => { const b = bench() await feedList(b, [{ id: 'root' }]) diff --git a/packages/experimental/agent-team/README.i18n.yaml b/packages/experimental/agent-team/README.i18n.yaml index 6a0bef282f..4b7b8f5ec1 100644 --- a/packages/experimental/agent-team/README.i18n.yaml +++ b/packages/experimental/agent-team/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/agent-team/README.md -README.md: 79fc15fa369ec4615d3a3531de7c1fd54aa41ee7 -README.zh.md: 8e1905ded14b8cb803298c470085b6ac2a426fd4 +README.md: 9c0a351cf6017bb026b864c643a2ebeaa19ccbdc +README.zh.md: 44a3ade10100d7d44d9ed9735cedfc75684fa2ae diff --git a/packages/experimental/agent-team/README.md b/packages/experimental/agent-team/README.md index 79fc15fa36..9c0a351cf6 100644 --- a/packages/experimental/agent-team/README.md +++ b/packages/experimental/agent-team/README.md @@ -176,7 +176,7 @@ Read these pages when the package-level contract is not enough. They move from t The `agentTeam` Session projection publishes the Lead Session's durable roster, non-deleted task views, and any `failure` beside the last valid state. Its `apply` replaces only the touched collection; mailbox-only changes retain the client view reference and produce no frame. The [subsystem reference](../../../docs/subsystems/agent-team.md#web-projection) defines the wire types. -The Web UI reads current-session values through the standard projection hook and overlays activity from Session status. Opening the panel refreshes the Lead baseline; other active members' baselines are loaded for durable model selection without opening their conversations. Task creation and updates belong to Team agents through the service and model tools. The `./client` export supplies browser-safe roster, task, and projection types. +The Web UI reads current-session values through the standard projection hook and overlays activity from Session status. The current Session supplies its baseline through conversation loading; other active members' baselines are loaded for durable model selection without opening their conversations. Task creation and updates belong to Team agents through the service and model tools. The `./client` export supplies browser-safe roster, task, and projection types. ## Model Experience diff --git a/packages/experimental/agent-team/README.zh.md b/packages/experimental/agent-team/README.zh.md index 8e1905ded1..44a3ade101 100644 --- a/packages/experimental/agent-team/README.zh.md +++ b/packages/experimental/agent-team/README.zh.md @@ -176,7 +176,7 @@ dispose 会关闭准入、中止并等待已获准的创建与 mailbox dispatch `agentTeam` Session 投影发布 Lead Session 的持久 roster、未删除任务视图,以及最后有效状态旁的 `failure`。其 `apply` 只替换被触及的集合;仅邮箱的变化保留客户端视图引用,不产生 frame。[子系统参考](../../../docs/subsystems/agent-team.zh.md#web-projection) 定义传输类型。 -Web UI 通过标准投影钩子读取当前会话的值,并从 Session 状态叠加活动信息。打开 panel 时会刷新 Lead 基线;其他 active 成员的基线用于读取持久模型选择,无需打开它们的会话。任务创建与更新由 Team agent 通过服务和模型工具完成。`./client` 导出可供浏览器使用的 roster、任务与投影类型。 +Web UI 通过标准投影钩子读取当前会话的值,并从 Session 状态叠加活动信息。当前 Session 通过会话加载提供基线;其他 active 成员的基线用于读取持久模型选择,无需打开它们的会话。任务创建与更新由 Team agent 通过服务和模型工具完成。`./client` 导出可供浏览器使用的 roster、任务与投影类型。 ## 模型体验 diff --git a/packages/experimental/client-ui-agent-team/README.i18n.yaml b/packages/experimental/client-ui-agent-team/README.i18n.yaml index 0affc2f3f3..435dcde232 100644 --- a/packages/experimental/client-ui-agent-team/README.i18n.yaml +++ b/packages/experimental/client-ui-agent-team/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/client-ui-agent-team/README.md -README.md: 8a94b57035d3ed56c2b88b91020eff659384fabe -README.zh.md: cbea68e5aa91ccc27bbcc9f3a89beed2bc2d5580 +README.md: b6930b9cc893cbdf1a9734c8e81c260d23fabbb3 +README.zh.md: 93f9a0d036947bda994298967bc3574e136ce9f4 diff --git a/packages/experimental/client-ui-agent-team/README.md b/packages/experimental/client-ui-agent-team/README.md index 8a94b57035..b6930b9cc8 100644 --- a/packages/experimental/client-ui-agent-team/README.md +++ b/packages/experimental/client-ui-agent-team/README.md @@ -29,7 +29,7 @@ Enable this package through [`@deepseek-ai/dsh-experimental-agent-team-profile`] ### Inspect and navigate the roster -The trigger shows the teammate count, and the panel shows the roster and task board of the Lead Session's `agentTeam` projection, so a task created by an agent or a teammate reaching `active` appears while the panel is open. Opening the panel refreshes the Lead's projection baseline, including when the Team plugin was enabled after the conversation opened. A read already in progress finishes before this refresh starts. Failed reads show a retry control alongside any last valid Team; a reconnect reloads previously requested projections. A successful read without the Team capability shows an unavailable notice. The panel also requests active members' projection baselines outside the current Session and Lead, including persisted Sessions that have not been opened, and loads new members when they become active. Roster rows show durable names and a status: `failed` and `provisioning` come from the durable member phase, and `running` or `inactive` comes from the member Session's live status. A model appears when the member Session's `modelSelection` projection records a durable selection or request. Provisioning and running members use the shared ongoing loader, inactive members use idle, and failed members use error. Selecting a healthy teammate opens the ordinary `{ parentSessionId, childSessionId, mode: 'continuable' }` address from its Lead and roster identity without refreshing or checking the parent catalog. The Host validates the parent, child, and mode when history opens. History and later human prompts continue through the stable addressed-subagent conversation path; this package adds no Team-specific address field. +The trigger shows the teammate count, and the panel shows the roster and task board of the Lead Session's `agentTeam` projection, so a task created by an agent or a teammate reaching `active` appears while the panel is open. Opening the panel requests the Lead's projection baseline once per connection, both in the Lead conversation and in a teammate conversation. A successful prior read is reused; a failed read shows a retry control alongside any last valid Team, and a reconnect reloads it. A successful read without the Team capability shows an unavailable notice. The panel also requests active members' projection baselines outside the current Session and Lead, including persisted Sessions that have not been opened, and loads new members when they become active. Roster rows show durable names and a status: `failed` and `provisioning` come from the durable member phase, and `running` or `inactive` comes from the member Session's live status. A model appears when the member Session's `modelSelection` projection records a durable selection or request. Provisioning and running members use the shared ongoing loader, inactive members use idle, and failed members use error. Selecting a healthy teammate opens the ordinary `{ parentSessionId, childSessionId, mode: 'continuable' }` address from its Lead and roster identity without refreshing or checking the parent catalog. The Host validates the parent, child, and mode when history opens. History and later human prompts continue through the stable addressed-subagent conversation path; this package adds no Team-specific address field. ### Inspect the task board @@ -47,7 +47,7 @@ The read-only task board shows task identity, owner, blockers, readiness, adviso The Client export registers its locale dictionaries and one conversation-header slot through Cordis effects; it mounts no Remote namespace. Disposing the plugin fiber removes both registrations. -The panel renders outside the conversation container and stays within the viewport. Opening moves focus into the panel; Escape or Close returns focus to its trigger. Clicking outside or moving focus outside the panel and trigger closes it without moving focus back. The component reads the current Session's Team through `useProjection('agentTeam')` and its model through `useProjection('modelSelection')`. In a teammate conversation, `useSession` supplies the Lead identity and `useSessions` selects the parent's Team value. Separate `useSessions` selectors read the Lead's baseline-read state and error in either conversation. Each roster row selects its own model and running state; the panel subscribes to neither the complete projection collection nor the complete summary or status collection. Its only injected callbacks read a Session's projection baseline and open a teammate. Switching conversations closes the panel and clears a navigation failure. +The panel renders outside the conversation container and stays within the viewport. Opening moves focus into the panel; Escape or Close returns focus to its trigger. Clicking outside or moving focus outside the panel and trigger closes it without moving focus back. The component reads the current Session's Team through `useProjection('agentTeam')` and its model through `useProjection('modelSelection')`. In a teammate conversation, `useSession` supplies the Lead identity and `useSessions` reads its Team value. Separate `useSessions` selectors read the Lead's baseline-read state and error in both conversation types. Each roster row selects its own model and running state; the panel subscribes to neither the complete projection collection nor the complete summary or status collection. Its only injected callbacks read a Session's projection baseline and open a teammate. Switching conversations closes the panel and clears a navigation failure. | File | Role | |---|---| @@ -64,7 +64,7 @@ The panel renders outside the conversation container and stays within the viewpo ## Further Exploration - [Agent Teams bundle](../agent-team-profile/README.md) — the published opt-in bundle that mounts this Client plugin. -- [Agent Teams service](../agent-team/README.md) — authoritative roster, task, and projection behavior. +- [Agent Teams service](../agent-team/README.md) — authoritative roster, task, projection, and Remote behavior. - [Conversation UI](../../client/ui-conversation/README.md) — the stable header slot and addressed-subagent navigation surface. - [Experimental packages](../README.md) — incubation status and publication policy. @@ -83,6 +83,7 @@ No direct effect; the Team tools and ordinary conversation submission own any la +- **Cached capability absence** — if another UI loaded the Lead projection baseline before Agent Teams was enabled, opening this panel reuses that baseline; reconnect to load the newly enabled capability. - **No mailbox timeline** — the projection view carries roster and tasks only; peer messages are not shown. - **Model before the first request** — a member without a durable model selection or request shows no model. Inactive persisted members can supply their model through the explicit projection read; live activity still requires a running Host agent. - **Ordinary child continuation** — a human message sent after navigation uses the stable addressed-subagent prompt path, not the Team peer mailbox. diff --git a/packages/experimental/client-ui-agent-team/README.zh.md b/packages/experimental/client-ui-agent-team/README.zh.md index cbea68e5aa..93f9a0d036 100644 --- a/packages/experimental/client-ui-agent-team/README.zh.md +++ b/packages/experimental/client-ui-agent-team/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 检查并导航 roster -触发按钮显示 teammate 数量,panel 显示 Lead Session `agentTeam` 投影中的 roster 与任务板,因此 agent 创建的任务或进入 `active` 的 teammate 会在 panel 打开期间直接出现。打开 panel 时会刷新 Lead 的投影基线,包括在会话打开后才启用 Team 插件的情况。若已有读取正在进行,本次刷新会在其结束后开始。读取失败时,最后有效 Team 旁会显示重试控件;重连会重新加载已请求的投影。读取成功但缺少 Team 能力时显示不可用提示。panel 还会请求当前 Session 与 Lead 之外 active 成员的投影基线,包括尚未打开的持久 Session,并在新成员进入 active 时加载它。Roster row 展示持久 name 与状态:`failed` 与 `provisioning` 来自持久成员 phase,`running` 或 `inactive` 来自成员 Session 的实时状态。当成员 Session 的 `modelSelection` 投影记录了持久选择或请求时显示 model。provisioning 和 running 成员使用共享 ongoing loading,inactive 成员使用 idle 灰点,failed 成员使用 error 红点。选择健康 teammate 时,系统直接根据其 Lead 与 roster 身份打开普通的 `{ parentSessionId, childSessionId, mode: 'continuable' }` address,不刷新或检查 parent catalog。Host 在打开历史时校验 parent、child 与 mode。History 与后续人类提示词继续使用稳定 addressed-subagent 会话路径;本包不会添加 Team 专用 address 字段。 +触发按钮显示 teammate 数量,panel 显示 Lead Session `agentTeam` 投影中的 roster 与任务板,因此 agent 创建的任务或进入 `active` 的 teammate 会在 panel 打开期间直接出现。无论在 Lead 会话还是 teammate 会话中,打开 panel 时都按每次连接一次的规则请求 Lead 的投影基线。之前读取成功则复用;读取失败会在最后有效 Team 旁显示重试控件,重连后会重新加载。读取成功但缺少 Team 能力时显示不可用提示。panel 还会请求当前 Session 与 Lead 之外 active 成员的投影基线,包括尚未打开的持久 Session,并在新成员进入 active 时加载它。Roster row 展示持久 name 与状态:`failed` 与 `provisioning` 来自持久成员 phase,`running` 或 `inactive` 来自成员 Session 的实时状态。当成员 Session 的 `modelSelection` 投影记录了持久选择或请求时显示 model。provisioning 和 running 成员使用共享 ongoing loading,inactive 成员使用 idle 灰点,failed 成员使用 error 红点。选择健康 teammate 时,系统直接根据其 Lead 与 roster 身份打开普通的 `{ parentSessionId, childSessionId, mode: 'continuable' }` address,不刷新或检查 parent catalog。Host 在打开历史时校验 parent、child 与 mode。History 与后续人类提示词继续使用稳定 addressed-subagent 会话路径;本包不会添加 Team 专用 address 字段。 ### 查看任务板 @@ -47,7 +47,7 @@ kind: "package-reference" Client export 通过 Cordis effect 注册 locale dictionary 与一个 conversation-header slot;它不挂载任何 Remote namespace。Dispose plugin fiber 会移除这两项 registration。 -面板渲染在会话容器外,并保持在视口范围内。打开时焦点移入面板;按 Escape 或选择关闭按钮时,焦点返回触发按钮。点击外部或将焦点移出面板与触发按钮时,面板关闭,但不会将焦点移回。组件通过 `useProjection('agentTeam')` 读取当前 Session 的 Team,通过 `useProjection('modelSelection')` 读取其 model。在 teammate 会话中,`useSession` 提供 Lead 身份,`useSessions` 选择父会话的 Team 值。两种会话都通过独立的 `useSessions` selector 读取 Lead 的基线读取状态和错误。每个 roster row 只选择自己的 model 和运行状态;panel 不订阅完整投影集合,也不订阅完整摘要或状态集合。它仅有的注入回调是读取 Session 的投影基线与打开 teammate。切换会话会关闭面板并清除导航失败。 +面板渲染在会话容器外,并保持在视口范围内。打开时焦点移入面板;按 Escape 或选择关闭按钮时,焦点返回触发按钮。点击外部或将焦点移出面板与触发按钮时,面板关闭,但不会将焦点移回。组件通过 `useProjection('agentTeam')` 读取当前 Session 的 Team,通过 `useProjection('modelSelection')` 读取其 model。在 teammate 会话中,`useSession` 提供 Lead 身份,`useSessions` 读取其 Team 值。两类会话均通过独立的 `useSessions` selector 读取 Lead 的基线读取状态和错误。每个 roster row 只选择自己的 model 和运行状态;panel 不订阅完整投影集合,也不订阅完整摘要或状态集合。它仅有的注入回调是读取 Session 的投影基线与打开 teammate。切换会话会关闭面板并清除导航失败。 | 文件 | 职责 | |---|---| @@ -64,7 +64,7 @@ Client export 通过 Cordis effect 注册 locale dictionary 与一个 conversati ## 进一步探索 - [Agent Teams bundle](../agent-team-profile/README.zh.md)——挂载本 Client plugin 的公开 opt-in bundle。 -- [Agent Teams service](../agent-team/README.zh.md)——权威 roster、task 与投影行为。 +- [Agent Teams service](../agent-team/README.zh.md)——权威 roster、task、投影与 Remote 行为。 - [会话 UI](../../client/ui-conversation/README.zh.md)——稳定 header slot 与 addressed-subagent 导航表层。 - [实验性包](../README.zh.md)——孵化状态与发布规则。 @@ -83,6 +83,7 @@ Client export 通过 Cordis effect 注册 locale dictionary 与一个 conversati +- **能力缺失的缓存** — 如果其他 UI 在启用 Agent Teams 前已读取 Lead 的投影基线,打开本面板会复用该基线;重新连接后才能加载新启用的能力。 - **没有 mailbox timeline**——投影视图只承载 roster 与任务;不显示 peer 消息。 - **首次请求前的模型** — 没有持久模型选择或请求的成员不显示模型。未运行的持久成员可通过显式投影读取提供模型;实时活动仍需要正在运行的 Host agent。 - **普通 child continuation**——导航后发送的人类消息使用稳定 addressed-subagent 提示词路径,而不是 Team peer mailbox。 diff --git a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx index cd9ba1ad75..cd1b765f0c 100644 --- a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx +++ b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx @@ -5,7 +5,7 @@ import type { TeamMemberProjection, TeamTaskView as TeamTask, } from '@deepseek-ai/dsh-experimental-agent-team/client' -import type { SessionProjectionRefreshOptions } from '@deepseek-ai/dsh-api-session-controller/client' +import type {} from '@deepseek-ai/dsh-api-session-controller/client' import { IconCloseOutlineRegular, IconRefreshOutlineRegular, IconUserOutlineRegular, StateDot, useAnchoredPosition, useDismissOnOutsidePointer, type StateDotState, @@ -17,8 +17,8 @@ import css from './TeamAction.module.css' /** Business actions injected by the browser plugin. */ export interface TeamActionInjected { - /** Read a Session's baseline, optionally refreshing after any existing read. */ - loadProjections: (sessionId: SessionId, options?: SessionProjectionRefreshOptions) => void + /** Read a Session's projection baseline once per connection, or retry a failed read. */ + loadProjections: (sessionId: SessionId) => void openTeammate: (sessionId: SessionId, member: TeamMemberProjection) => void } @@ -151,7 +151,7 @@ export function TeamAction({ }, [sessionId]) useEffect(() => { - if (open) loadProjections(leadSessionId, { force: true }) + if (open) loadProjections(leadSessionId) }, [open, leadSessionId, loadProjections]) useEffect(() => { @@ -219,7 +219,7 @@ export function TeamAction({
{failureText(readError)} -
diff --git a/packages/experimental/client-ui-agent-team/src/client/mount.ts b/packages/experimental/client-ui-agent-team/src/client/mount.ts index 2f945e4b12..1e2dfbdab3 100644 --- a/packages/experimental/client-ui-agent-team/src/client/mount.ts +++ b/packages/experimental/client-ui-agent-team/src/client/mount.ts @@ -36,8 +36,8 @@ export function registerAgentTeamUi(ctx: ClientContext): void { } const actions: TeamActionInjected = { - loadProjections(sessionId, options): void { - void sessions.refreshProjections(sessionId, options) + loadProjections(sessionId: SessionId): void { + void sessions.refreshProjections(sessionId) }, openTeammate(sessionId: SessionId, member: TeamMemberProjection): void { if (member.role !== 'teammate') return diff --git a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts index 7bf446d97c..d63394fd64 100644 --- a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts +++ b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts @@ -1,6 +1,5 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' -import type { SessionProjectionRefreshOptions } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' @@ -30,8 +29,8 @@ async function bench(options: { addressed?: boolean } = {}) { }, }) } } : undefined, - refreshProjections: (id: SessionId, options?: SessionProjectionRefreshOptions) => { - navigation.push(['refresh', id, options]) + refreshProjections: (id: SessionId) => { + navigation.push(['refresh', id]) return Promise.resolve() }, retainInfo: (id: SessionId) => ({ @@ -89,10 +88,10 @@ describe('ui-team browser plugin', () => { const t = b.ctx.locale.bind('agent-team') expect(t('trigger')).toBe('Agent Team') - b.actions().loadProjections(SESSION, { force: true }) - expect(b.navigation).toEqual([['refresh', SESSION, { force: true }]]) + b.actions().loadProjections(SESSION) + expect(b.navigation).toEqual([['refresh', SESSION]]) b.actions().openTeammate(SESSION, LEAD) - expect(b.navigation).toEqual([['refresh', SESSION, { force: true }]]) + expect(b.navigation).toEqual([['refresh', SESSION]]) await b.fiber.dispose() expect(b.entry()).toBeUndefined() diff --git a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx index 0f71861236..2f41bc5a94 100644 --- a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx +++ b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx @@ -125,7 +125,7 @@ describe('TeamAction', () => { openPanel() expect(await screen.findByText('Implement runtime')).toBeTruthy() expect(screen.getByText('write scopes overlap with task-2')).toBeTruthy() - expect(b.injected.loadProjections).toHaveBeenCalledWith(SESSION, { force: true }) + expect(b.injected.loadProjections).toHaveBeenCalledWith(SESSION) expect(screen.queryByRole('button', { name: /刷新|Refresh/u })).toBeNull() setProjection(b.sessions, SESSION, { @@ -192,7 +192,7 @@ describe('TeamAction', () => { render() openPanel() expect(screen.getByText('Implement runtime')).toBeTruthy() - expect(b.injected.loadProjections).toHaveBeenCalledWith(SESSION, { force: true }) + expect(b.injected.loadProjections).toHaveBeenCalledWith(SESSION) fireEvent.click(screen.getByRole('button', { name: /^worker/u })) expect(b.injected.openTeammate).toHaveBeenCalledWith(WORKER, worker) }) @@ -217,13 +217,13 @@ describe('TeamAction', () => { expect(screen.queryByRole('alert')).toBeNull() }) - it('refreshes an already-open Lead on every panel opening and retries failed reads', () => { + it('loads projections for an already-open Lead and retries failed reads', () => { const b = bench({ projections: {}, openState: 'open' }) render() expect(b.injected.loadProjections).not.toHaveBeenCalled() openPanel() expect(screen.getByRole('status').textContent).toBe(zh.loading) - expect(b.injected.loadProjections).toHaveBeenCalledExactlyOnceWith(SESSION, { force: true }) + expect(b.injected.loadProjections).toHaveBeenCalledExactlyOnceWith(SESSION) setProjectionSnapshot(b.sessions, SESSION, { state: 'error', error: new RemoteError('gateway/internal', 'offline', {}), values: {}, @@ -231,7 +231,7 @@ describe('TeamAction', () => { expect(screen.getByRole('alert').textContent).toBe('offline (gateway/internal)') fireEvent.click(screen.getByRole('button', { name: zh.retry })) expect(b.injected.loadProjections).toHaveBeenCalledTimes(2) - expect(b.injected.loadProjections).toHaveBeenLastCalledWith(SESSION, { force: true }) + expect(b.injected.loadProjections).toHaveBeenLastCalledWith(SESSION) setProjectionSnapshot(b.sessions, SESSION, { state: 'loading', error: null, values: {} }) expect(screen.getByRole('status').textContent).toBe(zh.loading) @@ -244,7 +244,7 @@ describe('TeamAction', () => { expect(b.injected.loadProjections).toHaveBeenCalledTimes(2) openPanel() expect(b.injected.loadProjections).toHaveBeenCalledTimes(3) - expect(b.injected.loadProjections).toHaveBeenLastCalledWith(SESSION, { force: true }) + expect(b.injected.loadProjections).toHaveBeenLastCalledWith(SESSION) }) it.each([false, true])('ignores unrelated Session updates (teammate page: %s)', (addressed) => { @@ -277,8 +277,7 @@ describe('TeamAction', () => { expect(screen.getByRole('button', { name: /worker.*updated-worker-model/u })).toBeTruthy() setProjection(b.sessions, SESSION, { ...team, tasks: [{ ...task, subject: 'Updated parent task' }] }) expect(screen.getByText('Updated parent task')).toBeTruthy() - expect(b.injected.loadProjections).toHaveBeenCalledWith(SESSION, { force: true }) - expect(b.injected.loadProjections).not.toHaveBeenCalledWith(WORKER, { force: true }) + expect(b.injected.loadProjections).toHaveBeenCalledWith(SESSION) }) it('shows capability absence after a successful read instead of loading forever', () => { @@ -299,7 +298,7 @@ describe('TeamAction', () => { expect(screen.getByText('Implement runtime')).toBeTruthy() expect(screen.getByRole('alert').textContent).toBe('offline (gateway/internal)') fireEvent.click(screen.getByRole('button', { name: zh.retry })) - expect(b.injected.loadProjections).toHaveBeenLastCalledWith(SESSION, { force: true }) + expect(b.injected.loadProjections).toHaveBeenLastCalledWith(SESSION) }) it('loads active members models without opening their conversations', () => { @@ -397,7 +396,7 @@ describe('TeamAction', () => { openPanel() expect(screen.queryByRole('alert')).toBeNull() expect(screen.getByRole('status').textContent).toBe(zh.loading) - expect(next.injected.loadProjections).toHaveBeenCalledWith('next-lead', { force: true }) + expect(next.injected.loadProjections).toHaveBeenCalledWith('next-lead') }) it('keeps panel interactions open and dismisses on outside pointer or Escape', () => { diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 6d51b921c0..8217db928c 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -201,10 +201,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'a stable read-only source across same-id generations, with zero counts when none is live.', }, { - signature: 'refreshProjections(sessionId: SessionId, options?: SessionProjectionRefreshOptions): Promise', - description: 'Load all Session projections once per connection; retry unsuccessful reads.', - parameters: [{ name: 'sessionId', description: 'Session to inspect without opening its conversation.' }, { name: 'options', description: 'force a fresh read even after a successful baseline; a running read finishes first.' }], - returns: 'completion of the requested read; removal, reconnect, or disposal cancels a queued refresh.', + signature: 'refreshProjections(sessionId: SessionId): Promise', + description: 'Load all Session projections once per connection; retry an unsuccessful initial read.', + parameters: [{ name: 'sessionId', description: 'Session to inspect without opening its conversation.' }], + returns: 'completion of the current or newly started refresh.', }, { signature: 'search( query: string, signal: AbortSignal, ): Promise>', @@ -871,10 +871,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionMaybeStandardProps', declaration: 'export interface SessionMaybeStandardProps {\n}', }, - { - name: 'SessionProjectionRefreshOptions', - declaration: 'export interface SessionProjectionRefreshOptions {\n readonly force?: boolean;\n}', - }, { name: 'SessionProviderComponent', declaration: 'export type SessionProviderComponent = (props: SessionAreaProps) => ReactNode;', From 3e4119ebdb331ebca0a78676a3967ed3db0f3734 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:12:01 +0800 Subject: [PATCH 032/192] fix(account): expire account credentials on inference HTTP 401 --- packages/llm/llm-deepseek/README.i18n.yaml | 4 ++-- packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/README.zh.md | 2 +- packages/llm/llm-deepseek/src/adapter.ts | 3 ++- packages/llm/llm-deepseek/src/transport.ts | 3 +-- packages/llm/llm-deepseek/tests/adapter.spec.ts | 15 ++++++++++----- 6 files changed, 17 insertions(+), 12 deletions(-) diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 765dd5a54a..7381689090 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: 8019a8be1f0c8e1f00ae5573e350c3a7eaf1c721 -README.zh.md: 86855c49ebeb9fd3c3ca578d52eefe7b6e4c8f92 +README.md: 8d8e28faddf91026c249dbbfb0cfc183c815ae9d +README.zh.md: cee5d30a17a39d9e0613747db4b1a0035bef9c53 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 8019a8be1f..8d8e28fadd 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -86,7 +86,7 @@ Messages sends text, thinking, tool calls, and tool results as content blocks, r `deepseek-official` resolves only its configured API-key reference. `deepseek-account` resolves only the stored grant from the [account provider](../../credentials/deepseek-account-platform/README.md), whose allowed `inferenceOrigin` defaults to `https://api.deepseek.com`. Neither route falls back to the other. Signing out removes the account grant and preserves API keys. -Messages and Files requests send account tokens as `x-dsh-auth-token` without a Bearer prefix; API keys use `x-api-key`. Neither credential mode follows redirects. HTTP 401 with the provider message `Authentication Fails (invalid dsh token)` becomes `ACCOUNT_TOKEN_INVALID`. On the account route, it removes the request token only if it still matches the stored login, then publishes account expiry and UI sign-out. Generic authentication errors and API-key routes do not remove account credentials. +Messages and Files requests send account tokens as `x-dsh-auth-token` without a Bearer prefix; API keys use `x-api-key`. Neither credential mode follows redirects. HTTP 401 on a request authenticated with an account token becomes `ACCOUNT_TOKEN_INVALID`, regardless of the response body. It removes that request token only if it still matches the stored login, then publishes account expiry and UI sign-out. API-key routes and other HTTP statuses do not remove account credentials. ### Streaming with thinking and images diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 86855c49eb..cee5d30a17 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -86,7 +86,7 @@ Messages 以内容块发送文本、思考、工具调用和工具结果,以 ` `deepseek-official` 仅解析配置的 API Key 引用。`deepseek-account` 仅解析[账号提供者](../../credentials/deepseek-account-platform/README.zh.md)保存的授权,其允许的 `inferenceOrigin` 默认为 `https://api.deepseek.com`。两条路由均不回退到另一凭证。退出登录删除账号授权,保留 API Key。 -Messages 和 Files 请求通过 `x-dsh-auth-token` 发送账号 token,不加 Bearer 前缀;API Key 使用 `x-api-key`。两种凭据模式均拒绝重定向。HTTP 401 且提供方消息为 `Authentication Fails (invalid dsh token)` 时归类为 `ACCOUNT_TOKEN_INVALID`。账号路由仅在请求 token 仍匹配已存登录凭据时删除它,并发布账号过期状态和 UI 退登通知。普通鉴权错误和 API Key 路由不会删除账号凭据。 +Messages 和 Files 请求通过 `x-dsh-auth-token` 发送账号 token,不加 Bearer 前缀;API Key 使用 `x-api-key`。两种凭据模式均拒绝重定向。使用账号 token 鉴权的请求返回 HTTP 401 时归类为 `ACCOUNT_TOKEN_INVALID`,不依赖响应体内容。仅当该请求的 token 仍匹配已存登录凭据时才删除它,并发布账号过期状态和 UI 退登通知。API Key 路由和其他 HTTP 状态码不会删除账号凭据。 ### 带 thinking 与图片的流式调用 diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 41a2cf4a96..2010a72dcf 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -139,7 +139,8 @@ export class DeepSeekAdapter extends LlmAdapter { if (await files.retry(detail)) continue const failure = providerError(raw, response.status, response.headers) const message = files.errorMessage(response.status, failure.message, detail) - throw new LlmError(message, failure.code, { ...failure.failure, cause: new Error(text) }) + const code = accountToken !== undefined && response.status === 401 ? 'ACCOUNT_TOKEN_INVALID' : failure.code + throw new LlmError(message, code, { ...failure.failure, cause: new Error(text) }) } await extensions.accept() if (response.body === null) throw new LlmError('DeepSeek Messages returned no response body', 'EMPTY_RESPONSE') diff --git a/packages/llm/llm-deepseek/src/transport.ts b/packages/llm/llm-deepseek/src/transport.ts index ddae610178..935e8855df 100644 --- a/packages/llm/llm-deepseek/src/transport.ts +++ b/packages/llm/llm-deepseek/src/transport.ts @@ -26,8 +26,7 @@ export function providerError(raw: unknown, status: number | undefined, headers? const type = typeof error.type === 'string' ? error.type : '' const detail = `${type} ${typeof error.code === 'string' ? error.code : ''} ${message}` let code: string - if (status === 401 && message === 'Authentication Fails (invalid dsh token)') code = 'ACCOUNT_TOKEN_INVALID' - else if (status === 401 || status === 403 || ['authentication_error', 'permission_error'].includes(type)) code = 'AUTH' + if (status === 401 || status === 403 || ['authentication_error', 'permission_error'].includes(type)) code = 'AUTH' else if (isQuotaExceededError(detail) || status === 402) code = 'QUOTA' else if (status === 429 || type === 'rate_limit_error') code = 'RATE_LIMIT' else if (isContextWindowExceededError(detail)) code = 'CONTEXT_WINDOW_EXCEEDED' diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index b051aedda6..c03a5f6d65 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -277,16 +277,21 @@ describe('Cordis provider composition', () => { return { ctx, http } } - it.each(['deepseek-account', 'deepseek-official'])('reports the rejected account token only for %s', async (provider) => { + it.each(['deepseek-account', 'deepseek-official'].flatMap(provider => [ + { provider, body: JSON.stringify({ error: { type: 'authentication_error', message: 'API key is invalid' } }) }, + { provider, body: JSON.stringify({ error: { message: 'Authentication Fails (invalid dsh token)' } }) }, + { provider, body: 'Unauthorized' }, + { provider, body: '' }, + ]))('handles $provider HTTP 401 independently of the response body ($body)', async ({ provider, body }) => { const { ctx } = await boot((response) => { response.writeHead(401, { 'content-type': 'application/json' }) - response.end(JSON.stringify({ error: { type: 'authentication_error', message: 'Authentication Fails (invalid dsh token)' } })) + response.end(body) }) const rejectToken = vi.fn(async (_token: string) => {}) ctx.provide('deepseekAccount', { resolveToken: async (_url: string): Promise => 'fixture-token', rejectToken: (token: string): Promise => rejectToken(token) } as DeepSeekAccount) expect((await chunks(ctx.llm.stream(options({ provider })))).at(-1)).toMatchObject({ - type: 'finish', reason: { kind: 'error', failure: { code: 'ACCOUNT_TOKEN_INVALID' } }, + type: 'finish', reason: { kind: 'error', failure: { code: provider === 'deepseek-account' ? 'ACCOUNT_TOKEN_INVALID' : 'AUTH' } }, }) expect(rejectToken.mock.calls).toEqual(provider === 'deepseek-account' ? [['fixture-token']] : []) }) @@ -318,9 +323,9 @@ describe('Cordis provider composition', () => { }) }) - it('does not remove account credentials for a generic authentication error', async () => { + it('does not remove account credentials for HTTP 403', async () => { const { ctx } = await boot((response) => { - response.writeHead(401) + response.writeHead(403) response.end(JSON.stringify({ error: { type: 'authentication_error', message: 'API key is invalid' } })) }) const rejectToken = vi.fn(async (_token: string) => {}) From ee0048558de046c175541b130feba66f218e4511 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 22 Sep 2026 16:13:15 +0800 Subject: [PATCH 033/192] test(web): capture terminal recovery readiness with its snapshot --- apps/web/tests/sidebar-terminal.e2e.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/web/tests/sidebar-terminal.e2e.ts b/apps/web/tests/sidebar-terminal.e2e.ts index aac87b9f54..75ee794ebe 100644 --- a/apps/web/tests/sidebar-terminal.e2e.ts +++ b/apps/web/tests/sidebar-terminal.e2e.ts @@ -475,11 +475,16 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { await page.context().setOffline(false) await page.reload({ waitUntil: 'load' }) await page.locator('[data-sidebar-right-expand]').click() - await expect.poll(() => page.getByRole('alert').innerText()).toContain('no longer exists') + const terminal = page.locator('[data-sidebar-terminal]') + let unavailableSnapshot = '' + // Mounting can refresh a failed recovered view; readiness and comparison use the same DOM sample. + await expect.poll(async () => { + unavailableSnapshot = await terminal.ariaSnapshot() + return unavailableSnapshot + }).toContain('no longer exists') expect(handles).toHaveLength(1) const unavailable = fileURLToPath(new URL('./expected/sidebar-terminal/unavailable.expected.md', import.meta.url)) - const terminal = page.locator('[data-sidebar-terminal]') - await compareOrRefreshGolden(unavailable, await terminal.ariaSnapshot(), webSnapshotMode()) + await compareOrRefreshGolden(unavailable, unavailableSnapshot, webSnapshotMode()) await terminal.screenshot({ path: `${shots}/unavailable.png`, animations: 'disabled' }) const tabCount = await page.locator('[data-dockkit-tab]').count() const create = terminal.getByRole('button', { name: 'New terminal', exact: true }) From f0f4bb73fccb4c01710e7ba5d36c6cf7c4882f32 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:58:55 +0800 Subject: [PATCH 034/192] fix(desktop): keep welcome back action on one line --- apps/desktop/renderer/welcome.css | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/renderer/welcome.css b/apps/desktop/renderer/welcome.css index 3d5fc88460..068bcc36fd 100644 --- a/apps/desktop/renderer/welcome.css +++ b/apps/desktop/renderer/welcome.css @@ -192,6 +192,7 @@ body { top: calc(100% + 16px); left: 50%; transform: translateX(-50%); + white-space: nowrap; min-width: 58px; height: 28px; padding: 4px 10px; From 03294cb6533c2d7c99f6b68f04fd074012864abe Mon Sep 17 00:00:00 2001 From: mektpoy Date: Tue, 22 Sep 2026 17:31:12 +0800 Subject: [PATCH 035/192] test(native-command): pin every Explorer open target through one table --- .../native-command/tests/path-opener.spec.ts | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/util/native-command/tests/path-opener.spec.ts b/packages/util/native-command/tests/path-opener.spec.ts index 9be8ae01e8..97167872ed 100644 --- a/packages/util/native-command/tests/path-opener.spec.ts +++ b/packages/util/native-command/tests/path-opener.spec.ts @@ -80,17 +80,21 @@ describe('native path opener', () => { expect(run).toHaveBeenCalledOnce() }) - it('encodes the target Explorer parses so a comma stays inside the path', async () => { + it.each([ + // Explorer parses its own command line and splits it at commas, and libuv + // quotes an argument only when it holds a space, so the encoded file URI is + // what keeps the whole path in one field. The URI encoding covers the comma, + // spaces, the percent sign, and non-ASCII; the apostrophe and ampersand stay + // literal because no shell interprets them. UNC takes the same encoding. + ["C:\\work\\o'reilly, & 100%.txt", "file:///C:/work/o'reilly%2C%20&%20100%25.txt"], + ['C:\\work\\plain.txt', 'file:///C:/work/plain.txt'], + ['C:\\work\\my report.txt', 'file:///C:/work/my%20report.txt'], + ['C:\\my files\\报告,#%.txt', 'file:///C:/my%20files/%E6%8A%A5%E5%91%8A%2C%23%25.txt'], + ['\\\\server\\share\\a,b.txt', 'file://server/share/a%2Cb.txt'], + ] as const)('opens %s through the one encoded Explorer target', async (path, target) => { const run = vi.fn(async () => ({ stdout: '', stderr: '' })) - // Explorer parses its own command line and splits it at commas, so the path - // crosses as a file URI with the comma percent-encoded. The apostrophe, - // ampersand, space, and percent need no escaping: no shell interprets them. - await openNativePath("C:\\work\\o'reilly, & 100%.txt", signal(), { platform: 'win32', run }) - expect(run).toHaveBeenCalledWith( - 'explorer.exe', - ["file:///C:/work/o'reilly%2C%20&%20100%25.txt"], - expect.any(AbortSignal), - ) + await openNativePath(path, signal(), { platform: 'win32', run }) + expect(run).toHaveBeenCalledExactlyOnceWith('explorer.exe', [target], expect.any(AbortSignal)) }) it('encodes a directory target the same way it encodes a file target', async () => { From fac0d8d877a6d85cde58edd27e13bfec953e4b32 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Tue, 22 Sep 2026 17:35:54 +0800 Subject: [PATCH 036/192] docs(native-command): record the interactive-session requirement for Explorer opens --- ...2026-09-22-windows-open-through-shell-resolution.i18n.yaml | 4 ++-- .../2026-09-22-windows-open-through-shell-resolution.md | 4 +++- .../2026-09-22-windows-open-through-shell-resolution.zh.md | 4 +++- packages/util/native-command/README.i18n.yaml | 4 ++-- packages/util/native-command/README.md | 1 + packages/util/native-command/README.zh.md | 1 + 6 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.i18n.yaml index 793d1c261f..5f9cf4bdf9 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md -2026-09-22-windows-open-through-shell-resolution.md: 359d4163a06cc39b84f423940a22a1bf862dfbc8 -2026-09-22-windows-open-through-shell-resolution.zh.md: 755f9beacf8a635cccd63131acde5a6d072ee077 +2026-09-22-windows-open-through-shell-resolution.md: dac6b089d8d69d40a4cd7c92f4f74ee06f304329 +2026-09-22-windows-open-through-shell-resolution.zh.md: 8d2a00c6769906b568f605451d2afe339aed6dcf diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md index 359d4163a0..dac6b089d8 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md +++ b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md @@ -34,8 +34,10 @@ The settings sheet's open gesture and the artifact and workspace opens share one The opener can no longer distinguish "the shell opened it" from "the shell declined": Explorer reports 0 or 1 for both, so the Remote still answers `{ opened: true }`, and a machine with no handler shows the shell's chooser as its only feedback. Nothing here verifies that a window appeared. +An interactive desktop session is a precondition. In a non-interactive Windows session (a service, or a scheduled task without an interactive logon) `explorer.exe` returned exit code 0 after about 30 seconds without invoking any association, where an `Invoke-Item` command opened the file in about 0.35 seconds: a Host started that way loses the capability and pays the wait, and a caller's abort signal is what bounds it. No fallback for that session is implemented. + Directories take the same path. The rejected alternative recorded in [the open-in-app note](../feature/2026-08-25-promote-open-anywhere-plugin.md) was a detached, credential-scrubbed `explorer.exe ` spawn, which is not what this handoff does: the `explorer.exe` child inherits the host environment and no detached process is created, while the application or folder window Explorer delegates to starts from the desktop session's environment rather than the host's. ## Verification -`path-opener.spec.ts` pins the encoded file URI Explorer receives for both intents, the accepted delegate exit code 1 for an open and a reveal, and the preserved failure and cancellation. `resolver.spec.ts` pins the open-in-app route's Windows command. Native desktop verification stays on Windows, per the package's test policy: on Windows 11 ARM64 build 26200 a probe association recorded the argument each handoff passed, and the opener's own commands opened the associated application for a plain file and for a file whose name contains a comma, while the same comma-bearing file handed over as a raw path opened the user's Documents folder instead — the shell split the path at the comma and reported nothing. A directory whose name contains a comma opened as a window rooted at that directory. Every one of these handoffs, successful ones included, exits 1. +`path-opener.spec.ts` pins the encoded file URI Explorer receives for both intents, the accepted delegate exit code 1 for an open and a reveal, and the preserved failure and cancellation. `resolver.spec.ts` pins the open-in-app route's Windows command. Native desktop verification stays on Windows, per the package's test policy. On Windows 11 ARM64 build 26200 an independent pass drove the module's own `openNativeAssociatedPath`, `openNativePath`, and `openNativeTextFile` from a sha256-matched copy beside an association probe that recorded every argument the shell passed: each comma-bearing target arrived complete as `%2C`, a plain file opened, and a directory named for a comma opened as a window rooted there. The negative control — the same file as a file URI whose comma is left raw — invoked no association and opened the user's Documents folder, and still exited 1. Exit 1 therefore records the delegation rather than the outcome, so accepting it cannot turn a wrong target into a success; the encoding is what keeps the path whole. Nothing here observes a window, and reveal's `/select,` highlight stays unverified. diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md index 755f9beacf..8d2a00c676 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md @@ -34,8 +34,10 @@ Windows 对 HTML 与 SVG 仍然无法命名浏览器,因此 `openInBrowser` 打开器再也无法区分「shell 打开了」与「shell 拒绝了」:两种情况 Explorer 都返回 0 或 1,因此 Remote 依旧回答 `{ opened: true }`,完全没有处理程序的机器只能看到 shell 的选择框。此处不校验窗口是否真的出现。 +交互桌面会话现在是前提。在非交互 Windows 会话(服务,或没有交互登录的计划任务)中,`explorer.exe` 在约 30 秒后以退出码 0 返回且不调用任何关联,而 `Invoke-Item` 命令在同一会话里约 0.35 秒就打开了文件:这样启动的 Host 既失去能力又要付等待代价,只有调用方的 abort 信号能给这段等待封顶。该会话下的兜底尚未实现。 + 目录走同一条路径。[open-in-app 记录](../feature/2026-08-25-promote-open-anywhere-plugin.zh.md)中被否决的方案是 detached 且经过凭据清洗的 `explorer.exe ` 派生进程,那不是本交接的做法:`explorer.exe` 子进程继承宿主环境,也不创建 detached 进程;而它转交给桌面会话启动的应用或文件夹窗口,继承的是桌面会话环境而不是宿主进程环境。 ## 验证 -`path-opener.spec.ts` 固定两种意图交给 Explorer 的编码文件 URI、打开与选中都可接受的交接退出码 1,以及仍然报错的普通失败与取消。`resolver.spec.ts` 固定 open-in-app 路由在 Windows 上的命令。按该包测试策略,原生桌面验证留在 Windows:在 Windows 11 ARM64 build 26200 上,一个探针关联记录了每次交接实际收到的参数——打开器自身的命令对普通文件、以及文件名含逗号的文件都启动了关联应用;而同一个含逗号文件若以原始路径交接,则会打开用户的「文档」文件夹,shell 在逗号处分段且不作任何提示。名称含逗号的目录会以该目录为根打开窗口。上述每一次交接(包括成功的那些)退出码都是 1。 +`path-opener.spec.ts` 固定两种意图交给 Explorer 的编码文件 URI、打开与选中都可接受的交接退出码 1,以及仍然报错的普通失败与取消。`resolver.spec.ts` 固定 open-in-app 路由在 Windows 上的命令。按该包测试策略,原生桌面验证留在 Windows。在 Windows 11 ARM64 build 26200 上,一次独立验证用 sha256 与源码一致的副本直接驱动模块自身的 `openNativeAssociatedPath`、`openNativePath`、`openNativeTextFile`,旁边挂一个记录 shell 实收参数的探针关联:每个含逗号的目标都以 `%2C` 完整到达、普通文件正常打开、名为 `dir,comma` 的目录打开为根目录正确的窗口。反证——同一个文件以逗号未编码的 file URI 交给 `explorer.exe`——没有触发任何关联、打开了用户的「文档」,而且**同样返回退出码 1**。因此退出码 1 记录的是委派而非结果,接受它不会把错误目标变成成功;真正保住路径完整的是那段编码。此处不观测窗口是否出现,选中路径的 `/select,` 高亮仍未验证。 diff --git a/packages/util/native-command/README.i18n.yaml b/packages/util/native-command/README.i18n.yaml index 0e2bf74b61..d829a16ffc 100644 --- a/packages/util/native-command/README.i18n.yaml +++ b/packages/util/native-command/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/native-command/README.md -README.md: b158cbf3153962af14ed99c07f7527f4bdb86f89 -README.zh.md: f02a37480934122dbf569983105b43b9962b894c +README.md: 72c9d0097975773d316e3f2fdba87da1eca4daa4 +README.zh.md: 35770a99756a9eb218b024406690b340c09bfd84 diff --git a/packages/util/native-command/README.md b/packages/util/native-command/README.md index b158cbf315..72c9d00979 100644 --- a/packages/util/native-command/README.md +++ b/packages/util/native-command/README.md @@ -109,6 +109,7 @@ Linux association discovery and explicit launching require GIO. Missing native c These limits define when this runner is not the right tool. They are current package constraints, not a task backlog. - Command output uses Node’s `execFile` buffer limit; oversized replies reject. Use the subprocess capability for streaming output. +- A Windows host needs an interactive desktop session. In a non-interactive session — a service, or a scheduled task without an interactive logon — `explorer.exe` returns exit code 0 after about 30 seconds without invoking any file association, so an open reports success having opened nothing; a caller's abort signal turns that wait into a timeout rather than a hang. An `Invoke-Item` command resolves the association in-process there in about 0.35 seconds, and its directory failure arrives as a non-zero exit code. No fallback for that session is implemented. ### Dev Note diff --git a/packages/util/native-command/README.zh.md b/packages/util/native-command/README.zh.md index f02a374809..35770a9975 100644 --- a/packages/util/native-command/README.zh.md +++ b/packages/util/native-command/README.zh.md @@ -109,6 +109,7 @@ Linux 的关联查询和指定应用启动需要 GIO。缺少原生命令时查 这些限制说明本运行器何时不是合适的工具。它们是当前包约束,不是任务积压。 - 命令输出受 Node 的 `execFile` 缓冲上限约束,超过上限时拒绝结果。流式输出由 subprocess 能力处理。 +- Windows 主机需要交互桌面会话。在非交互会话(服务,或没有交互登录的计划任务)中,`explorer.exe` 会在约 30 秒后以退出码 0 返回且不调用任何文件关联,于是「打开」会报告成功却什么都没打开;调用方的 abort 信号把这段等待变成超时而不是挂死。同一目标改用 `Invoke-Item` 命令时,该会话内约 0.35 秒即可在进程内解析关联,目录失败也会以非零退出码报出。该会话下的兜底尚未实现。 ### 开发备注 From 5a3c316401cd461f0234f64be39023702ccb4ea0 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:21:12 +0800 Subject: [PATCH 037/192] fix(desktop): deliver welcome expiry notice once across windows --- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 + apps/desktop/README.zh.md | 2 + apps/desktop/src/client/WelcomePage.tsx | 12 ++++-- apps/desktop/src/main.ts | 17 ++++++-- apps/desktop/src/preload-welcome.ts | 3 +- apps/desktop/src/welcome-api.ts | 6 +++ apps/desktop/src/welcome-window.ts | 5 ++- apps/desktop/tests/preload.e2e.ts | 3 ++ .../tests/welcome-renderer.client.spec.tsx | 39 +++++++++++++++++-- apps/desktop/tests/welcome-startup.spec.ts | 14 ++++++- apps/desktop/tests/welcome-window.spec.ts | 9 ++++- 12 files changed, 101 insertions(+), 15 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 7dbf8777b4..a9b5df858a 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: cb10794ae6e9c5cbdc9e0f58acdefe8a252e35e1 -README.zh.md: 778fe9c6bc8df8a727d2a03a818a44f6fbb2e0f9 +README.md: 4ad377430d679fc505c480f2766b474b060a9f60 +README.zh.md: a36ebf77394e016336018d02f38b6688b2936f7f diff --git a/apps/desktop/README.md b/apps/desktop/README.md index cb10794ae6..4ad377430d 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -409,6 +409,8 @@ An unpackaged Electron process uses `.desktop-build/development/project` under i Sign in opens the configured platform page in the system browser. The Host owns PKCE and a temporary loopback callback, saves the credential before entering the workspace, and redirects the browser to the platform completion page. Opened and copied authorization links carry the effective Desktop theme as `theme=light` or `theme=dark`; `system` resolves at the time of the action. Cancel withdraws the local attempt even if the platform page later approves it. Settings offers Account sign-out; without a separate API key, sign-out returns to the welcome window. Packaged applications register dsh://open to show the window without passing credentials. On macOS, the development launcher prepares an ad-hoc-signed `Harness Dev.app` under `.desktop-build/development`, declares `dsh` in its Info.plist, and registers it with Launch Services. It loads the current workspace and records the selected development home, browser-data path, and debug settings for cold starts. Starting this bundle makes it the default `dsh://` handler; starting the packaged application registers the packaged handler again. The generated bundle does not contain account tokens and requires the workspace and prepared runtime to remain available. +When an expired account returns to Welcome, the main process retains a one-time notification until the renderer requests it over window-owned IPC. Reloading Welcome does not repeat the notice; manual sign-out and cold startup do not create it. + An expired login displays a timeout heading with explicit Sign in again and Add API Key actions. Opening the API-key form dismisses the authorization view; late account-state notifications do not replace an in-progress key entry. Embedded Platform views remain hidden until document loading completes so the renderer loading indicator stays visible. Closing or replacing a pending view prevents it from appearing later. Reloading or replacing the owning application document, renderer termination, and window closure also destroy the native view without relying on React cleanup. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 778fe9c6bc..a36ebf7739 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -409,6 +409,8 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd 登录会在系统浏览器中打开配置的平台页面。Host 负责 PKCE 和临时本机回调,在进入工作区前保存凭证,再将浏览器跳转到平台完成页。打开和复制的授权链接通过 `theme=light` 或 `theme=dark` 携带当前生效的 Desktop 主题;`system` 在执行操作时解析。即使平台页面随后批准,取消仍会撤销本地尝试。设置中的账号页面提供退出;没有独立 API Key 时,退出后返回欢迎窗。打包应用注册 dsh://open,只显示窗口而不传递凭证。macOS 开发启动器在 `.desktop-build/development` 下准备经临时签名的 `Harness Dev.app`,在 Info.plist 中声明 `dsh` 并注册到 Launch Services。它加载当前工作区,并记录选定的开发 home、浏览器数据路径和调试设置,以供冷启动使用。启动此应用会将其设为 `dsh://` 默认处理程序;启动打包应用会重新注册打包版处理程序。生成的应用包不包含账号 token,依赖工作区和已准备的运行环境继续存在。 +账号失效并返回 Welcome 时,主进程保留一次性通知,直到渲染器通过所属窗口的 IPC 领取。重新加载 Welcome 不会重复提示;主动退登和冷启动不会生成该通知。 + 登录超时后显示超时标题,并提供重新登录和添加 API Key 按钮。打开 API Key 表单会关闭授权视图;后续账号状态通知不会覆盖正在填写的密钥。 内嵌 Platform 视图在文档加载完成前保持隐藏,让渲染层加载图标可见。关闭或替换待加载视图后,该视图不会再次出现。 所属应用文档刷新或替换、渲染进程终止以及窗口关闭也会销毁原生视图,不依赖 React 清理。 diff --git a/apps/desktop/src/client/WelcomePage.tsx b/apps/desktop/src/client/WelcomePage.tsx index 054b9b11bf..b24618a0da 100644 --- a/apps/desktop/src/client/WelcomePage.tsx +++ b/apps/desktop/src/client/WelcomePage.tsx @@ -15,9 +15,7 @@ type Page = 'entry' | 'key' | 'account' */ export function Welcome({ api }: { api: WelcomeApi }) { const { messages: m } = api - const [expired, setExpired] = useState(false) const [expiryNotice, setExpiryNotice] = useState(false) - useEffect(() => { setExpiryNotice(expired) }, [expired]) const [page, setPage] = useState('entry') const pageRef = useRef('entry') const [attempt, setAttempt] = useState(null) @@ -53,9 +51,17 @@ export function Welcome({ api }: { api: WelcomeApi }) { mounted.current = true document.documentElement.lang = api.id document.title = m.welcomeTitle + const takeNotice = (): void => { + void api.takeNotice().then((notice) => { + if (mounted.current && notice === 'session-expired') setExpiryNotice(true) + }).catch((_closedChannel: unknown) => { + // A closed Welcome IPC channel must not interrupt the sign-in page. + }) + } + takeNotice() const stop = api.onAccountState((state) => { revision.current++ - setExpired(state.status === 'signed-out' && state.signOutReason === 'expired') + takeNotice() showAccount(state) }) return () => { mounted.current = false; stop() } diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index d9c55f44ae..45a3a4a8c4 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -34,7 +34,7 @@ import { DesktopUpdateCoordinator } from './update-coordinator.ts' import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts' import { DesktopFatalRecovery } from './fatal-recovery.ts' import { openWelcomeWindow } from './welcome-window.ts' -import { WELCOME_IPC, needsWelcome } from './welcome-api.ts' +import { WELCOME_IPC, needsWelcome, type WelcomeNotice } from './welcome-api.ts' import { connectDesktopWelcome, type DesktopWelcomeBackend } from './welcome-backend.ts' import { DesktopUpdateJournal } from './update-journal.ts' import { DesktopUpdatePreparationError } from './update-error.ts' @@ -307,6 +307,7 @@ async function main(): Promise { let stopAccount: (() => void) | undefined let openedAttempt: string | undefined let returnedAttempt: string | undefined + let pendingWelcomeNotice: WelcomeNotice | undefined let previousAccountStatus: string | undefined const assertProductSender = (event: IpcMainInvokeEvent): void => { assertDesktopSender(event, ['app']) @@ -362,8 +363,13 @@ async function main(): Promise { } if (attempt?.phase === 'succeeded' && welcomeWindow !== undefined) void enterWorkspace().catch(() => undefined) if (previousAccountStatus === 'credential-stored' && state.status === 'signed-out') { - void readWelcomeState().then((value) => { - if (!value.hasApiKey && !quitting) { enteredWorkspace = false; return showWelcome() } + void readWelcomeState().then(async (value) => { + if (needsWelcome(value) && !quitting) { + if (state.signOutReason === 'expired') pendingWelcomeNotice = 'session-expired' + enteredWorkspace = false + await showWelcome() + if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) welcomeWindow.webContents.send(WELCOME_IPC.state, state) + } return undefined }).catch(() => undefined) } @@ -905,6 +911,11 @@ async function main(): Promise { } openingWelcome ??= (async () => { welcomeWindow = await openWelcomeWindow(locale, { + takeNotice: () => { + const notice = pendingWelcomeNotice + pendingWelcomeNotice = undefined + return Promise.resolve(notice) + }, startSignIn: async () => { if (welcomeBackend === undefined) throw new Error('desktop welcome: backend unavailable') return welcomeBackend.account.start(locale.id) diff --git a/apps/desktop/src/preload-welcome.ts b/apps/desktop/src/preload-welcome.ts index 490a894394..e9ec1aa910 100644 --- a/apps/desktop/src/preload-welcome.ts +++ b/apps/desktop/src/preload-welcome.ts @@ -3,13 +3,14 @@ import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-acc import { contextBridge, ipcRenderer } from 'electron' import { resolveDesktopLocale } from './locale.ts' -import { WELCOME_IPC, type WelcomeApi, type WelcomeSaveResult } from './welcome-api.ts' +import { WELCOME_IPC, type WelcomeApi, type WelcomeNotice, type WelcomeSaveResult } from './welcome-api.ts' const prefix = '--dsh-welcome-locale=' const locale = process.argv.find(argument => argument.startsWith(prefix))?.slice(prefix.length) if (locale === undefined) throw new Error('desktop welcome: missing window locale') const api: WelcomeApi = { ...resolveDesktopLocale(locale), + takeNotice: () => ipcRenderer.invoke(WELCOME_IPC.takeNotice) as Promise, startSignIn: () => ipcRenderer.invoke(WELCOME_IPC.start) as Promise, cancelSignIn: (id: SignInAttemptId) => ipcRenderer.invoke(WELCOME_IPC.cancel, id) as Promise, copySignInLink: (id: SignInAttemptId) => ipcRenderer.invoke(WELCOME_IPC.copyLink, id) as Promise, diff --git a/apps/desktop/src/welcome-api.ts b/apps/desktop/src/welcome-api.ts index 36dd85f17a..015296776a 100644 --- a/apps/desktop/src/welcome-api.ts +++ b/apps/desktop/src/welcome-api.ts @@ -11,13 +11,19 @@ export const WELCOME_IPC = { cancel: 'dsh-welcome:cancel', copyLink: 'dsh-welcome:copy-link', state: 'dsh-welcome:state', + takeNotice: 'dsh-welcome:take-notice', } as const /** Credential writes return a safe outcome without exposing Host diagnostics. */ export type WelcomeSaveResult = { readonly ok: true } | { readonly ok: false } +/** One-time notification retained by the main process until Welcome receives it. */ +export type WelcomeNotice = 'session-expired' + /** Host-owned operations used by the welcome window. */ export interface WelcomeOperations { + /** @returns the pending notification, clearing it before another renderer can receive it. */ + takeNotice(): Promise /** @returns account state after starting a login attempt. */ startSignIn(): Promise /** @param id - attempt to cancel. @returns the settled state. */ diff --git a/apps/desktop/src/welcome-window.ts b/apps/desktop/src/welcome-window.ts index 0edda21454..a32e1f1c56 100644 --- a/apps/desktop/src/welcome-window.ts +++ b/apps/desktop/src/welcome-window.ts @@ -63,7 +63,9 @@ export async function openWelcomeWindow(locale: DesktopLocale, operations: Welco const disposeHandlers = (): void => { if (!active) return active = false - for (const channel of [WELCOME_IPC.saveApiKey, WELCOME_IPC.skip, WELCOME_IPC.start, WELCOME_IPC.cancel, WELCOME_IPC.copyLink]) { + for (const channel of [ + WELCOME_IPC.takeNotice, WELCOME_IPC.saveApiKey, WELCOME_IPC.skip, WELCOME_IPC.start, WELCOME_IPC.cancel, WELCOME_IPC.copyLink, + ]) { ipcMain.removeHandler(channel) } disposeActiveHandlers = undefined @@ -74,6 +76,7 @@ export async function openWelcomeWindow(locale: DesktopLocale, operations: Welco throw new Error('desktop welcome: rejected action from an unowned frame') } } + ipcMain.handle(WELCOME_IPC.takeNotice, async (event) => { assertSender(event); return operations.takeNotice() }) ipcMain.handle(WELCOME_IPC.saveApiKey, async (event, value: unknown) => { assertSender(event) if (typeof value !== 'string' || !/^[\x21-\x7e]+$/.test(value)) return { ok: false } diff --git a/apps/desktop/tests/preload.e2e.ts b/apps/desktop/tests/preload.e2e.ts index 1022b3a8b8..d18f475bc1 100644 --- a/apps/desktop/tests/preload.e2e.ts +++ b/apps/desktop/tests/preload.e2e.ts @@ -80,6 +80,9 @@ describe.skipIf(!existsSync(preload('preload-app')))('built sandboxed Desktop pr expect(send).toHaveBeenCalledWith('dsh-desktop:locale-changed', 'zh') } else { expect(exposed.has('dshWelcome')).toBe(true) + const bridge = exposed.get('dshWelcome') as { takeNotice(): Promise } + void bridge.takeNotice() + expect(invoke).toHaveBeenCalledWith('dsh-welcome:take-notice') } }) }) diff --git a/apps/desktop/tests/welcome-renderer.client.spec.tsx b/apps/desktop/tests/welcome-renderer.client.spec.tsx index 0778b59805..0d3586ecfe 100644 --- a/apps/desktop/tests/welcome-renderer.client.spec.tsx +++ b/apps/desktop/tests/welcome-renderer.client.spec.tsx @@ -6,15 +6,16 @@ import { Welcome } from '../src/client/WelcomePage.tsx' import { afterEach, describe, expect, it, vi } from 'vitest' import { resolveDesktopLocale } from '../src/locale.ts' import type { AccountView } from '@deepseek-ai/dsh-deepseek-account/types' -import type { WelcomeSaveResult } from '../src/welcome-api.ts' +import type { WelcomeSaveResult, WelcomeNotice } from '../src/welcome-api.ts' const html = readFileSync(join(import.meta.dirname, '../renderer/welcome.html'), 'utf8') afterEach(cleanup) -function mount(language = 'zh-CN') { +function mount(language = 'zh-CN', takeNotice = vi.fn<() => Promise>().mockResolvedValue(undefined)) { cleanup() const stopAccount = vi.fn() const api = { + takeNotice, onAccountState: vi.fn((_listener: (state: AccountView) => void) => stopAccount), startSignIn: vi.fn(async (): Promise => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null })), cancelSignIn: vi.fn(async (): Promise => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null })), @@ -264,7 +265,9 @@ it.each(['copied', 'failed'] as const)('restores the copy action after %s feedba it.each(['zh-CN', 'en'])('keeps the expiry notice visible after returning to Welcome: %s', async (language) => { vi.useFakeTimers() try { - const view = mount(language) + const takeNotice = vi.fn<() => Promise>().mockResolvedValue(undefined).mockResolvedValueOnce('session-expired') + const view = mount(language, takeNotice) + await act(async () => {}) const publish = view.api.onAccountState.mock.calls[0]![0] const expired: AccountView = { status: 'signed-out', signOutReason: 'expired', attempt: null, links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' } } @@ -277,5 +280,35 @@ it.each(['zh-CN', 'en'])('keeps the expiry notice visible after returning to Wel expect(screen.queryByRole('alert')).toBeNull() await act(async () => { publish(expired) }) expect(screen.queryByRole('alert')).toBeNull() + view.unmount() + mount(language, takeNotice) + await act(async () => {}) + expect(screen.queryByRole('alert')).toBeNull() } finally { cleanup(); vi.useRealTimers() } }) + +it('does not infer a notification from a retained expired account snapshot', async () => { + const view = mount() + await act(async () => { + view.api.onAccountState.mock.calls[0]![0]({ status: 'signed-out', signOutReason: 'expired', attempt: null, + links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' } }) + }) + expect(screen.queryByRole('alert')).toBeNull() +}) + +it('keeps the entry usable when notification IPC fails', async () => { + const view = mount('en', vi.fn<() => Promise>().mockRejectedValue(new Error('closed'))) + await act(async () => {}) + expect(screen.queryByRole('alert')).toBeNull() + fireEvent.click(view.button('#api-key')) + expect(view.input.closest('[hidden]')).toBeNull() +}) + +it('ignores a notification received after its renderer unmounts', async () => { + const pending = Promise.withResolvers() + const view = mount('en', vi.fn<() => Promise>().mockReturnValue(pending.promise)) + view.unmount() + mount('en') + await act(async () => { pending.resolve('session-expired') }) + expect(screen.queryByRole('alert')).toBeNull() +}) diff --git a/apps/desktop/tests/welcome-startup.spec.ts b/apps/desktop/tests/welcome-startup.spec.ts index 20a5a441f4..737a3167be 100644 --- a/apps/desktop/tests/welcome-startup.spec.ts +++ b/apps/desktop/tests/welcome-startup.spec.ts @@ -131,7 +131,8 @@ vi.mock('../src/welcome-window.ts', () => ({ state.welcomeLocale = locale state.operations = operations await state.beforeWelcome() - return { once: vi.fn(), close: state.closeWelcome } + return { once: vi.fn(), close: state.closeWelcome, isDestroyed: () => false, + show: vi.fn(), focus: vi.fn(), webContents: { send: vi.fn() } } }, })) @@ -179,6 +180,7 @@ it('starts the Host for welcome onboarding and opens the workspace on skip witho expect(state.showWorkspace).not.toHaveBeenCalled() state.loadWorkspace.mockClear() expect(state.welcomeLocale).toMatchObject({ id: 'zh-CN' }) + expect(await state.operations!.takeNotice()).toBeUndefined() expect(state.dialogLocale!().id).toBe('zh-CN') const attemptId = 'login' as NonNullable['id'] const account: AccountView = { status: 'signed-out', links: { usageUrl: '', topUpUrl: '' }, @@ -228,9 +230,19 @@ it('starts the Host for welcome onboarding and opens the workspace on skip witho state.accountListener!({ ...account, status: 'signed-out', attempt: null, signOutReason: 'expired' }) await vi.advanceTimersByTimeAsync(0) expect(state.beforeWelcome).toHaveBeenCalledTimes(welcomeCount) + expect(await state.operations!.takeNotice()).toBeUndefined() state.hasApiKey = false state.accountListener!({ ...account, status: 'credential-stored', attempt: null }) state.accountListener!({ ...account, status: 'signed-out', attempt: null, signOutReason: 'expired' }) await vi.waitFor(() => { expect(state.beforeWelcome).toHaveBeenCalledTimes(welcomeCount + 1) }) + expect(await state.operations!.takeNotice()).toBe('session-expired') + expect(await state.operations!.takeNotice()).toBeUndefined() + state.accountListener!({ ...account, status: 'signed-out', attempt: null, signOutReason: 'expired' }) + await vi.advanceTimersByTimeAsync(0) + expect(await state.operations!.takeNotice()).toBeUndefined() + state.accountListener!({ ...account, status: 'credential-stored', attempt: null }) + state.accountListener!({ ...account, status: 'signed-out', attempt: null }) + await vi.advanceTimersByTimeAsync(0) + expect(await state.operations!.takeNotice()).toBeUndefined() }) diff --git a/apps/desktop/tests/welcome-window.spec.ts b/apps/desktop/tests/welcome-window.spec.ts index b357f8dcb5..5ab4a1c031 100644 --- a/apps/desktop/tests/welcome-window.spec.ts +++ b/apps/desktop/tests/welcome-window.spec.ts @@ -40,6 +40,7 @@ function createWindow() { beforeEach(() => { electron.create.mockReset(); electron.handlers.clear() }) const operations = { + takeNotice: async () => undefined, startSignIn: async () => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out' as const, attempt: null }), cancelSignIn: async () => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out' as const, attempt: null }), copySignInLink: async () => undefined, @@ -115,8 +116,14 @@ describe('desktop welcome window', () => { const saveApiKey = vi.fn(operations.saveApiKey) const skip = vi.fn(operations.skip) const copySignInLink = vi.fn(operations.copySignInLink) - await openWelcomeWindow(resolveDesktopLocale('en'), { ...operations, saveApiKey, skip, copySignInLink }) + const takeNotice = vi.fn(async () => 'session-expired' as const) + await openWelcomeWindow(resolveDesktopLocale('en'), { ...operations, saveApiKey, skip, copySignInLink, takeNotice }) const own = { sender: window.webContents, senderFrame: window.webContents.mainFrame } + const take = electron.handlers.get(WELCOME_IPC.takeNotice)! + await expect(take({ ...own, senderFrame: {} })).rejects.toThrow('unowned frame') + expect(takeNotice).not.toHaveBeenCalled() + expect(await take(own)).toBe('session-expired') + expect(takeNotice).toHaveBeenCalledOnce() const save = electron.handlers.get(WELCOME_IPC.saveApiKey)! await expect(save({ sender: {}, senderFrame: {} }, 'sk-test')).rejects.toThrow('unowned frame') await expect(save({ ...own, senderFrame: {} }, 'sk-test')).rejects.toThrow('unowned frame') From 06717aebbb79d78f69e2511eb63dcac26d49abe0 Mon Sep 17 00:00:00 2001 From: winewill Date: Tue, 22 Sep 2026 20:11:51 +0800 Subject: [PATCH 038/192] build(desktop): bundle the main process after the workspace tsdown pass The root tsdown workspace pass builds every member concurrently, so the Desktop main bundle could resolve its inlined workspace devDependencies (dsh-home-paths, dsh-app-boot, dsh-deepseek-account) before their lib/ output existed. rolldown then kept them as external imports, which the asar does not ship, and the packaged application failed at launch with ERR_MODULE_NOT_FOUND on a clean checkout; stale lib/ output on long-lived checkouts hid the defect. apps/desktop leaves the root workspace list and build:lib:host bundles it after the Host pass. Every Desktop bundle now runs a rolldown plugin that fails the build when a chunk imports a bare specifier the packaged application cannot resolve: electron and the manifest dependencies for the main bundle, electron alone for sandboxed preloads. --- ...in-bundle-after-workspace-tsdown.i18n.yaml | 6 ++ ...ktop-main-bundle-after-workspace-tsdown.md | 29 +++++++ ...p-main-bundle-after-workspace-tsdown.zh.md | 29 +++++++ apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 + apps/desktop/README.zh.md | 4 + apps/desktop/package.json | 3 +- .../scripts/desktop-bundle-imports.d.mts | 24 ++++++ .../scripts/desktop-bundle-imports.mjs | 66 ++++++++++++++++ .../tests/desktop-bundle-imports.spec.ts | 78 +++++++++++++++++++ apps/desktop/tsdown.config.ts | 15 ++++ docs/api-gateway.i18n.yaml | 4 +- docs/api-gateway.md | 2 +- docs/api-gateway.zh.md | 2 +- docs/development.i18n.yaml | 4 +- docs/development.md | 3 +- docs/development.zh.md | 3 +- package.json | 2 +- tsdown.config.ts | 7 +- 19 files changed, 273 insertions(+), 14 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.md create mode 100644 .agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.zh.md create mode 100644 apps/desktop/scripts/desktop-bundle-imports.d.mts create mode 100644 apps/desktop/scripts/desktop-bundle-imports.mjs create mode 100644 apps/desktop/tests/desktop-bundle-imports.spec.ts diff --git a/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.i18n.yaml b/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.i18n.yaml new file mode 100644 index 0000000000..d1ecdce5be --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.md +2026-09-22-desktop-main-bundle-after-workspace-tsdown.md: 4aea81fd11873a53d54b16bc8fd4326d923d714b +2026-09-22-desktop-main-bundle-after-workspace-tsdown.zh.md: 0f2fd83835254c241f9ccfffab93d5a0abd2c17b diff --git a/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.md b/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.md new file mode 100644 index 0000000000..4aea81fd11 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.md @@ -0,0 +1,29 @@ +# Agent Note: Bundle the Desktop main process after the workspace tsdown pass + +Status: implemented + +English | [中文](2026-09-22-desktop-main-bundle-after-workspace-tsdown.zh.md) + +## Problem + +A macOS Desktop package built from a fresh worktree crashed at launch with `ERR_MODULE_NOT_FOUND: Cannot find package '@deepseek-ai/dsh-home-paths' imported from app.asar/lib/main.js`. The packaging log showed why: rolldown had reported `[UNRESOLVED_IMPORT] Could not resolve '@deepseek-ai/dsh-home-paths' … treating it as an external dependency`, and the same for `@deepseek-ai/dsh-app-boot` and `@deepseek-ai/dsh-deepseek-account`. The three packages are workspace devDependencies of `apps/desktop` that the main-process bundle must inline, because electron-builder copies only the manifest's `dependencies` into `app.asar/node_modules`. + +The root tsdown config builds every matched workspace member concurrently (`Promise.all` over the resolved configs in tsdown 0.22, with no dependency ordering), and `apps/desktop` was one of those members. Its bundle resolved the three packages through their `exports` to `lib/index.js` files that other concurrent builds had not written yet. On a checkout that had been built before, the stale `lib/` outputs existed and the bundle inlined them, which is why the defect surfaced only on a clean tree; `clean: false` in both configs kept it hidden. The broken bundle was also smaller (189 KB instead of 315 KB) and the packaging run passed every existing check, because the runtime payload smoke exercises the dsh runtime, not the Electron main process. + +## Decision + +`apps/desktop` leaves the root tsdown `workspace` list. The root `build:lib:host` script runs the Host tsdown pass and then `pnpm --filter @deepseek-ai/dsh-desktop run bundle`, so the main bundle resolves workspace `lib/` outputs that already exist. The package's own `build` script keeps the same `tsc -b` then `bundle` order for local use. A standalone run of the Desktop config produces byte-identical output to the former workspace-mode run: the config was already self-contained, and the root plugins contributed nothing to it. + +The Desktop config also asserts the invariant the crash violated. [`desktop-bundle-imports`](../../../../apps/desktop/scripts/desktop-bundle-imports.mjs) is a rolldown plugin on every Desktop bundle whose `generateBundle` hook fails the build when a chunk imports a bare specifier the packaged application cannot resolve: the main bundle may import `electron` and the manifest's `dependencies`, sandboxed preloads only `electron`, and Node builtins are always allowed. The check names the actual requirement rather than the symptom, so it also rejects a workspace package moved out of `dependencies` or a broken `exports` entry, and a rejected bundle writes no output. + +## Alternatives considered + +**Declare the three packages as runtime `dependencies` so electron-builder ships them in the asar.** This is how `@deepseek-ai/cordis` and `@deepseek-ai/dsh-api-gateway` reach the asar today, and it removes the ordering problem. `dsh-app-boot` would bring a large dependency tree into `app.asar/node_modules`, duplicating the copy already inside the packed dsh runtime and enlarging the payload the runtime file policy must screen. Inlining the main-process helpers was a deliberate choice; the fix keeps it. + +**Fail on rolldown's `UNRESOLVED_IMPORT` warning** through `inputOptions.onLog` or `failOnWarn`. It catches only the unresolved case and would not have covered a dependency misdeclared as a devDependency, which produces a resolved, inlined bundle when the output exists and an external import when it does not. The packaged-imports check covers both with one rule; a warning-based check remains an optional addition. + +**Order the workspace build by dependency graph.** tsdown offers no such ordering, and every other member either inlines nothing from the workspace or externalizes declared `dependencies`, which need no ordering. One extra step for the one package that needs it is smaller than a general scheduler. + +## Consequences + +A clean checkout, including CI and a fresh worktree, produces the same Desktop main bundle as a long-lived one. A missing or misdeclared runtime import now fails `pnpm run build` and every packaging command with the offending file and specifiers, before signing and notarization spend their time. The Host build gains one sequential step of about a second on a development machine. `docs/development.md` lists the desktop bundle step in the root build order, and the [Desktop README](../../../../apps/desktop/README.md#bundled-workspace-dependencies) owns the import rule. diff --git a/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.zh.md b/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.zh.md new file mode 100644 index 0000000000..0f2fd83835 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.zh.md @@ -0,0 +1,29 @@ +# Agent Note:Desktop 主进程在工作区 tsdown 阶段之后再打 bundle + +Status: implemented + +[English](2026-09-22-desktop-main-bundle-after-workspace-tsdown.md) | 中文 + +## 问题 + +从全新 worktree 打出的 macOS Desktop 安装包启动即崩:`ERR_MODULE_NOT_FOUND: Cannot find package '@deepseek-ai/dsh-home-paths' imported from app.asar/lib/main.js`。打包日志给出了原因:rolldown 报告了 `[UNRESOLVED_IMPORT] Could not resolve '@deepseek-ai/dsh-home-paths' … treating it as an external dependency`,`@deepseek-ai/dsh-app-boot` 与 `@deepseek-ai/dsh-deepseek-account` 也是同样情况。这三个包是 `apps/desktop` 的工作区 devDependencies,主进程 bundle 必须把它们内联,因为 electron-builder 只把清单中的 `dependencies` 复制进 `app.asar/node_modules`。 + +根 tsdown 配置并发构建所有匹配到的 workspace 成员(tsdown 0.22 对解析出的配置做 `Promise.all`,没有任何依赖排序),而 `apps/desktop` 是其中一员。它的 bundle 通过这三个包的 `exports` 解析到 `lib/index.js`,而其他并发构建此时还没有写出这些文件。在此前构建过的检出目录里,陈旧的 `lib/` 产物存在,bundle 会把它们内联,因此缺陷只在干净树上出现;两份配置里的 `clean: false` 一直掩盖着它。坏的 bundle 也更小(189 KB 而不是 315 KB),并且打包流程通过了所有已有检查,因为运行时载荷 smoke 检验的是 dsh 运行时,不是 Electron 主进程。 + +## 决策 + +`apps/desktop` 退出根 tsdown 的 `workspace` 列表。根 `build:lib:host` 脚本先跑 Host tsdown 阶段,再执行 `pnpm --filter @deepseek-ai/dsh-desktop run bundle`,让主进程 bundle 解析的是已经存在的工作区 `lib/` 产物。该包自己的 `build` 脚本为本地使用保留同样的先 `tsc -b` 再 `bundle` 的顺序。单独运行 Desktop 配置得到的产物与此前 workspace 模式下的产物逐字节一致:该配置本来就是自足的,根配置的插件没有为它贡献任何内容。 + +Desktop 配置同时断言这次崩溃所违反的不变量。[`desktop-bundle-imports`](../../../../apps/desktop/scripts/desktop-bundle-imports.mjs) 是挂在每个 Desktop bundle 上的 rolldown 插件,其 `generateBundle` 钩子在某个 chunk 导入了打包应用无法解析的裸说明符时让构建失败:主进程 bundle 可以导入 `electron` 与清单的 `dependencies`,沙箱 preload 只能导入 `electron`,Node 内置模块始终允许。这项检查陈述的是真正的要求而非症状,因此它同样拒绝被移出 `dependencies` 的工作区包或损坏的 `exports` 条目,且被拒绝的 bundle 不写出任何产物。 + +## 备选方案 + +**把这三个包声明为运行时 `dependencies`,让 electron-builder 装进 asar。** `@deepseek-ai/cordis` 与 `@deepseek-ai/dsh-api-gateway` 今天就是这样进入 asar 的,这也消除了顺序问题。但 `dsh-app-boot` 会把一棵很大的依赖树带进 `app.asar/node_modules`,与 packed dsh 运行时内已有的副本重复,并扩大运行时文件策略必须筛查的载荷。内联主进程辅助包是有意的选择;本修复保留它。 + +**通过 `inputOptions.onLog` 或 `failOnWarn` 让 rolldown 的 `UNRESOLVED_IMPORT` warning 直接失败。** 它只覆盖未解析这一种情况,覆盖不了被误声明为 devDependency 的依赖:产物存在时它会被解析并内联,不存在时才留成外部导入。打包导入检查用一条规则同时覆盖两者;基于 warning 的检查仍可作为可选补充。 + +**按依赖图给 workspace 构建排序。** tsdown 不提供这种排序,而其他每个成员要么不从工作区内联任何内容,要么把已声明的 `dependencies` 留成外部导入,都不需要排序。为唯一需要它的包多加一步,比通用调度器更小。 + +## 后果 + +干净的检出目录(包括 CI 与全新 worktree)产出的 Desktop 主进程 bundle 与长期使用的检出目录一致。缺失或误声明的运行时导入现在会让 `pnpm run build` 和每条打包命令在签名与公证耗时之前失败,并报出违规文件与说明符。Host 构建多出一个串行步骤,在开发机上约一秒。`docs/development.md` 在根构建顺序中列出了 desktop bundle 步骤,导入规则由 [Desktop README](../../../../apps/desktop/README.zh.md#bundled-workspace-dependencies) 负责。 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 77500865f6..628c27e58b 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: 2b07b2b9d95c3d16a3aaa58b6d52f55cb1242e2e -README.zh.md: 4ebae34332b1d622a35d0bf857f1f3252736512c +README.md: e534a9443be4fdc576878f39f132fff34973db2f +README.zh.md: 8123832ac97185539bff87257cdb8921e332d2d9 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 2b07b2b9d9..e534a9443b 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -26,6 +26,8 @@ The macOS PNG uses an inset rounded background for legacy ICNS packaging, with r ### Bundled workspace dependencies +electron-builder copies only the manifest's `dependencies` into `app.asar/node_modules`, so the Electron main bundle `lib/main.js` inlines its workspace devDependencies and leaves `electron` and those `dependencies` as its only bare imports; sandboxed preloads import only `electron`. The main bundle resolves the inlined packages from their `lib/` output, so the root `build:lib:host` bundles `apps/desktop` after the concurrent workspace tsdown pass, and [`desktop-bundle-imports`](scripts/desktop-bundle-imports.mjs) fails any Desktop bundle whose external imports the packaged application cannot resolve. Without that check an import rolldown could not resolve ships as an external specifier and fails at launch with `ERR_MODULE_NOT_FOUND`. The [bundle-order decision](../../.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.md) records the alternatives. + Signed Windows packaging scans the primary runtime and application production dependencies for PE content, including files without a conventional extension. The final scan covers the entire unpacked application. Directory links, malformed `MZ` files and non-PE `.exe`, `.dll` or `.pyd` files stop packaging; data files beginning with `MZ` are also rejected unless they contain a valid PE header. It preserves valid vendor signatures and signs unsigned code before sealing runtime hashes or executing smoke checks. Public-key inspection uses batches of 32 files with at most four processes; hardware-token signing remains serial, and each new signature must match the configured certificate and carry a timestamp. Hardware signing and verification failures stop the run; isolated timestamp requests follow the bounded policy below. Electron-builder preserves copied runtime executables only after signature and exact-byte verification. A final PE-signature audit and a fresh-cache ASAR payload and Host smoke must pass before the release completion record is written. Development, preparation-only and unsigned builds do not use the hardware token and can be blocked by Windows code-integrity policy; no build mode disables that policy. Passing smoke checks does not establish compatibility with every enterprise policy. Desktop carries independent Python, Node.js and pnpm distributions. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml and XlsxWriter with their complete dependencies. The `load_workspace_dependencies` tool installs this payload offline on first use under `$DSH_HOME/dsh-runtimes/dsh-primary-runtime` (normally `~/.dsh/dsh-runtimes/dsh-primary-runtime`) and returns absolute interpreter, pnpm script and library paths plus `pythonDistributions`, the bundled distribution names and versions. The version report excludes user-installed additions. Office tasks prefer these libraries unless user or workspace instructions select another environment. Execute the pnpm script with the returned Node executable. The returned Node library directory is reserved for bundled libraries, not pnpm's global installation directory. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 4ebae34332..8123832ac9 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -24,8 +24,12 @@ Desktop Host 的 Platform API 请求与更新策略请求使用相同的 `x-clie macOS PNG 使用带留白的圆角底板,供传统 ICNS 打包使用,包含最高 1024 像素的表示。它是扁平图标,并非 Icon Composer 文档。Apple 的[应用图标指南](https://developer.apple.com/design/human-interface-guidelines/app-icons)要求向 Icon Composer 提供未遮罩的图层;这些输入需要在 macOS 上单独导出,不能复用已做圆角的 ICNS 图案。发布前须在支持的 macOS 版本中验收 Finder 和 Dock 的显示效果。 + + ### 内置工作区依赖 +electron-builder 只把清单中的 `dependencies` 复制进 `app.asar/node_modules`,因此 Electron 主进程 bundle `lib/main.js` 内联其工作区 devDependencies,裸导入只剩 `electron` 与这些 `dependencies`;沙箱 preload 只导入 `electron`。主进程 bundle 从被内联包的 `lib/` 产物解析它们,所以根 `build:lib:host` 在并发的工作区 tsdown 阶段之后才为 `apps/desktop` 打 bundle,并由 [`desktop-bundle-imports`](scripts/desktop-bundle-imports.mjs) 让任何外部导入无法在打包应用内解析的 Desktop bundle 直接失败。没有这项检查时,rolldown 无法解析的导入会作为外部说明符进入产物,并在启动时以 `ERR_MODULE_NOT_FOUND` 失败。[bundle 顺序决策](../../.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.zh.md)记录了备选方案。 + Windows 签名打包按 PE 文件内容扫描第一方运行时和应用生产依赖,包括没有常规扩展名的文件。最终扫描覆盖整个解包应用。目录链接、格式错误的 `MZ` 文件以及非 PE 的 `.exe`、`.dll` 或 `.pyd` 文件会使打包停止;以 `MZ` 开头的数据文件也会被拒绝,除非包含有效 PE 头。它保留有效的上游签名,并在记录运行时哈希或执行冒烟检查前为未签名代码补签。公钥验签每个进程处理最多 32 个文件,同时最多运行四个进程;硬件令牌签名仍串行执行,每个新签名必须匹配配置的证书且带时间戳。硬件签名或验签失败会停止本轮执行;独立的时间戳请求遵循下文的有界重试规则。electron-builder 只有在验签和逐字节比对通过后,才保留复制后运行时可执行文件的签名。写入发布完成记录前,必须通过最终 PE 签名检查,以及使用全新缓存的 ASAR 载荷和 Host 冒烟检查。开发、仅准备和未签名构建不使用硬件令牌,可能被 Windows 代码完整性策略阻止;任何构建模式都不会关闭该策略。冒烟检查通过不代表兼容所有企业策略。 Desktop 携带独立的 Python、Node.js 和 pnpm 分发包。Python 包含 numpy、pandas、python-docx、python-pptx、openpyxl、Pillow、lxml、XlsxWriter 及其完整依赖。`load_workspace_dependencies` 工具首次使用时,将该产物离线安装到 `$DSH_HOME/dsh-runtimes/dsh-primary-runtime`(通常为 `~/.dsh/dsh-runtimes/dsh-primary-runtime`),并返回解释器、pnpm 脚本和库目录的绝对路径,以及记录内置分发包名称与版本的 `pythonDistributions`。版本报告不包含用户自行安装的包。Office 任务默认使用这些库,用户或工作区指令指定其他环境时遵循其要求。pnpm 脚本通过返回的 Node 可执行文件运行。返回的 Node 库目录为随包交付的库预留,不是 pnpm 的全局安装目录。 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d7c8fbdd09..fa87ea8ad7 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -8,7 +8,8 @@ "main": "lib/main.js", "scripts": { "test:installer": "node scripts/test-windows-installer.mjs", - "build": "tsc -b && tsdown", + "build": "tsc -b && pnpm run bundle", + "bundle": "tsdown", "dev": "tsx scripts/dev.ts", "start": "tsx scripts/dev.ts --skip-build", "test:updates:local": "pnpm run build && node scripts/test-local-updater.mjs", diff --git a/apps/desktop/scripts/desktop-bundle-imports.d.mts b/apps/desktop/scripts/desktop-bundle-imports.d.mts new file mode 100644 index 0000000000..abb95a8474 --- /dev/null +++ b/apps/desktop/scripts/desktop-bundle-imports.d.mts @@ -0,0 +1,24 @@ +import type { Rolldown } from 'tsdown' + +/** + * Package name of a bare import specifier. + * @param specifier - Bare specifier as rolldown reports it, such as `@scope/name/subpath`. + * @returns The package name without its subpath. + */ +export function importPackageName(specifier: string): string + +/** + * Bare imports the packaged application cannot resolve. + * @param imports - External specifiers a chunk imports, statically or dynamically. + * @param packaged - Package names available to the bundle at runtime. + * @returns Offending specifiers in import order, each once. + */ +export function unpackagedImports(imports: readonly string[], packaged: ReadonlySet): string[] + +/** + * Rolldown plugin that fails a bundle whose external imports the packaged + * application cannot resolve. + * @param packaged - Package names available to the bundle at runtime. + * @returns The plugin. + */ +export function packagedImportsPlugin(packaged: ReadonlySet): Rolldown.Plugin diff --git a/apps/desktop/scripts/desktop-bundle-imports.mjs b/apps/desktop/scripts/desktop-bundle-imports.mjs new file mode 100644 index 0000000000..16d8c45990 --- /dev/null +++ b/apps/desktop/scripts/desktop-bundle-imports.mjs @@ -0,0 +1,66 @@ +/** + * Keep every bare import a Desktop bundle leaves to the runtime resolvable + * inside the packaged application. + * + * electron-builder copies only the manifest's `dependencies` into + * `app.asar/node_modules`, so a bare import of anything else bundles without + * complaint and fails at launch with `ERR_MODULE_NOT_FOUND`. The main-process + * bundle inlines its workspace devDependencies; when one of their `lib/` + * outputs is missing at bundle time, rolldown reports `UNRESOLVED_IMPORT` as a + * warning and keeps the specifier as an external import, which is exactly the + * broken artifact. Sandboxed preloads may `require` only `electron`. This + * check fails the bundle instead of the installed application. + */ + +import { isBuiltin } from 'node:module' + +/** + * Package name of a bare import specifier. + * @param {string} specifier - Bare specifier as rolldown reports it, such as `@scope/name/subpath`. + * @returns {string} The package name without its subpath. + */ +export function importPackageName(specifier) { + const segments = specifier.split('/') + return specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0] +} + +/** + * Bare imports the packaged application cannot resolve. + * @param {readonly string[]} imports - External specifiers a chunk imports, statically or dynamically. + * @param {ReadonlySet} packaged - Package names available to the bundle at runtime. + * @returns {string[]} Offending specifiers in import order, each once. + */ +export function unpackagedImports(imports, packaged) { + const offending = new Set() + for (const specifier of imports) { + if (isBuiltin(specifier)) continue + if (packaged.has(importPackageName(specifier))) continue + offending.add(specifier) + } + return [...offending] +} + +/** + * Rolldown plugin that fails a bundle whose external imports the packaged + * application cannot resolve. + * @param {ReadonlySet} packaged - Package names available to the bundle at runtime. + * @returns {import('tsdown').Rolldown.Plugin} The plugin. + */ +export function packagedImportsPlugin(packaged) { + return { + name: 'desktop-packaged-imports', + generateBundle(_options, bundle) { + for (const output of Object.values(bundle)) { + if (output.type !== 'chunk') continue + const external = [...output.imports, ...output.dynamicImports].filter(specifier => !(specifier in bundle)) + const offending = unpackagedImports(external, packaged) + if (offending.length === 0) continue + this.error( + `desktop bundle: ${output.fileName} imports ${offending.join(', ')}, which the packaged application does not ship. ` + + `A bare import must name electron or a package in this manifest's dependencies; anything else must be bundled, ` + + 'which requires its lib/ output to exist before this bundle runs (pnpm run build:lib:host).', + ) + } + }, + } +} diff --git a/apps/desktop/tests/desktop-bundle-imports.spec.ts b/apps/desktop/tests/desktop-bundle-imports.spec.ts new file mode 100644 index 0000000000..c37564020c --- /dev/null +++ b/apps/desktop/tests/desktop-bundle-imports.spec.ts @@ -0,0 +1,78 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Rolldown } from 'tsdown' +import { afterAll, describe, expect, it } from 'vitest' +import { + importPackageName, + packagedImportsPlugin, + unpackagedImports, +} from '../scripts/desktop-bundle-imports.mjs' + +const PACKAGED = new Set(['electron', 'electron-updater', '@deepseek-ai/dsh-api-gateway']) + +describe('desktop bundle imports', () => { + it('names the package behind a bare specifier', () => { + expect(importPackageName('electron-updater/out/electronHttpExecutor.js')).toBe('electron-updater') + expect(importPackageName('@deepseek-ai/dsh-api-gateway/stream-protocol')).toBe('@deepseek-ai/dsh-api-gateway') + expect(importPackageName('ws')).toBe('ws') + }) + + it('accepts Node builtins and packaged dependencies, including their subpaths', () => { + expect(unpackagedImports([ + 'node:fs/promises', + 'crypto', + 'electron', + 'electron-updater/out/electronHttpExecutor.js', + '@deepseek-ai/dsh-api-gateway/stream-protocol', + ], PACKAGED)).toEqual([]) + }) + + it('reports each specifier the packaged application cannot resolve once, in import order', () => { + expect(unpackagedImports([ + '@deepseek-ai/dsh-home-paths', + 'ws', + '@deepseek-ai/dsh-home-paths', + 'electron', + ], PACKAGED)).toEqual(['@deepseek-ai/dsh-home-paths', 'ws']) + }) +}) + +describe('packaged imports plugin', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-bundle-imports-')) + const entry = join(root, 'entry.js') + writeFileSync(entry, [ + 'import { app } from "electron";', + 'import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";', + 'export const home = resolveDshHome(app);', + ].join('\n')) + afterAll(() => { + rmSync(root, { recursive: true, force: true }) + }) + + async function generate(packaged: ReadonlySet): Promise { + // Every import stays external, which is what an unresolved workspace lib/ output produces. + const bundle = await Rolldown.rolldown({ + input: entry, + external: () => true, + logLevel: 'silent', + plugins: [packagedImportsPlugin(packaged)], + }) + try { + return await bundle.generate({ format: 'esm' }) + } finally { + await bundle.close() + } + } + + it('fails the bundle that leaves an import the packaged application does not ship', async () => { + await expect(generate(new Set(['electron']))).rejects.toThrow( + /entry\.js imports @deepseek-ai\/dsh-home-paths, which the packaged application does not ship/u, + ) + }) + + it('passes the bundle whose external imports are all packaged', async () => { + const output = await generate(new Set(['electron', '@deepseek-ai/dsh-home-paths'])) + expect(output.output.map(chunk => chunk.fileName)).toEqual(['entry.js']) + }) +}) diff --git a/apps/desktop/tsdown.config.ts b/apps/desktop/tsdown.config.ts index 6db212aee7..8af998c518 100644 --- a/apps/desktop/tsdown.config.ts +++ b/apps/desktop/tsdown.config.ts @@ -1,11 +1,25 @@ import { defineConfig } from 'tsdown' import { build } from 'vite' import { fileURLToPath } from 'node:url' +import { readFileSync } from 'node:fs' import { readFile } from 'node:fs/promises' +import { packagedImportsPlugin } from './scripts/desktop-bundle-imports.mjs' + +// This config runs after the workspace tsdown pass, not inside it: the main bundle inlines +// workspace devDependencies from their lib/ output, which the concurrent workspace pass does +// not order ahead of this package (root package.json build:lib:host). +const manifest = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')) as { + dependencies: Record +} +/** electron-builder ships these next to the main bundle; Electron itself provides `electron`. */ +const mainProcessImports = new Set(['electron', ...Object.keys(manifest.dependencies)]) +/** Sandboxed preloads can require nothing but `electron`. */ +const preloadImports = new Set(['electron']) export default defineConfig([ { entry: ['lib/types/main.js'], + plugins: [packagedImportsPlugin(mainProcessImports)], onSuccess: async () => { await build({ configFile: false, @@ -49,6 +63,7 @@ export default defineConfig([ ...(['preload-app', 'preload-welcome', 'preload-platform-account', 'preload-mandatory', 'preload-update-dialog'] as const).map(name => ({ // Sandboxed Electron preloads run as CommonJS even though the application package is ESM. entry: { [name]: `lib/types/${name}.js` }, + plugins: [packagedImportsPlugin(preloadImports)], outDir: 'lib', format: 'cjs' as const, codeSplitting: false, diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index e11ee881f4..f515b0eff1 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/api-gateway.md -api-gateway.md: 957f40775857d8f57af436a44690fd5a453aeeae -api-gateway.zh.md: 1062adcc89909d5ce3701ac82b55c5d0ece21ab0 +api-gateway.md: d6a8444c9f18a7c3df9cd64e4a836d1831f8a0d8 +api-gateway.zh.md: 23c7c18d3d784fc2b6abe4c1df75e08447100b98 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 957f407758..d6a8444c9f 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -98,7 +98,7 @@ The API Gateway package owns the Host dispatcher and Client Remote endpoint as p The root build runs `build:lib:host`, `build:lib:client`, and `build:web` in order. The Host lib phase first runs `tsc -b tsconfig.host.json`, then `tsdown --env.DSH_BUILD_FACE host`; the normal Host Project Reference graph compiles the Typert generator, which runs during this tsdown pass with the Host aggregate as its only `ts.Program` seed. The Client lib phase then runs `tsc -b tsconfig.client.json` and `tsdown --env.DSH_BUILD_FACE client`, consuming the newly generated Remote Client declarations and runtime contributions without starting Typert again. -Both tsdown passes receive the complete workspace and bundle only JavaScript emitted to `lib/types` by the corresponding tsc phase. The root config does not scan Client artifacts, classify package names, or pass a maintained filter to tsdown; package-local configs return entries for the current phase based on `DSH_BUILD_FACE`. An ordinary Client plugin produces both its Node loader entry and browser bundle during the Client phase. +Both tsdown passes receive the same workspace match ([build order](development.md#typescript-project-layout)) and bundle only JavaScript emitted to `lib/types` by the corresponding tsc phase. The root config does not scan Client artifacts, classify package names, or pass a maintained filter to tsdown; package-local configs return entries for the current phase based on `DSH_BUILD_FACE`. An ordinary Client plugin produces both its Node loader entry and browser bundle during the Client phase. `api/remotes`, `api/gateway`, `api/session-controller`, and `api/workspace-controller` (plus `client/connection`) split TypeScript faces. `api/remotes`' Client project depends on `/remote` declarations generated for business packages during Host tsdown; root aggregates and direct consumers must reference each split package's `tsconfig.host.json` or `tsconfig.client.json` respectively. `api-remotes`' `clientBundle(..., { hostPhase: true })` produces its Host entry during Host tsdown and leaves only the browser entry for Client tsdown. The Agent/Session lookup policy lives in `@deepseek-ai/dsh-api-session-controller`, not in `api-remotes`. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 1062adcc89..23c7c18d3d 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -98,7 +98,7 @@ API Gateway 包同时拥有 Host dispatcher 与 Client Remote endpoint 两个对 根构建依次执行 `build:lib:host`、`build:lib:client` 与 `build:web`。Host lib 阶段先运行 `tsc -b tsconfig.host.json`,再运行 `tsdown --env.DSH_BUILD_FACE host`;Typert generator 由正常 Host Project Reference 图编译,并在这次 tsdown 中以 Host aggregate 为唯一 `ts.Program` 种子运行。Client lib 阶段随后运行 `tsc -b tsconfig.client.json` 与 `tsdown --env.DSH_BUILD_FACE client`,使用刚生成的 Remote Client 声明和运行时贡献,但不再次启动 Typert。 -两次 tsdown 都接收完整 workspace,且都只打包 `lib/types` 中由对应 tsc 阶段发射的 JavaScript。根配置不扫描 Client 产物、不按包名分类,也不向 tsdown 传维护式 filter;各包的本地配置根据 `DSH_BUILD_FACE` 返回当前阶段的入口。普通 Client 插件在 Client 阶段一起生成 Node loader 入口与 browser bundle。 +两次 tsdown 都接收同一组 workspace 匹配([构建顺序](development.zh.md#typescript-project-layout)),且都只打包 `lib/types` 中由对应 tsc 阶段发射的 JavaScript。根配置不扫描 Client 产物、不按包名分类,也不向 tsdown 传维护式 filter;各包的本地配置根据 `DSH_BUILD_FACE` 返回当前阶段的入口。普通 Client 插件在 Client 阶段一起生成 Node loader 入口与 browser bundle。 `api/remotes`、`api/gateway`、`api/session-controller` 与 `api/workspace-controller`(外加 `client/connection`)都拆分 TypeScript face。`api/remotes` 的 Client project 依赖业务包在 Host tsdown 中生成的 `/remote` 声明;根 aggregate 与直接消费方必须分别引用各拆分包自己的 `tsconfig.host.json` 或 `tsconfig.client.json`。`api-remotes` 的 `clientBundle(..., { hostPhase: true })` 让 Host 入口在 Host tsdown 中生成,让 Client tsdown 只生成 browser 入口。Agent/Session lookup 策略位于 `@deepseek-ai/dsh-api-session-controller`,而非 `api-remotes`。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index fd6198a8ca..a14f6e2fd7 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/development.md -development.md: 7e8d0ded70ca880ce986cb3879d959fbe2a04304 -development.zh.md: 84d0d76d8c76f829c0ed928d28336df3e58ed7f0 +development.md: 812cabbe3e2af5965033ef0c73301e5cd28cba28 +development.zh.md: b45e8e3e7b3c5fc0ee05967273a1c73e407b32fc diff --git a/docs/development.md b/docs/development.md index 7e8d0ded70..812cabbe3e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -74,12 +74,13 @@ The root build follows the generated dependency order: ```sh tsc -b tsconfig.host.json tsdown --env.DSH_BUILD_FACE host +pnpm --filter @deepseek-ai/dsh-desktop run bundle tsc -b tsconfig.client.json tsdown --env.DSH_BUILD_FACE client pnpm run build:web ``` -Both tsdown passes use the same complete workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase. +Both tsdown passes use the same workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase. Tsdown builds the matched workspace members concurrently, so `apps/desktop`, whose main bundle inlines workspace devDependencies from their `lib/` output, bundles in its own step after the Host pass ([Desktop README](../apps/desktop/README.md#bundled-workspace-dependencies)). Typert runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start Typert. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. diff --git a/docs/development.zh.md b/docs/development.zh.md index 84d0d76d8c..b45e8e3e7b 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -78,12 +78,13 @@ Host 与 Client 保持两个 aggregate program,是因为两侧在相同键下 ```sh tsc -b tsconfig.host.json tsdown --env.DSH_BUILD_FACE host +pnpm --filter @deepseek-ai/dsh-desktop run bundle tsc -b tsconfig.client.json tsdown --env.DSH_BUILD_FACE client pnpm run build:web ``` -两次 tsdown 都使用同一组完整 workspace 匹配,不扫描构建产物来发现 Client 包,也不维护 Host/Client 包过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client 插件在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。 +两次 tsdown 都使用同一组 workspace 匹配,不扫描构建产物来发现 Client 包,也不维护 Host/Client 包过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client 插件在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。tsdown 并发构建匹配到的 workspace 成员,因此主进程 bundle 要从工作区 devDependencies 的 `lib/` 产物内联它们的 `apps/desktop` 在 Host 阶段之后单独一步打 bundle([Desktop README](../apps/desktop/README.zh.md#bundled-workspace-dependencies))。 Typert 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 Typert。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。 diff --git a/package.json b/package.json index a7acc3ab12..d71d45d55f 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "build:bench": "npm run build:native-system && npm run build:lib && tsdown --config benchmarks/tsdown.config.ts", "build:official": "tsx scripts/build.ts --profile official", "build:lib": "pnpm run build:lib:host && pnpm run build:lib:client", - "build:lib:host": "node --max-old-space-size=4096 ./node_modules/typescript/bin/tsc -b tsconfig.host.json && tsdown --env.DSH_BUILD_FACE host", + "build:lib:host": "node --max-old-space-size=4096 ./node_modules/typescript/bin/tsc -b tsconfig.host.json && tsdown --env.DSH_BUILD_FACE host && pnpm --filter @deepseek-ai/dsh-desktop run bundle", "build:lib:client": "tsc -b tsconfig.client.json && tsdown --env.DSH_BUILD_FACE client", "build:web": "pnpm --filter @deepseek-ai/dsh-web-frontend run build", "build:desktop": "pnpm --filter @deepseek-ai/dsh-desktop run build", diff --git a/tsdown.config.ts b/tsdown.config.ts index 2f24d4910e..2435127329 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -11,14 +11,17 @@ function isBuildFaceClient(value: unknown): boolean { * The ordinary workspace build consumes JavaScript emitted by the Host * TypeScript project and runs Typert. The Client pass selects packages that * declare a browser bundle and lets their package-local configs emit both - * their Node loader entry and browser artifact. + * their Node loader entry and browser artifact. `apps/desktop` bundles after + * this pass (root package.json `build:lib:host`): its main bundle inlines + * workspace devDependencies from their lib/ output, and tsdown builds + * workspace members concurrently without ordering them. */ export default defineConfig(({ env }) => { const client = isBuildFaceClient(env?.DSH_BUILD_FACE) return { workspace: client ? ['vendor/*', 'packages/*/*', 'apps/cli'] - : ['vendor/*', 'packages/*/*', 'apps/cli', 'apps/desktop', 'apps/desktop-host'], + : ['vendor/*', 'packages/*/*', 'apps/cli', 'apps/desktop-host'], entry: client ? '' : ['lib/types/{index,invariant,startup}.js'], outDir: 'lib', format: ['esm'], From fd784e44296456d13cee10f3271d4eef682fd9db Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Tue, 22 Sep 2026 20:23:45 +0800 Subject: [PATCH 039/192] fix(web): refine file diff review colors --- apps/web/tests/changed-files-turn.e2e.ts | 22 ++++++++++++++----- .../client/ui-deliverables/README.i18n.yaml | 4 ++-- packages/client/ui-deliverables/README.md | 2 +- packages/client/ui-deliverables/README.zh.md | 2 +- .../src/client/FileDiff.module.css | 12 +++++----- packages/client/ui-theme/README.i18n.yaml | 4 ++-- packages/client/ui-theme/README.md | 2 +- packages/client/ui-theme/README.zh.md | 2 +- .../ui-theme/src/styles/design-platform.css | 12 ++++++++++ 9 files changed, 42 insertions(+), 20 deletions(-) diff --git a/apps/web/tests/changed-files-turn.e2e.ts b/apps/web/tests/changed-files-turn.e2e.ts index 8365c34f45..8d7eeafa36 100644 --- a/apps/web/tests/changed-files-turn.e2e.ts +++ b/apps/web/tests/changed-files-turn.e2e.ts @@ -351,7 +351,8 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { if (number === undefined || text === null) throw new Error('added diff line is incomplete') const left = line.getBoundingClientRect().left return { - shadow: getComputedStyle(line).boxShadow, + shadow: getComputedStyle(number).boxShadow, + markerColour: getComputedStyle(number).color, textColour: getComputedStyle(text).color, numberLeft: number.getBoundingClientRect().left - left, textLeft: text.getBoundingClientRect().left - left, @@ -367,7 +368,8 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { textLeft: text.getBoundingClientRect().left - left, } }) - expect(addedRule.shadow).toContain(addedRule.textColour) + expect(addedRule.shadow).toContain(addedRule.markerColour) + expect(addedRule.textColour).not.toBe(addedRule.markerColour) expect(addedRule.numberLeft).toBeCloseTo(rightContextRule.numberLeft, 1) expect(addedRule.textLeft).toBeCloseTo(rightContextRule.textLeft, 1) // The ignored file has no snapshot; its comparison comes from the copies captured around the write call. @@ -402,7 +404,8 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { if (number === undefined || text === null) throw new Error('deleted diff line is incomplete') const left = line.getBoundingClientRect().left return { - shadow: getComputedStyle(line).boxShadow, + shadow: getComputedStyle(number).boxShadow, + markerColour: getComputedStyle(number).color, textColour: getComputedStyle(text).color, numberLeft: number.getBoundingClientRect().left - left, textLeft: text.getBoundingClientRect().left - left, @@ -418,7 +421,8 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { textLeft: text.getBoundingClientRect().left - left, } }) - expect(deletedRule.shadow).toContain(deletedRule.textColour) + expect(deletedRule.shadow).toContain(deletedRule.markerColour) + expect(deletedRule.textColour).not.toBe(deletedRule.markerColour) expect(deletedRule.numberLeft).toBeCloseTo(leftContextRule.numberLeft, 1) expect(deletedRule.textLeft).toBeCloseTo(leftContextRule.textLeft, 1) await compareTool.click() @@ -472,9 +476,15 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { const wrappedAdditionRule = await wrappedAddition.evaluate((cell) => { const text = cell.querySelector('[data-diff-code]') if (text === null) throw new Error('wrapped addition has no text') - return { shadow: getComputedStyle(cell).boxShadow, textColour: getComputedStyle(text).color } + const number = cell.firstElementChild! + return { + shadow: getComputedStyle(number).boxShadow, + markerColour: getComputedStyle(number).color, + textColour: getComputedStyle(text).color, + } }) - expect(wrappedAdditionRule.shadow).toContain(wrappedAdditionRule.textColour) + expect(wrappedAdditionRule.shadow).toContain(wrappedAdditionRule.markerColour) + expect(wrappedAdditionRule.textColour).not.toBe(wrappedAdditionRule.markerColour) // No desktop, so the tools offer the sidebar file but no native open. expect(await review.locator('[data-review-tool="open-file"]').count()).toBe(1) expect(await review.locator('[data-review-tool="open-native"]').count()).toBe(0) diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 890059d48e..ec9b6a2d71 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md -README.md: 7611e613073457c7843cc2a969e2f295fc204bbf -README.zh.md: 15162229dc4adc5c0143cd141481887a5c996be7 +README.md: c52db38df0c800f52de4eebb3a9692eabef6c9df +README.zh.md: 785c2215746e36ead16fe08b1a21b04a58ef418c diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 7611e61307..c52db38df0 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -42,7 +42,7 @@ The card renders the summary the Host serves for the turn's latest `workspace/ch ### The review tab -A row opens the turn's `changes-review` tab, addressed by the viewed Session and the announcing event's sequence and titled by the turn, on that row's file; another row of the same card reveals the same tab on its file. The header's file selector lists every recorded file with its counts and switches the comparison. A new tab starts in the side-by-side view without wrapping. Its controls switch between unified and side-by-side comparison, toggle line wrapping, open the whole current file in the Sidebar, and, with a Host desktop available, open it in the default application with the same pending and retryable-error states as the cards; the view and wrap choices are kept per tab. The tab reads the summary and each comparison once through the authenticated routes. A text comparison lists its hunks with the old and new line numbers of every line, and highlights each rendered hunk side independently with the code preview's filename grammar and Shiki token colours. Additions and deletions use aligned success or error markers and background tints. The tab also shows a note when the file was created or deleted in the turn, when both sides hold the same lines, when the Host's line comparison timed out and every line shows as replaced, or when the tab stopped drawing at 5,000 lines. A binary or oversized file, a comparison the Host no longer serves, and a failed read each show one line instead; a failed read offers Retry. The comparison is the turn's snapshot of the file, not its current content. +A row opens the turn's `changes-review` tab, addressed by the viewed Session and the announcing event's sequence and titled by the turn, on that row's file; another row of the same card reveals the same tab on its file. The header's file selector lists every recorded file with its counts and switches the comparison. A new tab starts in the side-by-side view without wrapping. Its controls switch between unified and side-by-side comparison, toggle line wrapping, open the whole current file in the Sidebar, and, with a Host desktop available, open it in the default application with the same pending and retryable-error states as the cards; the view and wrap choices are kept per tab. The tab reads the summary and each comparison once through the authenticated routes. A text comparison lists its hunks with the old and new line numbers of every line, and highlights each rendered hunk side independently with the code preview's filename grammar and Shiki token colours. Additions and deletions use separate code and line-number backgrounds in each theme, with coloured line numbers, signs, and aligned edge markers; plain code text keeps the primary text colour. The tab also shows a note when the file was created or deleted in the turn, when both sides hold the same lines, when the Host's line comparison timed out and every line shows as replaced, or when the tab stopped drawing at 5,000 lines. A binary or oversized file, a comparison the Host no longer serves, and a failed read each show one line instead; a failed read offers Retry. The comparison is the turn's snapshot of the file, not its current content. The selected-file title uses the shared `ui-primitives` [`PathLabel`](../ui-primitives/README.md#component-catalog): complete and left-aligned when it fits, otherwise clipped at the left with a fade that preserves trailing characters and the extension. Directories use subdued text, the filename uses primary text, and hovering reveals the complete displayed path. Resizing the pane or selecting another file updates the fade; the dropdown arrow, line counts, and toolbar actions retain their space. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 15162229dc..785c221574 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -42,7 +42,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present`,用于声明 ### review tab -行在该行的文件上打开本轮的 `changes-review` tab,其地址由当前查看的 Session 和宣告事件的序号组成,以轮号作标题;同一张卡片的另一行会在其文件上显示同一个 tab。头部的文件选择器列出所有记录的文件及其行数,用于切换对比。新 tab 默认使用不换行的左右对比。其控件用于切换单栏或左右对比、切换自动换行、在 Sidebar 中打开当前完整文件,并在 Host 有桌面时用默认应用打开文件;原生打开的等待与可重试错误状态与卡片一致,视图和换行选择按 tab 保留。tab 通过经过认证的路由各读取一次摘要和每个对比。文本对比逐个列出 hunk,每一行带旧侧和新侧的行号,并按代码预览的文件名 grammar 与 Shiki token 色分别高亮每个已渲染 hunk 的两侧。新增与删除使用对齐的成功色或错误色标记和背景色。文件是本轮新建或删除、两侧内容相同、Host 的逐行对比超时而按整文件替换显示,或 tab 在 5000 行处停止绘制时,tab 还会给出一行说明。二进制或过大的文件、Host 已不再提供的对比,以及读取失败各显示一行提示;读取失败时提供重试。对比是本轮对该文件的快照,不是它当前的内容。 +行在该行的文件上打开本轮的 `changes-review` tab,其地址由当前查看的 Session 和宣告事件的序号组成,以轮号作标题;同一张卡片的另一行会在其文件上显示同一个 tab。头部的文件选择器列出所有记录的文件及其行数,用于切换对比。新 tab 默认使用不换行的左右对比。其控件用于切换单栏或左右对比、切换自动换行、在 Sidebar 中打开当前完整文件,并在 Host 有桌面时用默认应用打开文件;原生打开的等待与可重试错误状态与卡片一致,视图和换行选择按 tab 保留。tab 通过经过认证的路由各读取一次摘要和每个对比。文本对比逐个列出 hunk,每一行带旧侧和新侧的行号,并按代码预览的文件名 grammar 与 Shiki token 色分别高亮每个已渲染 hunk 的两侧。新增与删除在各主题下使用独立的代码区与行号区底色,并为行号、增删符号和对齐的左侧色条着色;普通代码文字保留主文本色。文件是本轮新建或删除、两侧内容相同、Host 的逐行对比超时而按整文件替换显示,或 tab 在 5000 行处停止绘制时,tab 还会给出一行说明。二进制或过大的文件、Host 已不再提供的对比,以及读取失败各显示一行提示;读取失败时提供重试。对比是本轮对该文件的快照,不是它当前的内容。 当前文件标题使用 `ui-primitives` 的共享组件 [`PathLabel`](../ui-primitives/README.zh.md#component-catalog):空间足够时完整靠左显示,否则从左侧裁剪并渐隐,保留尾部字符和扩展名。目录使用弱化颜色,文件名使用主色,悬停显示完整路径。调整面板宽度或切换文件时更新渐隐;下拉箭头、行数和工具栏操作保留各自的空间。 diff --git a/packages/client/ui-deliverables/src/client/FileDiff.module.css b/packages/client/ui-deliverables/src/client/FileDiff.module.css index 21d18dad44..db25c09e87 100644 --- a/packages/client/ui-deliverables/src/client/FileDiff.module.css +++ b/packages/client/ui-deliverables/src/client/FileDiff.module.css @@ -1,6 +1,5 @@ /** Shared comparison layout, header, states, and numbered diff lines for previews and review tabs. */ -.root { --diff-add-fill: color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent); --diff-del-fill: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent); --diff-empty-fill: color-mix(in srgb, var(--dsw-alias-interactive-bg-hover) 50%, transparent); display: flex; flex-direction: column; box-sizing: border-box; width: 100%; min-height: 0; color: var(--dsw-alias-label-primary); } -:global(body[data-ds-dark-theme]) .root { --diff-add-fill: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent); --diff-del-fill: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent); } +.root { --diff-empty-fill: color-mix(in srgb, var(--dsw-alias-interactive-bg-hover) 50%, transparent); display: flex; flex-direction: column; box-sizing: border-box; width: 100%; min-height: 0; color: var(--dsw-alias-label-primary); } .header { display: flex; flex: 0 0 auto; gap: 6px; align-items: center; box-sizing: border-box; height: 38px; padding: 0 6px 0 8px; border-bottom: 0.5px solid var(--dsw-alias-border-l3); } .status { display: flex; gap: 12px; align-items: center; margin: 0; padding: 16px; font-size: 13px; color: var(--dsw-alias-label-secondary); } .body { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; overflow: auto; padding: 8px 0 16px; font: var(--dsw-font-markdown-code-block); } @@ -21,9 +20,10 @@ .text { padding-right: 16px; } .body[data-review-wrap] .line, .body[data-review-wrap] .splitLine { white-space: pre-wrap; } .body[data-review-wrap] .text { overflow-wrap: anywhere; } -.add { background: var(--diff-add-fill); box-shadow: inset 3px 0 0 var(--dsw-alias-state-success-primary); } -.add .sign, .add .text { color: var(--dsw-alias-state-success-primary); } -.del { background: var(--diff-del-fill); box-shadow: inset 3px 0 0 var(--dsw-alias-state-error-primary); } -.del .sign, .del .text { color: var(--dsw-alias-state-error-primary); } +.add { --diff-gutter-fill: var(--dsw-alias-file-diff-added-gutter); --diff-marker: var(--dsw-alias-file-diff-added-marker); background: var(--dsw-alias-file-diff-added-bg); } +.del { --diff-gutter-fill: var(--dsw-alias-file-diff-deleted-gutter); --diff-marker: var(--dsw-alias-file-diff-deleted-marker); background: var(--dsw-alias-file-diff-deleted-bg); } +.add .number, .del .number { background: var(--diff-gutter-fill); color: var(--diff-marker); } +.add .number:first-child, .del .number:first-child { box-shadow: inset 3px 0 0 var(--diff-marker); } +.add .sign, .del .sign { color: var(--diff-marker); } .context .text { color: var(--dsw-alias-label-secondary); } .empty { background: var(--diff-empty-fill); } diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index 397f847dab..046b137c88 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-theme/README.md -README.md: f8db4c3c353aa9b6854c05288316babd198475d7 -README.zh.md: 135878213908ea11093d267c361cf2f80b4d768a +README.md: 7248b7230afa28f817bec7c0eb26fd9512fff405 +README.zh.md: 6dc1db064dd9a6754f6c6475dde18ccf1a17841e diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index f8db4c3c35..7248b7230a 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -51,7 +51,7 @@ The service owns theme and font-size state and publishes snapshots. The ui-layou ### Stylesheets -`src/styles/` holds six sheets imported in order by ui-theme's dynamic client entry: `base.css`, `corner-shape.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. The client bundle compiles and injects them as plugin-owned global styles, so unload and HMR remove them with ui-theme. `scrollbar.css` consumes the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. Status marks use their own semantic state tokens. `design-platform.css` also owns the code-diff fill aliases and their static alpha palette entries; `shiki.css` owns syntax colors. +`src/styles/` holds six sheets imported in order by ui-theme's dynamic client entry: `base.css`, `corner-shape.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. The client bundle compiles and injects them as plugin-owned global styles, so unload and HMR remove them with ui-theme. `scrollbar.css` consumes the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. Status marks use their own semantic state tokens. `design-platform.css` also owns the code-diff fill aliases and their static alpha palette entries, plus the `--dsw-alias-file-diff-*` code, gutter, and marker palette for file comparisons; `shiki.css` owns syntax colors. `brand-font.css` exports the local Montserrat Light, Regular and Medium faces (normal style, weights 300, 400 and 500), with `montserrat-light.woff2`, `montserrat-regular.woff2`, `montserrat-medium.woff2` and its SIL Open Font License in `lib/styles/`. Desktop bundles the same stylesheet, font and license for offline welcome brand text; ordinary UI keeps its system font stack. diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index 1358782139..6dc1db064d 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -51,7 +51,7 @@ kind: "package-reference" ### 样式表 -`src/styles/` 下有六张样式表,由 ui-theme 的动态客户端 entry 依次导入:`base.css`、`corner-shape.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。客户端 bundle 将其编译并注入为插件持有的全局样式,因此卸载与 HMR(热模块替换)会随 ui-theme 一同移除。`scrollbar.css` 消费 `--dsw-alias-scrollbar-*` token,必须排在声明这些 token 的 `design-platform.css` 之后。状态标记使用各自的语义状态 token。`design-platform.css` 还负责代码差异底色的别名及其静态透明度色阶;`shiki.css` 负责语法颜色。 +`src/styles/` 下有六张样式表,由 ui-theme 的动态客户端 entry 依次导入:`base.css`、`corner-shape.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。客户端 bundle 将其编译并注入为插件持有的全局样式,因此卸载与 HMR(热模块替换)会随 ui-theme 一同移除。`scrollbar.css` 消费 `--dsw-alias-scrollbar-*` token,必须排在声明这些 token 的 `design-platform.css` 之后。状态标记使用各自的语义状态 token。`design-platform.css` 还负责代码差异底色的别名及其静态透明度色阶,以及文件对比所用的 `--dsw-alias-file-diff-*` 代码区、行号区和标记配色;`shiki.css` 负责语法颜色。 `brand-font.css` 导出本地 Montserrat Light、Regular 和 Medium 字体(正体、字重 300、400 和 500),`lib/styles/` 同时提供 `montserrat-light.woff2`、`montserrat-regular.woff2`、`montserrat-medium.woff2` 及其 SIL Open Font License。Desktop 将同一份样式表、字体和许可证打包,用于欢迎页品牌文字的离线显示;普通界面保留系统字体栈。 diff --git a/packages/client/ui-theme/src/styles/design-platform.css b/packages/client/ui-theme/src/styles/design-platform.css index 929b24be6c..8f447398fc 100644 --- a/packages/client/ui-theme/src/styles/design-platform.css +++ b/packages/client/ui-theme/src/styles/design-platform.css @@ -236,6 +236,12 @@ body { --dsw-alias-state-business-tertiary: var(--dsw-static-deepseek-100); --dsw-alias-code-diff-added: var(--dsw-static-green-500-a08); --dsw-alias-code-diff-deleted: var(--dsw-static-red-600-a08); + --dsw-alias-file-diff-added-bg: rgb(230, 244, 231); + --dsw-alias-file-diff-added-gutter: rgb(237, 247, 237); + --dsw-alias-file-diff-added-marker: rgb(1, 162, 65); + --dsw-alias-file-diff-deleted-bg: rgb(252, 230, 226); + --dsw-alias-file-diff-deleted-gutter: rgb(253, 236, 233); + --dsw-alias-file-diff-deleted-marker: rgb(186, 39, 35); --dsw-alias-state-error-primary: var(--dsw-static-red-600); --dsw-alias-state-error-secondary: var(--dsw-static-red-400); --dsw-alias-state-idle-primary: var(--dsw-static-neutral-300); @@ -336,6 +342,12 @@ body[data-ds-dark-theme] { --dsw-alias-state-business-tertiary: var(--dsw-static-deepseek-800); --dsw-alias-code-diff-added: var(--dsw-static-green-500-a12); --dsw-alias-code-diff-deleted: var(--dsw-static-red-400-a12); + --dsw-alias-file-diff-added-bg: rgb(31, 49, 36); + --dsw-alias-file-diff-added-gutter: rgb(19, 32, 22); + --dsw-alias-file-diff-added-marker: rgb(65, 201, 119); + --dsw-alias-file-diff-deleted-bg: rgb(60, 31, 27); + --dsw-alias-file-diff-deleted-gutter: rgb(40, 19, 14); + --dsw-alias-file-diff-deleted-marker: rgb(250, 66, 62); --dsw-alias-state-error-primary: var(--dsw-static-red-400); --dsw-alias-state-error-secondary: var(--dsw-static-red-400); --dsw-alias-state-idle-primary: var(--dsw-static-neutral-600); From b73031be9b5e7131228e628cf21bd4d7bbfdccb6 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Tue, 22 Sep 2026 20:27:45 +0800 Subject: [PATCH 040/192] test(native-command): pin the Explorer target for verbatim and device paths A verbatim `\\?\` prefix and a `\\.\` device namespace take Node's UNC resolution, and the comma encoding has to survive them. The drive-root case is pinned separately because Node restores a trailing separator against the host's `path.sep`, which differs on the Windows host this opener runs on. --- .../native-command/tests/path-opener.spec.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/util/native-command/tests/path-opener.spec.ts b/packages/util/native-command/tests/path-opener.spec.ts index 97167872ed..d4996539f2 100644 --- a/packages/util/native-command/tests/path-opener.spec.ts +++ b/packages/util/native-command/tests/path-opener.spec.ts @@ -91,12 +91,33 @@ describe('native path opener', () => { ['C:\\work\\my report.txt', 'file:///C:/work/my%20report.txt'], ['C:\\my files\\报告,#%.txt', 'file:///C:/my%20files/%E6%8A%A5%E5%91%8A%2C%23%25.txt'], ['\\\\server\\share\\a,b.txt', 'file://server/share/a%2Cb.txt'], + // Node resolves the path before encoding it, so a verbatim prefix is not + // part of the target: `\\?\` and `\\?\UNC\` arrive as the ordinary drive or + // UNC URI. A device-namespace path takes that same UNC handling; it names a + // device rather than a shell item, which this opener does not open. + ['\\\\?\\C:\\work\\a,b.txt', 'file:///C:/work/a%2Cb.txt'], + ['\\\\?\\UNC\\server\\share\\a,b.txt', 'file://server/share/a%2Cb.txt'], + ['\\\\.\\C:\\work\\a,b.txt', 'file://./C:/work/a%2Cb.txt'], + ['\\\\wsl$\\Ubuntu\\home\\t,est a.txt', 'file://wsl$/Ubuntu/home/t%2Cest%20a.txt'], ] as const)('opens %s through the one encoded Explorer target', async (path, target) => { const run = vi.fn(async () => ({ stdout: '', stderr: '' })) await openNativePath(path, signal(), { platform: 'win32', run }) expect(run).toHaveBeenCalledExactlyOnceWith('explorer.exe', [target], expect.any(AbortSignal)) }) + // Node restores a trailing separator against the host's `path.sep`, so a + // separator-terminated Windows path encodes differently on the Windows host + // this opener runs on than on the POSIX host a test run may use. + it('encodes a separator-terminated drive root as the hosting platform resolves it', async () => { + const run = vi.fn(async () => ({ stdout: '', stderr: '' })) + await openNativePath('C:\\', signal(), { platform: 'win32', run }) + expect(run).toHaveBeenCalledExactlyOnceWith( + 'explorer.exe', + [process.platform === 'win32' ? 'file:///C:/' : 'file:///C://'], + expect.any(AbortSignal), + ) + }) + it('encodes a directory target the same way it encodes a file target', async () => { const run = vi.fn(async () => ({ stdout: '', stderr: '' })) await openNativeAssociatedPath('C:\\work\\dir,comma', signal(), { platform: 'win32', run }) From a6b909c7675673e306fb3692971bd63910a1dc54 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Tue, 22 Sep 2026 20:27:46 +0800 Subject: [PATCH 041/192] docs(native-command): correct the non-interactive Explorer claim The recorded exit code 0 after about 30 seconds was not reproduced: an SSH session 0 on Windows 11 ARM64 build 26200 measured exit code 1 after about a second with no association invoked, the same exit code a delegated open returns. The README, both Agent Note languages, and the `explorerTarget` JSDoc now record that measurement, and the `Invoke-Item` contrast keeps its qualitative part without the timing. --- ...026-09-22-windows-open-through-shell-resolution.i18n.yaml | 4 ++-- .../2026-09-22-windows-open-through-shell-resolution.md | 2 +- .../2026-09-22-windows-open-through-shell-resolution.zh.md | 2 +- packages/util/native-command/README.i18n.yaml | 4 ++-- packages/util/native-command/README.md | 2 +- packages/util/native-command/README.zh.md | 2 +- packages/util/native-command/src/path-opener.ts | 5 ++++- 7 files changed, 12 insertions(+), 9 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.i18n.yaml index 5f9cf4bdf9..491f5edba4 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md -2026-09-22-windows-open-through-shell-resolution.md: dac6b089d8d69d40a4cd7c92f4f74ee06f304329 -2026-09-22-windows-open-through-shell-resolution.zh.md: 8d2a00c6769906b568f605451d2afe339aed6dcf +2026-09-22-windows-open-through-shell-resolution.md: 50caa066613ec372cbaef720e0bae575f800a162 +2026-09-22-windows-open-through-shell-resolution.zh.md: a7c7e4eeeb08e2c6e74fb0e24268ca4ea650d335 diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md index dac6b089d8..50caa06661 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md +++ b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md @@ -34,7 +34,7 @@ The settings sheet's open gesture and the artifact and workspace opens share one The opener can no longer distinguish "the shell opened it" from "the shell declined": Explorer reports 0 or 1 for both, so the Remote still answers `{ opened: true }`, and a machine with no handler shows the shell's chooser as its only feedback. Nothing here verifies that a window appeared. -An interactive desktop session is a precondition. In a non-interactive Windows session (a service, or a scheduled task without an interactive logon) `explorer.exe` returned exit code 0 after about 30 seconds without invoking any association, where an `Invoke-Item` command opened the file in about 0.35 seconds: a Host started that way loses the capability and pays the wait, and a caller's abort signal is what bounds it. No fallback for that session is implemented. +An interactive desktop session is a precondition. In a non-interactive Windows session (a service, or a scheduled task without an interactive logon) `explorer.exe` invokes no association and still exits 1, so that exit code does not tell this case apart from a delegated open: an SSH session 0 on Windows 11 ARM64 build 26200 measured exit code 1 after about a second, where an `Invoke-Item` command resolved the association in-process. A Host started that way loses the capability and pays the wait, and a caller's abort signal is what bounds it. No fallback for that session is implemented. Directories take the same path. The rejected alternative recorded in [the open-in-app note](../feature/2026-08-25-promote-open-anywhere-plugin.md) was a detached, credential-scrubbed `explorer.exe ` spawn, which is not what this handoff does: the `explorer.exe` child inherits the host environment and no detached process is created, while the application or folder window Explorer delegates to starts from the desktop session's environment rather than the host's. diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md index 8d2a00c676..a7c7e4eeeb 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md @@ -34,7 +34,7 @@ Windows 对 HTML 与 SVG 仍然无法命名浏览器,因此 `openInBrowser` 打开器再也无法区分「shell 打开了」与「shell 拒绝了」:两种情况 Explorer 都返回 0 或 1,因此 Remote 依旧回答 `{ opened: true }`,完全没有处理程序的机器只能看到 shell 的选择框。此处不校验窗口是否真的出现。 -交互桌面会话现在是前提。在非交互 Windows 会话(服务,或没有交互登录的计划任务)中,`explorer.exe` 在约 30 秒后以退出码 0 返回且不调用任何关联,而 `Invoke-Item` 命令在同一会话里约 0.35 秒就打开了文件:这样启动的 Host 既失去能力又要付等待代价,只有调用方的 abort 信号能给这段等待封顶。该会话下的兜底尚未实现。 +交互桌面会话现在是前提。在非交互 Windows 会话(服务,或没有交互登录的计划任务)中,`explorer.exe` 不调用任何关联,且同样以退出码 1 返回,因此该退出码无法与「已委派」区分:Windows 11 ARM64 build 26200 的 SSH session 0 实测约 1 秒后返回退出码 1,而同一会话里 `Invoke-Item` 命令能在进程内解析关联。这样启动的 Host 会失去能力且要付出等待,只有调用方的 abort 信号能给这段等待封顶。该会话下的兜底尚未实现。 目录走同一条路径。[open-in-app 记录](../feature/2026-08-25-promote-open-anywhere-plugin.zh.md)中被否决的方案是 detached 且经过凭据清洗的 `explorer.exe ` 派生进程,那不是本交接的做法:`explorer.exe` 子进程继承宿主环境,也不创建 detached 进程;而它转交给桌面会话启动的应用或文件夹窗口,继承的是桌面会话环境而不是宿主进程环境。 diff --git a/packages/util/native-command/README.i18n.yaml b/packages/util/native-command/README.i18n.yaml index d829a16ffc..3101109b93 100644 --- a/packages/util/native-command/README.i18n.yaml +++ b/packages/util/native-command/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/native-command/README.md -README.md: 72c9d0097975773d316e3f2fdba87da1eca4daa4 -README.zh.md: 35770a99756a9eb218b024406690b340c09bfd84 +README.md: 537101a2e1a5e60c1ea1c25d0700ae26d3f4df43 +README.zh.md: f7783a503a84baf0e78572b07794e17d6100e082 diff --git a/packages/util/native-command/README.md b/packages/util/native-command/README.md index 72c9d00979..537101a2e1 100644 --- a/packages/util/native-command/README.md +++ b/packages/util/native-command/README.md @@ -109,7 +109,7 @@ Linux association discovery and explicit launching require GIO. Missing native c These limits define when this runner is not the right tool. They are current package constraints, not a task backlog. - Command output uses Node’s `execFile` buffer limit; oversized replies reject. Use the subprocess capability for streaming output. -- A Windows host needs an interactive desktop session. In a non-interactive session — a service, or a scheduled task without an interactive logon — `explorer.exe` returns exit code 0 after about 30 seconds without invoking any file association, so an open reports success having opened nothing; a caller's abort signal turns that wait into a timeout rather than a hang. An `Invoke-Item` command resolves the association in-process there in about 0.35 seconds, and its directory failure arrives as a non-zero exit code. No fallback for that session is implemented. +- A Windows host needs an interactive desktop session. In a non-interactive session — a service, or a scheduled task without an interactive logon — `explorer.exe` invokes no file association and still exits 1 after about a second (an SSH session 0 on Windows 11 ARM64 build 26200), so an open reports success having opened nothing; a caller's abort signal turns that wait into a timeout rather than a hang. An `Invoke-Item` command resolves the association in-process there, and its directory failure arrives as a non-zero exit code. No fallback for that session is implemented. ### Dev Note diff --git a/packages/util/native-command/README.zh.md b/packages/util/native-command/README.zh.md index 35770a9975..f7783a503a 100644 --- a/packages/util/native-command/README.zh.md +++ b/packages/util/native-command/README.zh.md @@ -109,7 +109,7 @@ Linux 的关联查询和指定应用启动需要 GIO。缺少原生命令时查 这些限制说明本运行器何时不是合适的工具。它们是当前包约束,不是任务积压。 - 命令输出受 Node 的 `execFile` 缓冲上限约束,超过上限时拒绝结果。流式输出由 subprocess 能力处理。 -- Windows 主机需要交互桌面会话。在非交互会话(服务,或没有交互登录的计划任务)中,`explorer.exe` 会在约 30 秒后以退出码 0 返回且不调用任何文件关联,于是「打开」会报告成功却什么都没打开;调用方的 abort 信号把这段等待变成超时而不是挂死。同一目标改用 `Invoke-Item` 命令时,该会话内约 0.35 秒即可在进程内解析关联,目录失败也会以非零退出码报出。该会话下的兜底尚未实现。 +- Windows 主机需要交互桌面会话。在非交互会话(服务,或没有交互登录的计划任务)中,`explorer.exe` 不调用任何文件关联,且仍在约 1 秒后以退出码 1 返回(Windows 11 ARM64 build 26200 的 SSH session 0 实测),于是「打开」会报告成功却什么都没打开;调用方的 abort 信号把这段等待变成超时而不是挂死。同一目标改用 `Invoke-Item` 命令时可在该会话内解析关联,目录失败也会以非零退出码报出。该会话下的兜底尚未实现。 ### 开发备注 diff --git a/packages/util/native-command/src/path-opener.ts b/packages/util/native-command/src/path-opener.ts index e4341f8567..303cfc41e2 100644 --- a/packages/util/native-command/src/path-opener.ts +++ b/packages/util/native-command/src/path-opener.ts @@ -104,7 +104,10 @@ function isWsl(internals: PathOpenerInternals): boolean { * so a path handed over raw loses everything after its first comma and the * shell opens a different target without reporting it. A file URI has `,` * percent-encoded and keeps whitespace, and Explorer resolves it to the shell - * item the path names. + * item the path names. Node resolves the path before encoding it, so a verbatim + * `\\?\` or `\\?\UNC\` prefix reaches Explorer as the ordinary drive or UNC + * URI; a `\\.\` device path keeps that same UNC handling and names a device + * rather than a shell item, which this opener does not open. * @param windowsPath - path already translated for the Windows desktop. * @returns the target for an open, or the object of a `/select,` reveal. */ From 91423ea1b44c3332b574f633f9e8f227dbbf4036 Mon Sep 17 00:00:00 2001 From: Xinyu Yang Date: Tue, 22 Sep 2026 15:16:57 +0800 Subject: [PATCH 042/192] revert: restore configurable Web and desktop shortcuts Restore the feature removed by #4886 on current master. Preserve the current settings entry point, moving-menu behavior, and desktop update UI. --- ...evice-local-shortcut-preferences.i18n.yaml | 6 + ...09-20-device-local-shortcut-preferences.md | 27 + ...20-device-local-shortcut-preferences.zh.md | 27 + ...-21-desktop-page-close-shortcuts.i18n.yaml | 6 + ...2026-09-21-desktop-page-close-shortcuts.md | 27 + ...6-09-21-desktop-page-close-shortcuts.zh.md | 27 + .../2026-09-16-desktop-window-menus.i18n.yaml | 4 +- .../2026-09-16-desktop-window-menus.md | 8 +- .../2026-09-16-desktop-window-menus.zh.md | 8 +- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 9 +- apps/desktop/README.zh.md | 13 +- apps/desktop/package.json | 4 +- apps/desktop/scripts/dev.ts | 3 +- apps/desktop/src/browser-guests.ts | 8 +- apps/desktop/src/ipc.ts | 9 + apps/desktop/src/keybindings.ts | 27 + apps/desktop/src/keyboard.ts | 253 +++++++ apps/desktop/src/locale.ts | 4 + apps/desktop/src/main.ts | 25 +- apps/desktop/src/preload-app.ts | 34 +- apps/desktop/tests/keybindings.spec.ts | 35 + apps/desktop/tests/keyboard.spec.ts | 679 ++++++++++++++++++ apps/desktop/tests/main-startup.spec.ts | 13 +- apps/desktop/tests/preload-app.spec.ts | 97 ++- apps/desktop/tests/welcome-startup.spec.ts | 2 +- apps/desktop/tsconfig.host.json | 6 + apps/web/tests/approval-composer.e2e.ts | 8 +- apps/web/tests/composer-shortcuts.e2e.ts | 48 ++ .../desktop-locale/automatic.expected.md | 3 + .../desktop-locale/selected.expected.md | 3 + .../settings-chrome/dialog-en.expected.md | 3 + .../dialog-no-browser.expected.md | 3 + .../settings-chrome/dialog.expected.md | 3 + .../shortcuts-desktop/chord.expected.md | 5 + .../fixed-conflict.expected.md | 5 + .../macos-copy-override.expected.md | 5 + .../modal-layers.expected.md | 12 + .../shortcuts-desktop/recording.expected.md | 4 + .../windows-copy-override.expected.md | 5 + .../shortcuts-panels/browser.expected.md | 18 + .../shortcuts-panels/guide-close.expected.md | 9 + .../shortcuts-panels/page-actions.expected.md | 23 + .../shortcuts-panels/panels.expected.md | 25 + .../shortcuts-panels/tab-menu.expected.md | 2 + .../fork-unavailable.expected.md | 1 + .../shortcuts-workspace/fork.expected.md | 9 + .../macos-defaults.expected.md | 132 ++++ .../session-menu.expected.md | 5 + .../expected/shortcuts/edit-stale.expected.md | 5 + .../shortcuts/en-US-conflict.expected.md | 1 + .../shortcuts/en-US-empty.expected.md | 10 + .../shortcuts/en-US-inline.expected.md | 5 + .../shortcuts/en-US-reset-confirm.expected.md | 6 + .../shortcuts/en-US-saved.expected.md | 1 + .../expected/shortcuts/en-US.expected.md | 133 ++++ .../shortcuts/zh-CN-conflict.expected.md | 1 + .../shortcuts/zh-CN-empty.expected.md | 10 + .../shortcuts/zh-CN-inline.expected.md | 5 + .../shortcuts/zh-CN-reset-confirm.expected.md | 6 + .../shortcuts/zh-CN-saved.expected.md | 1 + .../expected/shortcuts/zh-CN.expected.md | 132 ++++ apps/web/tests/live-interactions.e2e.ts | 2 +- apps/web/tests/queue-actions.e2e.ts | 11 +- apps/web/tests/sessionless-header.e2e.ts | 11 +- apps/web/tests/shortcuts-desktop.e2e.ts | 258 +++++++ apps/web/tests/shortcuts-fixed.e2e.ts | 65 ++ apps/web/tests/shortcuts-panels.e2e.ts | 254 +++++++ apps/web/tests/shortcuts-workspace.e2e.ts | 311 ++++++++ apps/web/tests/shortcuts.e2e.ts | 336 +++++++++ apps/web/tests/sidebar-right.e2e.ts | 10 +- apps/web/tsconfig.json | 10 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 15 + docs/config-catalog.zh.md | 15 + docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 4 + docs/module-graph.zh.md | 4 + docs/subsystems/sidebar-right.i18n.yaml | 4 +- docs/subsystems/sidebar-right.md | 4 + docs/subsystems/sidebar-right.zh.md | 4 + docs/subsystems/web-client.i18n.yaml | 4 +- docs/subsystems/web-client.md | 2 + docs/subsystems/web-client.zh.md | 2 + packages/bundle/web-app/cordis.patch.yml | 6 + packages/bundle/web-app/package.json | 4 +- packages/client/README.i18n.yaml | 4 +- packages/client/README.md | 2 + packages/client/README.zh.md | 2 + packages/client/shortcuts/README.i18n.yaml | 6 + packages/client/shortcuts/README.md | 92 +++ packages/client/shortcuts/README.zh.md | 92 +++ packages/client/shortcuts/package.json | 67 ++ packages/client/shortcuts/src/binding.ts | 112 +++ packages/client/shortcuts/src/client/dom.ts | 118 +++ packages/client/shortcuts/src/client/index.ts | 152 ++++ .../client/shortcuts/src/client/native.ts | 44 ++ .../client/shortcuts/src/client/registry.ts | 180 +++++ .../client/shortcuts/src/client/storage.ts | 40 ++ packages/client/shortcuts/src/client/types.ts | 130 ++++ packages/client/shortcuts/src/config.ts | 13 + .../client/shortcuts/src/configuration.ts | 244 +++++++ packages/client/shortcuts/src/index.ts | 20 + packages/client/shortcuts/src/native.ts | 29 + packages/client/shortcuts/src/persistence.ts | 144 ++++ packages/client/shortcuts/src/protocol.ts | 5 + .../shortcuts/tests/config.host.spec.ts | 17 + .../tests/configuration.client.spec.ts | 174 +++++ .../tests/desktop-chords.client.spec.ts | 119 +++ .../client/shortcuts/tests/dom.client.spec.ts | 201 ++++++ .../tests/fixed-input.client.spec.ts | 150 ++++ .../shortcuts/tests/native.client.spec.ts | 167 +++++ .../tests/persistence.client.spec.ts | 144 ++++ .../shortcuts/tests/registry.client.spec.ts | 205 ++++++ .../shortcuts/tests/service.client.spec.ts | 182 +++++ .../shortcuts/tests/storage.client.spec.ts | 57 ++ .../client/shortcuts/tsconfig.client.json | 37 + packages/client/shortcuts/tsconfig.host.json | 37 + packages/client/shortcuts/tsconfig.json | 11 + packages/client/shortcuts/tsdown.config.ts | 3 + packages/client/ui-approval/README.i18n.yaml | 4 +- packages/client/ui-approval/README.md | 2 + packages/client/ui-approval/README.zh.md | 2 + packages/client/ui-approval/package.json | 3 +- .../ui-approval/src/client/ApprovalPanel.tsx | 37 +- .../ui-approval/src/client/contract/slots.ts | 8 + .../client/ui-approval/src/client/index.ts | 10 + .../tests/ui-approval.client.spec.tsx | 115 ++- packages/client/ui-approval/tsconfig.json | 3 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 8 +- packages/client/ui-conversation/README.zh.md | 8 +- packages/client/ui-conversation/package.json | 3 +- .../ui-conversation/src/client/apply.ts | 46 +- .../src/client/contract/slots.ts | 2 + .../src/client/conversation/assembler.ts | 8 +- .../src/client/conversation/assembly.ts | 8 +- .../src/client/input/editor/keymap.ts | 9 +- .../src/client/input/editor/runtime.ts | 18 +- .../ui-conversation/src/client/locales.ts | 10 + .../client/skeleton/ConversationContent.tsx | 4 +- .../src/client/skeleton/InputBar.tsx | 8 +- .../src/client/stop-sequence.ts | 52 ++ .../src/client/stop-shortcut.ts | 68 ++ .../conversation-registry.client.spec.ts | 33 + .../tests/editor-focus.client.spec.ts | 152 ++++ .../tests/input-bar.client.spec.tsx | 29 +- .../tests/input-matrix.client.spec.tsx | 1 + .../tests/input-scenarios.client.spec.tsx | 1 + .../tests/keymap-routing.client.spec.tsx | 44 +- .../tests/skeleton.client.spec.tsx | 1 + .../tests/stop-sequence.client.spec.ts | 71 ++ .../tests/stop-shortcut.client.spec.ts | 210 ++++++ .../ui-conversation/tsconfig.client.json | 3 + packages/client/ui-dockkit/README.i18n.yaml | 4 +- packages/client/ui-dockkit/README.md | 6 + packages/client/ui-dockkit/README.zh.md | 6 + .../ui-dockkit/src/components/FloatLayer.tsx | 5 +- .../ui-dockkit/src/components/TabLayout.tsx | 2 +- .../ui-dockkit/src/components/TabMenu.tsx | 25 +- .../ui-dockkit/src/components/TabPanel.tsx | 72 +- .../client/ui-dockkit/src/contract/adapter.ts | 8 + .../tests/components.client.spec.tsx | 76 +- packages/client/ui-layout/README.i18n.yaml | 4 +- packages/client/ui-layout/README.md | 4 +- packages/client/ui-layout/README.zh.md | 4 +- packages/client/ui-layout/package.json | 6 +- packages/client/ui-layout/src/client/index.ts | 31 +- .../client/ui-layout/src/client/service.ts | 6 +- .../ui-layout/src/client/shortcut-locales.ts | 4 + .../ui-layout/tests/apply.client.spec.ts | 5 +- .../ui-layout/tests/service.client.spec.ts | 15 +- packages/client/ui-layout/tsconfig.json | 3 + .../client/ui-open-in-app/README.i18n.yaml | 4 +- packages/client/ui-open-in-app/README.md | 4 +- packages/client/ui-open-in-app/README.zh.md | 4 +- packages/client/ui-open-in-app/package.json | 6 +- .../src/client/OpenInAppAction.tsx | 51 +- .../src/client/OpenTargetButton.tsx | 5 +- .../ui-open-in-app/src/client/applications.ts | 40 ++ .../ui-open-in-app/src/client/controller.ts | 44 +- .../client/ui-open-in-app/src/client/index.ts | 37 +- .../ui-open-in-app/src/client/locales.ts | 8 + .../tests/browser-plugin.client.spec.ts | 128 +++- .../tests/controller.client.spec.ts | 30 + .../tests/open-in-app-action.client.spec.tsx | 24 +- packages/client/ui-open-in-app/tsconfig.json | 6 + .../tests/browser-plugin.client.spec.ts | 2 + .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 4 +- packages/client/ui-primitives/README.zh.md | 4 +- .../client/ui-primitives/src/HoverCard.tsx | 14 +- .../client/ui-primitives/src/Menu.module.css | 13 + packages/client/ui-primitives/src/Menu.tsx | 33 +- .../client/ui-primitives/src/Modal.module.css | 1 + packages/client/ui-primitives/src/Modal.tsx | 21 +- .../ui-primitives/src/ShortcutKeys.module.css | 16 + .../client/ui-primitives/src/ShortcutKeys.tsx | 12 + .../client/ui-primitives/src/Toast.module.css | 2 +- packages/client/ui-primitives/src/Tooltip.tsx | 7 +- packages/client/ui-primitives/src/index.ts | 3 + .../ui-primitives/src/keyboard-composition.ts | 36 + .../client/ui-primitives/src/useModalLayer.ts | 77 ++ .../ui-primitives/tests/atoms.client.spec.tsx | 9 +- .../tests/hover-card.client.spec.tsx | 43 +- .../tests/modal-layer.client.spec.tsx | 125 ++++ .../ui-settings-account/README.i18n.yaml | 4 +- packages/client/ui-settings-account/README.md | 2 +- .../client/ui-settings-account/README.zh.md | 2 +- .../src/client/AccountMenu.tsx | 12 +- .../tests/account.client.spec.tsx | 29 + .../ui-settings-general/README.i18n.yaml | 4 +- packages/client/ui-settings-general/README.md | 2 + .../client/ui-settings-general/README.zh.md | 2 + .../client/ui-settings-general/package.json | 8 +- .../src/client/SettingsRoot.tsx | 83 +-- .../ui-settings-general/src/client/index.ts | 53 +- .../ui-settings-general/src/client/locales.ts | 4 + .../src/client/shell-contract.ts | 10 +- .../src/client/shell-store.ts | 26 + .../tests/apply.client.spec.ts | 2 +- .../tests/settings-root.client.spec.tsx | 56 +- .../tests/shell.client.spec.ts | 2 +- .../ui-settings-general/tsconfig.client.json | 3 +- packages/client/ui-settings/README.i18n.yaml | 4 +- packages/client/ui-settings/README.md | 2 +- packages/client/ui-settings/README.zh.md | 2 +- .../ui-settings/src/client/contract/slots.ts | 2 + packages/client/ui-shortcuts/README.i18n.yaml | 6 + packages/client/ui-shortcuts/README.md | 85 +++ packages/client/ui-shortcuts/README.zh.md | 85 +++ packages/client/ui-shortcuts/package.json | 68 ++ .../client/ui-shortcuts/src/client/Editor.tsx | 178 +++++ .../client/ui-shortcuts/src/client/Icons.tsx | 17 + .../src/client/Reference.module.css | 207 ++++++ .../ui-shortcuts/src/client/Reference.tsx | 196 +++++ .../ui-shortcuts/src/client/feedback.ts | 32 + .../client/ui-shortcuts/src/client/fixed.ts | 17 + .../client/ui-shortcuts/src/client/index.ts | 65 ++ .../client/ui-shortcuts/src/client/locales.ts | 70 ++ .../client/ui-shortcuts/src/client/store.ts | 20 + .../client/ui-shortcuts/src/css-modules.d.ts | 6 + packages/client/ui-shortcuts/src/index.ts | 4 + .../tests/commands.client.spec.ts | 97 +++ .../ui-shortcuts/tests/editor.client.spec.tsx | 563 +++++++++++++++ .../tests/reference.client.spec.tsx | 451 ++++++++++++ packages/client/ui-shortcuts/tsconfig.json | 42 ++ packages/client/ui-shortcuts/tsdown.config.ts | 3 + .../ui-sidebar-browser/README.i18n.yaml | 4 +- packages/client/ui-sidebar-browser/README.md | 4 + .../client/ui-sidebar-browser/README.zh.md | 4 + .../client/ui-sidebar-browser/package.json | 1 + .../src/client/definition.tsx | 3 +- .../electron/ElectronWebviewPresentation.ts | 1 + .../ui-sidebar-browser/src/client/index.ts | 22 + .../ui-sidebar-browser/src/client/locales.ts | 4 + .../src/client/view/BrowserBody.tsx | 9 +- .../tests/apply.client.spec.ts | 40 +- .../tests/browser-body.client.spec.tsx | 14 +- .../tests/electron-lifecycle.client.spec.ts | 1 + .../ui-sidebar-browser/tsconfig.client.json | 1 + .../README.i18n.yaml | 4 +- .../ui-sidebar-documentpreview/README.md | 4 + .../ui-sidebar-documentpreview/README.zh.md | 4 + .../src/client/TextPreview.tsx | 7 +- .../src/client/html/HtmlBody.tsx | 14 +- .../src/client/locales.ts | 2 + .../tests/document-seat.client.spec.tsx | 2 + .../tests/fixtures.client.ts | 2 +- .../tests/html-body.client.spec.tsx | 4 +- .../tests/text-preview.client.spec.tsx | 10 + .../client/ui-sidebar-files/README.i18n.yaml | 4 +- packages/client/ui-sidebar-files/README.md | 4 + packages/client/ui-sidebar-files/README.zh.md | 4 + packages/client/ui-sidebar-files/package.json | 6 +- .../ui-sidebar-files/src/client/FilesBody.tsx | 25 +- .../src/client/definition.tsx | 2 + .../ui-sidebar-files/src/client/index.ts | 24 +- .../ui-sidebar-files/src/client/locales.ts | 4 + .../tests/apply.client.spec.ts | 25 +- .../tests/files-body.client.spec.tsx | 16 +- .../ui-sidebar-files/tests/mount.client.tsx | 11 +- .../client/ui-sidebar-files/tsconfig.json | 3 + .../client/ui-sidebar-right/README.i18n.yaml | 4 +- packages/client/ui-sidebar-right/README.md | 14 +- packages/client/ui-sidebar-right/README.zh.md | 14 +- packages/client/ui-sidebar-right/package.json | 7 +- .../src/client/contract/slots.ts | 20 +- .../ui-sidebar-right/src/client/focus.ts | 137 ++++ .../ui-sidebar-right/src/client/index.ts | 28 +- .../ui-sidebar-right/src/client/labels.ts | 14 +- .../ui-sidebar-right/src/client/locales.ts | 28 + .../ui-sidebar-right/src/client/service.ts | 142 +++- .../src/client/shell/ExpandButton.tsx | 9 +- .../src/client/shell/SidebarRight.module.css | 9 + .../src/client/shell/SidebarRight.tsx | 73 +- .../src/client/shell/close-focus.ts | 35 + .../ui-sidebar-right/src/client/shortcuts.ts | 95 +++ .../ui-sidebar-right/src/client/tab-domain.ts | 26 +- .../ui-sidebar-right/src/client/tab-info.ts | 7 +- .../src/client/tab-registry.ts | 3 + .../client/tabs/guide/GuideBody.module.css | 3 +- .../src/client/tabs/guide/GuideBody.tsx | 17 +- .../tests/apply.client.spec.ts | 55 +- .../tests/close-focus.client.spec.ts | 72 ++ .../tests/expand-button.client.spec.tsx | 33 +- .../tests/focus.client.spec.ts | 591 +++++++++++++++ .../tests/guide-body.client.spec.tsx | 47 +- .../tests/seat.client.spec.tsx | 149 +++- .../tests/service.client.spec.ts | 15 +- .../tests/tab-info.client.spec.tsx | 2 +- .../client/ui-sidebar-right/tsconfig.json | 6 + .../ui-sidebar-terminal/README.i18n.yaml | 4 +- packages/client/ui-sidebar-terminal/README.md | 2 + .../client/ui-sidebar-terminal/README.zh.md | 2 + .../client/ui-sidebar-terminal/package.json | 6 +- .../src/client/TerminalGuide.module.css | 3 +- .../src/client/TerminalGuide.tsx | 28 +- .../ui-sidebar-terminal/src/client/index.ts | 20 +- .../ui-sidebar-terminal/src/client/locales.ts | 2 + .../tests/apply.client.spec.ts | 28 +- .../tests/terminal-guide.client.spec.tsx | 65 +- .../client/ui-sidebar-terminal/tsconfig.json | 3 + packages/client/ui-sidebar/README.i18n.yaml | 4 +- packages/client/ui-sidebar/README.md | 4 +- packages/client/ui-sidebar/README.zh.md | 4 +- packages/client/ui-sidebar/package.json | 6 +- .../src/client/HeaderLeadingControls.tsx | 11 +- .../ui-sidebar/src/client/SidebarRoot.tsx | 32 +- .../ui-sidebar/src/client/contract/slots.ts | 3 +- .../client/ui-sidebar/src/client/index.ts | 4 +- .../client/ui-sidebar/src/client/locales.ts | 2 + .../ui-sidebar/tests/apply.client.spec.tsx | 4 +- .../tests/panel-list.client.spec.tsx | 3 + .../tests/pointer-scrollbars.client.spec.tsx | 2 +- .../tests/sidebar-root.client.spec.tsx | 54 +- .../tests/sidebar-snapshot.client.spec.tsx | 2 + packages/client/ui-sidebar/tsconfig.json | 3 + packages/client/ui-theme/README.i18n.yaml | 4 +- packages/client/ui-theme/README.md | 2 + packages/client/ui-theme/README.zh.md | 2 + .../ui-theme/src/styles/design-platform.css | 2 + .../ui-trajectory/tests/views.client.spec.tsx | 1 + packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 + packages/client/ui-workspace/README.zh.md | 2 + packages/client/ui-workspace/package.json | 4 +- .../src/client/WorkspacePicker.tsx | 4 + .../ui-workspace/src/client/contract/slots.ts | 19 +- .../client/ui-workspace/src/client/index.ts | 24 +- .../client/ui-workspace/src/client/locales.ts | 12 + .../ui-workspace/src/client/rows/Rows.tsx | 7 +- .../src/client/rows/WorkspaceBrowser.tsx | 59 +- .../client/session-actions/ArchiveSession.tsx | 4 +- .../client/session-actions/ForkSession.tsx | 6 +- .../client/session-actions/RenameSession.tsx | 4 +- .../ui-workspace/src/client/shortcuts.ts | 122 ++++ .../ui-workspace/tests/apply.client.spec.ts | 7 +- .../tests/host-home-staleness.client.spec.tsx | 5 +- .../tests/rename-assembly.client.spec.tsx | 4 +- .../ui-workspace/tests/rows.client.spec.tsx | 62 +- .../tests/session-actions.client.spec.tsx | 34 +- .../tests/shortcuts.client.spec.ts | 170 +++++ .../tests/workspace-browser.client.spec.tsx | 29 +- .../tests/workspaces-service.client.spec.ts | 3 +- packages/client/ui-workspace/tsconfig.json | 3 + .../src/client/slot-catalog.ts | 50 +- .../references/packages.md | 2 + pnpm-lock.yaml | 133 ++++ scripts/check-workspace-constraints.spec.ts | 13 + scripts/check-workspace-constraints.ts | 2 + scripts/gen-cordis-catalog.ts | 1 + scripts/no-unknown-casts.baseline.json | 9 - .../verify-package-readme-model-experience.ts | 2 + tsconfig.base.json | 3 + tsconfig.client.json | 2 + tsconfig.host.json | 14 +- 380 files changed, 13421 insertions(+), 634 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.md create mode 100644 .agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.zh.md create mode 100644 .agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.md create mode 100644 .agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.zh.md create mode 100644 apps/desktop/src/keybindings.ts create mode 100644 apps/desktop/src/keyboard.ts create mode 100644 apps/desktop/tests/keybindings.spec.ts create mode 100644 apps/desktop/tests/keyboard.spec.ts create mode 100644 apps/web/tests/composer-shortcuts.e2e.ts create mode 100644 apps/web/tests/expected/shortcuts-desktop/chord.expected.md create mode 100644 apps/web/tests/expected/shortcuts-desktop/fixed-conflict.expected.md create mode 100644 apps/web/tests/expected/shortcuts-desktop/macos-copy-override.expected.md create mode 100644 apps/web/tests/expected/shortcuts-desktop/modal-layers.expected.md create mode 100644 apps/web/tests/expected/shortcuts-desktop/recording.expected.md create mode 100644 apps/web/tests/expected/shortcuts-desktop/windows-copy-override.expected.md create mode 100644 apps/web/tests/expected/shortcuts-panels/browser.expected.md create mode 100644 apps/web/tests/expected/shortcuts-panels/guide-close.expected.md create mode 100644 apps/web/tests/expected/shortcuts-panels/page-actions.expected.md create mode 100644 apps/web/tests/expected/shortcuts-panels/panels.expected.md create mode 100644 apps/web/tests/expected/shortcuts-panels/tab-menu.expected.md create mode 100644 apps/web/tests/expected/shortcuts-workspace/fork-unavailable.expected.md create mode 100644 apps/web/tests/expected/shortcuts-workspace/fork.expected.md create mode 100644 apps/web/tests/expected/shortcuts-workspace/macos-defaults.expected.md create mode 100644 apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md create mode 100644 apps/web/tests/expected/shortcuts/edit-stale.expected.md create mode 100644 apps/web/tests/expected/shortcuts/en-US-conflict.expected.md create mode 100644 apps/web/tests/expected/shortcuts/en-US-empty.expected.md create mode 100644 apps/web/tests/expected/shortcuts/en-US-inline.expected.md create mode 100644 apps/web/tests/expected/shortcuts/en-US-reset-confirm.expected.md create mode 100644 apps/web/tests/expected/shortcuts/en-US-saved.expected.md create mode 100644 apps/web/tests/expected/shortcuts/en-US.expected.md create mode 100644 apps/web/tests/expected/shortcuts/zh-CN-conflict.expected.md create mode 100644 apps/web/tests/expected/shortcuts/zh-CN-empty.expected.md create mode 100644 apps/web/tests/expected/shortcuts/zh-CN-inline.expected.md create mode 100644 apps/web/tests/expected/shortcuts/zh-CN-reset-confirm.expected.md create mode 100644 apps/web/tests/expected/shortcuts/zh-CN-saved.expected.md create mode 100644 apps/web/tests/expected/shortcuts/zh-CN.expected.md create mode 100644 apps/web/tests/shortcuts-desktop.e2e.ts create mode 100644 apps/web/tests/shortcuts-fixed.e2e.ts create mode 100644 apps/web/tests/shortcuts-panels.e2e.ts create mode 100644 apps/web/tests/shortcuts-workspace.e2e.ts create mode 100644 apps/web/tests/shortcuts.e2e.ts create mode 100644 packages/client/shortcuts/README.i18n.yaml create mode 100644 packages/client/shortcuts/README.md create mode 100644 packages/client/shortcuts/README.zh.md create mode 100644 packages/client/shortcuts/package.json create mode 100644 packages/client/shortcuts/src/binding.ts create mode 100644 packages/client/shortcuts/src/client/dom.ts create mode 100644 packages/client/shortcuts/src/client/index.ts create mode 100644 packages/client/shortcuts/src/client/native.ts create mode 100644 packages/client/shortcuts/src/client/registry.ts create mode 100644 packages/client/shortcuts/src/client/storage.ts create mode 100644 packages/client/shortcuts/src/client/types.ts create mode 100644 packages/client/shortcuts/src/config.ts create mode 100644 packages/client/shortcuts/src/configuration.ts create mode 100644 packages/client/shortcuts/src/index.ts create mode 100644 packages/client/shortcuts/src/native.ts create mode 100644 packages/client/shortcuts/src/persistence.ts create mode 100644 packages/client/shortcuts/src/protocol.ts create mode 100644 packages/client/shortcuts/tests/config.host.spec.ts create mode 100644 packages/client/shortcuts/tests/configuration.client.spec.ts create mode 100644 packages/client/shortcuts/tests/desktop-chords.client.spec.ts create mode 100644 packages/client/shortcuts/tests/dom.client.spec.ts create mode 100644 packages/client/shortcuts/tests/fixed-input.client.spec.ts create mode 100644 packages/client/shortcuts/tests/native.client.spec.ts create mode 100644 packages/client/shortcuts/tests/persistence.client.spec.ts create mode 100644 packages/client/shortcuts/tests/registry.client.spec.ts create mode 100644 packages/client/shortcuts/tests/service.client.spec.ts create mode 100644 packages/client/shortcuts/tests/storage.client.spec.ts create mode 100644 packages/client/shortcuts/tsconfig.client.json create mode 100644 packages/client/shortcuts/tsconfig.host.json create mode 100644 packages/client/shortcuts/tsconfig.json create mode 100644 packages/client/shortcuts/tsdown.config.ts create mode 100644 packages/client/ui-conversation/src/client/stop-sequence.ts create mode 100644 packages/client/ui-conversation/src/client/stop-shortcut.ts create mode 100644 packages/client/ui-conversation/tests/editor-focus.client.spec.ts create mode 100644 packages/client/ui-conversation/tests/stop-sequence.client.spec.ts create mode 100644 packages/client/ui-conversation/tests/stop-shortcut.client.spec.ts create mode 100644 packages/client/ui-layout/src/client/shortcut-locales.ts create mode 100644 packages/client/ui-open-in-app/src/client/applications.ts create mode 100644 packages/client/ui-primitives/src/ShortcutKeys.module.css create mode 100644 packages/client/ui-primitives/src/ShortcutKeys.tsx create mode 100644 packages/client/ui-primitives/src/keyboard-composition.ts create mode 100644 packages/client/ui-primitives/src/useModalLayer.ts create mode 100644 packages/client/ui-primitives/tests/modal-layer.client.spec.tsx create mode 100644 packages/client/ui-settings-general/src/client/shell-store.ts create mode 100644 packages/client/ui-shortcuts/README.i18n.yaml create mode 100644 packages/client/ui-shortcuts/README.md create mode 100644 packages/client/ui-shortcuts/README.zh.md create mode 100644 packages/client/ui-shortcuts/package.json create mode 100644 packages/client/ui-shortcuts/src/client/Editor.tsx create mode 100644 packages/client/ui-shortcuts/src/client/Icons.tsx create mode 100644 packages/client/ui-shortcuts/src/client/Reference.module.css create mode 100644 packages/client/ui-shortcuts/src/client/Reference.tsx create mode 100644 packages/client/ui-shortcuts/src/client/feedback.ts create mode 100644 packages/client/ui-shortcuts/src/client/fixed.ts create mode 100644 packages/client/ui-shortcuts/src/client/index.ts create mode 100644 packages/client/ui-shortcuts/src/client/locales.ts create mode 100644 packages/client/ui-shortcuts/src/client/store.ts create mode 100644 packages/client/ui-shortcuts/src/css-modules.d.ts create mode 100644 packages/client/ui-shortcuts/src/index.ts create mode 100644 packages/client/ui-shortcuts/tests/commands.client.spec.ts create mode 100644 packages/client/ui-shortcuts/tests/editor.client.spec.tsx create mode 100644 packages/client/ui-shortcuts/tests/reference.client.spec.tsx create mode 100644 packages/client/ui-shortcuts/tsconfig.json create mode 100644 packages/client/ui-shortcuts/tsdown.config.ts create mode 100644 packages/client/ui-sidebar-right/src/client/focus.ts create mode 100644 packages/client/ui-sidebar-right/src/client/shell/close-focus.ts create mode 100644 packages/client/ui-sidebar-right/src/client/shortcuts.ts create mode 100644 packages/client/ui-sidebar-right/tests/close-focus.client.spec.ts create mode 100644 packages/client/ui-sidebar-right/tests/focus.client.spec.ts create mode 100644 packages/client/ui-workspace/src/client/shortcuts.ts create mode 100644 packages/client/ui-workspace/tests/shortcuts.client.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.i18n.yaml new file mode 100644 index 0000000000..539358dc7c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.md +2026-09-20-device-local-shortcut-preferences.md: 3da3d3a29c5fd068f3498de78c2de84d3f30fe45 +2026-09-20-device-local-shortcut-preferences.zh.md: 3c5420e8708bf168a61d3d60e6a6a2add60a9455 diff --git a/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.md b/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.md new file mode 100644 index 0000000000..3da3d3a29c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.md @@ -0,0 +1,27 @@ +# Agent Note: Keep shortcut preferences device-local and commit before activation + +Status: implemented + +English | [中文](2026-09-20-device-local-shortcut-preferences.zh.md) + +## Problem + +Keyboard bindings depend on the receiving device's operating system and browser restrictions. A Host can serve several devices, while a local save can fail after a user records a new combination. Activating an unpersisted binding makes the interface disagree with the next application launch. Rewriting unreadable or future-version preferences can destroy choices an older client cannot interpret. + +## Decision + +[The shortcut service](../../../../packages/client/shortcuts/README.md) stores only versioned overrides per runtime/platform. Web owns origin-local browser storage; Electron main owns `userData/keybindings.json`, independently of Harness home. Both adapters share validation and transaction logic. Successful persistence publishes one accepted configuration from which command matching, keycaps, and native interception derive. + +Missing commands retain dormant overrides. Explicit overrides win over newly introduced defaults; conflicting explicit overrides are all disabled. Unreadable configuration blocks ordinary edits and preserves the last accepted values. Restore All clears only the accepted current profile and cannot overwrite unreadable or future-version data. + +## Alternatives considered + +**Host settings.** A server's operating system and settings scope do not identify the visitor's device or browser restrictions. Host storage would also couple remote visitors' preferences. + +**Optimistic activation.** Updating bindings before persistence makes a failed save appear successful and leaves restart behavior inconsistent with the visible controls. + +**Cross-tab atomic merging.** localStorage has no compare-and-swap transaction. Web rereads before each operation and rejects known stale drafts, but accepts last-writer-wins for simultaneous writes. Electron main serializes edits and rejects stale revisions. + +## Consequences + +Devices and Web/Desktop do not synchronize bindings automatically. Repairing unreadable preferences requires correcting the stored document; application edits preserve those bytes. Browser concurrency does not promise lossless merging. Atomic Desktop replacement uses the shared writer's Windows retry behavior without promising fsync durability. Deterministic transaction, browser-storage, and Desktop-file tests cover failure retention, conflicts, stale drafts, and refusal to replace unreadable data; platform keyboard acceptance remains separate from persistence correctness. diff --git a/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.zh.md b/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.zh.md new file mode 100644 index 0000000000..3c5420e870 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 快捷键偏好按设备保存,提交后才激活 + +Status: implemented + +[English](2026-09-20-device-local-shortcut-preferences.md) | 中文 + +## 问题 + +快捷键取决于接收输入设备的操作系统和浏览器限制。一个 Host 可服务多个设备,而用户录入新组合后,本地保存仍可能失败。激活未持久化的绑定会使界面与下一次启动不一致。重写不可读或未来版本的偏好可能破坏旧客户端无法解释的选择。 + +## 决策 + +[快捷键服务](../../../../packages/client/shortcuts/README.zh.md) 只保存按运行端/平台区分的版本化覆盖。Web 使用同 origin 的浏览器存储;Electron main 管理 `userData/keybindings.json`,与 Harness home 独立。两端适配器共享校验和事务逻辑。持久化成功后发布一个已接受配置,命令匹配、键帽和原生拦截都由此派生。 + +未加载命令保留休眠覆盖。显式覆盖优先于新增默认值,相互冲突的显式覆盖全部禁用。不可读配置阻止普通编辑,并保留上次接受的值。全部恢复只清除已接受的当前平台覆盖,不会覆盖不可读或未来版本的数据。 + +## 考虑过的替代方案 + +**Host 设置。** 服务端操作系统和设置范围不能标识访问者设备或浏览器限制,Host 存储还会耦合远程访问者的偏好。 + +**乐观激活。** 在持久化之前更新绑定会让保存失败看起来像成功,并使重启行为与可见控件不一致。 + +**跨标签页原子合并。** localStorage 没有比较并交换事务。Web 每次操作前重新读取并拒绝已知过期草稿,但同时写入采用最后成功写入生效。Electron main 串行处理编辑并拒绝旧 revision。 + +## 影响 + +不同设备以及 Web/Desktop 不自动同步绑定。修复不可读偏好需要更正存储文档;应用内编辑保留这些原始内容。浏览器并发不承诺无损合并。Desktop 原子替换使用共享写入器的 Windows 重试行为,不承诺 fsync 持久性。确定性的事务、浏览器存储和桌面文件测试覆盖失败保留、冲突、过期草稿以及拒绝替换不可读数据;平台键盘验收与持久化正确性分别验证。 diff --git a/.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.i18n.yaml new file mode 100644 index 0000000000..b10a560c64 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.md +2026-09-21-desktop-page-close-shortcuts.md: dc976806445f9b78c3dbad83d4cd9f3b0b2f4799 +2026-09-21-desktop-page-close-shortcuts.zh.md: 6ef60c084fe15cdc0f608992a700084cbf81b4c1 diff --git a/.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.md b/.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.md new file mode 100644 index 0000000000..dc97680644 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.md @@ -0,0 +1,27 @@ +# Agent Note: Desktop close shortcuts follow page focus + +Status: implemented + +English | [中文](2026-09-21-desktop-page-close-shortcuts.zh.md) + +## Problem + +The [shortcut PRD](https://trtgsjkv6r.feishu.cn/wiki/APvtwgbztiBLzvk9DM4cQbccnhf) gives the close command two targets: the focused right-sidebar page, or the Desktop window when no page can close. Electron's native close role bypasses the page owner. On Windows, closing the last window also quits the Desktop instance and stops its Host tasks, so this fallback is a lifecycle choice rather than a menu-label change. + +## Decision + +The sidebar page owner resolves close against live focus and captures the target's Session, pane, tab occurrence, and navigation revision. It closes that page through its resource cleanup handler, or collapses the sole docked guide. Stale sidebar targets do not fall back to another page or the window. Without a closeable page, Desktop requests native window closure; Web has no window fallback. + +The macOS File menu routes Close Page or Window through the same owner and displays the accepted single-key binding. Native closure requires the current configuration revision, a focused and enabled product window, and inactive shortcut recording. The existing window lifecycle applies without a shortcut-specific confirmation: macOS keeps the application alive after its last window closes; Windows and Linux quit and stop the Host. + +This decision owns File-menu and close behavior in place of the close-role choice in [the standard macOS menu note](../bug-fix/2026-09-16-desktop-window-menus.md). That note retains the rationale for native Window and application hide commands. [Shortcut preference persistence](2026-09-20-device-local-shortcut-preferences.md) owns accepted bindings and revision publication. + +## Alternatives considered + +**Use Electron's native close role.** It closes the window without asking the focused page owner and cannot preserve page cleanup or the accepted custom binding's routing. + +**Disable the Windows window fallback.** This avoids a task-stopping close shortcut but omits the PRD's explicit Desktop fallback. The product keeps native window-close semantics; users who need the application to remain running can minimize it. + +## Consequences + +Close behaves consistently across the shortcut and macOS File menu. On Windows and Linux, pressing it outside a closeable sidebar page can stop active tasks by quitting the instance. The command does not add a background Host lifetime or a task-aware shutdown confirmation. Desktop keyboard tests cover revision and focus guards; sidebar focus tests cover captured page identity and stale targets; startup tests cover the platform menu declarations. diff --git a/.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.zh.md b/.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.zh.md new file mode 100644 index 0000000000..6ef60c084f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.zh.md @@ -0,0 +1,27 @@ +# Agent Note: Desktop 关闭快捷键跟随页面焦点 + +Status: implemented + +[English](2026-09-21-desktop-page-close-shortcuts.md) | 中文 + +## 问题 + +[快捷键 PRD](https://trtgsjkv6r.feishu.cn/wiki/APvtwgbztiBLzvk9DM4cQbccnhf) 为关闭命令规定了两类目标:获得焦点的右侧栏页面,或没有可关闭页面时的 Desktop 窗口。Electron 原生 close role 绕过页面 owner。Windows 关闭最后一个窗口还会退出 Desktop 实例并停止其 Host 任务,因此该回退属于生命周期选择,不只是菜单标签变更。 + +## 决策 + +侧栏页面 owner 根据实时焦点解析关闭操作,并捕获目标的 Session、窗格、tab occurrence 和导航 revision。它通过资源清理处理器关闭该页面,或收起唯一的停靠引导页。过期侧栏目标不会回退到其他页面或窗口。没有可关闭页面时,Desktop 请求原生窗口关闭;Web 没有窗口回退。 + +macOS File 菜单通过同一个 owner 路由“关闭页面或窗口”,并显示已接受的单键绑定。原生关闭要求配置 revision 仍为当前值、产品窗口已聚焦且启用,并且没有在录制快捷键。现有窗口生命周期直接生效,不增加快捷键专用确认:macOS 最后一个窗口关闭后保留应用;Windows 和 Linux 退出并停止 Host。 + +本决策接管[标准 macOS 菜单记录](../bug-fix/2026-09-16-desktop-window-menus.zh.md)中 close role 选择所涉及的 File 菜单和关闭行为。该记录保留原生 Window 与应用隐藏命令的理由。[快捷键偏好持久化](2026-09-20-device-local-shortcut-preferences.zh.md)负责已接受绑定及 revision 发布。 + +## 考虑过的替代方案 + +**使用 Electron 原生 close role。** 它不询问获得焦点的页面 owner 就关闭窗口,无法保留页面清理和已接受自定义绑定的路由。 + +**禁用 Windows 窗口回退。** 这能避免关闭快捷键停止任务,但会遗漏 PRD 明确要求的 Desktop 回退。产品保留原生关窗语义;需要应用继续运行时,用户可以最小化窗口。 + +## 影响 + +快捷键与 macOS File 菜单的关闭行为一致。在 Windows 和 Linux 上,焦点不在可关闭侧栏页面时按下该快捷键,可能因退出实例而停止活动任务。该命令不增加后台 Host 生命周期或感知任务状态的退出确认。Desktop 键盘测试覆盖 revision 和焦点保护;侧栏焦点测试覆盖捕获的页面身份和过期目标;启动测试覆盖各平台菜单声明。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml index 9c2cdd061a..edd7fa1440 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md -2026-09-16-desktop-window-menus.md: 8a5343404e0cb3d2e7c4e20248a8b29f3bfbec47 -2026-09-16-desktop-window-menus.zh.md: d6ee6412462aecf49557a7994a683d37bf8e7253 +2026-09-16-desktop-window-menus.md: 34eff83707440719182f4ebe61aa552919bd44ee +2026-09-16-desktop-window-menus.zh.md: c3cb3e5d89497ae15210c034c0077fc8019e5688 diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md index 8a5343404e..34eff83707 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md +++ b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md @@ -10,9 +10,9 @@ The Desktop shell replaces Electron's default application menu with a custom tem ## Decision -On macOS the template declares `{ role: 'fileMenu' }` before the Edit menu and `{ role: 'windowMenu' }` after it, and a separator-delimited run of `hide`, `hideOthers`, and `unhide` before Quit in the application submenu. Those roles contribute only the standard items; no Services submenu, window list, or other macOS default is declared. Windows and Linux keep the application and Edit menus. +On macOS the template declares `{ role: 'windowMenu' }` after the Edit menu, and a separator-delimited run of `hide`, `hideOthers`, and `unhide` before Quit in the application submenu. Those roles contribute only the standard items; no Services submenu, window list, or other macOS default is declared. Windows and Linux keep the application and Edit menus. The [page-close decision](../architecture/2026-09-21-desktop-page-close-shortcuts.md) owns the custom File menu and close behavior. -Electron supplies English defaults for the File, Window, and Edit roles. Explicit labels override role defaults while retaining native actions and shortcuts; the localized application commands are documented in the [Desktop README](../../../../apps/desktop/README.md). No custom close, minimize, or hide code is added: ⌘W destroys the window through Electron's own role. +Electron supplies English defaults for the Window and Edit roles. Explicit labels override role defaults while retaining native actions and shortcuts; the localized application commands are documented in the [Desktop README](../../../../apps/desktop/README.md). Minimize and hide execute through Electron's roles without custom handlers. ## Alternatives considered @@ -22,13 +22,13 @@ Electron supplies English defaults for the File, Window, and Edit roles. Explici **Add a bare `{ role: 'close' }` item to the application submenu.** It avoids a File menu containing a single command, but places a window command among the app-wide Plugins, Updates, Hide, and Quit items where no macOS application puts it. -**Declare the File and Window menus on every platform.** The same template declares Ctrl+W there; with one window, closing it invokes `window-all-closed` and quits the application, turning a window shortcut into an unrequested quit path. +**Declare the File and Window menus on every platform.** The same template declares Ctrl+W there; with one window, closing it invokes `window-all-closed` and quits the application. The menu-restoration task did not request this quit path; the page-close decision explicitly specifies the Desktop fallback. **Recreate the main window on Dock activation regardless of other windows.** The plugin window can outlive the main window, so gating `activate` on `mainWindow` instead of `BrowserWindow.getAllWindows()` would let a Dock click always restore the main window. That changes window lifecycle beyond restoring the suppressed platform commands, so the existing `activate` condition stays. ## Consequences -macOS regains the window and application commands the custom menu suppressed. File, Window, and Edit retain Electron’s default English labels; application commands use the Desktop locale. +macOS retains native Window and application hide commands through four menu roles. Window and Edit retain Electron’s default English labels; application commands use the Desktop locale. ## Testing diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.zh.md b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.zh.md index d6ee641246..c3cb3e5d89 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.zh.md @@ -10,9 +10,9 @@ Desktop shell 用自定义模板替换了 Electron 的默认应用菜单,该 ## 决策 -macOS 上的模板在 Edit 菜单之前声明 `{ role: 'fileMenu' }`,在其之后声明 `{ role: 'windowMenu' }`,并在应用子菜单的 Quit 之前声明由分隔符隔开的 `hide`、`hideOthers` 和 `unhide`。这些 role 只提供标准菜单项;不声明 Services 子菜单、窗口列表或其他 macOS 默认项。Windows 和 Linux 保留应用菜单和 Edit 菜单。 +macOS 上的模板在 Edit 菜单之后声明 `{ role: 'windowMenu' }`,并在应用子菜单的 Quit 之前声明由分隔符隔开的 `hide`、`hideOthers` 和 `unhide`。这些 role 只提供标准菜单项;不声明 Services 子菜单、窗口列表或其他 macOS 默认项。Windows 和 Linux 保留应用菜单和 Edit 菜单。[页面关闭决策](../architecture/2026-09-21-desktop-page-close-shortcuts.zh.md)负责自定义 File 菜单和关闭行为。 -Electron 为 File、Window 和 Edit role 提供英文默认标签。显式标签会覆盖 role 的默认标签,同时保留原生命令和快捷键;本地化的应用命令见 [Desktop README](../../../../apps/desktop/README.zh.md)。这里不新增任何自定义的关闭、最小化或隐藏代码:⌘W 通过 Electron 自身的 role 销毁窗口。 +Electron 为 Window 和 Edit role 提供英文默认标签。显式标签会覆盖 role 的默认标签,同时保留原生命令和快捷键;本地化的应用命令见 [Desktop README](../../../../apps/desktop/README.zh.md)。最小化和隐藏通过 Electron 的 role 执行,没有自定义处理器。 ## 考虑过的替代方案 @@ -22,13 +22,13 @@ Electron 为 File、Window 和 Edit role 提供英文默认标签。显式标签 **在应用子菜单里直接加一个 `{ role: 'close' }` 项。** 这样可以避免只有一个命令的 File 菜单,但会把窗口命令放到 Plugins、Updates、Hide、Quit 这些应用级命令中间,没有 macOS 应用这样排布。 -**在所有平台都声明 File 和 Window 菜单。** 同一份模板在那些平台会把 Ctrl+W 声明为关闭;应用只有一个窗口,关闭它会触发 `window-all-closed` 并退出应用,把窗口快捷键变成用户没有要求的退出路径。 +**在所有平台都声明 File 和 Window 菜单。** 同一份模板在那些平台会把 Ctrl+W 声明为关闭;应用只有一个窗口,关闭它会触发 `window-all-closed` 并退出应用。恢复菜单的任务没有要求这条退出路径;页面关闭决策明确规定了 Desktop 回退。 **让 Dock 激活时无论其他窗口是否存在都重建主窗口。** 插件窗口可以比主窗口存活更久,因此把 `activate` 的判断从 `BrowserWindow.getAllWindows()` 改为 `mainWindow` 能让点击 Dock 总可以恢复主窗口。但这超出了恢复被压掉的平台命令,属于窗口生命周期的行为变更,因此保留现有的 `activate` 判断。 ## 影响 -macOS 恢复了自定义菜单压掉的窗口和应用命令。File、Window 和 Edit 保留 Electron 的默认英文标签;应用命令使用 Desktop 的语言。 +macOS 通过四个菜单 role 保留原生 Window 和应用隐藏命令。Window 和 Edit 保留 Electron 的默认英文标签;应用命令使用 Desktop 的语言。 ## 测试 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 77500865f6..1e05757729 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: 2b07b2b9d95c3d16a3aaa58b6d52f55cb1242e2e -README.zh.md: 4ebae34332b1d622a35d0bf857f1f3252736512c +README.md: 08c32be1d519b9d5185decf6811dc92939649f6e +README.zh.md: 70d8f969a1e3f2e7fd8af90c3d170a9bf60adefa diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 2b07b2b9d9..08c32be1d5 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -22,6 +22,10 @@ Press F12 (Fn+F12 on media-key keyboards), Command+Option+I on macOS, or Ctrl+Sh The original artwork lives in `resources/icon.png` and `resources/icon.svg`; platform adaptations retain the whale and gradients in `resources/icon-windows.*` and `resources/icon-macos.*`. Export each platform SVG as a transparent 1024×1024 PNG. Electron-builder generates the multi-size ICO for the Windows application, installer, and uninstaller ([Windows icon requirements](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction)). The installation pages use matching artwork in both themes; the uninstaller's welcome and finish pages share `installer/assets/uninstaller-sidebar.png`, converted to a 164×314 BMP during preparation. +Shortcut overrides are stored in `app.getPath('userData')/keybindings.json`, separately from `DSH_HOME`. The main process validates and serializes changes before publishing accepted bindings. Failed reads retain the last accepted bindings and block edits, including Restore All; unreadable and future-version files remain unchanged. Development can isolate these preferences with `DSH_DESKTOP_USER_DATA_DIR`; the launcher prints its resolved path. See the [shortcut service](../../packages/client/shortcuts/README.md) for format and conflict semantics. + +The macOS File menu displays the accepted single-key binding, including arrow keys, and routes Close Page or Window through the client page owner. On Windows and macOS, all complete accepted bindings are intercepted before main-document, embedded-frame, and browser-guest input, including editing and terminal input. Recording and input-method composition remain protected. Two-key chords leave the first key’s initial press available to the page and do not intercept its release; complete chords and their repeats are consumed. The renderer leaves configurable binding dispatch to the native adapter. Chords have no native menu accelerator. Accepted commands are forwarded once through the trusted preload. Linux dispatches main-document shortcuts through the DOM and forwards accepted embedded-frame bindings to the client resolver. Closing the last window preserves macOS application lifetime; Windows exits the desktop instance and stops its tasks. The [close decision](../../.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.md) records this lifecycle choice. + The macOS PNG uses an inset rounded background for legacy ICNS packaging, with representations up to 1024 pixels. It is a flattened icon, not an Icon Composer document. Apple's [app icon guidance](https://developer.apple.com/design/human-interface-guidelines/app-icons) describes unmasked layers for Icon Composer; those inputs require a separate macOS export and must not reuse the rounded ICNS artwork. Verify Finder and Dock appearance on supported macOS versions before release. ### Bundled workspace dependencies @@ -64,9 +68,9 @@ The product UI retains Web actions, including "Open In..." through the shared au Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. The macOS application bundle declares English and Simplified Chinese in `CFBundleLocalizations`, allowing macOS to match the initial application language to the user’s preferred languages. Saved Client UI language preferences still take precedence for the main interface. On Windows, the main document's language updates desktop menus, recovery and update prompts. The repository Client UI i18n gate checks desktop sources. -Windows uses a 40-DIP caption with native window controls and colors synchronized from the application palette. Localized Application and Edit entries beside the sidebar toggle open native popup menus. They mount only after the application frame publishes its shell overlay seat, and remain absent during startup loading. Application provides Check for Updates and Exit; Edit provides undo, redo, cut, copy, paste, delete, and select all by sending the corresponding keys to the focused editor. Plugin management uses the main application's Plugins page. No separate native menu row appears on Alt. Other platforms retain their native menus. Editable fields retain keyboard commands and a context menu without shortcut labels; Chromium supplies command availability, and selected read-only text offers Copy. +Windows uses a 40-DIP caption with native window controls and colors synchronized from the application palette. Localized Application and Edit entries beside the sidebar toggle open native popup menus. They mount only after the application frame publishes its shell overlay seat, and remain absent during startup loading. Application provides Check for Updates and Exit; Edit provides undo, redo, cut, copy, paste, delete, and select all by sending the corresponding keys to the focused editor, independently of custom shortcut bindings. Plugin management uses the main application's Plugins page. No separate native menu row appears on Alt. Other platforms retain their native menus. Editable fields retain keyboard commands and a context menu without shortcut labels; Chromium supplies command availability, and selected read-only text offers Copy. -On macOS the custom application menu also declares the standard File, Window, and application menus, because replacing Electron's default menu drops Close Window (⌘W), Minimize (⌘M), and Hide (⌘H). Linux keeps the application and Edit menus. +On macOS the custom menu retains Electron's standard Window menu and application hide commands, including Minimize (⌘M) and Hide (⌘H). Linux keeps the application and Edit menus. ### Runtime and plugin activation @@ -88,7 +92,6 @@ Before the first fatal dialog opens, Electron writes a crash report to the platf Recovery waits for Host shutdown before changing plugin activation. The native recovery action calls the shared app-boot recovery function under the profile transaction lock. It disables third-party bundles and renames the profile’s `cordis.patch.yml` to `cordis.patch.yml.bak-` (with an ordinal on collisions) without parsing it; the next startup creates an empty patch. Installed packages and earlier backups remain. The home-level patch is unchanged. The Electron console records the backup path (or its absence) and the unchanged home-level patch. Invalid profile data, rename failures, or write failures are reported as recovery-operation errors; completed changes remain, and Desktop does not restart as though recovery succeeded. Desktop has no profile-reset action or emergency HTML document. - ## Develop The development application menu offers Reload Page (Cmd+R on macOS, Ctrl+R elsewhere) and Restart App and Host. Restart waits for Host shutdown before relaunching Electron and starting a new Host; neither action rebuilds source files. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 4ebae34332..70d8f969a1 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -22,6 +22,10 @@ Desktop Host 的 Platform API 请求与更新策略请求使用相同的 `x-clie 设计师原稿位于 `resources/icon.png` 和 `resources/icon.svg`;平台适配保留鲸鱼与渐变,分别位于 `resources/icon-windows.*` 和 `resources/icon-macos.*`。将各平台 SVG 导出为透明的 1024×1024 PNG。electron-builder 为 Windows 应用、安装程序和卸载程序生成多尺寸 ICO([Windows 图标要求](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction))。安装页面在两种主题下使用匹配的图案;卸载程序的欢迎和完成页共用 `installer/assets/uninstaller-sidebar.png`,准备阶段将其转换为 164×314 BMP。 +快捷键覆盖保存在 `app.getPath('userData')/keybindings.json`,与 `DSH_HOME` 分离。主进程校验并串行保存修改后才发布已接受键位。读取失败保留上次接受的键位并阻止编辑,包括全部恢复;不可读和未来版本的文件保持不变。开发时可通过 `DSH_DESKTOP_USER_DATA_DIR` 隔离这些偏好,启动器会输出解析后的路径。格式和冲突语义见[快捷键服务](../../packages/client/shortcuts/README.zh.md)。 + +macOS“文件”菜单显示已接受的单键绑定(包括方向键),并通过 Client 页面 owner 路由“关闭页面或窗口”。Windows 和 macOS 在主文档、内嵌 frame 和浏览器 guest 输入之前拦截所有已接受的完整绑定,包括编辑和终端输入。录制和输入法组合状态仍受保护。双键组合会将首键的初次按下事件交给页面,且不拦截其松开事件;完整组合及其重复事件会被消费。渲染进程将可配置绑定的分发交给原生适配器。双键组合不注册原生菜单快捷键。已接受的命令通过可信 preload 转发一次。Linux 通过 DOM 分发主文档快捷键,并将已接受的内嵌 frame 绑定转发给 Client 解析器。关闭最后一个窗口后 macOS 保留应用生命周期;Windows 退出桌面实例并停止其任务。[关窗决策](../../.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.zh.md)记录了这一生命周期选择。 + macOS PNG 使用带留白的圆角底板,供传统 ICNS 打包使用,包含最高 1024 像素的表示。它是扁平图标,并非 Icon Composer 文档。Apple 的[应用图标指南](https://developer.apple.com/design/human-interface-guidelines/app-icons)要求向 Icon Composer 提供未遮罩的图层;这些输入需要在 macOS 上单独导出,不能复用已做圆角的 ICNS 图案。发布前须在支持的 macOS 版本中验收 Finder 和 Dock 的显示效果。 ### 内置工作区依赖 @@ -54,7 +58,7 @@ Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载 Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 安装的包;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/app.asar/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。打包应用选择 runtime profile 解析,不创建包链接;开发 profile 使用文件系统链接。宿主与插件在同一个 Electron Node 模式进程中执行;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。 -应用 preload 只向 `dsh-app://app` 文档暴露启动就绪、致命启动失败上报、原生目录选择、用于 composer 路径引用的 `__DSH_HOST_PATHS__` 桥接和租约范围内的 Browser 桥接。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 在 `dsh-app://shell/` 本地提供更新弹窗文档和资源,不依赖 Host 就绪。Electron 不提供插件管理 IPC 或独立管理页面。 +应用 preload 只向 `dsh-app://app` 文档暴露启动就绪、致命启动失败上报、原生目录选择、用于 composer 路径引用的 `__DSH_HOST_PATHS__` 桥接和租约范围内的 Browser 桥接。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 在 `dsh-app://shell/` 本地提供更新弹窗文档和资源,不依赖 Host 就绪。Electron 不提供插件管理 IPC 或独立管理页面。任何渲染进程都不会获得文件系统访问、原始 Electron IPC、shell 或任意 pnpm 参数。 只有主应用窗口启用 ``。guest 挂载必须匹配主进程签发的租约和分区;guest 保持 sandbox、context isolation 和 Web security,不启用 Node integration 或 guest preload。Browser IPC 监听只为应用文档创建。[Sidebar Browser](../../packages/client/ui-sidebar-browser/README.zh.md) 说明存储分组和 guest 限制;Host 鉴权仍独立于 URL 过滤而必需。 @@ -64,9 +68,9 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。macOS 应用包通过 `CFBundleLocalizations` 声明支持英语和简体中文,让 macOS 根据用户的首选语言匹配初始应用语言。主界面仍优先使用已保存的 Client UI 语言偏好。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。 -Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。 顶栏菜单保留鼠标和键盘操作,顶栏中的 Web 控件仍可触达。共享 DOM 只约束展示,不构成安全边界:产品脚本可以隐藏蒙层,但不能借此清除主进程策略或授权安装。更新操作与状态通过隔离预加载和壳 frame 之间的私有 MessageChannel 传递。 +Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选,不受自定义快捷键绑定影响。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。 -macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜单,因为替换 Electron 的默认菜单会丢掉 Close Window(⌘W)、Minimize(⌘M)和 Hide(⌘H)。Linux 保留应用菜单和 Edit 菜单。 +macOS 上自定义菜单保留 Electron 的标准 Window 菜单及应用隐藏命令,包括 Minimize(⌘M)和 Hide(⌘H)。Linux 保留应用菜单和 Edit 菜单。 ### 运行时与插件激活 @@ -88,7 +92,6 @@ macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜 恢复操作等待 Host 关闭后才修改插件启用状态。原生恢复操作在 profile 事务锁内调用共享 app-boot 恢复函数。它禁用第三方 bundle,并将 profile 的 `cordis.patch.yml` 重命名为 `cordis.patch.yml.bak-`(重名时追加序号),无需解析;下次启动创建空 patch。已安装包和已有备份保留。home 级 patch 不变。Electron 控制台记录备份路径(或原文件不存在)以及 home 级 patch 未修改。profile 数据无效、重命名失败或写入失败会作为恢复操作错误报告;已完成的修改保留,Desktop 不会假装恢复成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。 - ## 开发 开发环境应用菜单提供“刷新页面”(macOS 为 Cmd+R,其他平台为 Ctrl+R)和“重启应用与 Host”。重启会等待 Host 关闭,再重新启动 Electron 和新的 Host;这两项操作都不会重新构建源码。 @@ -377,7 +380,7 @@ macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-up 登录和策略请求共用内存 Session,与产品窗口及 updater 隔离;应用重启后需要重新登录。关闭窗口取消登录,导航失败提供本地化重试提示。返回服务后重新查询策略;重定向、Cookie 或 HTTP 422 都不是有效策略决定。取消、登录过期及无效响应均保留已知强更阻塞。固定登录结果写入进程诊断及可选更新日志;登录控制器不记录 Cookie、OAuth 参数或远程错误原文。真实 Harness 网关/API 联调及 macOS 登录验收仍未完成。 -扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层原位渐入渐出,在更新状态切换时保留蒙层,并将父窗口焦点和键盘输入重定向到蒙层。Windows 由隔离的应用 preload 在主窗口内挂载 shell 来源的 frame,以蒙层和弹窗覆盖 40 DIP 顶栏下方的内容区域。它阻止背景页面输入,不创建额外的原生窗口;移动和最大化只作用于主窗口。父窗口保持启用,原生移动、缩放、最小化、最大化和关闭控件仍可操作。退出应用会完成清理,不会解除更新要求;Esc 不会关闭覆盖层。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。 +扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层原位渐入渐出,在更新状态切换时保留蒙层,并将父窗口焦点和键盘输入重定向到蒙层。Windows 由隔离的应用 preload 在主窗口内挂载 shell 来源的 frame,以蒙层和弹窗覆盖 40 DIP 顶栏下方的内容区域。它阻止背景页面输入,不创建额外的原生窗口;移动和最大化只作用于主窗口。顶栏菜单保留鼠标和键盘操作,顶栏中的 Web 控件仍可触达。共享 DOM 只约束展示,不构成安全边界:产品脚本可以隐藏蒙层,但不能借此清除主进程策略或授权安装。更新操作与状态通过隔离 preload 和壳 frame 之间的私有 MessageChannel 传递。父窗口保持启用,原生移动、缩放、最小化、最大化和关闭控件仍可操作。退出应用会完成清理,不会解除更新要求;Esc 不会关闭覆盖层。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。 失败时在同一弹窗内保留阻塞、本地化重试提示和折叠诊断。白名单下载页面操作只在恢复状态出现,不与正常下载或安装并列。请求打开浏览器后立即提供复制替代入口,即使系统请求尚未返回;请求成功不证明网页已打开。复制失败时展示完整、只读的地址供手动复制。浏览器与剪贴板结果不覆盖 updater 错误。只有新的有效无需强更响应才解除阻塞;阻塞期间仍可使用顶部菜单检查。 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d7c8fbdd09..0a805dde09 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -70,6 +70,8 @@ "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", "vite": "^6.0.0", - "@deepseek-ai/dsh-client-ui-theme": "workspace:^" + "@deepseek-ai/dsh-client-ui-theme": "workspace:^", + "@deepseek-ai/dsh-client-shortcuts": "workspace:^", + "@deepseek-ai/dsh-atomic-write": "workspace:^" } } diff --git a/apps/desktop/scripts/dev.ts b/apps/desktop/scripts/dev.ts index 74840eae03..36d57325dc 100644 --- a/apps/desktop/scripts/dev.ts +++ b/apps/desktop/scripts/dev.ts @@ -63,7 +63,7 @@ async function launchElectron(): Promise { const rendererPort = debugPort('DSH_DESKTOP_RENDERER_DEBUG_PORT', 9222) const hostPort = debugPort('DSH_DESKTOP_HOST_INSPECT_PORT', 9230) const home = resolve(process.env.DSH_HOME ?? join(DEVELOPMENT_ROOT, 'home')) - const userData = join(DEVELOPMENT_ROOT, 'electron-user-data') + const userData = resolve(process.env.DSH_DESKTOP_USER_DATA_DIR ?? join(DEVELOPMENT_ROOT, 'electron-user-data')) const environment: NodeJS.ProcessEnv = { ...process.env, DSH_HOME: home, @@ -72,6 +72,7 @@ async function launchElectron(): Promise { ELECTRON_ENABLE_LOGGING: process.env.ELECTRON_ENABLE_LOGGING ?? '1', } console.log(`desktop development: DSH_HOME=${home}`) + console.log(`desktop development: userData=${userData}`) console.log(`desktop development: inspectors main=${String(mainPort)}, renderer=${String(rendererPort)}, host=${String(hostPort)}`) if (process.platform === 'darwin') { const executable = prepareDevelopmentApp({ electron, appRoot: APP_ROOT, directory: DEVELOPMENT_ROOT, home, userData, diff --git a/apps/desktop/src/browser-guests.ts b/apps/desktop/src/browser-guests.ts index 027b7cee5b..c3f0b10eea 100644 --- a/apps/desktop/src/browser-guests.ts +++ b/apps/desktop/src/browser-guests.ts @@ -9,6 +9,7 @@ interface GuestLease { readonly partition: string attached: boolean guest?: WebContents + releaseInput?: () => void } /** Owns workspace storage partitions independently from individual tab guests. */ @@ -51,6 +52,7 @@ export class DesktopBrowserGuests { const lease = this.leases.get(key) if (lease === undefined) return if (lease.owner !== owner) throw new Error('desktop browser: guest belongs to another window') + lease.releaseInput?.() this.leases.delete(key) const guest = lease.guest if (guest !== undefined && !guest.isDestroyed()) { @@ -63,8 +65,9 @@ export class DesktopBrowserGuests { /** * Install attachment checks before the application document can create a webview. * @param window - primary application window. + * @param attachInput - attaches native input after guest ownership is verified and returns its disposer. */ - bind(window: BrowserWindow): void { + bind(window: BrowserWindow, attachInput: (guest: WebContents, name: DesktopBrowserLeaseId) => () => void): void { const owner = window.webContents owner.on('will-attach-webview', (event, preferences, params) => { const id = typeof params.src === 'string' && params.src.startsWith('about:blank#') @@ -102,7 +105,8 @@ export class DesktopBrowserGuests { } lease.guest = guest attachedLease = id - guest.once('destroyed', () => { this.leases.delete(id) }) + lease.releaseInput = attachInput(guest, id) + guest.once('destroyed', () => { lease.releaseInput?.(); this.leases.delete(id) }) }) guest.setWindowOpenHandler(({ url, postBody }) => { const lease = attachedLease === undefined ? undefined : this.leases.get(attachedLease) diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index cf03b7e6c2..8cd7d8dddc 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -1,10 +1,17 @@ /** Typed preload operations exposed only by the Electron shell. */ +import type { DesktopKeyboardApi, DesktopShortcutsApi } from '@deepseek-ai/dsh-client-shortcuts/protocol' import type { IpcMainInvokeEvent } from 'electron' import type { DesktopBrowserBridge } from '@deepseek-ai/dsh-client-ui-sidebar-browser/types' /** IPC channel names kept private to the desktop application bundle. */ export const DESKTOP_IPC = { + shortcutsInput: 'dsh-desktop:shortcuts-input', + shortcutsCloseWindow: 'dsh-desktop:shortcuts-close-window', + shortcutsGet: 'dsh-desktop:shortcuts-get', + shortcutsEdit: 'dsh-desktop:shortcuts-edit', + shortcutsChanged: 'dsh-desktop:shortcuts-changed', + shortcutsRecording: 'dsh-desktop:shortcuts-recording', boot: 'dsh-desktop:boot', enterWorkspace: 'dsh-desktop:enter-workspace', bootFailed: 'dsh-desktop:boot-failed', @@ -62,6 +69,8 @@ export interface DesktopUpdatePresentation { export interface DshDesktopProductApi { readonly protocolVersion: 1 readonly browser: DesktopBrowserBridge + readonly keyboard: DesktopKeyboardApi + readonly shortcuts: DesktopShortcutsApi readonly updates: { status(): Promise open(): Promise diff --git a/apps/desktop/src/keybindings.ts b/apps/desktop/src/keybindings.ts new file mode 100644 index 0000000000..0825906c27 --- /dev/null +++ b/apps/desktop/src/keybindings.ts @@ -0,0 +1,27 @@ +/** Device-local shortcut preferences in Electron userData; writes share one serialized coordinator. */ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' +import { ShortcutPersistence } from '@deepseek-ai/dsh-client-shortcuts/protocol' +import type { ShortcutConfigSnapshot, ShortcutPlatform } from '@deepseek-ai/dsh-client-shortcuts/protocol' + +/** + * Open the device configuration with atomic replacement. + * @param userData - Electron-owned userData directory, never a Renderer-supplied path. + * @param platform - local input platform. + * @param publish - updates the native binding index and trusted product page after commit. + * @returns the single-writer transaction coordinator. + */ +export function desktopKeybindings(userData: string, platform: ShortcutPlatform, + publish: (snapshot: ShortcutConfigSnapshot) => void): ShortcutPersistence { + const path = join(userData, 'keybindings.json') + return new ShortcutPersistence({ + read: async () => { + try { return await readFile(path, 'utf8') } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null + throw error + } + }, + write: raw => writeFileAtomic(path, raw, { mode: 0o600, dirMode: 0o700 }), + }, 'desktop', platform, false, publish) +} diff --git a/apps/desktop/src/keyboard.ts b/apps/desktop/src/keyboard.ts new file mode 100644 index 0000000000..4cd994f8f7 --- /dev/null +++ b/apps/desktop/src/keyboard.ts @@ -0,0 +1,253 @@ +/** Product-window preference IPC and native menu interception during physical-key dispatch/recording. */ +import { ipcMain, type BrowserWindow, type IpcMainInvokeEvent, type Input, type MenuItemConstructorOptions, type WebContents } from 'electron' +import { bindingKey, effectiveShortcuts, presentBinding, parseShortcutDefinitions, parseShortcutEdit } from '@deepseek-ai/dsh-client-shortcuts/protocol' +import type { NormalizedBinding, ShortcutConfigSnapshot, ShortcutDefinition, ShortcutPlatform, ShortcutRevision } from '@deepseek-ai/dsh-client-shortcuts/protocol' +import type { DesktopBrowserLeaseId } from '@deepseek-ai/dsh-client-ui-sidebar-browser/types' +import { desktopKeybindings } from './keybindings.ts' +import { DESKTOP_IPC, assertDesktopSender } from './ipc.ts' + +/** + * Install application-owned configuration handlers and attach each product window's input lifecycle. + * @param getWindow - current product window. + * @param userData - Electron-resolved device preference directory. + * @param platform - local device platform. + * @param updateMenu - rebuild the application menu when the close accelerator or availability changes. + * @returns menu construction, editor key delivery, window attachment, and teardown operations. + */ +export function installDesktopShortcuts( + getWindow: () => BrowserWindow | undefined, userData: string, platform: ShortcutPlatform, updateMenu: () => void, +): { + fileMenu(labels: { fileMenu: string; closePage: string }): MenuItemConstructorOptions + /** + * Send a native Edit action to the editor without matching user shortcuts. + * @param keyCode - edit key. + * @param modifiers - edit modifiers. + */ + sendEditingKey(keyCode: string, modifiers: Array<'control'>): void + attach(window: BrowserWindow): void + /** + * Intercept an approved browser guest's keys until its lease ends. + * @param window - owning product window. + * @param guest - approved browser contents. + * @param name - main-issued lease used as the webview element name. + * @returns idempotent listener disposer. + */ + attachGuest(window: BrowserWindow, guest: WebContents, name: DesktopBrowserLeaseId): () => void + dispose(): void +} { + let definitions: readonly ShortcutDefinition[] = [] + let recording = false + let revision: ShortcutRevision | undefined + let closeBinding: NormalizedBinding | null = null + let editingInput: BrowserWindow['webContents'] | undefined + const scopedDesktop = platform === 'windows' || platform === 'macos' + const disposers = new Set<() => void>() + const guestInputs = new Map() + const closeAccelerator = (): string | undefined => presentBinding(closeBinding, platform).aria + ?.replace('Meta+', 'Command+').replace(/Arrow(Up|Down|Left|Right)$/u, '$1') + const sendMenuClose = (): void => { + const window = getWindow() + if (window === undefined || window.isDestroyed() || !window.isFocused() || !window.isEnabled() + || revision === undefined || recording) return + window.webContents.send(DESKTOP_IPC.shortcutsInput, { kind: 'menu', commandId: 'page.close', revision }) + } + let keys = new Set() + const publish = (snapshot: ShortcutConfigSnapshot): void => { + const wasEnabled = revision !== undefined + const previousAccelerator = closeAccelerator() + revision = definitions.length === 0 || snapshot.status === 'loading' ? undefined : snapshot.revision + const rows = snapshot.status === 'loading' ? [] : effectiveShortcuts(definitions, snapshot.document, 'desktop', platform) + keys = new Set(rows.flatMap(row => row.binding !== null && row.issue === null && row.conflicts.length === 0 + ? [bindingKey(row.binding)] : [])) + const close = rows.find(row => row.id === 'page.close') + closeBinding = close?.issue === null && close.conflicts.length === 0 ? close.binding : null + if (wasEnabled !== (revision !== undefined) || previousAccelerator !== closeAccelerator()) updateMenu() + const window = getWindow() + if (window !== undefined && !window.isDestroyed() && window.webContents.mainFrame.url.startsWith('dsh-app://app/')) { + window.webContents.send(DESKTOP_IPC.shortcutsChanged, snapshot) + } + } + const persistence = desktopKeybindings(userData, platform, publish) + const assertSender = (event: IpcMainInvokeEvent): BrowserWindow => { + const window = getWindow() + if (window === undefined || window.isDestroyed() || event.sender !== window.webContents + || event.senderFrame !== window.webContents.mainFrame) throw new Error('desktop shortcuts: rejected sender') + assertDesktopSender(event, ['app']) + return window + } + ipcMain.handle(DESKTOP_IPC.shortcutsGet, async (event, input: unknown) => { + assertSender(event) + definitions = parseShortcutDefinitions(input) + persistence.setDefinitions(definitions) + return persistence.readCurrent() + }) + ipcMain.handle(DESKTOP_IPC.shortcutsEdit, async (event, input: unknown, revision: unknown) => { + assertSender(event) + if (typeof revision !== 'string') throw new Error('desktop shortcuts: invalid revision') + return persistence.edit(parseShortcutEdit(input), revision as ShortcutRevision) + }) + ipcMain.handle(DESKTOP_IPC.shortcutsRecording, (event, active: unknown) => { + const window = assertSender(event) + if (typeof active !== 'boolean') throw new Error('desktop shortcuts: invalid recording state') + recording = active + window.webContents.setIgnoreMenuShortcuts(active) + }) + ipcMain.handle(DESKTOP_IPC.shortcutsCloseWindow, (event, expected: unknown) => { + const window = assertSender(event) + if (expected !== revision || revision === undefined || recording || !window.isFocused() || !window.isEnabled()) return + window.close() + }) + function attachInput(window: BrowserWindow, contents: WebContents, guestName?: DesktopBrowserLeaseId): () => void { + let deadKey = false + const held = new Set() + const consumed = new Map() + let inputFrame: typeof contents.focusedFrame = null + let inputRevision: ShortcutRevision | undefined + const resetInput = (): void => { + deadKey = false; held.clear(); consumed.clear(); inputFrame = null; inputRevision = undefined + if (!contents.isDestroyed()) contents.setIgnoreMenuShortcuts(false) + } + if (guestName !== undefined) guestInputs.set(contents, { window, reset: resetInput }) + const resetWindow = (): void => { + resetInput() + for (const guest of guestInputs.values()) if (guest.window === window) guest.reset() + } + const clear = (): void => { + resetInput() + if (guestName !== undefined) return + definitions = []; keys.clear(); recording = false + persistence.setDefinitions(null) + } + const navigation = (event: Electron.Event): void => { + if (event.isMainFrame && !event.isSameDocument) clear() + } + const beforeInput = (event: Electron.Event, input: Input): void => { + if (editingInput === contents) { + held.clear() + consumed.clear() + contents.setIgnoreMenuShortcuts(true) + return + } + if (window !== getWindow() || !window.isFocused() || !window.isEnabled() || revision === undefined + || (guestName !== undefined && !contents.isFocused())) { + contents.setIgnoreMenuShortcuts(false) + held.clear() + consumed.clear() + return + } + const modifiers = (['control', 'alt', 'shift', 'meta'] as const).filter(modifier => input[modifier]) + const key = bindingKey({ code: input.code, modifiers }) + const match = keys.has(key) + const menuMatch = closeBinding !== null && closeBinding.secondCode === undefined && modifiers.join('+') === closeBinding.modifiers.join('+') + && input.key.toUpperCase() === (closeBinding.code.startsWith('Key') ? closeBinding.code.slice(3) : input.code === closeBinding.code ? input.key.toUpperCase() : '') + contents.setIgnoreMenuShortcuts(recording || match || menuMatch) + const frame = contents.focusedFrame + const composing = input.isComposing || input.key === 'Dead' || deadKey || input.modifiers.includes('altgr') + if (input.type === 'keyDown') deadKey = input.key === 'Dead' + if (recording || composing || frame === null) { held.clear(); consumed.clear(); return } + let binding: NormalizedBinding = { code: input.code, modifiers } + let priority = false + if (scopedDesktop) { + if (frame !== inputFrame || inputRevision !== revision) { held.clear(); inputFrame = frame; inputRevision = revision } + const modifierKey = /^(Control|Alt|Shift|Meta)(Left|Right)$/u.test(input.code) + if (input.type === 'keyUp') { + // A chord's first key reached the renderer, so its release must reach the same input handlers. + if (consumed.get(input.code) === 'press') event.preventDefault() + consumed.delete(input.code) + held.delete(input.code) + if (modifierKey) held.clear() + return + } + if (!input.isAutoRepeat) consumed.delete(input.code) + if (modifierKey) { held.clear(); return } + if (input.isAutoRepeat && consumed.has(input.code) && !match) { event.preventDefault(); return } + if (input.isAutoRepeat && !held.has(input.code) && !match) return + held.add(input.code) + const codes: [string, ...string[]] = [input.code, ...[...held].filter(value => value !== input.code)] + codes.sort() + const pair = { code: codes[0], ...(codes[1] === undefined ? {} : { secondCode: codes[1] }), modifiers } + priority = keys.has(key) + if (codes.length === 2 && keys.has(bindingKey(pair))) { binding = pair; priority = true } + } + const main = guestName === undefined && frame === contents.mainFrame + if (!priority && (main || !match)) return + event.preventDefault() + if (input.type !== 'keyDown') return + if (scopedDesktop) { + if (!input.isAutoRepeat) { + if (binding.secondCode !== undefined) { + consumed.set(binding.code, 'repeat') + consumed.set(binding.secondCode, 'repeat') + } + consumed.set(input.code, 'press') + } + // Electron can omit both keyups after interception; completed presses cannot seed another chord. + held.clear() + } + let embedding = frame + while (guestName === undefined && !main && embedding.parent !== null && embedding.parent !== contents.mainFrame) { + embedding = embedding.parent + } + window.webContents.send(DESKTOP_IPC.shortcutsInput, { kind: guestName === undefined ? main ? 'keyboard' : 'iframe' : 'webview', + revision, frameName: guestName ?? (main ? '' : embedding.name), + code: binding.code, ...(binding.secondCode === undefined ? {} : { secondCode: binding.secondCode }), + repeat: input.isAutoRepeat, control: input.control, alt: input.alt, shift: input.shift, meta: input.meta }) + } + const dispose = (): void => { + contents.off('did-start-navigation', navigation) + contents.off('before-input-event', beforeInput) + contents.off('blur', resetInput) + contents.off('destroyed', dispose) + if (guestName === undefined) { window.off('blur', resetWindow); window.off('closed', closed) } + disposers.delete(dispose) + guestInputs.delete(contents) + if (!contents.isDestroyed()) contents.setIgnoreMenuShortcuts(false) + } + const closed = (): void => { clear(); dispose() } + contents.on('did-start-navigation', navigation) + contents.on('before-input-event', beforeInput) + contents.on('blur', resetInput) + contents.once('destroyed', dispose) + if (guestName === undefined) { window.on('closed', closed); window.on('blur', resetWindow) } + disposers.add(dispose) + return dispose + } + + return { + sendEditingKey(keyCode, modifiers) { + const window = getWindow() + if (window === undefined || window.isDestroyed()) return + const contents = [...guestInputs].find(([guest, owner]) => owner.window === window && !guest.isDestroyed() && guest.isFocused())?.[0] + ?? window.webContents + contents.focus() + const previous = editingInput + editingInput = contents + try { + // Electron emits before-input-event synchronously for these editor-owned keys. + contents.sendInputEvent({ type: 'keyDown', keyCode, modifiers }) + contents.sendInputEvent({ type: 'keyUp', keyCode, modifiers }) + } finally { + editingInput = previous + if (!contents.isDestroyed()) contents.setIgnoreMenuShortcuts(recording) + } + }, + fileMenu: (labels) => { + const accelerator = closeAccelerator() + return { label: labels.fileMenu, submenu: [{ + id: 'dsh-page-close', label: labels.closePage, enabled: revision !== undefined, + ...accelerator === undefined ? {} : { accelerator }, + click: sendMenuClose, + }] } + }, + attach(window) { attachInput(window, window.webContents) }, + attachGuest: attachInput, + dispose() { + for (const dispose of disposers) dispose() + persistence.dispose() + for (const channel of [DESKTOP_IPC.shortcutsGet, + DESKTOP_IPC.shortcutsEdit, DESKTOP_IPC.shortcutsRecording, DESKTOP_IPC.shortcutsCloseWindow]) { + ipcMain.removeHandler(channel) + } + }, + } +} diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index 7a1c935a8d..becf7eafda 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -2,6 +2,8 @@ export const en = { application: 'Application', + fileMenu: 'File', + closePage: 'Close Page or Window', aboutMenu: 'About DeepSeek Harness', aboutProduct: 'DeepSeek Harness', aboutVersion: 'Version V{version}', @@ -132,6 +134,8 @@ export type DesktopMessages = { readonly [Key in keyof typeof en]: string } export const zh = { application: '应用', + fileMenu: '文件', + closePage: '关闭页面或窗口', aboutMenu: '关于 DeepSeek Harness', aboutProduct: 'DeepSeek Harness', aboutVersion: '版本 V{version}', diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9f8ad5c22f..2b93427bf1 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -48,6 +48,7 @@ import { DesktopPolicyTestAuth } from './policy-test-auth.ts' import { DesktopUpdateDialog, type UpdateDialogOptions } from './update-dialog.ts' import { readDesktopRuntime } from './runtime-tree.ts' import { DesktopBrowserGuests } from './browser-guests.ts' +import { installDesktopShortcuts } from './keyboard.ts' let focusPrimaryWindow = (): void => {} let stopForRecovery = async (): Promise => {} @@ -567,6 +568,9 @@ async function main(): Promise { installDesktopDirectoryPicker(() => mainWindow) installMicrophonePermissions(session.defaultSession, () => mainWindow?.webContents) + const shortcuts = installDesktopShortcuts(() => mainWindow, app.getPath('userData'), + process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : 'linux', () => { refreshApplicationMenu() }) + app.on('will-quit', () => { shortcuts.dispose() }) ipcMain.handle(DESKTOP_IPC.boot, async (event) => { assertDesktopSender(event, ['app']) @@ -648,7 +652,7 @@ async function main(): Promise { locale = current platformView.notifyLocaleChanged() windowsLanguage = locale.id - installMenu() + refreshApplicationMenu() }) ipcMain.handle(DESKTOP_IPC.updatesStatus, (event) => { assertProductSender(event) @@ -800,8 +804,8 @@ async function main(): Promise { // its standard menus and application hide commands declared explicitly. // Keep app.name stable: Electron derives its default userData directory from it. const darwin = process.platform === 'darwin' - const platformMenus: MenuItemConstructorOptions[] = darwin - ? [{ role: 'fileMenu' }, { role: 'editMenu' }, { role: 'windowMenu' }] + const platformMenus = (): MenuItemConstructorOptions[] => darwin + ? [shortcuts.fileMenu(currentDesktopLocale().messages), { role: 'editMenu' }, { role: 'windowMenu' }] : [{ role: 'editMenu' }] const hideCommands: MenuItemConstructorOptions[] = darwin ? [{ role: 'hide', label: currentDesktopLocale().messages.hideApplication }, @@ -835,13 +839,13 @@ async function main(): Promise { { role: 'toggleDevTools', visible: false }, { role: 'toggleDevTools', visible: false, accelerator: 'F12' }, ] - const installMenu = (): void => { + const refreshApplicationMenu = (): void => { Menu.setApplicationMenu(Menu.buildFromTemplate(process.platform === 'win32' ? devToolsItems : [{ label: darwin ? app.name : currentDesktopLocale().messages.application, submenu: [...applicationItems(), ...devToolsItems], - }, ...platformMenus])) + }, ...platformMenus()])) } - installMenu() + refreshApplicationMenu() if (process.platform === 'win32') { ipcMain.handle(DESKTOP_IPC.windowsMenu, (event, name: unknown, x: unknown, y: unknown) => { @@ -857,9 +861,7 @@ async function main(): Promise { label, ...(accelerator === undefined ? {} : { accelerator }), click: () => { - window.webContents.focus() - window.webContents.sendInputEvent({ type: 'keyDown', keyCode, modifiers }) - window.webContents.sendInputEvent({ type: 'keyUp', keyCode, modifiers }) + shortcuts.sendEditingKey(keyCode, modifiers) }, }) const items: MenuItemConstructorOptions[] = name === 'application' ? applicationItems() : [ @@ -895,7 +897,8 @@ async function main(): Promise { const createMainWindow = (): BrowserWindow => { const window = createWindow(appPreload, false, true) mainWindow = window - browserGuests.bind(window) + browserGuests.bind(window, (guest, name) => shortcuts.attachGuest(window, guest, name)) + shortcuts.attach(window) window.on('focus', automaticCheck) window.on('closed', () => { if (mainWindow === window) mainWindow = undefined }) window.webContents.on('console-message', (details) => { @@ -990,7 +993,7 @@ async function main(): Promise { if (isQuitting() || backend.state.phase !== 'ready') return locale = resolveDesktopStartupLocale(state.localePreference, systemLanguages) windowsLanguage = locale.id - installMenu() + refreshApplicationMenu() if (!enteredWorkspace && needsWelcome({ loggedIn: state.loggedIn, hasApiKey: state.hasApiKey })) { await showWelcome() } else { diff --git a/apps/desktop/src/preload-app.ts b/apps/desktop/src/preload-app.ts index 13bbc20690..a500f6ed93 100644 --- a/apps/desktop/src/preload-app.ts +++ b/apps/desktop/src/preload-app.ts @@ -1,5 +1,6 @@ /** Origin-scoped boot, native directory selection, host paths of picked files, and update presentation with native confirmation actions. */ +import type { DesktopShortcutInput, ShortcutConfigSnapshot, ShortcutSaveResult } from '@deepseek-ai/dsh-client-shortcuts/protocol' import { contextBridge, ipcRenderer, webUtils } from 'electron' import { DESKTOP_IPC, SCHEME, type DshDesktopProductApi, type DesktopUpdatePresentation } from './ipc.ts' import { PLATFORM_IPC } from './platform-ipc.ts' @@ -13,6 +14,37 @@ function createProductApi(): DshDesktopProductApi { return { protocolVersion: 1, browser: createDesktopBrowserBridge(), + keyboard: { + closeWindow: revision => ipcRenderer.invoke(DESKTOP_IPC.shortcutsCloseWindow, revision) as Promise, + subscribe: (listener) => { + const handle = (_event: Electron.IpcRendererEvent, input: DesktopShortcutInput): void => { + if (input.kind === 'iframe') { + const element = document.activeElement + if (!(element instanceof HTMLIFrameElement) || !element.isConnected + || !element.matches('iframe[data-sidebar-browser-frame], iframe[data-html-preview]')) return + if (input.frameName === '' || element.name !== input.frameName) return + } + if (input.kind === 'webview') { + const element = document.activeElement + if (element?.matches('webview[data-sidebar-browser-frame]') !== true || !element.isConnected + || input.frameName === '' || element.getAttribute('name') !== input.frameName) return + } + listener(input) + } + ipcRenderer.on(DESKTOP_IPC.shortcutsInput, handle) + return () => { ipcRenderer.off(DESKTOP_IPC.shortcutsInput, handle) } + }, + }, + shortcuts: { + get: definitions => ipcRenderer.invoke(DESKTOP_IPC.shortcutsGet, definitions) as Promise, + edit: (edit, revision) => ipcRenderer.invoke(DESKTOP_IPC.shortcutsEdit, edit, revision) as Promise, + recording: active => ipcRenderer.invoke(DESKTOP_IPC.shortcutsRecording, active) as Promise, + subscribe(listener) { + const handle = (_event: Electron.IpcRendererEvent, snapshot: ShortcutConfigSnapshot): void => { listener(snapshot) } + ipcRenderer.on(DESKTOP_IPC.shortcutsChanged, handle) + return () => { ipcRenderer.off(DESKTOP_IPC.shortcutsChanged, handle) } + }, + }, updates: { status: () => ipcRenderer.invoke(DESKTOP_IPC.updatesStatus) as Promise, open: () => ipcRenderer.invoke(DESKTOP_IPC.updatesOpen) as Promise, @@ -60,7 +92,7 @@ markDocumentPlatform() syncWindowFullscreen() syncNativeTheme() // Main-process IPC also verifies the owning window and top frame. -contextBridge.exposeInMainWorld('dshDesktop', location.protocol === `${SCHEME}:` && location.hostname === 'app' ? createProductApi() : { protocolVersion: 1 }) +contextBridge.exposeInMainWorld('dshDesktop', location.protocol === `${SCHEME}:` && location.hostname === 'app' && process.isMainFrame ? createProductApi() : { protocolVersion: 1 }) if (location.protocol === `${SCHEME}:` && location.hostname === 'app') { contextBridge.exposeInMainWorld('__DSH_LOCALE__', { diff --git a/apps/desktop/tests/keybindings.spec.ts b/apps/desktop/tests/keybindings.spec.ts new file mode 100644 index 0000000000..d826a36151 --- /dev/null +++ b/apps/desktop/tests/keybindings.spec.ts @@ -0,0 +1,35 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it, onTestFinished } from 'vitest' +import { desktopKeybindings } from '../src/keybindings.ts' +import type { ShortcutCommandId, ShortcutConfigSnapshot } from '@deepseek-ai/dsh-client-shortcuts/protocol' + +it('persists into the supplied userData, reloads null bindings, and retains original future-version bytes', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-keybindings-')) + onTestFinished(async () => { await rm(root, { recursive: true, force: true }) }) + let state!: ShortcutConfigSnapshot + const first = desktopKeybindings(root, 'windows', (value) => { state = value }) + onTestFinished(() => { first.dispose() }) + const id = 'test.toggle' as ShortcutCommandId + const definitions = [{ id, defaults: { + 'desktop:macos': { code: 'KeyB', modifiers: ['primary'] as const }, + 'desktop:windows': { code: 'KeyB', modifiers: ['primary'] as const }, + 'desktop:linux': { code: 'KeyB', modifiers: ['primary'] as const }, + } }] + first.setDefinitions(definitions) + await first.readCurrent() + expect((await first.edit({ type: 'set', id, binding: null }, state.revision)).status).toBe('saved') + const path = join(root, 'keybindings.json') + const saved: unknown = JSON.parse(await readFile(path, 'utf8')) + expect(saved).toMatchObject({ profiles: { 'desktop:windows': { [id]: null } } }) + const second = desktopKeybindings(root, 'windows', (value) => { state = value }) + onTestFinished(() => { second.dispose() }) + second.setDefinitions(definitions) + expect((await second.readCurrent()).document.profiles['desktop:windows']?.[id]).toBeNull() + const original = '{"schemaVersion":7,"custom":"retain exactly"}\n' + await writeFile(path, original) + expect((await second.readCurrent()).error).toBe('future') + expect((await second.edit({ type: 'reset-all' }, state.revision)).status).toBe('unreadable') + expect(await readFile(path, 'utf8')).toBe(original) +}) diff --git a/apps/desktop/tests/keyboard.spec.ts b/apps/desktop/tests/keyboard.spec.ts new file mode 100644 index 0000000000..210ba9852b --- /dev/null +++ b/apps/desktop/tests/keyboard.spec.ts @@ -0,0 +1,679 @@ +// @vitest-environment jsdom +import { EventEmitter } from 'node:events' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, onTestFinished, vi } from 'vitest' +import type { BrowserWindow, WebContents, WebFrameMain } from 'electron' +import type { DesktopShortcutInput, ShortcutBinding, ShortcutCommandId, ShortcutConfigSnapshot, + ShortcutDefinition, ShortcutSaveResult } from '@deepseek-ai/dsh-client-shortcuts/protocol' +import { ShortcutRegistry } from '../../../packages/client/shortcuts/src/client/registry.ts' +import { installKeyboard } from '../../../packages/client/shortcuts/src/client/dom.ts' +import { installNativeKeyboard } from '../../../packages/client/shortcuts/src/client/native.ts' +import type { DesktopBrowserLeaseId, DesktopBrowserReservation } from '@deepseek-ai/dsh-client-ui-sidebar-browser/types' +import { DESKTOP_IPC } from '../src/ipc.ts' + +const ipc = vi.hoisted(() => ({ handle: vi.fn(), removeHandler: vi.fn() })) +vi.mock('electron', () => ({ ipcMain: ipc, app: { isPackaged: true }, session: { fromPartition: () => ({ + setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(), setDevicePermissionHandler: vi.fn(), + setDisplayMediaRequestHandler: vi.fn(), on: vi.fn(), webRequest: { onBeforeRequest: vi.fn() }, +}) } })) +const { installDesktopShortcuts } = await import('../src/keyboard.ts') +const { DesktopBrowserGuests } = await import('../src/browser-guests.ts') +afterEach(() => { vi.clearAllMocks() }) + +type FrameFixture = { url: WebFrameMain['url']; name: WebFrameMain['name']; parent: FrameFixture | null } +type ContentsFixture = EventEmitter & Pick & { + mainFrame: FrameFixture + focusedFrame: FrameFixture | null + } +type WindowFixture = EventEmitter & Pick & { + webContents: ContentsFixture +} +type InvokeFixture = { sender: object; senderFrame: object | null } +type KeyboardFixture = Omit, 'attach' | 'attachGuest'> & { + attach(window: WindowFixture): void + attachGuest(window: WindowFixture, guest: ContentsFixture, name: DesktopBrowserLeaseId): () => void +} +// Electron is substituted at module load; fixtures implement the native members exercised by this installer. +const installFixture = installDesktopShortcuts as ( + getWindow: () => WindowFixture | undefined, userData: string, + platform: Parameters[2], updateMenu: () => void, +) => KeyboardFixture +type GuestsFixture = { + acquire(owner: ContentsFixture, workspace: unknown): DesktopBrowserReservation + release(owner: ContentsFixture, id: unknown): Promise + bind(window: WindowFixture, attachInput: (guest: ContentsFixture, name: DesktopBrowserLeaseId) => () => void): void +} + +function desktopDefaults(binding: ShortcutBinding): ShortcutDefinition['defaults'] { + return { 'desktop:macos': binding, 'desktop:windows': binding, 'desktop:linux': binding } +} + +async function fixture(platform: 'macos' | 'windows' | 'linux' = 'macos') { + const root = await mkdtemp(join(tmpdir(), 'dsh-keyboard-')) + onTestFinished(async () => { await rm(root, { recursive: true, force: true }) }) + const frame: FrameFixture = { url: 'dsh-app://app/', name: '', parent: null } + const contents = Object.assign(new EventEmitter(), { mainFrame: frame, focusedFrame: frame, + isDestroyed: () => false, isFocused: () => true, send: vi.fn(), + setIgnoreMenuShortcuts: vi.fn(), focus: vi.fn(), sendInputEvent: vi.fn() }) + const window = Object.assign(new EventEmitter(), { webContents: contents, isDestroyed: vi.fn(() => false), + isFocused: vi.fn(() => true), isEnabled: vi.fn(() => true), close: vi.fn() }) + let current: WindowFixture | undefined = window + const updateMenu = vi.fn() + const keyboard = installFixture(() => current, root, platform, updateMenu) + keyboard.attach(current) + onTestFinished(() => { keyboard.dispose() }) + const handlers = new Map unknown>( + ipc.handle.mock.calls.map(([channel, handler]) => [channel, handler])) + const event: InvokeFixture = { sender: contents, senderFrame: frame } + const call = async (channel: string, ...args: unknown[]): Promise => await handlers.get(channel)!(event, ...args) as T + const definitions: readonly ShortcutDefinition[] = [ + { id: 'sidebar.left.toggle' as ShortcutCommandId, defaults: desktopDefaults({ code: 'KeyB', modifiers: ['primary'] }) }, + ] + return { keyboard, window, contents, frame, event, handlers, call, definitions, updateMenu, detach: () => { current = undefined } } +} + +it('mirrors only successful bindings, suppresses recording menus, and invalidates pre-navigation drafts', async () => { + const f = await fixture() + const initial = await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + const press = (code: string) => f.contents.emit('before-input-event', { preventDefault: vi.fn() }, + { type: 'keyDown', modifiers: [], code, key: code.replace('Key', ''), meta: true, control: false, alt: false, shift: false }) + press('KeyB'); expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(true) + press('KeyJ'); expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(false) + const saved = await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'set', id: 'sidebar.left.toggle', binding: { code: 'KeyJ', modifiers: ['primary'] } }, initial.revision) + expect(saved.status).toBe('saved') + press('KeyB'); expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(false) + press('KeyJ'); expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(true) + expect((await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'reset-all' }, initial.revision)).status).toBe('stale') + await f.call(DESKTOP_IPC.shortcutsRecording, true) + press('KeyW'); expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(true) + f.window.isFocused.mockReturnValue(false) + press('KeyW'); expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(false) + f.contents.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false }, 'dsh-app://app/', false, true) + expect((await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'reset-all' }, saved.snapshot.revision)).status).toBe('not-ready') + await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + expect((await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'reset-all' }, saved.snapshot.revision)).status).toBe('stale') +}) + +it('rejects other windows, subframes, remote/shell pages, and malformed edits', async () => { + const f = await fixture() + const get = f.handlers.get(DESKTOP_IPC.shortcutsGet)! + for (const event of [{ ...f.event, sender: {} }, { ...f.event, senderFrame: null }, { ...f.event, senderFrame: {} }]) { + await expect(get(event, f.definitions)).rejects.toThrow('rejected sender') + } + for (const url of ['dsh-app://shell/', 'https://example.com/', 'http://localhost/']) { + f.frame.url = url + await expect(get(f.event, f.definitions)).rejects.toThrow('unowned renderer') + } + f.frame.url = 'dsh-app://app/' + const ready = await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + await expect(f.call(DESKTOP_IPC.shortcutsEdit, { type: 'reset-all', path: '/tmp' }, ready.revision)).rejects.toThrow('Invalid') + await expect(f.call(DESKTOP_IPC.shortcutsEdit, { type: 'reset-all' }, 1)).rejects.toThrow('revision') + await expect(f.call(DESKTOP_IPC.shortcutsRecording, 'true')).rejects.toThrow('recording') + f.window.isDestroyed.mockReturnValue(true) + await expect(get(f.event, f.definitions)).rejects.toThrow('rejected sender') + f.detach(); await expect(get(f.event, f.definitions)).rejects.toThrow('rejected sender') +}) + +it('routes embedded input once, follows rebindings, and guards native window closure by revision and focus', async () => { + const f = await fixture() + const closeItem = () => (f.keyboard.fileMenu({ fileMenu: 'File', closePage: 'Close' }).submenu as Electron.MenuItemConstructorOptions[])[0]! + const initial = await f.call(DESKTOP_IPC.shortcutsGet, [ + { id: 'page.close', defaults: desktopDefaults({ code: 'KeyW', modifiers: ['primary'] }) }, + { id: 'page.refresh', defaults: desktopDefaults({ code: 'KeyR', modifiers: ['primary'] }) }, + ]) + expect(closeItem().accelerator).toBe('Command+W') + const preventDefault = vi.fn() + const input = { modifiers: [], type: 'keyDown', code: 'KeyW', key: 'w', meta: true, control: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false } + f.contents.send.mockClear() + f.contents.emit('before-input-event', { preventDefault }, input) + expect(preventDefault).toHaveBeenCalledOnce() + expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, + expect.objectContaining({ kind: 'keyboard', code: 'KeyW' })) + preventDefault.mockClear(); f.contents.send.mockClear() + Object.assign(f.contents, { focusedFrame: { name: 'embedded', parent: f.frame } }) + f.contents.emit('before-input-event', { preventDefault }, input) + expect(preventDefault).toHaveBeenCalledTimes(1) + expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, expect.objectContaining({ kind: 'iframe', revision: initial.revision, frameName: 'embedded', code: 'KeyW' })) + f.contents.emit('before-input-event', { preventDefault }, { ...input, type: 'keyUp' }) + expect(f.contents.send).toHaveBeenCalledTimes(1) + const saved = await f.call(DESKTOP_IPC.shortcutsEdit, + { type: 'set', id: 'page.close', binding: { code: 'KeyJ', modifiers: ['primary'] } }, initial.revision) + expect(closeItem().accelerator).toBe('Command+J') + f.contents.send.mockClear() + f.contents.emit('before-input-event', { preventDefault }, input) + expect(f.contents.send).not.toHaveBeenCalled() + f.contents.emit('before-input-event', { preventDefault }, { ...input, code: 'KeyZ', key: 'j' }) + expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(true) + expect(f.contents.send).not.toHaveBeenCalled() + await f.call(DESKTOP_IPC.shortcutsCloseWindow, initial.revision) + expect(f.window.close).not.toHaveBeenCalled() + f.window.isEnabled.mockReturnValue(false) + await f.call(DESKTOP_IPC.shortcutsCloseWindow, saved.snapshot.revision) + expect(f.window.close).not.toHaveBeenCalled() + f.window.isEnabled.mockReturnValue(true) + await f.call(DESKTOP_IPC.shortcutsCloseWindow, saved.snapshot.revision) + expect(f.window.close).toHaveBeenCalledTimes(1) + await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'set', id: 'page.close', binding: null }, saved.snapshot.revision) + expect(closeItem().accelerator).toBeUndefined() + f.keyboard.dispose() + expect(f.contents.listenerCount('before-input-event')).toBe(0) +}) + +it.each([ + ['browser.new', 'KeyT'], ['session.new', 'KeyN'], +])('forwards Linux %s from embedded frames while leaving main-document input to the DOM', async (id, code) => { + const f = await fixture('linux') + const initial = await f.call(DESKTOP_IPC.shortcutsGet, + [{ id, defaults: desktopDefaults({ code, modifiers: ['primary'] }) }]) + const input = { modifiers: ['control'], type: 'keyDown', code, key: code.slice(3).toLowerCase(), + control: true, meta: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false } + const preventDefault = vi.fn() + f.contents.send.mockClear() + f.contents.emit('before-input-event', { preventDefault }, input) + expect(preventDefault).not.toHaveBeenCalled() + expect(f.contents.send).not.toHaveBeenCalled() + + Object.assign(f.contents, { focusedFrame: { name: 'browser', parent: f.frame } }) + f.contents.emit('before-input-event', { preventDefault }, input) + expect(preventDefault).toHaveBeenCalledOnce() + expect(f.contents.send).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.shortcutsInput, + expect.objectContaining({ kind: 'iframe', frameName: 'browser', code, revision: initial.revision })) + f.contents.emit('before-input-event', { preventDefault }, { ...input, type: 'keyUp' }) + expect(f.contents.send).toHaveBeenCalledOnce() + + const cleared = await f.call(DESKTOP_IPC.shortcutsEdit, + { type: 'set', id, binding: null }, initial.revision) + expect(cleared.status).toBe('saved') + preventDefault.mockClear(); f.contents.send.mockClear() + f.contents.emit('before-input-event', { preventDefault }, input) + expect(preventDefault).not.toHaveBeenCalled() + expect(f.contents.send).not.toHaveBeenCalled() +}) + +it.each([ + ['ArrowUp', 'Command+Up'], ['ArrowDown', 'Command+Down'], + ['ArrowLeft', 'Command+Left'], ['ArrowRight', 'Command+Right'], +])('uses the Electron accelerator for a saved %s binding', async (code, accelerator) => { + const f = await fixture() + const initial = await f.call(DESKTOP_IPC.shortcutsGet, + [{ id: 'page.close', defaults: desktopDefaults({ code: 'KeyW', modifiers: ['primary'] }) }]) + const saved = await f.call(DESKTOP_IPC.shortcutsEdit, + { type: 'set', id: 'page.close', binding: { code, modifiers: ['primary'] } }, initial.revision) + expect(saved.status).toBe('saved') + const menu = f.keyboard.fileMenu({ fileMenu: 'File', closePage: 'Close' }).submenu as Electron.MenuItemConstructorOptions[] + expect(menu[0]?.accelerator).toBe(accelerator) +}) + +it('refreshes the native menu only when its accelerator or availability changes', async () => { + const f = await fixture() + const close = { id: 'page.close', defaults: desktopDefaults({ code: 'KeyW', modifiers: ['primary'] }) } + expect(f.updateMenu).not.toHaveBeenCalled() + await f.call(DESKTOP_IPC.shortcutsGet, [close]) + expect(f.updateMenu).toHaveBeenCalledOnce() + const menu = f.keyboard.fileMenu({ fileMenu: 'File', closePage: 'Close' }).submenu as Electron.MenuItemConstructorOptions[] + const registered = await f.call(DESKTOP_IPC.shortcutsGet, [close, ...f.definitions]) + const edited = await f.call(DESKTOP_IPC.shortcutsEdit, + { type: 'set', id: 'sidebar.left.toggle', binding: { code: 'KeyJ', modifiers: ['primary'] } }, registered.revision) + expect(edited.status).toBe('saved') + expect(f.updateMenu).toHaveBeenCalledOnce() + f.contents.send.mockClear() + const click = menu[0]!.click as () => void + click() + expect(f.contents.send).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.shortcutsInput, + { kind: 'menu', commandId: 'page.close', revision: edited.snapshot.revision }) + await f.call(DESKTOP_IPC.shortcutsEdit, + { type: 'set', id: 'page.close', binding: { code: 'KeyQ', modifiers: ['primary'] } }, edited.snapshot.revision) + expect(f.updateMenu).toHaveBeenCalledTimes(2) + f.contents.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false }) + expect(f.updateMenu).toHaveBeenCalledTimes(3) + f.contents.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false }) + expect(f.updateMenu).toHaveBeenCalledTimes(3) +}) + +it.each(['macos', 'windows'] as const)('prioritizes %s custom editing bindings before both main and embedded frame handlers', async (platform) => { + const f = await fixture(platform) + const initial = await f.call(DESKTOP_IPC.shortcutsGet, + [{ id: 'session.new', defaults: desktopDefaults({ code: 'KeyN', modifiers: ['primary'] }) }]) + const saved = await f.call(DESKTOP_IPC.shortcutsEdit, + { type: 'set', id: 'session.new', binding: { code: 'KeyC', modifiers: ['primary'] } }, initial.revision) + expect(saved.status).toBe('saved') + const input = { modifiers: [], type: 'keyDown', code: 'KeyC', key: 'c', meta: platform === 'macos', + control: platform === 'windows', alt: false, shift: false, isAutoRepeat: false, isComposing: false } + const preventDefault = vi.fn() + const press = (changes = {}) => f.contents.emit('before-input-event', { preventDefault }, { ...input, ...changes }) + f.contents.send.mockClear() + press() + expect(preventDefault).toHaveBeenCalledOnce() + expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, expect.objectContaining({ kind: 'keyboard', code: 'KeyC' })) + press({ type: 'keyUp' }) + expect(preventDefault).toHaveBeenCalledTimes(2) + expect(f.contents.send).toHaveBeenCalledOnce() + Object.assign(f.contents, { focusedFrame: { name: 'embedded', parent: f.frame } }) + press() + expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, expect.objectContaining({ kind: 'iframe', frameName: 'embedded' })) + await f.call(DESKTOP_IPC.shortcutsRecording, true) + preventDefault.mockClear(); f.contents.send.mockClear() + press() + expect(preventDefault).not.toHaveBeenCalled() + expect(f.contents.send).not.toHaveBeenCalled() + await f.call(DESKTOP_IPC.shortcutsRecording, false) + press({ isComposing: true }) + press({ modifiers: ['altgr'] }) + press({ key: 'Dead' }) + press() + expect(preventDefault).not.toHaveBeenCalled() + await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'set', id: 'session.new', binding: null }, saved.snapshot.revision) + f.contents.send.mockClear() + press() + expect(preventDefault).not.toHaveBeenCalled() + expect(f.contents.send).not.toHaveBeenCalled() + expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(false) +}) + +it.each(['macos', 'windows'] as const)('intercepts complete %s chords once and leaves their first key untouched', async (platform) => { + const f = await fixture(platform) + const initial = await f.call(DESKTOP_IPC.shortcutsGet, + [{ id: 'page.close', defaults: desktopDefaults({ code: 'KeyW', modifiers: ['primary'] }) }]) + const saved = await f.call(DESKTOP_IPC.shortcutsEdit, + { type: 'set', id: 'page.close', binding: { code: 'KeyA', secondCode: 'KeyB', modifiers: [] } }, initial.revision) + expect(saved.status).toBe('saved') + const menu = f.keyboard.fileMenu({ fileMenu: 'File', closePage: 'Close' }).submenu as Electron.MenuItemConstructorOptions[] + expect(menu[0]?.accelerator).toBeUndefined() + const preventDefault = vi.fn() + const press = (code: string, type = 'keyDown', isAutoRepeat = false) => { + f.contents.emit('before-input-event', { preventDefault }, { code, key: code.slice(3), type, modifiers: [], + control: false, alt: false, shift: false, meta: false, isAutoRepeat, isComposing: false }) + } + for (const embedded of [false, true]) { + Object.assign(f.contents, { focusedFrame: embedded ? { name: 'browser', parent: f.frame } : f.frame }) + f.contents.send.mockClear(); preventDefault.mockClear() + press('KeyA'); press('KeyA', 'keyUp'); press('KeyB'); press('KeyB', 'keyUp') + expect(preventDefault).not.toHaveBeenCalled() + expect(f.contents.send).not.toHaveBeenCalled() + press('KeyB') + expect(preventDefault).not.toHaveBeenCalled() + press('KeyA') + expect(preventDefault).toHaveBeenCalledOnce() + expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, + expect.objectContaining({ kind: embedded ? 'iframe' : 'keyboard', code: 'KeyA', secondCode: 'KeyB', repeat: false })) + press('KeyA', 'keyUp'); press('KeyB', 'keyUp') + } + for (const reset of [() => { f.window.emit('blur') }, + () => { press('MetaLeft', 'keyUp') }, + () => { Object.assign(f.contents, { focusedFrame: f.frame }) }]) { + press('KeyA'); reset(); f.contents.send.mockClear(); press('KeyB') + expect(f.contents.send).not.toHaveBeenCalled() + press('KeyA', 'keyUp'); press('KeyB', 'keyUp') + } + f.contents.send.mockClear() + press('KeyC'); press('KeyA'); press('KeyB') + expect(f.contents.send).not.toHaveBeenCalled() +}) + +it.each(['C', 'V', 'Z'])('delivers Windows Edit %s to the editor and physical input to its shortcut', async (keyCode) => { + const f = await fixture('windows') + const initial = await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + const saved = await f.call(DESKTOP_IPC.shortcutsEdit, + { type: 'set', id: 'sidebar.left.toggle', binding: { code: `Key${keyCode}`, modifiers: ['control'] } }, initial.revision) + const registry = new ShortcutRegistry('desktop', 'windows', saved.snapshot) + const run = vi.fn() + registry.register({ ...f.definitions[0]!, label: () => 'Toggle sidebar', aliases: [], regions: ['page', 'editable'], modals: [], + resolve: () => ({ status: 'handled', run }) }) + let nativeInput: (input: DesktopShortcutInput) => void = () => {} + onTestFinished(installNativeKeyboard(window, { + closeWindow: vi.fn(), subscribe: (listener) => { nativeInput = listener; return () => {} }, + }, registry, () => registry.config.getSnapshot())) + onTestFinished(installKeyboard(window, registry, undefined, true)) + f.contents.send.mockImplementation((channel: string, input: DesktopShortcutInput) => { + if (channel === DESKTOP_IPC.shortcutsInput) nativeInput(input) + }) + const editorInput: string[] = [] + const deliver = (type: 'keyDown' | 'keyUp'): void => { + const preventDefault = vi.fn() + f.contents.emit('before-input-event', { preventDefault }, { type, code: `Key${keyCode}`, key: keyCode.toLowerCase(), + modifiers: ['control'], control: true, meta: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false }) + if (!preventDefault.mock.calls.length) { + editorInput.push(type) + document.body.dispatchEvent(new KeyboardEvent(type === 'keyDown' ? 'keydown' : 'keyup', + { code: `Key${keyCode}`, key: keyCode.toLowerCase(), ctrlKey: true, bubbles: true, cancelable: true })) + } + } + f.contents.sendInputEvent.mockImplementation((input: { type: 'keyDown' | 'keyUp' }) => { deliver(input.type) }) + f.contents.send.mockClear() + + f.keyboard.sendEditingKey(keyCode, ['control']) + + expect(f.contents.focus).toHaveBeenCalledOnce() + expect(editorInput).toEqual(['keyDown', 'keyUp']) + expect(f.contents.send).not.toHaveBeenCalled() + expect(run).not.toHaveBeenCalled() + expect(f.contents.sendInputEvent.mock.calls).toEqual([ + [{ type: 'keyDown', keyCode, modifiers: ['control'] }], + [{ type: 'keyUp', keyCode, modifiers: ['control'] }], + ]) + editorInput.length = 0 + deliver('keyDown') + deliver('keyUp') + expect(editorInput).toEqual([]) + expect(f.contents.send).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.shortcutsInput, + expect.objectContaining({ kind: 'keyboard', code: `Key${keyCode}` })) + expect(run).toHaveBeenCalledOnce() +}) + +it('restores shortcut interception after native Edit delivery fails', async () => { + const f = await fixture('windows') + await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + f.contents.sendInputEvent.mockImplementationOnce(() => { throw new Error('editor unavailable') }) + expect(() => { f.keyboard.sendEditingKey('C', ['control']) }).toThrow('editor unavailable') + const preventDefault = vi.fn() + f.contents.emit('before-input-event', { preventDefault }, { type: 'keyDown', code: 'KeyB', key: 'b', modifiers: ['control'], + control: true, meta: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false }) + expect(preventDefault).toHaveBeenCalledOnce() + f.window.isDestroyed.mockReturnValue(true) + expect(() => { f.keyboard.sendEditingKey('C', ['control']) }).not.toThrow() + f.detach() + expect(() => { f.keyboard.sendEditingKey('C', ['control']) }).not.toThrow() + expect(f.contents.sendInputEvent).toHaveBeenCalledOnce() +}) + +it.each(['macos', 'windows'] as const)('intercepts %s standalone custom keys and clears held state when preferences change', async (platform) => { + const f = await fixture(platform) + let snapshot = await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + for (const code of ['KeyA', 'F1', 'ArrowLeft', 'Escape', 'Tab', 'Enter']) { + const saved = await f.call(DESKTOP_IPC.shortcutsEdit, + { type: 'set', id: 'sidebar.left.toggle', binding: { code, modifiers: [] } }, snapshot.revision) + expect(saved.status).toBe('saved'); snapshot = saved.snapshot + const preventDefault = vi.fn() + f.contents.send.mockClear() + f.contents.emit('before-input-event', { preventDefault }, { type: 'keyDown', code, key: code, modifiers: [], + control: false, alt: false, shift: false, meta: false, isAutoRepeat: false, isComposing: false }) + expect(preventDefault).toHaveBeenCalledOnce() + expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, + expect.objectContaining({ code, revision: snapshot.revision })) + } +}) + +it.each(['macos', 'windows'] as const)('keeps %s native interception identical for inherited, saved and reset bindings', async (platform) => { + const f = await fixture(platform) + let snapshot = await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + for (const operation of [null, + { type: 'set', id: 'sidebar.left.toggle', binding: { code: 'KeyB', modifiers: ['primary'] } }, + { type: 'reset', id: 'sidebar.left.toggle' }]) { + if (operation !== null) { + const result = await f.call(DESKTOP_IPC.shortcutsEdit, operation, snapshot.revision) + expect(result.status).toBe('saved'); snapshot = result.snapshot + } + for (const embedded of [false, true]) { + Object.assign(f.contents, { focusedFrame: embedded ? { name: 'browser', parent: f.frame } : f.frame }) + const preventDefault = vi.fn() + f.contents.send.mockClear() + f.contents.emit('before-input-event', { preventDefault }, { type: 'keyDown', code: 'KeyB', key: 'b', modifiers: [], + control: platform === 'windows', meta: platform === 'macos', alt: false, shift: false, isAutoRepeat: false, isComposing: false }) + expect(preventDefault).toHaveBeenCalledOnce() + expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, + expect.objectContaining({ kind: embedded ? 'iframe' : 'keyboard', code: 'KeyB', revision: snapshot.revision })) + } + } +}) + +it('consumes a held single-key repeat after focus reset without treating it as a fresh press', async () => { + const f = await fixture('macos') + await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + const preventDefault = vi.fn() + const input = { type: 'keyDown', code: 'KeyB', key: 'b', modifiers: [], + control: false, meta: true, alt: false, shift: false, isAutoRepeat: false, isComposing: false } + f.contents.emit('before-input-event', { preventDefault }, input) + f.window.emit('blur') + f.contents.send.mockClear(); preventDefault.mockClear() + f.contents.emit('before-input-event', { preventDefault }, { ...input, isAutoRepeat: true }) + expect(preventDefault).toHaveBeenCalledOnce() + expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, expect.objectContaining({ code: 'KeyB', repeat: true })) +}) + +it('delivers a fresh unbound key release after macOS omitted the previous Command character release', async () => { + const f = await fixture('macos') + await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + const preventDefault = vi.fn() + const press = (code: string, type: string, meta: boolean): void => { + f.contents.emit('before-input-event', { preventDefault }, { type, code, key: code, modifiers: [], + control: false, meta, alt: false, shift: false, isAutoRepeat: false, isComposing: false }) + } + press('KeyB', 'keyDown', true) + expect(preventDefault).toHaveBeenCalledOnce() + press('MetaLeft', 'keyUp', false) + preventDefault.mockClear() + press('KeyB', 'keyDown', false); press('KeyB', 'keyUp', false) + expect(preventDefault).not.toHaveBeenCalled() +}) + +it.each(['macos', 'windows'] as const)('releases %s native input when a saved binding overlaps a mounted fixed action', async (platform) => { + const f = await fixture(platform) + const initial = await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + const saved = await f.call(DESKTOP_IPC.shortcutsEdit, + { type: 'set', id: 'sidebar.left.toggle', binding: { code: 'Escape', modifiers: [] } }, initial.revision) + expect(saved.status).toBe('saved') + await f.call(DESKTOP_IPC.shortcutsGet, [...f.definitions, + { id: 'menu.dismiss', defaults: {}, fixed: [{ code: 'Escape', modifiers: [] }] }]) + const preventDefault = vi.fn() + f.contents.send.mockClear() + f.contents.emit('before-input-event', { preventDefault }, { type: 'keyDown', code: 'Escape', key: 'Escape', modifiers: [], + control: false, meta: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false }) + expect(preventDefault).not.toHaveBeenCalled() + expect(f.contents.send).not.toHaveBeenCalled() +}) + +it.each(['macos', 'windows'] as const)('keeps %s chord presses singular across native interception and renderer key delivery', async (platform) => { + const f = await fixture(platform) + const initial = await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + const saved = await f.call(DESKTOP_IPC.shortcutsEdit, + { type: 'set', id: 'sidebar.left.toggle', binding: { code: 'KeyF', secondCode: 'KeyG', modifiers: [] } }, initial.revision) + const registry = new ShortcutRegistry('desktop', platform, saved.snapshot) + const run = vi.fn() + registry.register({ ...f.definitions[0]!, label: () => 'Toggle sidebar', aliases: [], regions: ['page'], modals: [], + resolve: () => ({ status: 'handled', run }) }) + let deliver: (input: DesktopShortcutInput) => void = () => {} + onTestFinished(installNativeKeyboard(window, { closeWindow: vi.fn(), subscribe: (listener) => { deliver = listener; return () => {} } }, + registry, () => registry.config.getSnapshot())) + onTestFinished(installKeyboard(window, registry, undefined, true)) + f.contents.send.mockImplementation((channel: string, input: DesktopShortcutInput) => { + if (channel === DESKTOP_IPC.shortcutsInput) deliver(input) + }) + const rendererReleases: string[] = [] + const press = (code: string, type: 'keyDown' | 'keyUp', repeat = false): void => { + const preventDefault = vi.fn() + f.contents.emit('before-input-event', { preventDefault }, { code, key: code.slice(3).toLowerCase(), type, modifiers: [], + control: false, meta: false, alt: false, shift: false, isAutoRepeat: repeat, isComposing: false }) + // Electron only delivers input to the document when the main process did not intercept it. + if (!preventDefault.mock.calls.length) { + if (type === 'keyUp') rendererReleases.push(code) + document.body.dispatchEvent(new KeyboardEvent(type === 'keyDown' ? 'keydown' : 'keyup', + { code, key: code.slice(3).toLowerCase(), repeat, bubbles: true, cancelable: true })) + } + } + for (const [index, [first, second]] of ([['KeyF', 'KeyG'], ['KeyG', 'KeyF']] as const).entries()) { + press(first, 'keyDown'); press(first, 'keyUp'); press(second, 'keyDown'); press(second, 'keyUp') + expect(run).toHaveBeenCalledTimes(index) + press(first, 'keyDown'); press(second, 'keyDown'); press(second, 'keyDown', true); press(first, 'keyDown', true) + expect(run).toHaveBeenCalledTimes(index + 1) + if (index === 0) { press(first, 'keyUp'); press(second, 'keyUp') } + else { press(second, 'keyUp'); press(first, 'keyUp') } + press(second, 'keyDown'); press(second, 'keyUp'); press(first, 'keyDown'); press(first, 'keyUp') + expect(run).toHaveBeenCalledTimes(index + 1) + } + expect(rendererReleases.filter(code => code === 'KeyF')).toHaveLength(5) + expect(rendererReleases.filter(code => code === 'KeyG')).toHaveLength(5) +}) + +it.each(['macos', 'windows'] as const)('requires fresh %s chord presses when Electron omits intercepted key releases', async (platform) => { + const f = await fixture(platform) + const bindings = [ + { id: 'sidebar.left.toggle', binding: { code: 'KeyJ', secondCode: 'KeyK', modifiers: [] } }, + { id: 'sidebar.right.toggle', binding: { code: 'KeyF', secondCode: 'KeyG', modifiers: [] } }, + { id: 'shortcuts.open', binding: { code: 'KeyV', modifiers: [] } }, + ] + let snapshot = await f.call(DESKTOP_IPC.shortcutsGet, + bindings.map(({ id }) => ({ id, defaults: {} }))) + for (const binding of bindings) { + const saved = await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'set', ...binding }, snapshot.revision) + expect(saved.status).toBe('saved') + snapshot = saved.snapshot + } + const press = (code: string, type = 'keyDown', repeat = false): boolean => { + const preventDefault = vi.fn() + f.contents.emit('before-input-event', { preventDefault }, { code, key: code.slice(3).toLowerCase(), type, modifiers: [], + control: false, meta: false, alt: false, shift: false, isAutoRepeat: repeat, isComposing: false }) + return preventDefault.mock.calls.length > 0 + } + for (const embedded of [false, true]) { + Object.assign(f.contents, { focusedFrame: embedded ? { name: 'browser', parent: f.frame } : f.frame }) + f.contents.send.mockClear() + expect(press('KeyJ')).toBe(false) + expect(press('KeyK')).toBe(true) + expect(f.contents.send).toHaveBeenCalledOnce() + // Electron on macOS can omit both releases after intercepting the second keydown. + for (const code of ['KeyJ', 'KeyK']) { + expect(press(code)).toBe(false) + expect(press(code, 'keyUp')).toBe(false) + } + expect(f.contents.send).toHaveBeenCalledOnce() + expect(press('KeyK')).toBe(false) + expect(press('KeyJ')).toBe(true) + expect(f.contents.send).toHaveBeenCalledTimes(2) + expect(press('KeyJ', 'keyDown', true)).toBe(true) + expect(press('KeyK', 'keyDown', true)).toBe(true) + expect(f.contents.send).toHaveBeenCalledTimes(2) + expect(press('KeyF')).toBe(false) + expect(press('KeyG')).toBe(true) + expect(f.contents.send).toHaveBeenCalledTimes(3) + expect(press('KeyV')).toBe(true) + expect(press('KeyJ')).toBe(false) + expect(press('KeyK')).toBe(true) + expect(f.contents.send).toHaveBeenCalledTimes(5) + } +}) + +it.each(['macos', 'windows', 'linux'] as const)('routes approved %s browser guest input to its owner and stops delivery before guest destruction', async (platform) => { + const f = await fixture(platform) + const snapshot = await f.call(DESKTOP_IPC.shortcutsGet, + [{ id: 'browser.new', defaults: desktopDefaults({ code: 'KeyT', modifiers: ['primary'] }) }]) + const guests = new DesktopBrowserGuests(() => undefined) as GuestsFixture + const attach = vi.fn((guest: ContentsFixture, name: DesktopBrowserLeaseId) => f.keyboard.attachGuest(f.window, guest, name)) + guests.bind(f.window, attach) + const reservation = guests.acquire(f.contents, 'session:test') + const frame: FrameFixture = { url: `about:blank#${reservation.lease}`, name: '', parent: null } + const guest = Object.assign(new EventEmitter(), { mainFrame: frame, focusedFrame: frame, + getURL: () => frame.url, isDestroyed: () => false, isFocused: vi.fn(() => true), + setWindowOpenHandler: vi.fn(), setIgnoreMenuShortcuts: vi.fn(), send: vi.fn(), close: vi.fn(), + focus: vi.fn(), sendInputEvent: vi.fn() }) + onTestFinished(() => { guest.emit('destroyed') }) + const rejected = { preventDefault: vi.fn() } + f.contents.emit('will-attach-webview', rejected, {}, { src: 'about:blank#unknown', partition: reservation.partition }) + expect(rejected.preventDefault).toHaveBeenCalledOnce() + const invalid = Object.assign(new EventEmitter(), { getURL: () => 'about:blank#unknown', setWindowOpenHandler: vi.fn(), close: vi.fn() }) + f.contents.emit('did-attach-webview', {}, invalid) + invalid.emit('dom-ready') + expect(invalid.close).toHaveBeenCalledExactlyOnceWith({ waitForBeforeUnload: false }) + expect(attach).not.toHaveBeenCalled() + + const approved = { preventDefault: vi.fn() } + f.contents.emit('will-attach-webview', approved, {}, { src: frame.url, partition: reservation.partition }) + expect(approved.preventDefault).not.toHaveBeenCalled() + f.contents.emit('did-attach-webview', {}, guest) + guest.emit('dom-ready') + expect(attach).toHaveBeenCalledExactlyOnceWith(guest, reservation.lease) + const input = { modifiers: [], type: 'keyDown', code: 'KeyT', key: 't', meta: platform === 'macos', + control: platform !== 'macos', alt: false, shift: false, isAutoRepeat: false, isComposing: false } + const preventDefault = vi.fn() + f.contents.send.mockClear() + guest.emit('before-input-event', { preventDefault }, input) + expect(preventDefault).toHaveBeenCalledOnce() + expect(guest.send).not.toHaveBeenCalled() + expect(f.contents.send).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.shortcutsInput, { + kind: 'webview', frameName: reservation.lease, revision: snapshot.revision, code: 'KeyT', + meta: platform === 'macos', control: platform !== 'macos', alt: false, shift: false, repeat: false, + }) + guest.isFocused.mockReturnValue(false) + guest.emit('before-input-event', { preventDefault }, input) + expect(f.contents.send).toHaveBeenCalledOnce() + guest.isFocused.mockReturnValue(true) + const release = guests.release(f.contents, reservation.lease) + expect(guest.close).toHaveBeenCalledExactlyOnceWith({ waitForBeforeUnload: false }) + expect(guest.listenerCount('before-input-event')).toBe(0) + guest.emit('before-input-event', { preventDefault }, input) + expect(f.contents.send).toHaveBeenCalledOnce() + guest.emit('destroyed') + await release +}) + +it('keeps browser guest chord state local and leaves accepted bindings active through guest navigation', async () => { + const f = await fixture('macos') + const initial = await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'set', id: 'sidebar.left.toggle', + binding: { code: 'KeyF', secondCode: 'KeyG', modifiers: [] } }, initial.revision) + const frame: FrameFixture = { url: 'https://example.test/', name: '', parent: null } + const guest = Object.assign(new EventEmitter(), { mainFrame: frame, focusedFrame: frame, + isDestroyed: () => false, isFocused: () => true, setIgnoreMenuShortcuts: vi.fn(), + send: vi.fn(), focus: vi.fn(), sendInputEvent: vi.fn() }) + const blurListeners = f.window.listenerCount('blur') + const closedListeners = f.window.listenerCount('closed') + const dispose = f.keyboard.attachGuest(f.window, guest, 'guest' as DesktopBrowserLeaseId) + onTestFinished(dispose) + expect(f.window.listenerCount('blur')).toBe(blurListeners) + expect(f.window.listenerCount('closed')).toBe(closedListeners) + const press = (contents: EventEmitter, code: string): void => { + contents.emit('before-input-event', { preventDefault: vi.fn() }, { modifiers: [], type: 'keyDown', code, key: code, + meta: false, control: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false }) + } + f.contents.send.mockClear() + press(guest, 'KeyF') + guest.emit('blur') + press(guest, 'KeyG') + expect(f.contents.send).not.toHaveBeenCalled() + f.window.emit('blur') + press(guest, 'KeyF') + expect(f.contents.send).not.toHaveBeenCalled() + guest.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false }) + press(guest, 'KeyF') + press(f.contents, 'KeyG') + expect(f.contents.send).not.toHaveBeenCalled() + press(guest, 'KeyG') + expect(f.contents.send).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.shortcutsInput, + expect.objectContaining({ kind: 'webview', frameName: 'guest', code: 'KeyF', secondCode: 'KeyG' })) + guest.emit('destroyed') + press(f.contents, 'KeyF') + expect(f.contents.send).toHaveBeenCalledTimes(2) + expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, + expect.objectContaining({ kind: 'keyboard', code: 'KeyF', secondCode: 'KeyG' })) +}) + +it('delivers Windows Edit actions to the focused browser guest without invoking its custom shortcut', async () => { + const f = await fixture('windows') + const initial = await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'set', id: 'sidebar.left.toggle', + binding: { code: 'KeyC', modifiers: ['control'] } }, initial.revision) + const frame: FrameFixture = { url: 'https://example.test/', name: '', parent: null } + const guest = Object.assign(new EventEmitter(), { mainFrame: frame, focusedFrame: frame, + isDestroyed: () => false, isFocused: () => true, setIgnoreMenuShortcuts: vi.fn(), + send: vi.fn(), focus: vi.fn(), sendInputEvent: vi.fn() }) + const dispose = f.keyboard.attachGuest(f.window, guest, 'guest' as DesktopBrowserLeaseId) + onTestFinished(dispose) + const events: string[] = [] + guest.sendInputEvent.mockImplementation((input: { type: 'keyDown' | 'keyUp' }) => { + const preventDefault = vi.fn() + guest.emit('before-input-event', { preventDefault }, { ...input, code: 'KeyC', key: 'c', modifiers: ['control'], + control: true, meta: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false }) + if (!preventDefault.mock.calls.length) events.push(input.type) + }) + f.contents.send.mockClear() + f.keyboard.sendEditingKey('C', ['control']) + expect(guest.focus).toHaveBeenCalledOnce() + expect(events).toEqual(['keyDown', 'keyUp']) + expect(f.contents.send).not.toHaveBeenCalled() + expect(f.contents.sendInputEvent).not.toHaveBeenCalled() + dispose() + f.keyboard.sendEditingKey('C', ['control']) + expect(f.contents.sendInputEvent).toHaveBeenCalledTimes(2) +}) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 0242d214a3..06509c7ae4 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -1,6 +1,6 @@ import type { AccountView } from '@deepseek-ai/dsh-deepseek-account/types' import { WINDOWS_TITLEBAR_HEIGHT } from '../src/windows-layout.ts' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import type { IpcMainInvokeEvent } from 'electron' import { join } from 'node:path' import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' @@ -69,6 +69,8 @@ const harness = await vi.hoisted(async () => { getURL: () => this.urls.at(-1) ?? '', mainFrame: { url: '' }, getZoomFactor: () => 1, + isDestroyed: () => this.destroyed, + setIgnoreMenuShortcuts: vi.fn(), focus: vi.fn(), sendInputEvent: vi.fn(), send: vi.fn((channel: string, state: { policy?: { blocking: boolean } }) => { @@ -140,7 +142,7 @@ const harness = await vi.hoisted(async () => { getVersion: () => '1.0.0', getAppPath: (): string => 'desktop-test-app', setAppLogsPath: vi.fn(), - getPath: (name: string): string => `desktop-test-${name}`, + getPath: vi.fn<(name: string) => string>(), setAboutPanelOptions: vi.fn<(options: Electron.AboutPanelOptionsOptions) => void>(), requestSingleInstanceLock: () => true, setAsDefaultProtocolClient: vi.fn(), @@ -324,6 +326,9 @@ beforeEach(() => { testAuth.login.mockResolvedValue('cancelled') vi.useFakeTimers() harness.reset() + const userData = mkdtempSync(join(tmpdir(), 'dsh-main-user-data-')) + onTestFinished(() => { rmSync(userData, { recursive: true, force: true }) }) + harness.app.getPath.mockImplementation(name => name === 'userData' ? userData : `desktop-test-${name}`) harness.dialog.showMessageBox.mockImplementation((options: { title?: string }) => { if (options.title !== en.startupFailed) return Promise.resolve({ response: 1 }) harness.dialogShown.resolve() @@ -781,7 +786,7 @@ describe('desktop main startup', () => { await edit }) - it.each(['darwin', 'linux'] as const)('adds the standard macOS window commands only on macOS (%s)', async (platform) => { + it.each(['darwin', 'linux'] as const)('adds the product File menu and standard window commands only on macOS (%s)', async (platform) => { vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) await import('../src/main.ts') await harness.preparing.promise @@ -792,7 +797,7 @@ describe('desktop main startup', () => { .find(items => items.some(item => item.role === 'editMenu')) if (template === undefined) throw new Error('application menu missing') expect(template.map(describeItem)).toEqual(platform === 'darwin' - ? ['Desktop test', 'fileMenu', 'editMenu', 'windowMenu'] + ? ['Desktop test', en.fileMenu, 'editMenu', 'windowMenu'] : ['Application', 'editMenu']) const application = template[0]!.submenu as MenuItemConstructorOptions[] expect(application.filter(item => item.visible !== false).map(describeItem)).toEqual(platform === 'darwin' diff --git a/apps/desktop/tests/preload-app.spec.ts b/apps/desktop/tests/preload-app.spec.ts index 16c94456a4..0564fdf91a 100644 --- a/apps/desktop/tests/preload-app.spec.ts +++ b/apps/desktop/tests/preload-app.spec.ts @@ -1,5 +1,6 @@ +// @vitest-environment jsdom import { JSDOM } from 'jsdom' -import { afterEach, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { installMandatoryUpdateOverlay } from '../src/preload-mandatory-overlay.ts' import { syncWindowsAppearance } from '../src/preload-windows.ts' import { DESKTOP_IPC, type DshDesktopProductApi } from '../src/ipc.ts' @@ -15,7 +16,8 @@ vi.mock('../src/preload-theme.ts', () => ({ syncNativeTheme: vi.fn() })) vi.mock('../src/preload-windows.ts', () => ({ syncWindowsAppearance: vi.fn() })) vi.mock('../src/preload-mandatory-overlay.ts', () => ({ installMandatoryUpdateOverlay: vi.fn() })) -afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); vi.clearAllMocks(); vi.resetModules() }) +beforeEach(() => { vi.stubGlobal('process', { ...process, isMainFrame: true }) }) +afterEach(() => { document.body.replaceChildren(); vi.unstubAllGlobals(); vi.restoreAllMocks(); vi.clearAllMocks(); vi.resetModules() }) it('limits product documents to update status and a native confirmation action', async () => { vi.stubGlobal('location', new URL('dsh-app://app/index.html')) @@ -121,7 +123,7 @@ it('moves welcome-entry focus to the document without changing keyboard tab orde }) it.each(['win32', 'darwin'] as const)('installs the embedded mandatory UI only in the Windows app document (%s)', async (platform) => { - vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + vi.stubGlobal('process', { ...process, platform }) for (const url of ['dsh-app://app/', 'dsh-app://shell/mandatory-update.html', 'https://example.com/']) { vi.resetModules() vi.mocked(installMandatoryUpdateOverlay).mockClear() @@ -130,3 +132,92 @@ it.each(['win32', 'darwin'] as const)('installs the embedded mandatory UI only i expect(installMandatoryUpdateOverlay).toHaveBeenCalledTimes(platform === 'win32' && url === 'dsh-app://app/' ? 1 : 0) } }) + +it('exposes constrained shortcut operations and releases configuration subscriptions', async () => { + vi.stubGlobal('location', new URL('dsh-app://app/')) + await import('../src/preload-app.ts') + const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === 'dshDesktop')?.[1] as DshDesktopProductApi + await api.shortcuts.get([]) + expect(api.shortcuts).not.toHaveProperty('reload') + await api.shortcuts.recording(true) + expect(electron.ipcRenderer.invoke).toHaveBeenCalledWith(DESKTOP_IPC.shortcutsGet, []) + expect(electron.ipcRenderer.invoke).toHaveBeenCalledWith(DESKTOP_IPC.shortcutsRecording, true) + const listener = vi.fn() + const off = api.shortcuts.subscribe(listener) + const handler = electron.ipcRenderer.on.mock.calls.find(([name]) => name === DESKTOP_IPC.shortcutsChanged)![1] as + (event: unknown, state: unknown) => void + handler({}, { status: 'ready' }) + expect(listener).toHaveBeenCalledWith({ status: 'ready' }) + off(); expect(electron.ipcRenderer.off).toHaveBeenCalledWith(DESKTOP_IPC.shortcutsChanged, handler) +}) + +it('withholds the product API from same-origin child frames', async () => { + vi.stubGlobal('location', new URL('dsh-app://app/')) + vi.stubGlobal('process', { ...process, isMainFrame: false }) + await import('../src/preload-app.ts') + expect(electron.contextBridge.exposeInMainWorld).toHaveBeenCalledWith('dshDesktop', { protocolVersion: 1 }) +}) + +it('forwards only the focused product iframe and releases native input subscriptions', async () => { + vi.stubGlobal('location', new URL('dsh-app://app/')) + await import('../src/preload-app.ts') + const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === 'dshDesktop')?.[1] as DshDesktopProductApi + const listener = vi.fn() + const off = api.keyboard.subscribe(listener) + const handler = electron.ipcRenderer.on.mock.calls.find(([name]) => name === DESKTOP_IPC.shortcutsInput)![1] as + (event: unknown, state: unknown) => void + const input = { kind: 'iframe', frameName: 'preview', revision: 'current' } + handler({}, input) + const frame = document.createElement('iframe') + document.body.append(frame) + frame.name = 'preview' + frame.focus() + handler({}, input) + expect(listener).not.toHaveBeenCalled() + frame.setAttribute('data-html-preview', '') + handler({}, { ...input, frameName: 'stale' }) + handler({}, { ...input, frameName: '' }) + expect(listener).not.toHaveBeenCalled() + handler({}, input) + expect(listener).toHaveBeenCalledExactlyOnceWith(input) + frame.remove() + handler({}, input) + expect(listener).toHaveBeenCalledTimes(1) + const menu = { kind: 'menu', commandId: 'page.close', revision: 'current' } + handler({}, menu) + expect(listener).toHaveBeenLastCalledWith(menu) + off() + expect(electron.ipcRenderer.off).toHaveBeenCalledWith(DESKTOP_IPC.shortcutsInput, handler) +}) + +it('forwards browser guest input only for the focused live webview lease', async () => { + vi.stubGlobal('location', new URL('dsh-app://app/')) + await import('../src/preload-app.ts') + const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === 'dshDesktop')?.[1] as DshDesktopProductApi + const listener = vi.fn() + const off = api.keyboard.subscribe(listener) + const handler = electron.ipcRenderer.on.mock.calls.find(([name]) => name === DESKTOP_IPC.shortcutsInput)![1] as + (event: unknown, state: unknown) => void + const input = { kind: 'webview', frameName: 'guest', revision: 'current' } + const frame = document.createElement('webview') + frame.tabIndex = 0 + frame.setAttribute('name', 'guest') + document.body.append(frame) + frame.focus() + handler({}, input) + expect(listener).not.toHaveBeenCalled() + frame.setAttribute('data-sidebar-browser-frame', 'webview') + handler({}, { ...input, frameName: '' }) + handler({}, { ...input, frameName: 'old' }) + expect(listener).not.toHaveBeenCalled() + handler({}, input) + expect(listener).toHaveBeenCalledExactlyOnceWith(input) + const other = document.createElement('input') + document.body.append(other) + other.focus() + handler({}, input) + frame.remove() + handler({}, input) + expect(listener).toHaveBeenCalledOnce() + off() +}) diff --git a/apps/desktop/tests/welcome-startup.spec.ts b/apps/desktop/tests/welcome-startup.spec.ts index 4a823afdf9..942d30b488 100644 --- a/apps/desktop/tests/welcome-startup.spec.ts +++ b/apps/desktop/tests/welcome-startup.spec.ts @@ -51,7 +51,7 @@ vi.mock('electron', () => ({ getVersion: () => '1.0.0', setAboutPanelOptions: vi.fn(), getAppPath: () => '/development-app', - getPath: (name: string) => `/development-${name}`, + getPath: (name: string) => name === 'userData' ? '/desktop-user-data' : `/development-${name}`, setAppLogsPath: vi.fn(), getPreferredSystemLanguages: () => ['en-US'], on: (name: string, callback: (...args: unknown[]) => void) => { state.appListeners.set(name, callback) }, diff --git a/apps/desktop/tsconfig.host.json b/apps/desktop/tsconfig.host.json index 37a5be420d..370ee0a448 100644 --- a/apps/desktop/tsconfig.host.json +++ b/apps/desktop/tsconfig.host.json @@ -22,6 +22,12 @@ }, { "path": "../../packages/client/ui-sidebar-browser/tsconfig.host.json" + }, + { + "path": "../../packages/client/shortcuts/tsconfig.host.json" + }, + { + "path": "../../packages/util/atomic-write" } ], "exclude": [ diff --git a/apps/web/tests/approval-composer.e2e.ts b/apps/web/tests/approval-composer.e2e.ts index d1bc84a488..8ffee0001a 100644 --- a/apps/web/tests/approval-composer.e2e.ts +++ b/apps/web/tests/approval-composer.e2e.ts @@ -30,7 +30,7 @@ const PROMPT = `Write a file named notes.txt in the workspace containing exactly /** Draft used to measure the composer's own text cap: enough lines to pass it. */ const CAP_PROBE = Array.from({ length: 40 }, (_, index) => `line ${index}`).join('\n') -describe('web e2e: approval takeover keeps its actions reachable', () => { +describe.each(MODE === 'record' ? ['button'] as const : ['button', 'keyboard'] as const)('web e2e: approval takeover through %s', (method) => { let scaffold: WebScaffold let browser: Browser let page: Page @@ -119,7 +119,11 @@ describe('web e2e: approval takeover keeps its actions reachable', () => { await page.setViewportSize(original) } - await panel.getByRole('button', { name: 'Allow once' }).click() + if (method === 'button') await panel.getByRole('button', { name: 'Allow once' }).click() + else { + await scroll.focus() + await page.keyboard.press('Enter') + } const sessionId = await settled if (MODE === 'record') { diff --git a/apps/web/tests/composer-shortcuts.e2e.ts b/apps/web/tests/composer-shortcuts.e2e.ts new file mode 100644 index 0000000000..ce38a1b5f6 --- /dev/null +++ b/apps/web/tests/composer-shortcuts.e2e.ts @@ -0,0 +1,48 @@ +/** The shipped composer preserves drafts when an application Enter chord has extra modifiers. */ +import { chromium, type Page } from 'playwright' +import { expect, it } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { launchWebScaffold, watchConsole } from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts' + +it('preserves the composer draft and menu for application Enter combinations', async () => { + const scaffold = await launchWebScaffold({}) + const events: SessionEvent[] = [] + const off = scaffold.ctx.on('session/event', (_session, event) => { events.push(event) }) + try { + const browser = await chromium.launch() + let failurePage: Page | undefined + try { + const page = await newEnglishPage(browser) + failurePage = page + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl) + await connectFreshWorkspace(page, scaffold.workspaceCwd, 'composer-shortcuts') + const input = page.locator('[data-composer-input][contenteditable="true"]').first() + for (const draft of ['unsent draft', '/']) { + await writeComposerDraft(page, input, draft) + const markup = await input.innerHTML() + for (const chord of ['Alt+Enter', 'Meta+Alt+Enter', 'Control+Alt+Enter', 'Control+Meta+Enter', 'Meta+Shift+Enter', 'Control+Shift+Enter']) { + await input.press(chord) + expect(await input.innerHTML(), `${draft}: ${chord}`).toBe(markup) + } + await input.press('Escape') + } + await writeComposerDraft(page, input, 'first line') + await input.press('Shift+Enter') + await page.keyboard.type('second line') + expect(await input.innerText()).toBe('first line\nsecond line') + expect(events.filter(event => event.type === 'user/message')).toHaveLength(0) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } catch (error) { + if (failurePage !== undefined) await saveFailureShot(failurePage, 'web-e2e-composer-shortcuts') + throw error + } finally { + await browser.close() + } + } finally { + off() + await scaffold.close() + } +}) diff --git a/apps/web/tests/expected/desktop-locale/automatic.expected.md b/apps/web/tests/expected/desktop-locale/automatic.expected.md index dcb0ed4d1b..9e60aaae27 100644 --- a/apps/web/tests/expected/desktop-locale/automatic.expected.md +++ b/apps/web/tests/expected/desktop-locale/automatic.expected.md @@ -24,6 +24,9 @@ - button "详细" - text: 开发者工具 显示用于调试和排查问题的工具与信息 - switch "开发者工具" [checked] + - text: 快捷键 + - paragraph: 查看当前可用的快捷键和输入操作 + - button "查看快捷键" - text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为 - button "排队发送" - text: 当前版本:{{version}} diff --git a/apps/web/tests/expected/desktop-locale/selected.expected.md b/apps/web/tests/expected/desktop-locale/selected.expected.md index eab4f4e977..85683ee2c1 100644 --- a/apps/web/tests/expected/desktop-locale/selected.expected.md +++ b/apps/web/tests/expected/desktop-locale/selected.expected.md @@ -24,6 +24,9 @@ - button "Detailed" - text: Developer tools Show tools and information for debugging and troubleshooting - switch "Developer tools" [checked] + - text: Keyboard shortcuts + - paragraph: Browse available shortcuts and input actions + - button "View shortcuts" - text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior - button "Queue" - text: "Current version: {{version}}" diff --git a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md index 3f945d9cce..7e8776fa0c 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md @@ -24,6 +24,9 @@ - button "Detailed" - text: Developer tools Show tools and information for debugging and troubleshooting - switch "Developer tools" + - text: Keyboard shortcuts + - paragraph: Browse available shortcuts and input actions + - button "View shortcuts" - text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior - button "Queue" - text: "Current version: {{version}}" diff --git a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md index 3f945d9cce..7e8776fa0c 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md @@ -24,6 +24,9 @@ - button "Detailed" - text: Developer tools Show tools and information for debugging and troubleshooting - switch "Developer tools" + - text: Keyboard shortcuts + - paragraph: Browse available shortcuts and input actions + - button "View shortcuts" - text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior - button "Queue" - text: "Current version: {{version}}" diff --git a/apps/web/tests/expected/settings-chrome/dialog.expected.md b/apps/web/tests/expected/settings-chrome/dialog.expected.md index c0a1b2ed35..37d224d2a2 100644 --- a/apps/web/tests/expected/settings-chrome/dialog.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md @@ -24,6 +24,9 @@ - button "详细" - text: 开发者工具 显示用于调试和排查问题的工具与信息 - switch "开发者工具" + - text: 快捷键 + - paragraph: 查看当前可用的快捷键和输入操作 + - button "查看快捷键" - text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为 - button "排队发送" - text: 当前版本:{{version}} diff --git a/apps/web/tests/expected/shortcuts-desktop/chord.expected.md b/apps/web/tests/expected/shortcuts-desktop/chord.expected.md new file mode 100644 index 0000000000..3bf739802b --- /dev/null +++ b/apps/web/tests/expected/shortcuts-desktop/chord.expected.md @@ -0,0 +1,5 @@ +- listitem: + - text: New Session + - button "Remove shortcut for New Session" + - button "Edit shortcut for New Session" + - button "New Session A B": A B diff --git a/apps/web/tests/expected/shortcuts-desktop/fixed-conflict.expected.md b/apps/web/tests/expected/shortcuts-desktop/fixed-conflict.expected.md new file mode 100644 index 0000000000..256f346bd7 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-desktop/fixed-conflict.expected.md @@ -0,0 +1,5 @@ +- group "New Session": + - button "Restore default" + - button "Remove" + - button "Press a shortcut" + - text: Already used by “Open reference menu” diff --git a/apps/web/tests/expected/shortcuts-desktop/macos-copy-override.expected.md b/apps/web/tests/expected/shortcuts-desktop/macos-copy-override.expected.md new file mode 100644 index 0000000000..0881936876 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-desktop/macos-copy-override.expected.md @@ -0,0 +1,5 @@ +- listitem: + - text: New Session + - button "Remove shortcut for New Session" + - button "Edit shortcut for New Session" + - button "New Session ⌘ C": ⌘ C diff --git a/apps/web/tests/expected/shortcuts-desktop/modal-layers.expected.md b/apps/web/tests/expected/shortcuts-desktop/modal-layers.expected.md new file mode 100644 index 0000000000..33afa9e582 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-desktop/modal-layers.expected.md @@ -0,0 +1,12 @@ +# Modal layers + +{ + "settingsOnTop": { + "focused": "Settings", + "foreground": "Settings" + }, + "referenceOnTop": { + "focused": "Keyboard shortcuts", + "foreground": "Keyboard shortcuts" + } +} diff --git a/apps/web/tests/expected/shortcuts-desktop/recording.expected.md b/apps/web/tests/expected/shortcuts-desktop/recording.expected.md new file mode 100644 index 0000000000..4f4e54a0c5 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-desktop/recording.expected.md @@ -0,0 +1,4 @@ +- group "New Session": + - button "Restore default" + - button "Remove" + - button "Press a shortcut" diff --git a/apps/web/tests/expected/shortcuts-desktop/windows-copy-override.expected.md b/apps/web/tests/expected/shortcuts-desktop/windows-copy-override.expected.md new file mode 100644 index 0000000000..fd3ec59c13 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-desktop/windows-copy-override.expected.md @@ -0,0 +1,5 @@ +- listitem: + - text: New Session + - button "Remove shortcut for New Session" + - button "Edit shortcut for New Session" + - button "New Session Ctrl + C": Ctrl + C diff --git a/apps/web/tests/expected/shortcuts-panels/browser.expected.md b/apps/web/tests/expected/shortcuts-panels/browser.expected.md new file mode 100644 index 0000000000..40c2378f41 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-panels/browser.expected.md @@ -0,0 +1,18 @@ +- tablist: + - tab "Browser Close": + - text: Browser + - button "Close" + - tab "Browser Close" [selected]: + - text: Browser + - button "Close" + - button "New tab" + - button "Split" + - button "Fullscreen" + - button "Collapse right sidebar" +- button "Back" [disabled] +- button "Forward" [disabled] +- button "Reload" [disabled] +- textbox "Enter an HTTP(S) address" +- button "Open in system browser" [disabled] +- button "Disable sandbox restrictions" +- text: Enter an HTTP(S) address to start browsing diff --git a/apps/web/tests/expected/shortcuts-panels/guide-close.expected.md b/apps/web/tests/expected/shortcuts-panels/guide-close.expected.md new file mode 100644 index 0000000000..c255700433 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-panels/guide-close.expected.md @@ -0,0 +1,9 @@ +- tablist: + - tab "Start" [selected] + - button "Split" + - button "Exit fullscreen" + - button "Collapse right sidebar" +- button "Workspace files Browse files in this session's workspace" +- button "New terminal Run commands in the Session workspace" +- button "Choose shell" +- button "Browser Browse web pages" diff --git a/apps/web/tests/expected/shortcuts-panels/page-actions.expected.md b/apps/web/tests/expected/shortcuts-panels/page-actions.expected.md new file mode 100644 index 0000000000..63ee48e589 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-panels/page-actions.expected.md @@ -0,0 +1,23 @@ +- tablist: + - tab "Files Close": + - text: Files + - button "Close" + - tab "bash Close" [selected]: + - text: bash + - button "Close" + - button "New tab" + - button "Split" [disabled] + - button "Fullscreen" + - button "Collapse right sidebar" +- textbox "Terminal" +- tablist: + - tab "Files Close" [selected]: + - text: Files + - button "Close" + - button "New tab" + - button "Split" [disabled] +- text: {{cwd}}/workspace +- button "Reload" +- list: + - listitem: + - button "t6-refresh.txt" diff --git a/apps/web/tests/expected/shortcuts-panels/panels.expected.md b/apps/web/tests/expected/shortcuts-panels/panels.expected.md new file mode 100644 index 0000000000..7b6537870e --- /dev/null +++ b/apps/web/tests/expected/shortcuts-panels/panels.expected.md @@ -0,0 +1,25 @@ +- tablist: + - tab "Files Close": + - text: Files + - button "Close" + - tab "bash Close": + - text: bash + - button "Close" + - tab "bash Close" [selected]: + - text: bash + - button "Close" + - button "New tab" + - button "Split" [disabled] + - button "Exit fullscreen" + - button "Collapse right sidebar" +- textbox "Terminal" +- tablist: + - tab "Files Close" [selected]: + - text: Files + - button "Close" + - button "New tab" + - button "Split" [disabled] +- text: {{cwd}}/workspace +- button "Reload" +- list: + - listitem: Empty directory diff --git a/apps/web/tests/expected/shortcuts-panels/tab-menu.expected.md b/apps/web/tests/expected/shortcuts-panels/tab-menu.expected.md new file mode 100644 index 0000000000..c8ab6426ab --- /dev/null +++ b/apps/web/tests/expected/shortcuts-panels/tab-menu.expected.md @@ -0,0 +1,2 @@ +- menu: + - menuitem "Close" diff --git a/apps/web/tests/expected/shortcuts-workspace/fork-unavailable.expected.md b/apps/web/tests/expected/shortcuts-workspace/fork-unavailable.expected.md new file mode 100644 index 0000000000..074a26e764 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-workspace/fork-unavailable.expected.md @@ -0,0 +1 @@ +- alert: This session has no completed turn diff --git a/apps/web/tests/expected/shortcuts-workspace/fork.expected.md b/apps/web/tests/expected/shortcuts-workspace/fork.expected.md new file mode 100644 index 0000000000..a906aa0e5b --- /dev/null +++ b/apps/web/tests/expected/shortcuts-workspace/fork.expected.md @@ -0,0 +1,9 @@ +- text: Workspaces +- button "Search sessions" +- textbox "Search session names" +- button "View options" +- button "Add workspace" +- tree "Sessions": + - treeitem "Ungrouped" [expanded] + - treeitem "T4 source (1) now" + - treeitem "T4 source 1min" [selected] diff --git a/apps/web/tests/expected/shortcuts-workspace/macos-defaults.expected.md b/apps/web/tests/expected/shortcuts-workspace/macos-defaults.expected.md new file mode 100644 index 0000000000..7c9861aec6 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-workspace/macos-defaults.expected.md @@ -0,0 +1,132 @@ +- dialog "Keyboard shortcuts": + - banner: + - heading "Keyboard shortcuts" [level=2] + - button "Close keyboard shortcuts" + - search "Search shortcuts": + - searchbox "Search shortcuts" + - region "Application": + - list: + - listitem: + - text: Open keyboard shortcuts + - button "Remove shortcut for Open keyboard shortcuts" + - button "Edit shortcut for Open keyboard shortcuts" + - button "Open keyboard shortcuts ⌘ /": ⌘ / + - listitem: + - text: Toggle left sidebar + - button "Remove shortcut for Toggle left sidebar" + - button "Edit shortcut for Toggle left sidebar" + - button "Toggle left sidebar ⌥ ⌘ B": ⌥ ⌘ B + - listitem: + - text: Toggle right sidebar + - button "Remove shortcut for Toggle right sidebar" + - button "Edit shortcut for Toggle right sidebar" + - button "Toggle right sidebar ⇧ ⌘ B": ⇧ ⌘ B + - listitem: + - text: Split + - button "Remove shortcut for Split" + - button "Edit shortcut for Split" + - button "Split ⌘ \\": ⌘ \ + - listitem: + - text: Toggle panel fullscreen + - button "Remove shortcut for Toggle panel fullscreen" + - button "Edit shortcut for Toggle panel fullscreen" + - button "Toggle panel fullscreen ⌥ ⌘ Enter": ⌥ ⌘ Enter + - listitem: + - text: Close current page or window + - button "Remove shortcut for Close current page or window" + - button "Edit shortcut for Close current page or window" + - button "Close current page or window ⌥ ⌘ W": ⌥ ⌘ W + - listitem: + - text: Refresh current page + - button "Edit shortcut for Refresh current page" + - button "Refresh current page No shortcut": No shortcut + - listitem: + - text: New Session + - button "Remove shortcut for New Session" + - button "Edit shortcut for New Session" + - button "New Session ⌥ ⌘ N": ⌥ ⌘ N + - listitem: + - text: Search sessions + - button "Remove shortcut for Search sessions" + - button "Edit shortcut for Search sessions" + - button "Search sessions ⌥ ⌘ K": ⌥ ⌘ K + - listitem: + - text: Add workspace + - button "Remove shortcut for Add workspace" + - button "Edit shortcut for Add workspace" + - button "Add workspace ⌥ ⌘ O": ⌥ ⌘ O + - listitem: + - text: Rename session + - button "Remove shortcut for Rename session" + - button "Edit shortcut for Rename session" + - button "Rename session ⇧ ⌘ R": ⇧ ⌘ R + - listitem: + - text: Fork session + - button "Remove shortcut for Fork session" + - button "Edit shortcut for Fork session" + - button "Fork session ⇧ ⌘ F": ⇧ ⌘ F + - listitem: + - text: Archive session + - button "Remove shortcut for Archive session" + - button "Edit shortcut for Archive session" + - button "Archive session ⌥ ⌘ A": ⌥ ⌘ A + - listitem: + - text: Open settings + - button "Remove shortcut for Open settings" + - button "Edit shortcut for Open settings" + - button "Open settings ⌘ ,": ⌘ , + - listitem: + - text: Workspace files + - button "Remove shortcut for Workspace files" + - button "Edit shortcut for Workspace files" + - button "Workspace files ⌥ ⌘ P": ⌥ ⌘ P + - listitem: + - text: New terminal + - button "Remove shortcut for New terminal" + - button "Edit shortcut for New terminal" + - 'button "New terminal ⌃ `"': "⌃ `" + - region "Message input": + - heading "Message input" [level=3] + - list: + - listitem: + - text: Send message + - button "Send message Enter" [disabled]: Enter + - listitem: + - text: New line + - button "New line ⇧ Enter" [disabled]: ⇧ Enter + - listitem: + - text: Use the complementary Queue / Steer action + - button "Use the complementary Queue / Steer action ⌘ Enter" [disabled]: ⌘ Enter + - listitem: + - text: Open command menu + - button "Open command menu /" [disabled]: / + - listitem: + - text: Open reference menu + - button "Open reference menu @" [disabled]: "@" + - listitem: + - text: Stop generating + - button "Stop generating Esc Esc" [disabled]: Esc Esc + - region "Menus and dialogs": + - heading "Menus and dialogs" [level=3] + - list: + - listitem: + - text: Move menu selection + - button "Move menu selection ↑ ↓" [disabled]: ↑ ↓ + - listitem: + - text: Select menu item + - button "Select menu item Enter" [disabled]: Enter + - listitem: + - text: Close menu or top dialog + - button "Close menu or top dialog Esc" [disabled]: Esc + - region "Approval area": + - heading "Approval area" [level=3] + - list: + - listitem: + - text: Allow once + - button "Allow once Enter" [disabled]: Enter + - listitem: + - text: Reject + - button "Reject Esc" [disabled]: Esc + - contentinfo: + - button "Restore all defaults" [disabled] + - text: 0 customized diff --git a/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md b/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md new file mode 100644 index 0000000000..88405f3950 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md @@ -0,0 +1,5 @@ +- menu: + - menuitem "Pin session" + - menuitem "Rename" + - menuitem "Fork session" + - menuitem "Archive session" diff --git a/apps/web/tests/expected/shortcuts/edit-stale.expected.md b/apps/web/tests/expected/shortcuts/edit-stale.expected.md new file mode 100644 index 0000000000..0293a87c04 --- /dev/null +++ b/apps/web/tests/expected/shortcuts/edit-stale.expected.md @@ -0,0 +1,5 @@ +- group "Toggle left sidebar": + - button "Restore default" [disabled] + - button "Press a shortcut" + - text: Shortcut configuration or available commands changed. Review the latest bindings before saving. Shortcut configuration or available commands changed. Review the latest bindings before saving. + - button "I have reviewed the latest configuration" diff --git a/apps/web/tests/expected/shortcuts/en-US-conflict.expected.md b/apps/web/tests/expected/shortcuts/en-US-conflict.expected.md new file mode 100644 index 0000000000..f359af0fc7 --- /dev/null +++ b/apps/web/tests/expected/shortcuts/en-US-conflict.expected.md @@ -0,0 +1 @@ +- alert: Already used by “Open keyboard shortcuts” diff --git a/apps/web/tests/expected/shortcuts/en-US-empty.expected.md b/apps/web/tests/expected/shortcuts/en-US-empty.expected.md new file mode 100644 index 0000000000..0661c89929 --- /dev/null +++ b/apps/web/tests/expected/shortcuts/en-US-empty.expected.md @@ -0,0 +1,10 @@ +- dialog "Keyboard shortcuts": + - banner: + - heading "Keyboard shortcuts" [level=2] + - button "Close keyboard shortcuts" + - search "Search shortcuts": + - searchbox "Search shortcuts": abc + - status: No matching shortcuts + - contentinfo: + - button "Restore all defaults" + - text: 1 customized diff --git a/apps/web/tests/expected/shortcuts/en-US-inline.expected.md b/apps/web/tests/expected/shortcuts/en-US-inline.expected.md new file mode 100644 index 0000000000..5361718dc6 --- /dev/null +++ b/apps/web/tests/expected/shortcuts/en-US-inline.expected.md @@ -0,0 +1,5 @@ +- group "Toggle left sidebar": + - button "Restore default" + - button "Remove" + - button "Press a shortcut" + - text: Use Ctrl+/, Ctrl+,, Ctrl+Alt+key, or Ctrl+Shift+key. Combinations with three or four distinct modifiers are also supported. Browser or system shortcuts may not reach the page. diff --git a/apps/web/tests/expected/shortcuts/en-US-reset-confirm.expected.md b/apps/web/tests/expected/shortcuts/en-US-reset-confirm.expected.md new file mode 100644 index 0000000000..5ad9adc331 --- /dev/null +++ b/apps/web/tests/expected/shortcuts/en-US-reset-confirm.expected.md @@ -0,0 +1,6 @@ +- dialog "Restore all default shortcuts?": + - heading "Restore all default shortcuts?" [level=2] + - button "Close confirmation" + - paragraph: Restore the default shortcuts for this platform. All modified or removed shortcuts will be restored. Other platforms are unaffected. + - button "Cancel" + - button "Restore default" diff --git a/apps/web/tests/expected/shortcuts/en-US-saved.expected.md b/apps/web/tests/expected/shortcuts/en-US-saved.expected.md new file mode 100644 index 0000000000..be3933029d --- /dev/null +++ b/apps/web/tests/expected/shortcuts/en-US-saved.expected.md @@ -0,0 +1 @@ +- alert: Modified diff --git a/apps/web/tests/expected/shortcuts/en-US.expected.md b/apps/web/tests/expected/shortcuts/en-US.expected.md new file mode 100644 index 0000000000..5a2447642e --- /dev/null +++ b/apps/web/tests/expected/shortcuts/en-US.expected.md @@ -0,0 +1,133 @@ +- dialog "Keyboard shortcuts": + - banner: + - heading "Keyboard shortcuts" [level=2] + - button "Close keyboard shortcuts" + - search "Search shortcuts": + - searchbox "Search shortcuts" + - region "Application": + - list: + - listitem: + - text: Open keyboard shortcuts + - button "Remove shortcut for Open keyboard shortcuts" + - button "Edit shortcut for Open keyboard shortcuts" + - button "Open keyboard shortcuts Ctrl + /": Ctrl + / + - listitem: + - text: Toggle left sidebar + - button "Remove shortcut for Toggle left sidebar" + - button "Edit shortcut for Toggle left sidebar" + - button "Toggle left sidebar Ctrl + Alt + B": Ctrl + Alt + B + - listitem: + - text: Toggle right sidebar + - button "Remove shortcut for Toggle right sidebar" + - button "Edit shortcut for Toggle right sidebar" + - button "Toggle right sidebar Ctrl + Shift + B": Ctrl + Shift + B + - listitem: + - text: Split + - button "Remove shortcut for Split" + - button "Edit shortcut for Split" + - button "Split Ctrl + \\": Ctrl + \ + - listitem: + - text: Toggle panel fullscreen + - button "Remove shortcut for Toggle panel fullscreen" + - button "Edit shortcut for Toggle panel fullscreen" + - button "Toggle panel fullscreen Ctrl + Alt + Enter": Ctrl + Alt + Enter + - listitem: + - text: Close current page or window + - button "Remove shortcut for Close current page or window" + - button "Edit shortcut for Close current page or window" + - button "Close current page or window Ctrl + Alt + W": Ctrl + Alt + W + - listitem: + - text: Refresh current page + - button "Remove shortcut for Refresh current page" + - button "Edit shortcut for Refresh current page" + - button "Refresh current page Ctrl + Alt + R": Ctrl + Alt + R + - listitem: + - text: New Session + - button "Remove shortcut for New Session" + - button "Edit shortcut for New Session" + - button "New Session Ctrl + Alt + N": Ctrl + Alt + N + - listitem: + - text: Search sessions + - button "Remove shortcut for Search sessions" + - button "Edit shortcut for Search sessions" + - button "Search sessions Ctrl + Alt + K": Ctrl + Alt + K + - listitem: + - text: Add workspace + - button "Remove shortcut for Add workspace" + - button "Edit shortcut for Add workspace" + - button "Add workspace Ctrl + Alt + O": Ctrl + Alt + O + - listitem: + - text: Rename session + - button "Remove shortcut for Rename session" + - button "Edit shortcut for Rename session" + - button "Rename session Ctrl + Shift + R": Ctrl + Shift + R + - listitem: + - text: Fork session + - button "Remove shortcut for Fork session" + - button "Edit shortcut for Fork session" + - button "Fork session Ctrl + Shift + F": Ctrl + Shift + F + - listitem: + - text: Archive session + - button "Remove shortcut for Archive session" + - button "Edit shortcut for Archive session" + - button "Archive session Ctrl + Alt + A": Ctrl + Alt + A + - listitem: + - text: Open settings + - button "Remove shortcut for Open settings" + - button "Edit shortcut for Open settings" + - button "Open settings Ctrl + ,": Ctrl + , + - listitem: + - text: Workspace files + - button "Remove shortcut for Workspace files" + - button "Edit shortcut for Workspace files" + - button "Workspace files Ctrl + Alt + P": Ctrl + Alt + P + - listitem: + - text: New terminal + - button "Remove shortcut for New terminal" + - button "Edit shortcut for New terminal" + - 'button "New terminal Ctrl + `"': "Ctrl + `" + - region "Message input": + - heading "Message input" [level=3] + - list: + - listitem: + - text: Send message + - button "Send message Enter" [disabled]: Enter + - listitem: + - text: New line + - button "New line Shift + Enter" [disabled]: Shift + Enter + - listitem: + - text: Use the complementary Queue / Steer action + - button "Use the complementary Queue / Steer action Ctrl + Enter" [disabled]: Ctrl + Enter + - listitem: + - text: Open command menu + - button "Open command menu /" [disabled]: / + - listitem: + - text: Open reference menu + - button "Open reference menu @" [disabled]: "@" + - listitem: + - text: Stop generating + - button "Stop generating Esc Esc" [disabled]: Esc Esc + - region "Menus and dialogs": + - heading "Menus and dialogs" [level=3] + - list: + - listitem: + - text: Move menu selection + - button "Move menu selection ↑ ↓" [disabled]: ↑ ↓ + - listitem: + - text: Select menu item + - button "Select menu item Enter" [disabled]: Enter + - listitem: + - text: Close menu or top dialog + - button "Close menu or top dialog Esc" [disabled]: Esc + - region "Approval area": + - heading "Approval area" [level=3] + - list: + - listitem: + - text: Allow once + - button "Allow once Enter" [disabled]: Enter + - listitem: + - text: Reject + - button "Reject Esc" [disabled]: Esc + - contentinfo: + - button "Restore all defaults" [disabled] + - text: 0 customized diff --git a/apps/web/tests/expected/shortcuts/zh-CN-conflict.expected.md b/apps/web/tests/expected/shortcuts/zh-CN-conflict.expected.md new file mode 100644 index 0000000000..91f6c75f5f --- /dev/null +++ b/apps/web/tests/expected/shortcuts/zh-CN-conflict.expected.md @@ -0,0 +1 @@ +- alert: 已被「快捷键速查」占用 diff --git a/apps/web/tests/expected/shortcuts/zh-CN-empty.expected.md b/apps/web/tests/expected/shortcuts/zh-CN-empty.expected.md new file mode 100644 index 0000000000..8bc73cf849 --- /dev/null +++ b/apps/web/tests/expected/shortcuts/zh-CN-empty.expected.md @@ -0,0 +1,10 @@ +- dialog "快捷键": + - banner: + - heading "快捷键" [level=2] + - button "关闭快捷键" + - search "搜索快捷键": + - searchbox "搜索快捷键": abc + - status: 没有匹配的快捷键 + - contentinfo: + - button "恢复全部默认" + - text: 1 项已自定义 diff --git a/apps/web/tests/expected/shortcuts/zh-CN-inline.expected.md b/apps/web/tests/expected/shortcuts/zh-CN-inline.expected.md new file mode 100644 index 0000000000..72256d0b99 --- /dev/null +++ b/apps/web/tests/expected/shortcuts/zh-CN-inline.expected.md @@ -0,0 +1,5 @@ +- group "展开/收起左侧栏": + - button "恢复默认" + - button "移除" + - button "按下快捷键" + - text: 可设置 Command+/、Command+,、Command+反斜杠、Control+反引号、Command+Option+按键或 Command+Shift+按键。也支持三个或四个不同修饰键的组合。浏览器或系统占用的组合可能无法送达页面。 diff --git a/apps/web/tests/expected/shortcuts/zh-CN-reset-confirm.expected.md b/apps/web/tests/expected/shortcuts/zh-CN-reset-confirm.expected.md new file mode 100644 index 0000000000..2974c54bb9 --- /dev/null +++ b/apps/web/tests/expected/shortcuts/zh-CN-reset-confirm.expected.md @@ -0,0 +1,6 @@ +- dialog "恢复全部默认快捷键?": + - heading "恢复全部默认快捷键?" [level=2] + - button "关闭确认" + - paragraph: 将恢复当前平台的默认快捷键。你修改或移除的快捷键都会恢复,其他平台不受影响。 + - button "取消" + - button "恢复默认" diff --git a/apps/web/tests/expected/shortcuts/zh-CN-saved.expected.md b/apps/web/tests/expected/shortcuts/zh-CN-saved.expected.md new file mode 100644 index 0000000000..bc0fb851da --- /dev/null +++ b/apps/web/tests/expected/shortcuts/zh-CN-saved.expected.md @@ -0,0 +1 @@ +- alert: 已修改 diff --git a/apps/web/tests/expected/shortcuts/zh-CN.expected.md b/apps/web/tests/expected/shortcuts/zh-CN.expected.md new file mode 100644 index 0000000000..9eb7945d00 --- /dev/null +++ b/apps/web/tests/expected/shortcuts/zh-CN.expected.md @@ -0,0 +1,132 @@ +- dialog "快捷键": + - banner: + - heading "快捷键" [level=2] + - button "关闭快捷键" + - search "搜索快捷键": + - searchbox "搜索快捷键" + - region "应用操作": + - list: + - listitem: + - text: 快捷键速查 + - button "移除快捷键速查快捷键" + - button "修改快捷键速查快捷键" + - button "快捷键速查 ⌘ /": ⌘ / + - listitem: + - text: 展开/收起左侧栏 + - button "移除展开/收起左侧栏快捷键" + - button "修改展开/收起左侧栏快捷键" + - button "展开/收起左侧栏 ⌥ ⌘ B": ⌥ ⌘ B + - listitem: + - text: 展开/收起右侧栏 + - button "移除展开/收起右侧栏快捷键" + - button "修改展开/收起右侧栏快捷键" + - button "展开/收起右侧栏 ⇧ ⌘ B": ⇧ ⌘ B + - listitem: + - text: 分栏 + - button "移除分栏快捷键" + - button "修改分栏快捷键" + - button "分栏 ⌘ \\": ⌘ \ + - listitem: + - text: 面板全屏/退出全屏 + - button "移除面板全屏/退出全屏快捷键" + - button "修改面板全屏/退出全屏快捷键" + - button "面板全屏/退出全屏 ⌥ ⌘ Enter": ⌥ ⌘ Enter + - listitem: + - text: 关闭当前页面/窗口 + - button "移除关闭当前页面/窗口快捷键" + - button "修改关闭当前页面/窗口快捷键" + - button "关闭当前页面/窗口 ⌥ ⌘ W": ⌥ ⌘ W + - listitem: + - text: 刷新当前页面 + - button "修改刷新当前页面快捷键" + - button "刷新当前页面 暂无快捷键": 暂无快捷键 + - listitem: + - text: 新会话 + - button "移除新会话快捷键" + - button "修改新会话快捷键" + - button "新会话 ⌥ ⌘ N": ⌥ ⌘ N + - listitem: + - text: 搜索会话 + - button "移除搜索会话快捷键" + - button "修改搜索会话快捷键" + - button "搜索会话 ⌥ ⌘ K": ⌥ ⌘ K + - listitem: + - text: 添加工作区 + - button "移除添加工作区快捷键" + - button "修改添加工作区快捷键" + - button "添加工作区 ⌥ ⌘ O": ⌥ ⌘ O + - listitem: + - text: 重命名会话 + - button "移除重命名会话快捷键" + - button "修改重命名会话快捷键" + - button "重命名会话 ⇧ ⌘ R": ⇧ ⌘ R + - listitem: + - text: 分叉会话 + - button "移除分叉会话快捷键" + - button "修改分叉会话快捷键" + - button "分叉会话 ⇧ ⌘ F": ⇧ ⌘ F + - listitem: + - text: 归档会话 + - button "移除归档会话快捷键" + - button "修改归档会话快捷键" + - button "归档会话 ⌥ ⌘ A": ⌥ ⌘ A + - listitem: + - text: 打开设置 + - button "移除打开设置快捷键" + - button "修改打开设置快捷键" + - button "打开设置 ⌘ ,": ⌘ , + - listitem: + - text: 工作区文件 + - button "移除工作区文件快捷键" + - button "修改工作区文件快捷键" + - button "工作区文件 ⌥ ⌘ P": ⌥ ⌘ P + - listitem: + - text: 新建终端 + - button "移除新建终端快捷键" + - button "修改新建终端快捷键" + - 'button "新建终端 ⌃ `"': "⌃ `" + - region "消息输入": + - heading "消息输入" [level=3] + - list: + - listitem: + - text: 发送消息 + - button "发送消息 Enter" [disabled]: Enter + - listitem: + - text: 换行 + - button "换行 ⇧ Enter" [disabled]: ⇧ Enter + - listitem: + - text: 使用互补的 Queue/Steer 方式 + - button "使用互补的 Queue/Steer 方式 ⌘ Enter" [disabled]: ⌘ Enter + - listitem: + - text: 打开命令菜单 + - button "打开命令菜单 /" [disabled]: / + - listitem: + - text: 打开引用菜单 + - button "打开引用菜单 @" [disabled]: "@" + - listitem: + - text: 停止生成 + - button "停止生成 Esc Esc" [disabled]: Esc Esc + - region "菜单与弹层": + - heading "菜单与弹层" [level=3] + - list: + - listitem: + - text: 移动菜单选择 + - button "移动菜单选择 ↑ ↓" [disabled]: ↑ ↓ + - listitem: + - text: 选择菜单项 + - button "选择菜单项 Enter" [disabled]: Enter + - listitem: + - text: 关闭菜单或顶层弹窗 + - button "关闭菜单或顶层弹窗 Esc" [disabled]: Esc + - region "审批区域": + - heading "审批区域" [level=3] + - list: + - listitem: + - text: 允许一次 + - button "允许一次 Enter" [disabled]: Enter + - listitem: + - text: 拒绝 + - button "拒绝 Esc" [disabled]: Esc + - contentinfo: + - button "恢复全部默认" [disabled] + - text: 0 项已自定义 diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index 6592322148..e3c91a03a4 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/apps/web/tests/live-interactions.e2e.ts @@ -187,7 +187,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { const stopButton = page.getByRole('button', { name: 'Stop generating' }) await stopButton.hover() - await page.getByRole('tooltip', { name: 'Stop generating', exact: true }).waitFor() + await page.getByRole('tooltip', { name: 'Stop generating Esc Esc', exact: true }).waitFor() await stopButton.click() await settled expect(turnEndReasons(sessionEvents).at(-1)).toBe('aborted') diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts index c5317cb3fb..d1cf569346 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/apps/web/tests/queue-actions.e2e.ts @@ -80,7 +80,7 @@ describe('web e2e: queue row actions', () => { await expect.poll(() => row.getByRole('button', { name: 'Remove queued message' }).isEnabled()).toBe(true) } - it.skipIf(MODE === 'record')('edits and removes exact occurrences and preserves Queue across stop', async () => { + it.skipIf(MODE === 'record').each(['button', 'keyboard'] as const)('edits and removes exact occurrences and preserves Queue across %s stop', async (method) => { overrideDir = await mkdtemp(join(tmpdir(), 'dsh-web-queue-actions-')) const readyFile = join(overrideDir, '.hang-ready') const overridePath = join(overrideDir, 'replay.override.json') @@ -285,8 +285,13 @@ describe('web e2e: queue row actions', () => { const stopButton = page.getByRole('button', { name: 'Stop generating' }) await stopButton.hover() - await page.getByRole('tooltip', { name: 'Stop generating', exact: true }).waitFor() - await stopButton.click() + await page.getByRole('tooltip', { name: 'Stop generating Esc Esc', exact: true }).waitFor() + if (method === 'button') await stopButton.click() + else { + await input.focus() + await page.keyboard.press('Escape') + await page.keyboard.press('Escape') + } await firstSettled await expect.poll(() => page.getByRole('button', { name: 'Stop generating' }).count()) .toBe(0) diff --git a/apps/web/tests/sessionless-header.e2e.ts b/apps/web/tests/sessionless-header.e2e.ts index b2c1a85fb0..142777bfcd 100644 --- a/apps/web/tests/sessionless-header.e2e.ts +++ b/apps/web/tests/sessionless-header.e2e.ts @@ -3,6 +3,7 @@ import { chromium, type Browser } from 'playwright' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts' import { newEnglishPage, saveFailureShot } from './support.ts' +import { initialShortcutConfig } from '@deepseek-ai/dsh-client-shortcuts/protocol' let scaffold: WebScaffold let browser: Browser @@ -22,11 +23,17 @@ describe('navigation without a selected Session', () => { const page = await newEnglishPage(browser) try { if (platform !== 'web') { - await page.addInitScript((value) => { + await page.addInitScript(({ value, snapshot }) => { + Object.assign(window, { dshDesktop: { protocolVersion: 1, + keyboard: { subscribe: () => () => {}, closeWindow: async () => {} }, + shortcuts: { get: async () => ({ ...snapshot, status: 'ready' }), + subscribe: () => () => {}, recording: async () => {}, + edit: async () => ({ status: 'not-ready', snapshot }) }, + } }) const mark = () => { document.documentElement.setAttribute('data-platform', value) } if (document.documentElement === null) document.addEventListener('DOMContentLoaded', mark, { once: true }) else mark() - }, platform) + }, { value: platform, snapshot: initialShortcutConfig() }) } const tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) diff --git a/apps/web/tests/shortcuts-desktop.e2e.ts b/apps/web/tests/shortcuts-desktop.e2e.ts new file mode 100644 index 0000000000..172d919126 --- /dev/null +++ b/apps/web/tests/shortcuts-desktop.e2e.ts @@ -0,0 +1,258 @@ +/** Real application composition and Desktop persistence with a substituted Electron preload transport. */ +import { mkdir, mkdtemp, readFile, rename, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' +import { expect, it } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session' +import { initialShortcutConfig, parseShortcutDefinitions, parseShortcutEdit } from '@deepseek-ai/dsh-client-shortcuts/protocol' +import type { DesktopShortcutInput, DesktopShortcutsApi, ShortcutRevision } from '@deepseek-ai/dsh-client-shortcuts/protocol' +import { desktopKeybindings } from '../../desktop/src/keybindings.ts' +import { compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode } from './scaffold.ts' +import { writeComposerDraft } from './support.ts' + +declare global { + interface Window { + desktopShortcutsGet: DesktopShortcutsApi['get'] + desktopShortcutsEdit: DesktopShortcutsApi['edit'] + shortcutFixture: { deliver(input: DesktopShortcutInput): void; recording: boolean; closedWindows: number } + } +} + +const expected = fileURLToPath(new URL('./expected/shortcuts-desktop', import.meta.url)) +const seed = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) +const mode = webSnapshotMode() + +it.each([ + { platform: 'macos', marker: 'darwin', primary: 'Meta' }, + { platform: 'windows', marker: 'win32', primary: 'Control' }, +] as const)('records, persists and prioritizes $platform Desktop single keys and chords over recorded history', async ({ platform, marker, primary }) => { + const userData = await mkdtemp(join(tmpdir(), 'dsh-shortcuts-desktop-')) + let snapshot = initialShortcutConfig() + const persistence = desktopKeybindings(userData, platform, (value) => { snapshot = value }) + try { + const scaffold = await launchWebScaffold({ developerTools: false }) + try { + await seedSession(scaffold, await readFile(seed, 'utf8'), SessionId('desktop-shortcuts-source')) + const browser = await chromium.launch() + try { + const page = await browser.newPage({ locale: 'en-US', viewport: { width: 1440, height: 1000 } }) + await page.exposeFunction('desktopShortcutsGet', async (definitions: unknown) => { + persistence.setDefinitions(parseShortcutDefinitions(definitions)) + return persistence.readCurrent() + }) + await page.exposeFunction('desktopShortcutsEdit', (edit: unknown, revision: ShortcutRevision) => persistence.edit(parseShortcutEdit(edit), revision)) + // Only the Electron transport is substituted; preference storage and all Client plugins are real. + await page.addInitScript((device) => { + const mark = () => { document.documentElement.dataset.platform = device } + if (document.documentElement === null) window.addEventListener('DOMContentLoaded', mark) + else mark() + const listeners = new Set<(input: DesktopShortcutInput) => void>() + const fixture = { recording: false, closedWindows: 0, + deliver(input: DesktopShortcutInput) { for (const listener of listeners) listener(input) }, + } + Object.assign(window, { shortcutFixture: fixture, dshDesktop: { protocolVersion: 1, + shortcuts: { + get: window.desktopShortcutsGet, edit: window.desktopShortcutsEdit, + recording: async (active: boolean) => { fixture.recording = active }, subscribe: () => () => {}, + }, + keyboard: { subscribe: (listener: (input: DesktopShortcutInput) => void) => { + listeners.add(listener); return () => { listeners.delete(listener) } + }, closeWindow: async () => { fixture.closedWindows++ } }, + } }) + }, marker) + const console = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + const deliverPrimary = (code: string) => page.evaluate((input) => { + window.shortcutFixture.deliver(input) + }, { + kind: 'keyboard', frameName: '', revision: snapshot.revision, code, + control: platform === 'windows', meta: platform === 'macos', alt: false, shift: false, repeat: false, + } satisfies DesktopShortcutInput) + const openReference = () => deliverPrimary('Slash') + const group = page.getByRole('treeitem').first() + await group.waitFor() + if (await group.getAttribute('aria-expanded') !== 'true') await group.click() + await page.getByRole('treeitem').nth(1).click() + await page.getByText('DONE', { exact: true }).waitFor() + const composer = page.locator('[data-composer-input]').first() + await composer.focus() + await page.keyboard.insertText('Desktop focus draft') + const selectDraft = () => composer.evaluate((element) => { + const text = element.querySelector('[data-lexical-text]')!.firstChild! + document.getSelection()!.setBaseAndExtent(text, 0, text, 7) + }) + await selectDraft() + await deliverPrimary('KeyP') + const panel = page.locator('[data-sidebar-right-panel][data-sidebar-right-open]') + const files = panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }) + await files.waitFor() + expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true) + expect(await page.evaluate(() => document.getSelection()?.toString())).toBe('Desktop') + await composer.focus() + await selectDraft() + await deliverPrimary('KeyP') + expect(await files.count()).toBe(1) + expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true) + expect(await page.evaluate(() => document.getSelection()?.toString())).toBe('Desktop') + await deliverPrimary('KeyW') + await expect.poll(() => page.locator('[data-sidebar-right-open]').count()).toBe(0) + expect(await page.evaluate(() => window.shortcutFixture.closedWindows)).toBe(0) + expect(await composer.innerText()).toBe('Desktop focus draft') + await writeComposerDraft(page, composer, '') + await expect.poll(() => composer.textContent()).toBe('') + await openReference() + const dialog = page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true }) + await dialog.waitFor() + const settings = page.getByRole('dialog', { name: 'Settings', exact: true }) + const modalState = () => page.evaluate(() => { + const name = (node: Element | null) => node?.getAttribute('aria-label') + ?? document.getElementById(node?.getAttribute('aria-labelledby') ?? '')?.textContent ?? null + return { + focused: name(document.activeElement?.closest('[role="dialog"]') ?? null), + foreground: name(document.elementFromPoint(innerWidth / 2, innerHeight / 2)?.closest('[role="dialog"]') ?? null), + } + }) + await deliverPrimary('Comma') + await settings.waitFor() + const settingsOnTop = await modalState() + expect(settingsOnTop).toEqual({ focused: 'Settings', foreground: 'Settings' }) + await page.keyboard.press('Escape') + await settings.waitFor({ state: 'hidden' }) + expect(await dialog.isVisible()).toBe(true) + await page.keyboard.press('Escape') + await dialog.waitFor({ state: 'hidden' }) + await deliverPrimary('Comma') + await settings.waitFor() + await openReference() + await dialog.waitFor() + const referenceOnTop = await modalState() + expect(referenceOnTop).toEqual({ focused: 'Keyboard shortcuts', foreground: 'Keyboard shortcuts' }) + await compareOrRefreshGolden(join(expected, 'modal-layers.expected.md'), + `# Modal layers\n\n${JSON.stringify({ settingsOnTop, referenceOnTop }, null, 2)}`, mode) + await page.keyboard.press('Escape') + await dialog.waitFor({ state: 'hidden' }) + expect(await settings.isVisible()).toBe(true) + await page.keyboard.press('Escape') + await settings.waitFor({ state: 'hidden' }) + await openReference() + await dialog.waitFor() + const rowHeights = () => dialog.getByRole('listitem').evaluateAll(rows => rows.map(row => row.getBoundingClientRect().height)) + const heights = await rowHeights() + expect(new Set(heights)).toEqual(new Set([42])) + await dialog.getByRole('button', { name: 'Edit shortcut for New Session', exact: true }).click() + await expect.poll(() => page.evaluate(() => window.shortcutFixture.recording)).toBe(true) + expect(await rowHeights()).toEqual(heights) + const firstRecorder = dialog.getByRole('button', { name: 'Press a shortcut', exact: true }) + expect(await firstRecorder.evaluate(node => getComputedStyle(node).boxShadow)).toBe('none') + expect(await dialog.getByRole('button', { name: 'Cancel recording', exact: true }).count()).toBe(0) + await compareOrRefreshGolden(join(expected, 'recording.expected.md'), await dialog.getByRole('group').ariaSnapshot(), mode) + await page.keyboard.press(`${primary}+C`) + await dialog.getByRole('group').waitFor({ state: 'hidden' }) + const saved: unknown = JSON.parse(await readFile(join(userData, 'keybindings.json'), 'utf8')) + expect(saved).toMatchObject({ schemaVersion: 2, profiles: { [`desktop:${platform}`]: { + 'session.new': { code: 'KeyC', modifiers: [platform === 'macos' ? 'meta' : 'control'] }, + } } }) + await page.reload({ waitUntil: 'load' }) + await page.getByText('DONE', { exact: true }).waitFor() + await openReference() + await dialog.getByRole('searchbox').fill('New Session') + const row = dialog.getByRole('listitem') + await expect.poll(() => row.count()).toBe(1) + await compareOrRefreshGolden(join(expected, `${platform}-copy-override.expected.md`), await row.ariaSnapshot(), mode) + await page.keyboard.press('Escape') + await dialog.waitFor({ state: 'hidden' }) + await composer.focus() + await page.evaluate((input) => { window.shortcutFixture.deliver(input) }, { + kind: 'keyboard', frameName: '', revision: snapshot.revision, code: 'KeyC', + control: platform === 'windows', meta: platform === 'macos', alt: false, shift: false, repeat: false, + } satisfies DesktopShortcutInput) + await page.getByText('DONE', { exact: true }).waitFor({ state: 'hidden' }) + await expect.poll(() => page.getByRole('treeitem', { selected: true }).count()).toBe(0) + await page.getByRole('treeitem').nth(1).click() + await page.getByText('DONE', { exact: true }).waitFor() + await openReference() + await dialog.getByRole('searchbox').fill('New Session') + for (const code of ['a', 'F1', 'ArrowLeft', 'Tab']) { + await dialog.getByRole('button', { name: 'Edit shortcut for New Session', exact: true }).click() + const recorder = dialog.getByRole('button', { name: 'Press a shortcut', exact: true }) + await expect.poll(() => recorder.isEnabled()).toBe(true) + await recorder.focus() + await page.keyboard.press(code) + await dialog.getByRole('group').waitFor({ state: 'hidden' }) + expect(snapshot.document.profiles[`desktop:${platform}`]?.['session.new']).toEqual({ + code: code === 'a' ? 'KeyA' : code, modifiers: [], + }) + } + await dialog.getByRole('button', { name: 'Edit shortcut for New Session', exact: true }).click() + const recorder = dialog.getByRole('button', { name: 'Press a shortcut', exact: true }) + await expect.poll(() => recorder.isEnabled()).toBe(true) + await recorder.focus() + const accepted = snapshot.document + for (const key of [`${primary}+Slash`, 'Escape', 'Enter', 'ArrowUp', 'ArrowDown', 'Shift+Enter', `${primary}+Enter`, 'Slash', 'Shift+Digit2']) { + expect(await recorder.evaluate(node => node === document.activeElement)).toBe(true) + const keys = key.split('+') + for (const held of keys) await page.keyboard.down(held) + await expect.poll(() => recorder.getAttribute('aria-invalid')).toBe('false') + expect(await recorder.textContent()).not.toBe('Press a shortcut') + for (const held of keys.toReversed()) await page.keyboard.up(held) + await expect.poll(() => recorder.getAttribute('aria-invalid')).toBe('true') + if (key === `${primary}+Slash`) { + await dialog.getByText('Already used by “Open keyboard shortcuts”', { exact: true }).waitFor() + } + expect(await recorder.evaluate(node => node === document.activeElement)).toBe(true) + expect(snapshot.document).toEqual(accepted) + expect(await dialog.getByRole('listitem').evaluateAll(rows => rows.map(row => row.getBoundingClientRect().height))) + .toEqual([42]) + } + await compareOrRefreshGolden(join(expected, 'fixed-conflict.expected.md'), await dialog.getByRole('group').ariaSnapshot(), mode) + const preferencePath = join(userData, 'keybindings.json') + const savedPreferencePath = join(userData, 'keybindings.saved.json') + await rename(preferencePath, savedPreferencePath) + try { + // A directory prevents atomic file replacement on every supported filesystem. + await mkdir(preferencePath) + await page.keyboard.press('j') + await dialog.getByText('Could not save. Your previous shortcuts and current draft are preserved. Please retry.', { exact: true }).waitFor() + expect(snapshot.document).toEqual(accepted) + expect(await recorder.getAttribute('aria-invalid')).toBe('true') + expect(await recorder.evaluate(node => node === document.activeElement)).toBe(true) + } finally { + await rm(preferencePath, { recursive: true, force: true }) + await rename(savedPreferencePath, preferencePath) + } + await page.keyboard.down('b') + await page.keyboard.down('a') + await expect.poll(() => recorder.getAttribute('aria-invalid')).toBe('false') + expect(await recorder.textContent()).toContain('A') + expect(await recorder.textContent()).toContain('B') + await page.keyboard.up('a') + await page.keyboard.up('b') + await dialog.getByRole('group').waitFor({ state: 'hidden' }) + expect(snapshot.document.profiles[`desktop:${platform}`]?.['session.new']).toEqual({ code: 'KeyA', secondCode: 'KeyB', modifiers: [] }) + await compareOrRefreshGolden(join(expected, 'chord.expected.md'), await dialog.getByRole('listitem').ariaSnapshot(), mode) + await page.reload({ waitUntil: 'load' }) + await page.getByText('DONE', { exact: true }).waitFor() + await composer.focus() + await page.keyboard.press('a'); await page.keyboard.press('b') + await expect.poll(() => composer.textContent()).toBe('ab') + await expect.poll(() => page.getByText('DONE', { exact: true }).isVisible()).toBe(true) + await page.keyboard.down('a') + await expect.poll(() => composer.textContent()).toBe('aba') + await page.evaluate((input) => { window.shortcutFixture.deliver(input) }, { + kind: 'keyboard', frameName: '', revision: snapshot.revision, code: 'KeyA', secondCode: 'KeyB', + control: false, meta: false, alt: false, shift: false, repeat: false, + } satisfies DesktopShortcutInput) + await page.keyboard.up('a') + await page.getByText('DONE', { exact: true }).waitFor({ state: 'hidden' }) + await expect.poll(() => page.getByRole('treeitem', { selected: true }).count()).toBe(0) + expect(console.pageErrors).toEqual([]) + expect(console.warnings).toEqual([]) + } finally { await browser.close() } + } finally { await scaffold.close() } + } finally { + persistence.dispose() + await rm(userData, { recursive: true, force: true }) + } +}) diff --git a/apps/web/tests/shortcuts-fixed.e2e.ts b/apps/web/tests/shortcuts-fixed.e2e.ts new file mode 100644 index 0000000000..02e76a4566 --- /dev/null +++ b/apps/web/tests/shortcuts-fixed.e2e.ts @@ -0,0 +1,65 @@ +/** Fixed approval keys through the real browser, Remote waterfall and recorded model command. */ +import { access, readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import { chromium, type Browser, type Page } from 'playwright' +import { afterEach, describe, expect, it } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-user-approval' +import { fixtureUserPrompts, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage } from './support.ts' + +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/approval-composer/session.v3.jsonl', import.meta.url)) + +describe('web e2e: fixed approval rejection keys', () => { + let scaffold: WebScaffold | undefined + let browser: Browser | undefined + let page: Page + afterEach(async () => { + const results = await Promise.allSettled([browser?.close(), scaffold?.close()]) + browser = undefined + scaffold = undefined + const failed = results.filter(result => result.status === 'rejected') + if (failed.length > 0) throw new AggregateError(failed.map((result): unknown => result.reason), 'fixed shortcut teardown failed') + }) + + it.skipIf(webSnapshotMode() === 'record').each(['reject-button-enter', 'container-escape'] as const)( + 'rejects through %s without running the requested write', async (method) => { + const events: SessionEvent[] = [] + scaffold = await launchWebScaffold({ replayFixture: FIXTURE, compareReplaySession: false }) + scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + await page.locator('[aria-label^="Access mode"]').click() + await page.getByRole('menuitem', { name: 'Read Only' }).click() + await page.locator('[aria-label="Access mode, current: Read Only"]').waitFor() + const input = page.locator('[data-composer-input]').first() + const prompts = fixtureUserPrompts(await readFile(FIXTURE, 'utf8')) + expect(prompts).toHaveLength(1) + const settled = scaffold.whenTurnSettled() + await input.fill(prompts[0]!) + await input.press('Enter') + const panel = page.locator('[data-approval-key]') + await panel.waitFor() + if (method === 'reject-button-enter') { + await panel.getByRole('button', { name: 'Reject', exact: true }).focus() + await page.keyboard.press('Enter') + } else { + await panel.locator('[data-approval-scroll]').focus() + await page.keyboard.press('Escape') + } + await settled + expect(events.filter(event => event.type === 'approval/decided').map(event => event.data)) + .toMatchObject([{ outcome: 'rejected' }]) + expect(events.filter(event => event.type === 'turn/end').map(event => event.data.reason.kind)) + .toEqual(['completed']) + await expect(access(join(scaffold.workspaceCwd, 'workspace', 'notes.txt'))).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await panel.count()).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, + ) +}) diff --git a/apps/web/tests/shortcuts-panels.e2e.ts b/apps/web/tests/shortcuts-panels.e2e.ts new file mode 100644 index 0000000000..7369666245 --- /dev/null +++ b/apps/web/tests/shortcuts-panels.e2e.ts @@ -0,0 +1,254 @@ +/** Sidebar commands through the shipped Loader, real sidebar/file/terminal owners, and Web keybinding editor. */ +import { writeFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import { chromium, type Browser, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' +import { connectFreshWorkspace } from './support.ts' + +const expected = fileURLToPath(new URL('./expected/shortcuts-panels', import.meta.url)) +const mode = webSnapshotMode() + +/** Reassign a spare Web combination through the actual editor, leaving browser-reserved defaults untouched. */ +async function bind(page: Page, primary: string, label: string, previous?: string): Promise { + await page.keyboard.press(`${primary}+/`) + const dialog = page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true }) + await dialog.waitFor() + if (label === 'Split' || label === 'Toggle panel fullscreen') { + const row = dialog.getByRole('listitem').filter({ has: page.getByRole('button', { name: `Edit shortcut for ${label}`, exact: true }) }) + expect(await row.getByText(label, { exact: true }).count()).toBe(1) + expect(await row.getByText('Focus a right sidebar pane first', { exact: true }).count()).toBe(0) + expect(await row.getByText('Unavailable', { exact: true }).count()).toBe(0) + } + if (previous !== undefined) { + const remove = dialog.getByRole('button', { name: `Remove shortcut for ${previous}`, exact: true }) + await remove.click() + await remove.waitFor({ state: 'hidden' }) + } + await dialog.getByRole('button', { name: `Edit shortcut for ${label}`, exact: true }).click() + const recorder = dialog.getByRole('group', { name: label, exact: true }) + await recorder.waitFor() + await page.keyboard.press(`${primary}+Shift+,`) + await recorder.waitFor({ state: 'hidden' }) + await page.keyboard.press('Escape') + await dialog.waitFor({ state: 'detached' }) +} + +// The fixture owns a POSIX shell; Windows has a separate physical-input acceptance run. +describe.skipIf(process.platform === 'win32')('Web sidebar shortcuts', () => { + let browser: Browser + beforeAll(async () => { + browser = await chromium.launch() + }, 120_000) + afterAll(async () => { await browser?.close() }) + + it.each([ + { platform: 'MacIntel', primary: 'Meta', aria: 'Shift+Meta+,' }, + { platform: 'Win32', primary: 'Control', aria: 'Control+Shift+,' }, + ].flatMap(platform => (['left', 'right'] as const).map(closeFirst => ({ ...platform, closeFirst }))))('targets panes and closes $closeFirst first with $platform bindings', async ({ platform, primary, aria, closeFirst }) => { + const scaffold: WebScaffold = await launchWebScaffold({ + extraOverlayPath: [ + fileURLToPath(new URL('./fixtures/sidebar-terminal.patch.yml', import.meta.url)), + fileURLToPath(new URL('./sidebar-browser.overlay.yml', import.meta.url)), + ], + }) + const context = await browser.newContext({ locale: 'en-US', timezoneId: 'Asia/Shanghai', viewport: { width: 1680, height: 1000 } }) + try { + // Device-label/DOM branch evidence only; physical Windows input has its own acceptance checklist. + await context.addInitScript((value) => { Object.defineProperty(navigator, 'platform', { value }) }, platform) + const page = await context.newPage() + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + const before = new Set(scaffold.ctx.agents.list().map(agent => agent.id)) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + const agent = scaffold.ctx.agents.list().find(agent => !before.has(agent.id)) + if (agent === undefined) throw new Error('The workspace did not create a Session') + // A settled fixture activates the ordinary Session UI; this scenario issues no model requests. + agent.session.append('turn/start', { turn: 1 }) + agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Prepare panel actions.' }], source: { kind: 'user' } }), { surfaceOp: 'append' }) + agent.session.append('step/start', { turn: 1, step: 1 }) + agent.session.append('assistant/message', { turn: 1, step: 1, stream: [], message: createMessage({ role: 'assistant', content: [{ type: 'text', text: 'Panel actions are ready.' }], source: { kind: 'model', provider: 'fixture', model: 'fixture' } }) }, { surfaceOp: 'append' }) + agent.session.append('step/end', { turn: 1, step: 1 }) + agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await scaffold.ctx.sessions.flush(agent.session) + await page.getByText('Panel actions are ready.', { exact: true }).waitFor() + const composer = page.locator('[data-composer-input]') + await composer.click() + await page.keyboard.insertText('T5 draft remains unsent') + await page.keyboard.press(`${primary}+Alt+Enter`) + expect(await composer.innerText()).toContain('T5 draft remains unsent') + const eventCount = agent.session.seq + + await bind(page, primary, 'Toggle right sidebar') + await composer.click() + await page.keyboard.press(`${primary}+Shift+,`) + const panel = page.locator('[data-sidebar-right-panel][data-sidebar-right-open]') + await panel.waitFor() + expect(await panel.getByRole('button', { name: 'Collapse right sidebar' }).getAttribute('aria-keyshortcuts')).toBe(aria) + await panel.getByRole('tab').focus() + await page.keyboard.press(`${primary}+Shift+,`) + await expect.poll(() => page.locator('[data-sidebar-right-open]').count()).toBe(0) + await page.keyboard.press(`${primary}+Shift+,`) + await panel.waitFor() + + const terminalGuide = panel.locator('[data-sidebar-right-guide-entry="terminal"]').getByRole('button').first() + await terminalGuide.hover() + expect(await page.getByRole('tooltip').count()).toBe(0) + await page.mouse.move(10, 10) + await terminalGuide.focus() + expect(await page.getByRole('tooltip').count()).toBe(0) + + await bind(page, primary, 'Browser', 'Toggle right sidebar') + const browserGuide = panel.locator('[data-sidebar-right-guide-entry="browser"]') + await browserGuide.hover() + await browserGuide.focus() + expect(await browserGuide.getAttribute('aria-keyshortcuts')).toBe(aria) + expect(await page.getByRole('tooltip').count()).toBe(0) + await composer.click() + const selectedDraft = await composer.evaluate((element) => { + const text = element.querySelector('[data-lexical-text]')!.firstChild! + const selection = document.getSelection()! + selection.setBaseAndExtent(text, 0, text, text.textContent!.length) + return selection.toString() + }) + await page.keyboard.press(`${primary}+Shift+,`) + await panel.getByPlaceholder('Enter an HTTP(S) address').waitFor() + expect(await page.evaluate(() => document.getSelection()?.toString())).toBe(selectedDraft) + await panel.getByPlaceholder('Enter an HTTP(S) address').focus() + await page.keyboard.press(`${primary}+Shift+,`) + await expect.poll(() => panel.getByRole('tab', { name: /Browser/ }).count()).toBe(2) + expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true) + await page.keyboard.press(`${primary}+Alt+W`) + await expect.poll(() => panel.getByRole('tab', { name: /Browser/ }).count()).toBe(1) + await page.keyboard.press(`${primary}+Shift+,`) + await expect.poll(() => panel.getByRole('tab', { name: /Browser/ }).count()).toBe(2) + await expect.poll(() => panel.getByRole('button', { name: 'Disable sandbox restrictions', exact: true }).isEnabled()).toBe(true) + await compareOrRefreshGolden(join(expected, 'browser.expected.md'), + await captureStableAria(page, '[data-sidebar-right-panel]', scaffold.workspaceCwd), mode) + await panel.locator('[data-dockkit-tab-close]').last().click() + await panel.locator('[data-dockkit-tab-close]').click() + await page.getByRole('button', { name: 'Open right sidebar', exact: true }).click() + await bind(page, primary, 'Workspace files', 'Browser') + await composer.click() + await page.keyboard.press(`${primary}+Shift+,`) + await panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).waitFor() + expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true) + await page.keyboard.press(`${primary}+Shift+,`) + expect(await panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).count()).toBe(1) + expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true) + + await bind(page, primary, 'Toggle panel fullscreen', 'Workspace files') + await composer.click() + await page.keyboard.press(`${primary}+Shift+,`) + expect(await page.locator('[data-sidebar-right-panel="fullscreen"]').count()).toBe(0) + const filesTab = panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }) + await filesTab.focus() + await expect.poll(() => filesTab.evaluate(element => document.activeElement === element)).toBe(true) + await page.keyboard.press(`${primary}+Shift+,`) + await page.locator('[data-sidebar-right-panel="fullscreen"]').waitFor() + expect(await composer.innerText()).toContain('T5 draft remains unsent') + expect(agent.session.seq).toBe(eventCount) + + await bind(page, primary, 'Split', 'Toggle panel fullscreen') + await panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).focus() + await page.keyboard.press(`${primary}+Shift+,`) + await expect.poll(() => panel.locator('[data-dockkit-pane]').count()).toBe(2) + const left = panel.locator('[data-dockkit-pane][data-dockkit-column="0"]') + const right = panel.locator('[data-dockkit-pane][data-dockkit-column="1"]') + const disabled = right.locator('[data-dockkit-split-button]') + expect(await disabled.isDisabled()).toBe(true) + await disabled.locator('..').focus() + const splitKeys = primary === 'Meta' ? '⇧ ⌘ ,' : 'Ctrl + Shift + ,' + await page.getByRole('tooltip', { name: `Two panes is the limit ${splitKeys}`, exact: true }).waitFor() + + await bind(page, primary, 'Workspace files', 'Split') + await right.getByRole('tab').focus() + await page.keyboard.press(`${primary}+Shift+,`) + await expect.poll(() => panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).count()).toBe(2) + await page.keyboard.press(`${primary}+Shift+,`) + expect(await right.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).count()).toBe(1) + + await bind(page, primary, 'New terminal', 'Workspace files') + await right.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).focus() + await page.keyboard.press(`${primary}+Shift+,`) + await right.locator('[data-sidebar-terminal]').waitFor() + await expect.poll(() => right.locator('.xterm-rows').innerText()).toContain('bash-') + await right.locator('.xterm-helper-textarea').focus() + await page.keyboard.press(`${primary}+Shift+,`) + await expect.poll(() => right.getByRole('tab').count()).toBe(3) + await expect.poll(() => right.locator('.xterm-rows').innerText()).toContain('bash-') + expect(await left.getByRole('tab').count()).toBe(1) + await compareOrRefreshGolden(join(expected, 'panels.expected.md'), + await captureStableAria(page, '[data-sidebar-right-panel]', scaffold.workspaceCwd), mode) + await bind(page, primary, 'Refresh current page', 'New terminal') + await right.locator('.xterm-helper-textarea').focus() + await page.keyboard.press(`${primary}+Shift+,`) + expect(await right.getByRole('tab').count()).toBe(3) + await left.getByRole('tab', { name: /Files/ }).click() + await writeFile(join(scaffold.workspaceCwd, 'workspace', 't6-refresh.txt'), 'page refresh evidence\n') + await left.getByRole('tab', { name: /Files/ }).focus() + await page.keyboard.press(`${primary}+Shift+,`) + await left.getByText('t6-refresh.txt', { exact: true }).waitFor() + expect(await left.getByRole('button', { name: 'Reload', exact: true }).getAttribute('aria-keyshortcuts')).toBe(aria) + await bind(page, primary, 'Close current page or window', 'Refresh current page') + await right.getByRole('tab').last().focus() + await page.keyboard.press(`${primary}+Shift+,`) + await expect.poll(() => right.getByRole('tab').count()).toBe(2) + expect(await left.getByRole('tab').count()).toBe(1) + await panel.getByRole('button', { name: 'Exit fullscreen', exact: true }).click() + await composer.click() + await page.keyboard.press(`${primary}+Shift+,`) + expect(await right.getByRole('tab').count()).toBe(2) + await compareOrRefreshGolden(join(expected, 'page-actions.expected.md'), + await captureStableAria(page, '[data-sidebar-right-panel]', scaffold.workspaceCwd), mode) + const menuTab = left.getByRole('tab', { name: /Files/ }) + await menuTab.locator('[data-dockkit-tab-close]').focus() + await page.getByRole('tooltip').filter({ hasText: 'Close' }).waitFor() + for (let attempt = 0; attempt < 2; attempt++) { + await menuTab.click({ button: 'right' }) + await page.getByRole('menu').waitFor() + expect(await page.getByRole('tooltip').count()).toBe(0) + expect(await page.getByRole('menuitem').getAttribute('aria-keyshortcuts')).toBeNull() + if (attempt === 0) { + await compareOrRefreshGolden(join(expected, 'tab-menu.expected.md'), + await captureStableAria(page, '[data-dockkit-tab-menu]', scaffold.workspaceCwd), mode) + } else { + await page.getByRole('menuitem').focus() + } + await page.keyboard.press('Escape') + await page.getByRole('menu').waitFor({ state: 'detached' }) + expect(await left.getByRole('tab').count()).toBe(1) + expect(await right.getByRole('tab').count()).toBe(2) + } + expect(await menuTab.evaluate(tab => tab === document.activeElement)).toBe(true) + await (closeFirst === 'right' ? right : left).getByRole('tab').last().focus() + await page.keyboard.press(`${primary}+Shift+,`) + await expect.poll(() => panel.getByRole('tab').count()).toBe(2) + await page.keyboard.press(`${primary}+Shift+,`) + await expect.poll(() => panel.getByRole('tab').count()).toBe(1) + expect(await panel.locator('[data-dockkit-pane]').count()).toBe(1) + expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => pane === document.activeElement)).toBe(true) + await page.keyboard.press(`${primary}+Shift+,`) + await expect.poll(() => page.locator('[data-sidebar-right-open]').count()).toBe(0) + await page.getByRole('button', { name: 'Open right sidebar', exact: true }).click() + await panel.getByRole('button', { name: 'Fullscreen', exact: true }).click() + await panel.locator('[data-dockkit-split-button]').click() + await expect.poll(() => panel.locator('[data-dockkit-pane]').count()).toBe(2) + await (closeFirst === 'left' ? left : right).getByRole('tab').focus() + await page.keyboard.press(`${primary}+Shift+,`) + await expect.poll(() => panel.getByRole('tab').count()).toBe(1) + expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => pane === document.activeElement)).toBe(true) + await compareOrRefreshGolden(join(expected, 'guide-close.expected.md'), + await captureStableAria(page, '[data-sidebar-right-panel]', scaffold.workspaceCwd), mode) + await page.keyboard.press(`${primary}+Shift+,`) + await expect.poll(() => page.locator('[data-sidebar-right-open]').count()).toBe(0) + expect(await composer.innerText()).toContain('T5 draft remains unsent') + expect(agent.session.seq).toBe(eventCount) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } finally { try { await context.close() } finally { await scaffold.close() } } + }) +}) diff --git a/apps/web/tests/shortcuts-workspace.e2e.ts b/apps/web/tests/shortcuts-workspace.e2e.ts new file mode 100644 index 0000000000..58a84096a2 --- /dev/null +++ b/apps/web/tests/shortcuts-workspace.e2e.ts @@ -0,0 +1,311 @@ +/** Workspace keyboard commands over the real Web composition and a recorded Session. */ +import { mkdir, readFile } from 'node:fs/promises' +import { basename, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium, type Browser, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session' +import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { + captureStableAria, compareOrRefreshGolden, launchWebScaffold, readPersistedEvents, + seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace } from './support.ts' + +const root = fileURLToPath(new URL('./expected/shortcuts-workspace', import.meta.url)) +const seed = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) +const sourceId = SessionId('shortcuts-workspace-source') +const mode = webSnapshotMode() + +describe.skipIf(mode === 'record')('web e2e: workspace shortcuts', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let previous: string | undefined + const launched: { app: string; path: string }[] = [] + beforeAll(async () => { + scaffold = await launchWebScaffold({ developerTools: false, openInAppEnvironment: createLaunchEnvironmentSnapshot([]) }) + await seedSession(scaffold, await readFile(seed, 'utf8'), sourceId) + await mkdir(join(scaffold.workspaceCwd, 'keyboard-project')) + browser = await chromium.launch() + const context = await browser.newContext({ locale: 'en-US', viewport: { width: 1440, height: 1000 } }) + await context.addInitScript(() => { + Object.defineProperty(navigator, 'platform', { value: 'MacIntel' }) + localStorage.setItem('dsh.open-in-app.choice', JSON.stringify('vscode')) + }) + page = await context.newPage() + // OS application discovery and launch are the external boundary; the command and HTTP carrier stay real. + await page.route('**/open-in-app/apps', route => route.fulfill({ json: { apps: ['finder', 'vscode'] } })) + await page.route('**/open-in-app/open', async (route) => { + launched.push(route.request().postDataJSON() as { app: string; path: string }) + await route.fulfill({ json: { ok: true } }) + }) + const startup = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + const group = page.getByRole('treeitem').first() + await group.waitFor({ timeout: 10_000 }).catch((error: unknown) => { throw new Error(JSON.stringify(startup), { cause: error }) }) + if (await group.getAttribute('aria-expanded') !== 'true') await group.click() + await page.getByRole('treeitem').nth(1).click() + await page.getByText('DONE', { exact: true }).waitFor() + }) + afterAll(async () => { await browser?.close(); await scaffold?.close() }) + + async function bind(label: string): Promise { + await page.keyboard.press('Meta+Slash') + const reference = page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true }) + await reference.waitFor() + if (['Rename session', 'Fork session', 'Archive session'].includes(label)) { + const row = reference.getByRole('listitem').filter({ has: page.getByText(label, { exact: true }) }) + expect(await row.count()).toBe(1) + expect(await row.getByText('Unavailable', { exact: true }).count()).toBe(0) + } + if (previous !== undefined) { + await reference.getByRole('button', { name: `Remove shortcut for ${previous}`, exact: true }).click() + await expect.poll(() => reference.getByRole('button', { name: `Remove shortcut for ${previous}`, exact: true }).count()).toBe(0) + } + await reference.getByRole('button', { name: `Edit shortcut for ${label}`, exact: true }).click() + await page.keyboard.press('Meta+Shift+Comma') + await expect.poll(() => reference.getByRole('group').count()).toBe(0) + await page.keyboard.press('Escape') + await reference.waitFor({ state: 'hidden' }) + previous = label + } + + it('runs seven owner operations with saved bindings and preserves the completed fork prefix', async () => { + const console = watchConsole(page) + await bind('Search sessions') + await page.keyboard.press('Meta+Shift+Comma') + const search = page.getByPlaceholder('Search session names', { exact: true }) + await expect.poll(() => search.evaluate(element => element === document.activeElement)).toBe(true) + expect(await page.getByRole('button', { name: 'Search sessions', exact: true }).getAttribute('aria-keyshortcuts')).toBe('Shift+Meta+,') + await page.keyboard.press('Escape') + + await bind('Rename session') + await page.keyboard.press('Meta+Shift+Comma') + const rename = page.getByRole('dialog', { name: 'Rename session', exact: true }) + await rename.getByLabel('Session name').fill('T4 source') + await rename.getByRole('button', { name: 'Rename', exact: true }).click() + await rename.waitFor({ state: 'hidden' }) + await expect.poll(async () => (await readPersistedEvents(scaffold, sourceId)).some(event => event.type === 'session/title' && event.data.title === 'T4 source')).toBe(true) + + await bind('Fork session') + const original = await readPersistedEvents(scaffold, sourceId) + const lastEnd = original.findLastIndex(event => event.type === 'turn/end') + await page.keyboard.press('Meta+Shift+Comma') + await expect.poll(() => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === sourceId)).toBeDefined() + const child = scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === sourceId)! + const childId = child.session.header.id + await expect.poll(async () => (await readPersistedEvents(scaffold, childId)).some(event => event.type === 'session/title' && event.data.title === 'T4 source (1)')).toBe(true) + const copied = await readPersistedEvents(scaffold, childId) + expect(copied.slice(0, lastEnd + 1)).toEqual(original.slice(0, lastEnd + 1)) + const sourceRow = page.getByRole('treeitem').filter({ has: page.getByText('T4 source', { exact: true }) }) + const childRow = page.getByRole('treeitem').filter({ has: page.getByText('T4 source (1)', { exact: true }) }) + await childRow.waitFor() + expect(await sourceRow.getAttribute('aria-selected')).toBe('true') + expect(await childRow.getAttribute('aria-selected')).toBe('false') + await compareOrRefreshGolden(join(root, 'fork.expected.md'), + await captureStableAria(page, '[data-slot="sidebar.workspaces"]', scaffold.workspaceCwd), mode) + + await childRow.click() + await expect.poll(() => childRow.getAttribute('aria-selected')).toBe('true') + await bind('Archive session') + await sourceRow.hover() + await page.getByRole('button', { name: 'Session actions for T4 source', exact: true }).click() + await page.getByRole('menu').waitFor() + await compareOrRefreshGolden(join(root, 'session-menu.expected.md'), + await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), mode) + expect(await page.getByRole('menuitem', { name: /^Archive session/ }).getAttribute('aria-keyshortcuts')).toBe('Shift+Meta+,') + await page.keyboard.press('Escape') + await page.getByRole('menu').waitFor({ state: 'hidden' }) + await page.getByRole('treeitem', { selected: true }).filter({ hasText: 'T4 source (1)' }).waitFor() + await page.keyboard.press('Meta+Shift+Comma') + await expect.poll(() => scaffold.ctx.workspaceRegistry.archivedSessionIds).toContain(childId) + expect(scaffold.ctx.workspaceRegistry.archivedSessionIds).not.toContain(sourceId) + expect((await readPersistedEvents(scaffold, sourceId)).length).toBeGreaterThan(0) + + await bind('Add workspace') + await page.keyboard.press('Meta+Shift+Comma') + const picker = page.getByRole('dialog', { name: 'Select Workspace Directory', exact: true }) + await picker.waitFor() + await page.keyboard.press('Meta+Shift+Comma') + expect(await page.getByRole('dialog', { name: 'Select Workspace Directory', exact: true }).count()).toBe(1) + await picker.getByRole('button', { name: 'Edit path', exact: true }).click() + await picker.getByRole('textbox', { name: 'Edit path', exact: true }).fill(join(scaffold.workspaceCwd, 'keyboard-project')) + await page.keyboard.press('Enter') + await picker.getByRole('button', { name: 'Open', exact: true }).click() + await picker.waitFor({ state: 'hidden' }) + await expect.poll(() => scaffold.ctx.workspaceRegistry.resolveByPath(join(scaffold.workspaceCwd, 'keyboard-project'))).toBeDefined() + const workspace = (await scaffold.ctx.workspaceRegistry.resolveByPath(join(scaffold.workspaceCwd, 'keyboard-project')))! + + const workspaceLabel = basename(workspace.path) + const workspaceRow = page.locator('[role="treeitem"][aria-expanded]') + .filter({ has: page.getByText(workspaceLabel, { exact: true }) }) + const details = page.getByRole('button', { name: `Copy: ${workspace.path}`, exact: true }) + await workspaceRow.getByText(workspaceLabel, { exact: true }).hover() + await details.waitFor() + await workspaceRow.getByRole('button', { name: `New session in ${workspaceLabel}`, exact: true }).hover() + await page.getByRole('tooltip').filter({ hasText: 'New Session' }).waitFor() + await expect.poll(() => details.count()).toBe(0) + await workspaceRow.getByText(workspaceLabel, { exact: true }).hover() + await details.waitFor() + await page.mouse.move(700, 100) + + await bind('New Session') + await page.keyboard.press('Meta+Shift+Comma') + await expect.poll(() => page.getByRole('treeitem', { selected: true }).textContent()).toContain('New Session') + expect(workspace.sessionIds.length).toBe(1) + + await bind('Open locally') + await page.keyboard.press('Meta+Shift+Comma') + await expect.poll(() => launched).toEqual([{ app: 'vscode', path: workspace.path }]) + expect(console.pageErrors).toEqual([]) + }) +}) + +it.skipIf(mode === 'record')('explains a first-turn fork refusal and retries from the completed prefix', async () => { + const scaffold = await launchWebScaffold({ developerTools: false }) + let browser: Browser | undefined + try { + browser = await chromium.launch() + const context = await browser.newContext({ locale: 'en-US', viewport: { width: 1440, height: 1000 } }) + await context.addInitScript(() => { Object.defineProperty(navigator, 'platform', { value: 'MacIntel' }) }) + const page = await context.newPage() + const tripwire = watchConsole(page) + const forkWarnings: string[] = [] + page.on('console', (message) => { if (message.text().startsWith('session fork rejected:')) forkWarnings.push(message.text()) }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + const before = new Set(scaffold.ctx.agents.list().map(agent => agent.id)) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + const source = scaffold.ctx.agents.list().find(agent => !before.has(agent.id)) + if (source === undefined) throw new Error('The workspace did not create a Session') + source.session.append('turn/start', { turn: 1 }) + const user = source.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'The first turn has not ended.' }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + source.session.append('session/title', { title: 'First turn', messageSeqs: [user.seq], source: { kind: 'fallback' } }) + await scaffold.ctx.sessions.flush(source.session) + await page.getByText('The first turn has not ended.', { exact: true }).waitFor() + const selected = page.getByRole('treeitem', { selected: true }) + await selected.filter({ hasText: 'First turn' }).waitFor() + await page.keyboard.press('Meta+Slash') + const reference = page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true }) + await reference.getByRole('button', { name: 'Edit shortcut for Fork session', exact: true }).click() + await page.keyboard.press('Meta+Shift+Comma') + await reference.getByRole('group').waitFor({ state: 'hidden' }) + await page.keyboard.press('Escape') + await reference.waitFor({ state: 'hidden' }) + await page.keyboard.press('Meta+Shift+Comma') + const unavailable = page.getByRole('alert').filter({ hasText: 'This session has no completed turn' }) + await unavailable.waitFor() + await compareOrRefreshGolden(join(root, 'fork-unavailable.expected.md'), + await unavailable.ariaSnapshot(), mode) + expect(await selected.textContent()).toContain('First turn') + expect(scaffold.ctx.agents.list().filter(agent => agent.session.header.parentSession === source.id)).toHaveLength(0) + expect(forkWarnings).toEqual([]) + + source.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + const completed = source.session.snapshotEvents() + source.session.append('turn/start', { turn: 2 }) + source.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'The second turn has not ended.' }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + await scaffold.ctx.sessions.flush(source.session) + await page.getByText('The second turn has not ended.', { exact: true }).waitFor() + await page.keyboard.press('Meta+Shift+Comma') + await expect.poll(() => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === source.id)).toBeDefined() + const child = scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === source.id)! + const childRow = page.getByRole('treeitem').filter({ has: page.getByText('First turn (1)', { exact: true }) }) + await childRow.waitFor() + expect(await childRow.getAttribute('aria-selected')).toBe('false') + expect(await selected.getByText('First turn', { exact: true }).count()).toBe(1) + expect((await readPersistedEvents(scaffold, child.id)).slice(0, completed.length)).toEqual(completed) + await page.getByText('The second turn has not ended.', { exact: true }).waitFor() + expect(tripwire.warnings).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + } finally { + await browser?.close() + await scaffold.close() + } +}) + +it.skipIf(mode === 'record').each([ + { platform: 'Win32', primary: 'Control', file: '../shortcuts/en-US', custom: 'Control+Alt+Shift+Meta+J' }, + { platform: 'MacIntel', primary: 'Meta', file: 'macos-defaults', custom: 'Meta+Alt+Shift+J' }, +])('runs $platform defaults over recorded history and restores them after a multi-modifier binding', async ({ platform, primary, file, custom }) => { + const scaffold = await launchWebScaffold({ developerTools: false }) + let browser: Browser | undefined + try { + await seedSession(scaffold, await readFile(seed, 'utf8'), SessionId('shortcut-defaults-source')) + browser = await chromium.launch() + const context = await browser.newContext({ locale: 'en-US', viewport: { width: 1440, height: 1000 } }) + await context.addInitScript((value) => { Object.defineProperty(navigator, 'platform', { value }) }, platform) + const page = await context.newPage() + const console = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + const group = page.getByRole('treeitem').first() + await group.waitFor() + if (await group.getAttribute('aria-expanded') !== 'true') await group.click() + await page.getByRole('treeitem').nth(1).click() + await page.getByText('DONE', { exact: true }).waitFor() + await page.keyboard.press(`${primary}+Slash`) + const reference = page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true }) + await reference.waitFor() + await compareOrRefreshGolden(join(root, `${file}.expected.md`), + await captureStableAria(page, '[data-shortcut-modal="shortcuts"]', scaffold.workspaceCwd), mode) + await page.keyboard.press('Escape') + await reference.waitFor({ state: 'hidden' }) + await page.keyboard.press(`${primary}+Alt+K`) + const search = page.getByPlaceholder('Search session names', { exact: true }) + await expect.poll(() => search.evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press('Escape') + await page.keyboard.press(`${primary}+Alt+B`) + await page.getByRole('button', { name: 'Open sidebar', exact: true }).waitFor() + await page.keyboard.press(`${primary}+Alt+B`) + await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).waitFor() + await page.keyboard.press(`${primary}+Slash`) + await reference.getByRole('button', { name: 'Edit shortcut for Toggle left sidebar', exact: true }).click() + await page.keyboard.press(custom) + await reference.getByRole('group').waitFor({ state: 'hidden' }) + await page.keyboard.press('Escape') + await page.keyboard.press(custom) + await page.getByRole('button', { name: 'Open sidebar', exact: true }).waitFor() + await page.keyboard.press(custom) + await page.keyboard.press(`${primary}+Slash`) + await reference.getByRole('button', { name: 'Edit shortcut for Toggle left sidebar', exact: true }).click() + await reference.getByRole('button', { name: 'Restore default', exact: true }).click() + await reference.getByRole('group').waitFor({ state: 'hidden' }) + await page.keyboard.press('Escape') + expect(await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).getAttribute('aria-keyshortcuts')).toBe(primary === 'Meta' ? 'Alt+Meta+B' : 'Control+Alt+B') + if (platform === 'MacIntel') { + await page.keyboard.press('Meta+Slash') + await reference.getByRole('button', { name: 'Edit shortcut for New Session', exact: true }).click() + const recorder = reference.getByRole('button', { name: 'Press a shortcut', exact: true }) + // Reproduce the macOS Dead-key payload through the real recorder and command owners. + expect(await recorder.evaluate((element) => { + const event = new KeyboardEvent('keydown', { key: 'Dead', code: 'KeyN', metaKey: true, altKey: true, bubbles: true, cancelable: true }) + element.dispatchEvent(event) + element.dispatchEvent(new KeyboardEvent('keyup', { key: 'Meta', code: 'MetaLeft', bubbles: true })) + return event.defaultPrevented + })).toBe(true) + await reference.getByRole('group').waitFor({ state: 'hidden' }) + await page.keyboard.press('Escape') + const composer = page.locator('[data-composer-input]').first() + await composer.focus() + expect(await composer.evaluate((element) => { + const event = new KeyboardEvent('keydown', { key: 'Dead', code: 'KeyN', metaKey: true, altKey: true, bubbles: true, cancelable: true }) + element.dispatchEvent(event) + return event.defaultPrevented + })).toBe(true) + } else { + await page.keyboard.press(`${primary}+Alt+N`) + } + await page.getByText('DONE', { exact: true }).waitFor({ state: 'hidden' }) + await expect.poll(() => page.getByRole('treeitem', { selected: true }).count()).toBe(0) + await page.locator('[data-composer-input]').first().waitFor() + expect(console.pageErrors).toEqual([]) + } finally { + await browser?.close() + await scaffold.close() + } +}) diff --git a/apps/web/tests/shortcuts.e2e.ts b/apps/web/tests/shortcuts.e2e.ts new file mode 100644 index 0000000000..bd21136cb3 --- /dev/null +++ b/apps/web/tests/shortcuts.e2e.ts @@ -0,0 +1,336 @@ +/** Real Web composition: keyboard routing, inline editing, and localized system feedback. */ +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import { chromium, type Browser } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { openSettings } from './support.ts' +import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' + +const expected = fileURLToPath(new URL('./expected/shortcuts', import.meta.url)) +const mode = webSnapshotMode() + +describe('web e2e: shortcut reference', () => { + let scaffold: WebScaffold + let browser: Browser + beforeAll(async () => { + scaffold = await launchWebScaffold({ developerTools: false }) + browser = await chromium.launch() + }, 120_000) + afterAll(async () => { await browser?.close(); await scaffold?.close() }) + + it.each([ + { locale: 'zh-CN', platform: 'MacIntel', title: '快捷键', settings: '设置', view: '查看快捷键', search: '搜索快捷键', key: 'Meta' }, + { locale: 'en-US', platform: 'Win32', title: 'Keyboard shortcuts', settings: 'Settings', view: 'View shortcuts', search: 'Search shortcuts', key: 'Control' }, + ])('opens, searches, and restores nested focus in $locale', async ({ locale, platform, title, settings, view, search, key }) => { + const referenceKey = platform === 'Win32' ? 'Control+Slash' : 'Meta+Slash' + const context = await browser.newContext({ locale, colorScheme: 'light', viewport: { width: 1440, height: 1000 } }) + try { + // Branch coverage for device labels; native Windows input is separately verified on Windows. + await context.addInitScript((value) => { Object.defineProperty(navigator, 'platform', { value }) }, platform) + const page = await context.newPage() + const console = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + const trigger = page.getByRole('button', { name: settings, exact: true }) + expect(await trigger.getAttribute('aria-keyshortcuts')).toBe(`${key}+,`) + const settingsHint = `${settings} ${key === 'Meta' ? '⌘ ,' : 'Ctrl + ,'}` + await trigger.hover() + await page.getByRole('tooltip', { name: settingsHint, exact: true }).waitFor() + await trigger.click() + await page.getByRole('button', { name: view, exact: true }).click() + const dialog = page.getByRole('dialog', { name: title, exact: true }) + await dialog.waitFor() + const referenceNode = await dialog.elementHandle() + expect(await page.getByRole('dialog').count()).toBe(2) + expect(await dialog.locator('footer').count()).toBe(1) + const resetAll = dialog.getByRole('button', { name: locale === 'zh-CN' ? '恢复全部默认' : 'Restore all defaults', exact: true }) + expect(await resetAll.isDisabled()).toBe(true) + expect(await dialog.boundingBox()).toMatchObject({ width: 480, height: 600 }) + expect(await dialog.getByRole('search').boundingBox()).toMatchObject({ width: 432, height: 36 }) + const rows = dialog.getByRole('listitem') + expect(new Set(await rows.evaluateAll(items => items.map(row => row.getBoundingClientRect().height)))) + .toEqual(new Set([42])) + expect(await rows.evaluateAll(items => items.every(row => row.getBoundingClientRect().height >= 42 + && row.scrollWidth <= row.clientWidth))).toBe(true) + expect(await page.getByRole('searchbox', { name: search }).evaluate(element => element === document.activeElement)).toBe(true) + await compareOrRefreshGolden(join(expected, `${locale}.expected.md`), + await captureStableAria(page, '[data-shortcut-modal="shortcuts"]', scaffold.workspaceCwd), mode) + const fixedButtons = dialog.getByRole('button', { disabled: true }) + const fixedButton = fixedButtons.first() + expect(await fixedButton.evaluate(element => getComputedStyle(element).opacity)).toBe('0.45') + const fixedBackground = await fixedButton.locator('span').evaluate(element => getComputedStyle(element).backgroundColor) + await fixedButton.hover() + expect(await fixedButton.locator('span').evaluate(element => getComputedStyle(element).backgroundColor)).toBe(fixedBackground) + const editLabel = locale === 'zh-CN' ? '修改展开/收起左侧栏快捷键' : 'Edit shortcut for Toggle left sidebar' + const removeLabel = locale === 'zh-CN' ? '移除展开/收起左侧栏快捷键' : 'Remove shortcut for Toggle left sidebar' + const row = dialog.getByRole('listitem').filter({ has: page.getByRole('button', { name: editLabel, exact: true }) }) + const editButton = page.getByRole('button', { name: editLabel, exact: true }) + expect(await editButton.evaluate(element => getComputedStyle(element.parentElement!).opacity)).toBe('0') + const boundRow = dialog.getByRole('listitem').filter({ has: page.getByText( + locale === 'zh-CN' ? '快捷键速查' : 'Open keyboard shortcuts', { exact: true }, + ) }) + const badge = boundRow.getByRole('button').last() + const badgeColor = () => badge.locator('span').evaluate(element => getComputedStyle(element).backgroundColor) + const restingColor = await badgeColor() + await boundRow.locator(':scope > span').first().hover() + expect(await badgeColor()).toBe(restingColor) + await badge.hover() + expect(await badgeColor()).toBe('rgba(38, 49, 72, 0.14)') + await boundRow.getByRole('button').first().hover() + expect(await badgeColor()).toBe(restingColor) + await badge.click() + await boundRow.getByRole('group').waitFor() + expect(new Set(await rows.evaluateAll(items => items.map(row => row.getBoundingClientRect().height)))) + .toEqual(new Set([42])) + expect(await boundRow.getByRole('button', { name: locale === 'zh-CN' ? '移除' : 'Remove', exact: true }).count()).toBe(1) + await page.keyboard.press('Escape') + await boundRow.getByRole('group').waitFor({ state: 'hidden' }) + await dialog.getByRole('heading', { name: title, exact: true }).hover() + const boundActionsOpacity = () => boundRow.getByRole('button').first().evaluate(element => getComputedStyle(element.parentElement!).opacity) + expect(await boundActionsOpacity()).toBe('0') + expect(await dialog.evaluate(element => element === document.activeElement)).toBe(true) + await badge.click() + await boundRow.getByRole('button', { name: locale === 'zh-CN' ? '恢复默认' : 'Restore default', exact: true }).click() + await boundRow.getByRole('group').waitFor({ state: 'hidden' }) + await dialog.getByRole('heading', { name: title, exact: true }).hover() + expect(await boundActionsOpacity()).toBe('0') + await row.hover() + expect(await editButton.evaluate(element => getComputedStyle(element.parentElement!).opacity)).toBe('1') + expect(await row.getByRole('button', { name: removeLabel, exact: true }).count()).toBe(1) + await editButton.click() + const inline = dialog.getByRole('group', { name: locale === 'zh-CN' ? '展开/收起左侧栏' : 'Toggle left sidebar', exact: true }) + const recorder = inline.getByRole('button', { name: locale === 'zh-CN' ? '按下快捷键' : 'Press a shortcut', exact: true }) + expect(await inline.getByRole('button', { name: locale === 'zh-CN' ? '移除' : 'Remove', exact: true }).count()).toBe(1) + expect(await page.getByRole('dialog').count()).toBe(2) + expect(await recorder.evaluate(element => getComputedStyle(element).color)).toBe('rgb(129, 133, 140)') + await compareOrRefreshGolden(join(expected, `${locale}-inline.expected.md`), + await captureStableAria(page, '[data-shortcut-modal="shortcut-edit"]', scaffold.workspaceCwd), mode) + await page.keyboard.press(referenceKey) + const errorToast = page.getByRole('alert').filter({ hasText: locale === 'zh-CN' ? '已被「快捷键速查」占用' : 'Already used by “Open keyboard shortcuts”' }) + await errorToast.waitFor() + expect(await errorToast.evaluate(element => ({ + background: getComputedStyle(element).backgroundColor, color: getComputedStyle(element).color, + }))) + .toEqual({ background: 'rgb(53, 54, 56)', color: 'rgb(255, 255, 255)' }) + expect(await errorToast.evaluate(element => element.closest('[role="dialog"]') === null)).toBe(true) + expect(await recorder.evaluate(element => ({ + color: getComputedStyle(element).borderColor, width: getComputedStyle(element).borderWidth, + }))) + .toEqual({ color: 'rgb(236, 19, 19)', width: '1px' }) + await compareOrRefreshGolden(join(expected, `${locale}-conflict.expected.md`), await errorToast.ariaSnapshot(), mode) + await page.keyboard.press('i') + await page.getByRole('alert').filter({ hasText: locale === 'zh-CN' + ? '请同时按下 Command、Ctrl 或 Alt 修饰键。' : 'Include Command, Ctrl, or Alt in the combination.' }).waitFor() + await page.keyboard.press(`${key}+Shift+.`) + await inline.waitFor({ state: 'hidden' }) + expect(await dialog.evaluate(element => element === document.activeElement)).toBe(true) + expect(await editButton.evaluate(element => element.matches(':focus-visible'))).toBe(false) + await dialog.getByRole('heading', { name: title, exact: true }).hover() + expect(await editButton.evaluate(element => getComputedStyle(element.parentElement!).opacity)).toBe('0') + const successToast = page.getByRole('alert').filter({ hasText: locale === 'zh-CN' ? '已修改' : 'Modified' }) + await successToast.waitFor() + await compareOrRefreshGolden(join(expected, `${locale}-saved.expected.md`), await successToast.ariaSnapshot(), mode) + await page.emulateMedia({ colorScheme: 'dark' }) + await expect.poll(() => successToast.evaluate(element => ({ + background: getComputedStyle(element).backgroundColor, color: getComputedStyle(element).color, + }))).toEqual({ background: 'rgb(67, 69, 74)', color: 'rgb(255, 255, 255)' }) + await page.emulateMedia({ colorScheme: 'light' }) + await expect.poll(() => successToast.evaluate(element => getComputedStyle(element).backgroundColor)).toBe('rgb(53, 54, 56)') + await page.getByRole('button', { name: removeLabel, exact: true }).click() + const unbound = row.getByText(locale === 'zh-CN' ? '暂无快捷键' : 'No shortcut', { exact: true }) + await unbound.waitFor() + expect(await unbound.evaluate(element => getComputedStyle(element).color)).toBe('rgb(129, 133, 140)') + expect(await row.getByRole('button', { name: removeLabel, exact: true }).count()).toBe(0) + await unbound.click() + await inline.waitFor() + expect(await inline.getByRole('button', { name: locale === 'zh-CN' ? '移除' : 'Remove', exact: true }).count()).toBe(0) + await page.keyboard.press('Escape') + await inline.waitFor({ state: 'hidden' }) + expect(await dialog.evaluate(element => element === document.activeElement)).toBe(true) + await dialog.getByRole('heading', { name: title, exact: true }).hover() + expect(await editButton.evaluate(element => getComputedStyle(element.parentElement!).opacity)).toBe('0') + expect(await page.getByRole('dialog').count()).toBe(2) + for (const query of ['abc', 'sendEnter']) { + await page.getByRole('searchbox').fill(query) + expect(await dialog.getByRole('listitem').count()).toBe(0) + expect(await dialog.getByRole('status').textContent()).toBe(locale === 'zh-CN' ? '没有匹配的快捷键' : 'No matching shortcuts') + if (query === 'abc') await compareOrRefreshGolden(join(expected, `${locale}-empty.expected.md`), + await captureStableAria(page, '[data-shortcut-modal="shortcuts"]', scaffold.workspaceCwd), mode) + } + for (const query of [locale === 'zh-CN' ? '左侧栏' : 'toggle left', 'tgllft', 'toggle left sidebar']) { + await page.getByRole('searchbox').fill(query) + expect(await dialog.getByRole('listitem').count()).toBe(1) + expect(await dialog.getByRole('listitem').textContent()).toContain(locale === 'zh-CN' ? '展开/收起左侧栏' : 'Toggle left sidebar') + } + await page.keyboard.press(referenceKey) + expect(await referenceNode!.evaluate(element => element.isConnected)).toBe(true) + expect(await page.getByRole('dialog').count()).toBe(2) + expect(await page.getByRole('searchbox').inputValue()).toBe('toggle left sidebar') + await page.keyboard.press('Escape') + expect(await page.getByRole('dialog').count()).toBe(1) + expect(await page.getByRole('button', { name: view, exact: true }).evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press('Escape') + expect(await page.getByRole('dialog').count()).toBe(0) + expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press(referenceKey) + await dialog.waitFor() + await page.keyboard.press(`${key}+/`) + expect(await page.getByRole('dialog').count()).toBe(1) + expect(await page.getByRole('searchbox').inputValue()).toBe('') + await page.setViewportSize({ width: 420, height: 500 }) + const compactBounds = (await dialog.boundingBox())! + expect(compactBounds.x).toBeGreaterThanOrEqual(24) + expect(compactBounds.x + compactBounds.width).toBeLessThanOrEqual(396) + expect(compactBounds.y + compactBounds.height).toBeLessThanOrEqual(476) + await dialog.getByRole('region').last().scrollIntoViewIfNeeded() + expect(await dialog.getByRole('heading', { name: title }).isVisible()).toBe(true) + await page.keyboard.press('Escape') + // Observe preventDefault without invoking destructive browser defaults in the test runner. + expect(await page.evaluate(modifier => ['KeyN', 'KeyP', 'KeyO', 'KeyR', 'KeyW', 'KeyB'].map((code) => { + const event = new KeyboardEvent('keydown', { code, ctrlKey: modifier === 'Control', metaKey: modifier === 'Meta', bubbles: true, cancelable: true }) + document.body.dispatchEvent(event) + return event.defaultPrevented + }), key)).toEqual(Array(6).fill(false)) + await page.setViewportSize({ width: 1440, height: 1000 }) + await openSettings(page, locale === 'zh-CN' ? 'zh' : 'en') + const viewButton = page.getByRole('button', { name: view, exact: true }) + const viewHint = `${view} ${key === 'Meta' ? '⌘ /' : 'Ctrl + /'}` + await viewButton.hover() + expect(await page.getByRole('tooltip').textContent()).toBe(viewHint) + await viewButton.click() + await boundRow.getByRole('button').first().click() + await boundRow.getByText(locale === 'zh-CN' ? '暂无快捷键' : 'No shortcut', { exact: true }).waitFor() + await page.keyboard.press('Escape') + await dialog.waitFor({ state: 'hidden' }) + await viewButton.hover() + expect(await page.getByRole('tooltip').count()).toBe(0) + expect(await viewButton.getAttribute('aria-keyshortcuts')).toBeNull() + await viewButton.click() + await badge.click() + await boundRow.getByRole('button', { name: locale === 'zh-CN' ? '恢复默认' : 'Restore default', exact: true }).click() + await boundRow.getByRole('group').waitFor({ state: 'hidden' }) + await page.keyboard.press('Escape') + await dialog.waitFor({ state: 'hidden' }) + await viewButton.hover() + expect(await page.getByRole('tooltip').textContent()).toBe(viewHint) + await viewButton.click() + await page.getByRole('searchbox').fill('abc') + expect(await dialog.getByRole('listitem').count()).toBe(0) + expect(await dialog.locator('footer').textContent()).toContain(locale === 'zh-CN' ? '1 项已自定义' : '1 customized') + await resetAll.click() + const confirm = page.getByRole('dialog', { name: locale === 'zh-CN' ? '恢复全部默认快捷键?' : 'Restore all default shortcuts?', exact: true }) + const cancel = confirm.getByRole('button', { name: locale === 'zh-CN' ? '取消' : 'Cancel', exact: true }) + expect(await cancel.evaluate(element => element === document.activeElement)).toBe(true) + await compareOrRefreshGolden(join(expected, `${locale}-reset-confirm.expected.md`), await confirm.ariaSnapshot(), mode) + await cancel.click() + expect(await resetAll.evaluate(element => element === document.activeElement)).toBe(true) + expect(await dialog.locator('footer').textContent()).toContain(locale === 'zh-CN' ? '1 项已自定义' : '1 customized') + await resetAll.click() + await confirm.getByRole('button', { name: locale === 'zh-CN' ? '恢复默认' : 'Restore default', exact: true }).click() + await confirm.waitFor({ state: 'hidden' }) + expect(await resetAll.isDisabled()).toBe(true) + expect(await dialog.locator('footer').textContent()).toContain(locale === 'zh-CN' ? '0 项已自定义' : '0 customized') + expect(await page.getByRole('searchbox').evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press('Escape') + await page.keyboard.press('Escape') + await page.keyboard.press(referenceKey) + const settingsCommand = locale === 'zh-CN' ? '打开设置' : 'Open settings' + await dialog.getByRole('button', { name: locale === 'zh-CN' ? `修改${settingsCommand}快捷键` : `Edit shortcut for ${settingsCommand}`, exact: true }).click() + await page.keyboard.press(`${key}+Shift+Comma`) + await dialog.getByRole('group').waitFor({ state: 'hidden' }) + await page.keyboard.press('Escape') + expect(await trigger.getAttribute('aria-keyshortcuts')).toBe(key === 'Meta' ? 'Shift+Meta+,' : 'Control+Shift+,') + await trigger.hover() + await page.getByRole('tooltip', { name: `${settings} ${key === 'Meta' ? '⇧ ⌘ ,' : 'Ctrl + Shift + ,'}`, exact: true }).waitFor() + await trigger.click() + await page.keyboard.press('Escape') + expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press(referenceKey) + await dialog.getByRole('button', { name: locale === 'zh-CN' ? `修改${settingsCommand}快捷键` : `Edit shortcut for ${settingsCommand}`, exact: true }).click() + await dialog.getByRole('button', { name: locale === 'zh-CN' ? '恢复默认' : 'Restore default', exact: true }).click() + await dialog.getByRole('group').waitFor({ state: 'hidden' }) + await page.keyboard.press('Escape') + expect(await trigger.getAttribute('aria-keyshortcuts')).toBe(`${key}+,`) + await trigger.hover() + await page.getByRole('tooltip', { name: settingsHint, exact: true }).waitFor() + const sidebar = page.getByRole('button', { name: locale === 'zh-CN' ? '收起侧边栏' : 'Collapse sidebar', exact: true }) + expect(await sidebar.getAttribute('aria-keyshortcuts')).toBe(key === 'Meta' ? 'Alt+Meta+B' : 'Control+Alt+B') + await page.reload({ waitUntil: 'load' }) + await sidebar.waitFor() + expect(await sidebar.getAttribute('aria-keyshortcuts')).toBe(key === 'Meta' ? 'Alt+Meta+B' : 'Control+Alt+B') + expect(console.pageErrors).toEqual([]) + expect(console.warnings).toEqual([]) + } finally { await context.close() } + }) + + it.each([ + { platform: 'Win32', modifier: 'Control', defaultBinding: 'Control+Alt+B' }, + { platform: 'MacIntel', modifier: 'Meta', defaultBinding: 'Alt+Meta+B' }, + { platform: 'Linux x86_64', modifier: 'Control', defaultBinding: null }, + ])('records, rejects conflicts, persists across reload, and synchronizes stale drafts between tabs on $platform', async ({ platform, modifier, defaultBinding }) => { + const context = await browser.newContext({ locale: 'en-US', viewport: { width: 1440, height: 1000 } }) + try { + await context.addInitScript((value) => { Object.defineProperty(navigator, 'platform', { value }) }, platform) + const page = await context.newPage() + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.getByRole('button', { name: 'Settings', exact: true }).waitFor() + const referenceKey = `${modifier}+Slash` + const open = async () => { + await page.keyboard.press(referenceKey) + await page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true }).waitFor() + } + await open() + await page.getByRole('button', { name: 'Edit shortcut for Toggle left sidebar', exact: true }).click() + const editor = page.getByRole('group', { name: 'Toggle left sidebar', exact: true }) + await editor.getByRole('button', { name: 'Press a shortcut', exact: true }).focus() + await page.keyboard.press(referenceKey) + await page.getByRole('alert').filter({ hasText: 'Already used by “Open keyboard shortcuts”' }).waitFor() + expect(await editor.getByRole('button', { name: 'Press a shortcut', exact: true }).getAttribute('aria-invalid')).toBe('true') + await page.keyboard.press(`${modifier}+Shift+.`) + await editor.waitFor({ state: 'hidden' }) + await page.keyboard.press('Escape') + const sidebarKey = `${modifier}+Shift+.` + expect(await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).getAttribute('aria-keyshortcuts')) + .toBe(modifier === 'Meta' ? 'Shift+Meta+.' : 'Control+Shift+.') + await page.keyboard.press(sidebarKey) + await page.getByRole('button', { name: 'Open sidebar', exact: true }).waitFor() + await page.reload({ waitUntil: 'load' }) + await page.getByRole('button', { name: 'Settings', exact: true }).waitFor() + const toggleAfterReload = page.getByRole('button', { name: /^(Open|Collapse) sidebar$/ }) + await expect.poll(() => toggleAfterReload.getAttribute('aria-keyshortcuts')) + .toBe(modifier === 'Meta' ? 'Shift+Meta+.' : 'Control+Shift+.') + const wasOpen = await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).isVisible() + await page.keyboard.press(sidebarKey) + await page.getByRole('button', { name: wasOpen ? 'Open sidebar' : 'Collapse sidebar', exact: true }).waitFor() + if (wasOpen) await page.getByRole('button', { name: 'Open sidebar', exact: true }).click() + await open() + await page.getByRole('button', { name: 'Edit shortcut for Toggle left sidebar', exact: true }).click() + await editor.getByRole('button', { name: 'Press a shortcut', exact: true }).focus() + const other = await context.newPage() + await other.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await other.getByRole('button', { name: 'Settings', exact: true }).waitFor() + await other.keyboard.press(referenceKey) + await other.getByRole('button', { name: 'Remove shortcut for Toggle left sidebar', exact: true }).click() + await editor.getByText('Shortcut configuration or available commands changed. Review the latest bindings before saving.', { exact: true }).waitFor() + await page.bringToFront() + await editor.getByRole('button', { name: 'Press a shortcut', exact: true }).focus() + await page.keyboard.press(`${modifier}+Shift+,`) + expect(await editor.getByRole('button', { name: 'Retry save', exact: true }).count()).toBe(0) + await compareOrRefreshGolden(join(expected, 'edit-stale.expected.md'), + await captureStableAria(page, '[data-shortcut-modal="shortcut-edit"]', scaffold.workspaceCwd), mode) + await editor.getByRole('button', { name: 'I have reviewed the latest configuration', exact: true }).click() + await editor.getByRole('button', { name: 'Retry save', exact: true }).click() + await editor.waitFor({ state: 'hidden' }) + await page.getByRole('button', { name: 'Edit shortcut for Toggle left sidebar', exact: true }).click() + await editor.getByRole('button', { name: 'Restore default', exact: true }).click() + await editor.waitFor({ state: 'hidden' }) + await page.keyboard.press('Escape') + expect(await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).getAttribute('aria-keyshortcuts')) + .toBe(defaultBinding) + const stored = await page.evaluate((): unknown => JSON.parse(localStorage.getItem('dsh.keybindings.v1')!)) + const profile = platform === 'MacIntel' ? 'macos' : platform === 'Win32' ? 'windows' : 'linux' + expect(stored).toEqual({ schemaVersion: 1, profiles: { [`web:${profile}`]: {} } }) + } finally { await context.close() } + }) + +}) diff --git a/apps/web/tests/sidebar-right.e2e.ts b/apps/web/tests/sidebar-right.e2e.ts index af37b968e1..f6c0c4f596 100644 --- a/apps/web/tests/sidebar-right.e2e.ts +++ b/apps/web/tests/sidebar-right.e2e.ts @@ -934,10 +934,11 @@ describe('web e2e: shipped right Sidebar', () => { await expect.poll(async () => await tabTitles(paneAt(column, 1))).toContain(title) const splitButtons = column.locator('[data-dockkit-split-button]') - await expect.poll(async () => await splitButtons.count()).toBe(0) + await expect.poll(async () => await splitButtons.count()).toBe(2) + expect(await splitButtons.evaluateAll(buttons => buttons.every(button => (button as HTMLButtonElement).disabled))).toBe(true) expect(await panes.count()).toBe(2) await setPanelWidth(page, 560) - await expect.poll(async () => await splitButtons.count()).toBe(0) + expect(await splitButtons.evaluateAll(buttons => buttons.every(button => (button as HTMLButtonElement).disabled))).toBe(true) const outer = column.locator('[data-dockkit-divider]').first() const before = await width(paneAt(column, 1)) @@ -956,7 +957,7 @@ describe('web e2e: shipped right Sidebar', () => { await dragElement(page, outer, { x: surfaceBox.x + surfaceBox.width / 2, y: grip.y }) await expect.poll(ratio).toBeCloseTo(0.5, 2) expect(await panes.count()).toBe(2) - expect(await splitButtons.count()).toBe(0) + expect(await splitButtons.evaluateAll(buttons => buttons.every(button => (button as HTMLButtonElement).disabled))).toBe(true) // 5. The split's guide and the document float while Files stays docked. const floatOne = paneAt(column, 1).locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }) @@ -1096,6 +1097,7 @@ describe('web e2e: shipped right Sidebar', () => { // depends on a sibling block's setup passes only in the right order. it('renders the shipped Chinese copy on a Chinese page', async () => { const zhPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + await zhPage.addInitScript(() => { Object.defineProperty(navigator, 'platform', { configurable: true, value: 'MacIntel' }) }) const zhTripwire = watchConsole(zhPage) onTestFailed(() => saveFailureShot(zhPage, 'web-e2e-sidebar-right-zh')) try { @@ -1121,7 +1123,7 @@ describe('web e2e: shipped right Sidebar', () => { expect(await width(column)).toBeGreaterThan(300) await expect.poll(async () => await tabTitles(column)).toEqual(['文件', '开始']) await expect.poll(async () => await guide.locator('[data-sidebar-right-guide-entry="files"]').innerText()) - .toBe('工作区文件\n浏览会话工作区的文件') + .toBe('工作区文件\n浏览会话工作区的文件\n⌥\n⌘\nP') await shot(zhPage, '05-guide-copy-zh') expect(zhTripwire.pageErrors).toEqual([]) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 4554f81ee0..d65483176b 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -22,12 +22,18 @@ // this client-registered project must not also hold them (one program // cannot see both sides of the cordis Context merges). "exclude": [ - "tests/connection-recovery.e2e.ts", - "tests/server-restart.e2e.ts", + "tests/shortcuts-desktop.e2e.ts", + "tests/shortcuts-workspace.e2e.ts", + "tests/shortcuts-panels.e2e.ts", + "tests/shortcuts-fixed.e2e.ts", + "tests/shortcuts.e2e.ts", + "tests/composer-shortcuts.e2e.ts", "tests/default-product-isolation.e2e.ts", "tests/diff-context.e2e.ts", "tests/code-card-layout.e2e.ts", "tests/scaffold.ts", + "tests/connection-recovery.e2e.ts", + "tests/server-restart.e2e.ts", "tests/voice-input.e2e.ts", "tests/voice-setup.e2e.ts", "tests/voice-download.e2e.ts", diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 106db59c55..8148c4f57c 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: b9e75f6679d43aef41808a30ed370e26070c1331 -config-catalog.zh.md: d76c88fb806998abee414347822a3d11c3b2b7de +config-catalog.md: a3c879b60271b4d6c707a0dc91400be0e63152de +config-catalog.zh.md: 960066e8524375cfb42c26a34478e5cc83b15e5c diff --git a/docs/config-catalog.md b/docs/config-catalog.md index b9e75f6679..a3c879b602 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -481,6 +481,20 @@ export interface Config { Source: [`packages/client/hmr/src/index.ts:30`](../packages/client/hmr/src/index.ts) + + +## `@deepseek-ai/dsh-client-shortcuts` + +```ts config-catalog +/** Fixed shortcut sequence settings. */ +export interface Config { + /** Maximum interval between independent Escape presses for stopping a reply, in milliseconds. */ + stopSequenceMs: number +} +``` + +Source: [`packages/client/shortcuts/src/config.ts:5`](../packages/client/shortcuts/src/config.ts) + ## `@deepseek-ai/dsh-client-ui-plugin-manager` @@ -4142,6 +4156,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-settings-shell` ([`packages/client/ui-settings-shell/src/index.ts`](../packages/client/ui-settings-shell/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-subagent` ([`packages/client/ui-settings-subagent/src/index.ts`](../packages/client/ui-settings-subagent/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-web-search` ([`packages/client/ui-settings-web-search/src/index.ts`](../packages/client/ui-settings-web-search/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-shortcuts` ([`packages/client/ui-shortcuts/src/index.ts`](../packages/client/ui-shortcuts/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-browser` ([`packages/client/ui-sidebar-browser/src/index.ts`](../packages/client/ui-sidebar-browser/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-files` ([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index d76c88fb80..960066e852 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -483,6 +483,20 @@ export interface Config { 来源: [`packages/client/hmr/src/index.ts:30`](../packages/client/hmr/src/index.ts) + + +## `@deepseek-ai/dsh-client-shortcuts` + +```ts config-catalog +/** Fixed shortcut sequence settings. */ +export interface Config { + /** Maximum interval between independent Escape presses for stopping a reply, in milliseconds. */ + stopSequenceMs: number +} +``` + +来源: [`packages/client/shortcuts/src/config.ts:5`](../packages/client/shortcuts/src/config.ts) + ## `@deepseek-ai/dsh-client-ui-plugin-manager` @@ -4144,6 +4158,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-settings-shell`([`packages/client/ui-settings-shell/src/index.ts`](../packages/client/ui-settings-shell/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-subagent`([`packages/client/ui-settings-subagent/src/index.ts`](../packages/client/ui-settings-subagent/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-web-search`([`packages/client/ui-settings-web-search/src/index.ts`](../packages/client/ui-settings-web-search/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-shortcuts` ([`packages/client/ui-shortcuts/src/index.ts`](../packages/client/ui-shortcuts/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar`([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-browser`([`packages/client/ui-sidebar-browser/src/index.ts`](../packages/client/ui-sidebar-browser/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-files`([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 5f3fa3d374..c74bb1441d 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 13ac82b2c9fb0b2aeeaa2bec9c736db5b5b63992 -event-producer-consumer.zh.md: fce8463041181139e45fb5f3a9ab14c87117135f +event-producer-consumer.md: bb80e1a51bb5e718d2bef7fdf24b3a0048645df2 +event-producer-consumer.zh.md: ccdf22451656aa80493dc060170ca6e5e3f4b46f diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 13ac82b2c9..bb80e1a51b 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -75,7 +75,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:190`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:198`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/deliverables/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:88`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | -| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `ui-sidebar-documentpreview`, `ui-theme` | +| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index fce8463041..ccdf224516 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -77,7 +77,7 @@ | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:190`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:198`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/deliverables/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:88`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | -| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `ui-sidebar-documentpreview`, `ui-theme` | +| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 6ae367c64a..98c36bdbb0 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: bfe3e5faa7168b03848ac76d2dd2584d4aa31f5e -module-graph.zh.md: bfa0f83e14cbcf89599fa207ec919195128db49e +module-graph.md: da3bd944c7926cc293b302887e4cea0de183f1ad +module-graph.zh.md: 5887f3a9ac0439525a72911187721dbd6d309a35 diff --git a/docs/module-graph.md b/docs/module-graph.md index bfe3e5faa7..da3bd944c7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -152,6 +152,7 @@ flowchart TD pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] pkg_client_resources["client-resources"] + pkg_client_shortcuts["client-shortcuts"] pkg_client_store["client-store"] pkg_client_ui_agent_preset["client-ui-agent-preset"] pkg_client_ui_approval["client-ui-approval"] @@ -189,6 +190,7 @@ flowchart TD pkg_client_ui_settings_shell["client-ui-settings-shell"] pkg_client_ui_settings_subagent["client-ui-settings-subagent"] pkg_client_ui_settings_web_search["client-ui-settings-web-search"] + pkg_client_ui_shortcuts["client-ui-shortcuts"] pkg_client_ui_sidebar["client-ui-sidebar"] pkg_client_ui_sidebar_browser["client-ui-sidebar-browser"] pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"] @@ -1375,6 +1377,7 @@ flowchart TD | [`client-locale`](../packages/client/locale) | `client` | — | | [`client-modules`](../packages/client/modules) | `client` | — | | [`client-resources`](../packages/client/resources) | `client` | — | +| [`client-shortcuts`](../packages/client/shortcuts) | `client` | — | | [`client-store`](../packages/client/store) | `client` | — | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | — | | [`client-ui-approval`](../packages/client/ui-approval) | `client` | — | @@ -1412,6 +1415,7 @@ flowchart TD | [`client-ui-settings-shell`](../packages/client/ui-settings-shell) | `client` | — | | [`client-ui-settings-subagent`](../packages/client/ui-settings-subagent) | `client` | — | | [`client-ui-settings-web-search`](../packages/client/ui-settings-web-search) | `client` | — | +| [`client-ui-shortcuts`](../packages/client/ui-shortcuts) | `client` | — | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — | | [`client-ui-sidebar-browser`](../packages/client/ui-sidebar-browser) | `client` | — | | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index bfa0f83e14..5887f3a9ac 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -154,6 +154,7 @@ flowchart TD pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] pkg_client_resources["client-resources"] + pkg_client_shortcuts["client-shortcuts"] pkg_client_store["client-store"] pkg_client_ui_agent_preset["client-ui-agent-preset"] pkg_client_ui_approval["client-ui-approval"] @@ -191,6 +192,7 @@ flowchart TD pkg_client_ui_settings_shell["client-ui-settings-shell"] pkg_client_ui_settings_subagent["client-ui-settings-subagent"] pkg_client_ui_settings_web_search["client-ui-settings-web-search"] + pkg_client_ui_shortcuts["client-ui-shortcuts"] pkg_client_ui_sidebar["client-ui-sidebar"] pkg_client_ui_sidebar_browser["client-ui-sidebar-browser"] pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"] @@ -1377,6 +1379,7 @@ flowchart TD | [`client-locale`](../packages/client/locale) | `client` | — | | [`client-modules`](../packages/client/modules) | `client` | — | | [`client-resources`](../packages/client/resources) | `client` | — | +| [`client-shortcuts`](../packages/client/shortcuts) | `client` | — | | [`client-store`](../packages/client/store) | `client` | — | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | — | | [`client-ui-approval`](../packages/client/ui-approval) | `client` | — | @@ -1414,6 +1417,7 @@ flowchart TD | [`client-ui-settings-shell`](../packages/client/ui-settings-shell) | `client` | — | | [`client-ui-settings-subagent`](../packages/client/ui-settings-subagent) | `client` | — | | [`client-ui-settings-web-search`](../packages/client/ui-settings-web-search) | `client` | — | +| [`client-ui-shortcuts`](../packages/client/ui-shortcuts) | `client` | — | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — | | [`client-ui-sidebar-browser`](../packages/client/ui-sidebar-browser) | `client` | — | | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — | diff --git a/docs/subsystems/sidebar-right.i18n.yaml b/docs/subsystems/sidebar-right.i18n.yaml index 07ae54182f..a1fe394b21 100644 --- a/docs/subsystems/sidebar-right.i18n.yaml +++ b/docs/subsystems/sidebar-right.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/sidebar-right.md -sidebar-right.md: 7ec6d931ef92a23cf5cbd647d97e03fb55c32f84 -sidebar-right.zh.md: 413ea2145de19738e939f24778da0765c7aa5424 +sidebar-right.md: 6315827c2777f6b20c726d55d2fb40f36a55b090 +sidebar-right.zh.md: 1df6007e12980b0c563e670ac1a58a628d3e5848 diff --git a/docs/subsystems/sidebar-right.md b/docs/subsystems/sidebar-right.md index 7ec6d931ef..6315827c27 100644 --- a/docs/subsystems/sidebar-right.md +++ b/docs/subsystems/sidebar-right.md @@ -90,6 +90,8 @@ Placement is the caller's option, never a type's property. The conversation call `focus(tabId)` makes a tab its pane's active tab; `split(paneId?)` splits the active docked pane, or the named one, and returns the new pane's id — or `undefined`, recording nothing, when the pane budget or the column's width forbids a split; `float(tabId, rect?)` lifts a tab into a floating pane; `dock(paneId)` returns a floating pane to the docked area. All four run the store's existing actions and record one history entry each; a target that does not exist or is already in the requested state is a no-op, and like `open` they throw without a mounted Session surface. `TabId`, `PaneId`, `TabRecord`, and `FloatRect` are re-exported from the package's `/client` entry so a caller needs no dockkit import. +`focusedTarget(element?)` captures the visible page under live DOM focus, including an embedding iframe; outside or stale sidebar markup yields no target. `commandTarget(element?)` also permits the mounted Session's active dock pane for an open initiated outside the sidebar. A `SidebarRightTarget` captures Session, pane, host, tab occurrence, and navigation revision. `isTargetCurrent(target)` checks that identity before execution, so reopening a record or navigating it invalidates a captured action; it never retargets to another page. See the [controller methods](../../packages/client/ui-sidebar-right/src/client/service.ts) for close and placement outcomes. + ## Slots and owner props The Sidebar declares four extension slots; its document tab declares the additional keyed document body below ([hierarchy](slots.md)). @@ -106,6 +108,8 @@ A body, title and guide replacement receive the framework-injected `useTabInfo() `navigation.revision` increments on every navigation to the tab whether or not `params` changed, so a body can act on "navigated again" alone; it is `1` for a tab opened by address and `0` for a record nobody opened by address — a seeded guide, or a tab restored by undo. The Tab domain holds one occurrence per open record: a record that appears is pinned in the resource model, so switching tabs unmounts a body without dropping its content; a record that vanishes is aborted and dropped; a record restored by undo is a new occurrence ([Tab domain](../../packages/client/ui-sidebar-right/README.md#the-tab-domain)). +`tab.actions.bindCommands(commands)` contributes `SidebarRightTabCommands`, currently an optional `refresh` callback, for the mounted body. Omitted operations are unavailable; the returned disposer releases that registration without removing a newer body's commands. The tab lifetime also releases them. `tab.refreshShortcut` supplies the effective refresh catalog entry for page controls; it is optional when no entry is available. These fields are declared in the [slot owner types](../../packages/client/ui-sidebar-right/src/client/contract/slots.ts). + ## Document renderers The `text` tab is the shared Document Preview owner. Its [root registration](../../packages/client/ui-sidebar-documentpreview/src/client/index.ts) declares `sidebar.right.tab.document` and provides `ctx.documentPreviews`. A renderer registers `DocumentPreviewDefinition` metadata in its own effect, then waits through `ctx.slots.inject('sidebar.right.tab.document', ...)` and registers its component with `key: definition.id` and its locale namespace. A renderer registers its own body and can reuse shared presentation through its child slots. Changing the renderer does not change the tab or resource address; the [extension decision](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md) separates preview policy from resource ownership. diff --git a/docs/subsystems/sidebar-right.zh.md b/docs/subsystems/sidebar-right.zh.md index 413ea2145d..1df6007e12 100644 --- a/docs/subsystems/sidebar-right.zh.md +++ b/docs/subsystems/sidebar-right.zh.md @@ -90,6 +90,8 @@ export function apply(ctx: Context): void { `focus(tabId)` 让一个 tab 成为其 pane 的活动 tab;`split(paneId?)` 分割活动的停靠 pane 或点名的 pane,返回新 pane 的 id——pane 数预算或列宽不允许时返回 `undefined` 且不记账;`float(tabId, rect?)` 把一个 tab 浮出为浮窗 pane;`dock(paneId)` 把浮窗 pane 收回停靠区。四者都走 store 既有动作、各记一条历史;目标不存在或已处于目标状态时是空操作,与 `open` 一样在没有已挂载会话面时抛错。`TabId`、`PaneId`、`TabRecord`、`FloatRect` 自本包 `/client` 入口再导出,调用方无需引 dockkit。 +`focusedTarget(element?)` 从实时 DOM 焦点(包括内嵌 iframe)捕获可见页面;侧栏外部或过期的侧栏标记不产生目标。`commandTarget(element?)` 还允许侧栏外部发起的打开操作使用已挂载 Session 的活动停靠窗格。`SidebarRightTarget` 捕获 Session、窗格、host、tab occurrence 和导航 revision。`isTargetCurrent(target)` 在执行前检查这些身份,因此重新打开记录或再次导航都会使已捕获的动作失效;它不会改为操作其他页面。关闭与落位结果见[控制器方法](../../packages/client/ui-sidebar-right/src/client/service.ts)。 + ## Slot 与 owner props Sidebar 声明四个扩展 slot;其文档 tab 另行声明下表中的 keyed 文档正文 slot([层级](slots.zh.md))。 @@ -106,6 +108,8 @@ Sidebar 声明四个扩展 slot;其文档 tab 另行声明下表中的 keyed `navigation.revision` 在每次导航到该 tab 时递增,`params` 不变也递增,正文可仅凭「又被导航了」行动;按地址打开的 tab 为 `1`,没有人按地址打开的记录——种入的引导、撤销恢复的 tab——为 `0`。Tab 域为每条打开的记录保有一个 occurrence:记录出现即在资源模型里钉住,因此切换 tab 卸载正文也不丢内容;记录消失即中止并丢弃;撤销恢复的记录是新的 occurrence([Tab 域](../../packages/client/ui-sidebar-right/README.zh.md#the-tab-domain))。 +`tab.actions.bindCommands(commands)` 为已挂载正文提供 `SidebarRightTabCommands`,目前包含可选的 `refresh` 回调。省略的操作不可用;返回的 disposer 释放本次注册,不会移除较新正文的命令。标签生命周期结束时也会释放这些命令。`tab.refreshShortcut` 为页面控件提供生效的刷新目录项;没有可用条目时该字段可省略。这些字段声明在 [slot owner 类型](../../packages/client/ui-sidebar-right/src/client/contract/slots.ts)中。 + ## 文档渲染器 `text` tab 是共享的 Document Preview 所有者。其[根注册](../../packages/client/ui-sidebar-documentpreview/src/client/index.ts)声明 `sidebar.right.tab.document` 并提供 `ctx.documentPreviews`。渲染器在自己的 effect 中注册 `DocumentPreviewDefinition` 元数据,再通过 `ctx.slots.inject('sidebar.right.tab.document', ...)` 等待 slot,以 `key: definition.id` 和自己的 locale 命名空间注册组件。渲染器注册自己的正文,并可通过子 slot 复用共享展示组件。切换渲染器不改变 tab 或资源地址;[扩展决议](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md)将预览策略与资源归属分开。 diff --git a/docs/subsystems/web-client.i18n.yaml b/docs/subsystems/web-client.i18n.yaml index f89b3c13dc..344c102ba6 100644 --- a/docs/subsystems/web-client.i18n.yaml +++ b/docs/subsystems/web-client.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/web-client.md -web-client.md: 80d233b634f57bb150d8eb6604428557e1e16033 -web-client.zh.md: f14cdbe9b1d5605b90bd595a384ef7ac9c0d7286 +web-client.md: f07094d64993fd00c27f3c485ea5bd89bb92d5be +web-client.zh.md: 5a0069abb44cbd373eb3a4e31a9d7354347e95cf diff --git a/docs/subsystems/web-client.md b/docs/subsystems/web-client.md index 80d233b634..f07094d649 100644 --- a/docs/subsystems/web-client.md +++ b/docs/subsystems/web-client.md @@ -4,6 +4,8 @@ English | [中文](web-client.zh.md) The Web Client is a browser-side Cordis application assembled from independently loaded plugins. Its architecture has four reusable foundations: [Client Modules](client-modules.md) loads the plugin graph, the [API Gateway](../api-gateway.md) provides typed Host communication, [Slots](slots.md) composes React UI, and [Conversation](conversation.md) turns a Session history window into target-owned views. This page connects those systems and defines where Client models and feature packages belong. +[Keyboard shortcuts](../../packages/client/shortcuts/README.md) owns window-local command registration and physical-key dispatch; [the shortcut reference](../../packages/client/ui-shortcuts/README.md) presents available commands and local input actions. Command owners declare each runtime/platform default and share their existing actions with mouse controls. The shared modal primitive arbitrates top-layer Escape and restores focus. + ## Layers and ownership | Layer | Main owners | Responsibility | diff --git a/docs/subsystems/web-client.zh.md b/docs/subsystems/web-client.zh.md index f14cdbe9b1..5a0069abb4 100644 --- a/docs/subsystems/web-client.zh.md +++ b/docs/subsystems/web-client.zh.md @@ -4,6 +4,8 @@ Web Client 是由独立加载插件组装而成的浏览器侧 Cordis 应用。它有四个可复用底座:[Client Modules](client-modules.zh.md) 加载插件图,[API Gateway](../api-gateway.zh.md) 提供类型化 Host 通信,[Slots](slots.zh.md) 组合 React UI,[Conversation](conversation.zh.md) 把 Session 历史窗口变成各 target 自有的视图。本文串联这些系统,并规定 Client model 与功能包各自所在的位置。 +[快捷键](../../packages/client/shortcuts/README.zh.md)负责窗口内命令注册和物理键分发;[快捷键速查](../../packages/client/ui-shortcuts/README.zh.md)展示可用命令及局部输入操作。命令 owner 声明各运行端/平台的默认键位,并与鼠标控件共用既有操作。共用模态组件裁决顶层 Esc 并恢复焦点。 + ## 分层与所有权 | 层 | 主要 owner | 职责 | diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 5ed86cceea..ba61e4835c 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -220,6 +220,12 @@ - id: locale name: '@deepseek-ai/dsh-client-locale' + - id: shortcuts + name: '@deepseek-ai/dsh-client-shortcuts' + + - id: ui-shortcuts + name: '@deepseek-ai/dsh-client-ui-shortcuts' + - id: ui-layout name: '@deepseek-ai/dsh-client-ui-layout' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 6d3db15d18..a7ac5f7275 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -172,7 +172,9 @@ "commander": "^15.0.0", "open": "^11.0.0", "@deepseek-ai/dsh-api-account-controller": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-account": "workspace:^" + "@deepseek-ai/dsh-client-ui-settings-account": "workspace:^", + "@deepseek-ai/dsh-client-shortcuts": "workspace:^", + "@deepseek-ai/dsh-client-ui-shortcuts": "workspace:^" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index b7d80f9227..398ed88e7c 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/README.md -README.md: 8a5f789a4ee6343e00ee2b9cdc68d6bf7e7f8d79 -README.zh.md: ae5dbb237f504ca0cd66a8105dd772493b356326 +README.md: a7e4228fac5e4e9c7b50a67b8d66cb244df945f7 +README.zh.md: d5c33dc99d40e1e203848aa14a8c23c492b011a8 diff --git a/packages/client/README.md b/packages/client/README.md index 8a5f789a4e..a7e4228fac 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -30,6 +30,8 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`modules/`](modules/README.md) | Loads browser-side client modules | `ctx.clientModules` / `ctx.modules` | | [`connection/`](connection/README.md) | Maintains browser-host RPC communication and event delivery | `ctx.connection` | | [`file-upload/`](file-upload/README.md) | Sends raw Blob and byte-stream request bodies outside the page thread | `ctx.fileUpload` | +| [`shortcuts/`](shortcuts/README.md) | Registers application keyboard commands | `ctx.shortcuts` | +| [`ui-shortcuts/`](ui-shortcuts/README.md) | Presents the keyboard shortcut reference | — | | [`store/`](store/README.md) | Provides React-free observable and snapshot-store primitives | — | | [`hmr/`](hmr/README.md) | Refreshes client plugins during development | — | | [`locale/`](locale/README.md) | Provides localization preferences and message dictionaries | `ctx.locale` | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index ae5dbb237f..d5c33dc99d 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -30,6 +30,8 @@ kind: "package-group" | [`modules/`](modules/README.zh.md) | 加载浏览器侧客户端模块 | `ctx.clientModules` / `ctx.modules` | | [`connection/`](connection/README.zh.md) | 维护浏览器与宿主之间的 RPC 通信与事件投递 | `ctx.connection` | | [`file-upload/`](file-upload/README.zh.md) | 在页面线程之外发送原始 Blob 与字节流请求体 | `ctx.fileUpload` | +| [`shortcuts/`](shortcuts/README.zh.md) | 注册应用键盘命令 | `ctx.shortcuts` | +| [`ui-shortcuts/`](ui-shortcuts/README.zh.md) | 展示快捷键速查 | — | | [`store/`](store/README.zh.md) | 提供不依赖 React 的 observable 与快照存储原语 | — | | [`hmr/`](hmr/README.zh.md) | 在开发期间刷新客户端插件 | — | | [`locale/`](locale/README.zh.md) | 提供本地化偏好与消息词典 | `ctx.locale` | diff --git a/packages/client/shortcuts/README.i18n.yaml b/packages/client/shortcuts/README.i18n.yaml new file mode 100644 index 0000000000..9f91920d60 --- /dev/null +++ b/packages/client/shortcuts/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/shortcuts/README.md +README.md: 3583a31046946a29d3f8ddb1b263d4b676d45dc0 +README.zh.md: 0f1fef10405509740b2e0ce1f672946c1d898abb diff --git a/packages/client/shortcuts/README.md b/packages/client/shortcuts/README.md new file mode 100644 index 0000000000..3583a31046 --- /dev/null +++ b/packages/client/shortcuts/README.md @@ -0,0 +1,92 @@ +--- +description: "Customize application keyboard commands for each device" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-shortcuts + +English | [中文](README.zh.md) + +## Summary + +Customize application keyboard commands for each device. Desktop and Web choose separate defaults for the receiving device. Custom bindings survive reloads on the same device. Commands disappear when their owning plugin unloads, while their saved overrides remain available for reinstallation. + +Windows uses ` + ` between modifiers and the first ordinary key. Two simultaneous ordinary keys appear side by side, such as `F G`, on Windows and macOS Desktop, including tooltips and the shortcut reference. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +The Web app bundle mounts this package automatically. Feature plugins register commands through `ctx.shortcuts` inside `ctx.effect()`. Each command declares defaults for explicit `desktop:macos`, `desktop:windows`, `desktop:linux`, `web:macos`, `web:windows`, and `web:linux` profiles; an omitted profile is unbound. The service accepts physical `code` values, expands `primary` to the device modifier, and rejects duplicate ids and overlapping default combinations across supported profiles. Labels, keycaps, modified flags, and binding conflicts share one observable catalog. Command owners resolve the current target before executing an action. + +Feature plugins contribute read-only sequences through `registerFixed()` and observe locally arbitrated input through `observeFixedInput()`. Fixed rows follow the owning registration and locale. Each action declares at least one physical combination and has no saved overrides. These combinations participate in conflict checks and cannot be assigned to editable commands. The Host's `stopSequenceMs` setting controls the maximum double-Escape interval, defaults to 500 milliseconds, and accepts integers from 1 to 2,147,483,646 so expiry stays within the browser timer limit; pages adopt the validated value on load. + +Preferences store only overrides: a missing command inherits its default, `null` clears its binding, and restoring a default removes its override. Restore All affects only the current runtime/platform profile. Web uses origin-local `dsh.keybindings.v1` storage; Desktop uses Electron's device-local `userData/keybindings.json`, independently of Harness home and workspace settings. The schema version is independent of Session data. macOS and Windows Desktop read version 1 without rewriting it and save version 2 on the next successful edit; version 2 adds optional `secondCode`. Web and Linux retain their existing binding restrictions. + +Windows and macOS Desktop accept one or two distinct supported non-modifier keys, with zero to four modifiers. Two-key bindings require overlapping presses in either order; sequential presses do not match. Modifier-only bindings are rejected. A single key and a pair containing that key conflict when their modifiers match, including keys reserved by fixed actions. Previously saved bindings that overlap a mounted fixed action remain visible but cannot execute. All effective bindings, including Command+C and Ctrl+C, take priority over native actions, editors, terminals, embedded pages, and modal controls. Recording and input-method composition remain protected. A pair takes priority only once both keys are held; the first key retains its normal behavior and may insert a character. + +Bindings activate after the first read. Failed reads preserve the last accepted configuration, or use defaults when none exists. Edits, including Restore All, remain disabled while preferences are unreadable. Repair the stored document and reopen the application; reset operations never replace unreadable or future-version data. Write failures preserve active bindings and drafts. Another browser tab's update invalidates open drafts; saving requires reviewing the latest configuration. Browser writes reread first, but simultaneous cross-tab writes remain last-writer-wins. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +Windows and macOS Desktop intercept complete accepted combinations before local handlers and forward them through the trusted preload. Desktop startup requires the native keyboard bridge on every platform. On Windows and macOS, only native input dispatches configurable commands; DOM input continues to feed fixed actions. The adapter intercepts a key release only when it intercepted that key’s initial press. Default and user-saved bindings have identical priority, including over modal controls. Other environments dispatch after local handlers and respect their declared regions. Composition, dead keys, AltGraph, and consumed input pass through; macOS Web can consume a bound Option+Command+N reported as a dead key outside composition without guarding the next input. Web and Linux modal layers block background commands. Owned repeats remain consumed without rerunning the action. The context carries the original DOM input element, including elements inside a shadow root. Owners synchronously resolve their target and retain responsibility for action errors. The pure `./protocol` export has no React, DOM, or Electron dependency and shares validation and serialized persistence between adapters. Explicit overrides displace conflicting new defaults; conflicting explicit overrides are all disabled. Desktop rejects stale revisions and restricts IPC to the current product window and top frame. Rejected adapter calls are logged and returned as save failures; errors while publishing an accepted command catalog propagate to the caller. + +DOM fixed-input observers receive composition and consumption flags before application dispatch. Consuming an event makes it unavailable to subsequent observers and commands. Focus, pointer, window blur, composition, modal changes, and locally stopped propagation reset pending DOM sequences. Native chords reset on window blur, focused-frame or configuration changes, composition, recording, and modifier release. Electron can omit keyups after native interception; completing a native binding discards held-key state, so each chord activation needs two fresh presses. Releasing a modifier clears held-key state because macOS may omit character keyup events while Command is held. The adapter removes its listeners and modal observer when disposed; one feature's observer failure does not prevent the remaining observers from receiving input. + +Desktop native menus and embedded frames use the same command registry as DOM input. Preload and the client match the focused iframe name or browser webview lease against native input; the client rejects messages from an older configuration. Browser guest input ends when its lease is released, before guest destruction finishes. Native menu selection is explicit and works without a bound accelerator. Two-key chords have no native menu accelerator; their labels remain visible in the shortcut reference. Terminal Ctrl+W/R remains local input outside Windows and macOS Desktop. Explicit native-menu actions retain their modal checks; keyboard bindings use the device dispatch policy. + +
+ +----- + + +## Further Exploration + +- [Web Client](../../../docs/subsystems/web-client.md) +- [UI primitives](../ui-primitives/README.md) +- [Web app bundle](../../bundle/web-app/README.md) + +----- + + +## Model Experience + +None, as the package is a browser-side UI plugin layer that registers nothing model-facing. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + + + +- Feature owners declare the Windows and macOS Web defaults explicitly: Mod+Slash, Mod+Comma, Mod+Backslash, and Control+Backquote stay two-key combinations; other defaults without Alt use Mod+Alt, and defaults with Alt use Mod+Shift except fullscreen uses Mod+Alt+Enter. macOS Web leaves Refresh current page unbound by default; its button and explicit user bindings remain available. Mod is Command on macOS and Control on Windows. Windows and macOS accept any three or four distinct modifiers in Web and Desktop. Web also accepts the listed combinations and Mod+Shift combinations; browser or system delivery requires platform testing. Linux Web accepts Mod+Slash, Mod+Shift+Comma, and Mod+Shift+Period. Operating-system shortcuts that do not reach the window cannot be intercepted. A chord does not delay or consume its first key while waiting for the second; a native action or focus change can therefore prevent completion. Command registration or disposal invalidates open edit drafts even when saved preferences are unchanged. Desktop rereads its file on every catalog update; there is no file watcher. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
+ +**Runtime invariant:** No companion is published. The catalog and binding index are derived together from the private registry; there is no independently maintained runtime relationship to inspect. diff --git a/packages/client/shortcuts/README.zh.md b/packages/client/shortcuts/README.zh.md new file mode 100644 index 0000000000..0f1fef1040 --- /dev/null +++ b/packages/client/shortcuts/README.zh.md @@ -0,0 +1,92 @@ +--- +description: "为每台设备自定义应用键盘命令" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-shortcuts + +[English](README.md) | 中文 + +## 概述 + +为每台设备自定义应用键盘命令。桌面端和 Web 按接收输入的设备选择不同默认键位。自定义键位在同一设备重新加载后保留。命令随所属插件卸载而移除,保存的覆盖仍保留供重新安装使用。 + +Windows 在修饰键之间及第一个普通键之前使用 ` + `。Windows 和 macOS Desktop 上同时按下的两个普通键均并排显示,如 `F G`,提示和快捷键速查保持一致。 + +## 目录 + +- [使用此包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步阅读](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用此包 + +Web 应用 bundle 自动挂载此包。功能插件在 `ctx.effect()` 中通过 `ctx.shortcuts` 注册命令。每个命令为 `desktop:macos`、`desktop:windows`、`desktop:linux`、`web:macos`、`web:windows` 和 `web:linux` 显式声明默认值;省略的配置不绑定按键。服务接收物理 `code`,按设备展开 `primary`,并拒绝重复 ID 和任一支持配置中重叠的默认键位。同一可观察目录提供名称、键帽、已修改标记和键位冲突。命令 owner 在执行操作前解析当前目标。 + +功能插件通过 `registerFixed()` 贡献只读序列,通过 `observeFixedInput()` 观察局部控件处理后的输入。固定操作行随所属注册和语言变化。每个操作至少声明一个物理按键组合,不保存覆盖配置。这些组合参与冲突校验,不能分配给可编辑命令。Host 的 `stopSequenceMs` 配置控制连续两次 Esc 的最大间隔,默认为 500 毫秒,只接受 1 至 2,147,483,646 的整数,确保过期计时不超过浏览器定时器上限;页面加载时采用已校验的值。 + +偏好只保存覆盖:缺少命令表示继承默认,`null` 表示清除绑定,恢复默认则删除覆盖。全部恢复只影响当前运行端/平台配置。Web 使用同 origin 的 `dsh.keybindings.v1` 存储;Desktop 使用 Electron 设备本地的 `userData/keybindings.json`,与 Harness home 和工作区设置独立。其 schema 版本与 Session 数据无关。 macOS 和 Windows Desktop 读取版本 1 时不重写,在下次成功编辑时保存为版本 2;版本 2 增加可选的 `secondCode`。Web 和 Linux 保留原有绑定限制。 + +Windows 和 macOS Desktop 接受一至两个不同的受支持非修饰键,可搭配零至四个修饰键。双键绑定要求按住时间重叠,顺序不限;先后按键不匹配。仅含修饰键的绑定会被拒绝。同一修饰键集合下,单键与包含该键的双键组合冲突,包括固定操作占用的按键。已保存的绑定若与当前挂载的固定操作冲突,会保留显示但不能执行。包括 Command+C 和 Ctrl+C 在内的所有有效绑定优先于原生操作、编辑器、终端、内嵌页面和模态控件。录制和输入法组合状态仍受保护。只有两个键都按住时,双键组合才优先接管;第一个键仍正常处理,可能输入一个字符。 + +首次读取完成后激活键位。读取失败保留上次接受的配置;没有已接受配置时使用默认值。偏好不可读时,编辑和全部恢复均保持禁用。修复存储文档后重新打开应用;重置操作不会替换不可读或未来版本的数据。写入失败保留有效键位和草稿。其他浏览器标签页的更新使打开的草稿过期,保存前需要核对最新配置。浏览器写入前重新读取,但同时跨标签页写入仍采用最后一次成功写入。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +Windows 和 macOS Desktop 在局部处理器之前拦截已接受的完整组合,通过可信 preload 转发。所有平台的 Desktop 启动都要求原生键盘桥接。Windows 和 macOS 的可配置命令只由原生输入分发;DOM 输入继续交给固定操作。适配器仅拦截初次按下事件已被拦截的按键的松开事件。默认绑定与用户保存的绑定优先级一致,均优先于模态控件。其他环境在局部处理器之后分发,并遵守声明的区域。输入法组合、死键、AltGraph 和已消费的输入继续传递;macOS Web 可在输入法组合之外消费已绑定且报告为死键的 Option+Command+N,不保护后续输入。Web 和 Linux 的模态层阻止后台命令。已接管的重复事件仍被消费,但不重复执行。上下文携带原始 DOM 输入元素,包括 shadow root 内的元素。owner 同步解析目标,并负责操作错误。纯 `./protocol` 导出不依赖 React、DOM 或 Electron,在两个适配器间共享校验和串行持久化。显式覆盖取代冲突的新默认值;互相冲突的显式覆盖全部禁用。Desktop 拒绝过期版本,并将 IPC 限制在当前产品窗口和顶层 frame。适配器调用被拒绝时会记录日志并返回保存失败;发布已接受命令目录时的错误会向调用方抛出。 + +DOM 固定输入观察者在应用分发前收到输入法组合和消费标记。消费事件后,后续观察者和命令不能使用它。焦点、指针、窗口失焦、输入法组合、模态变化和局部停止传播会重置待完成的 DOM 序列。原生双键状态在窗口失焦、焦点 frame 或配置变化、输入法组合、录制和修饰键释放时重置。Electron 在原生拦截后可能省略 keyup;原生绑定完成时立即清空按住状态,因此每次触发双键组合都需要重新按下两个键。释放修饰键会清空按住状态,因为 macOS 在按住 Command 时可能省略字符键的 keyup。适配器释放时移除监听器和模态观察者;单个功能观察者失败不妨碍其他观察者接收输入。 + +Desktop 原生菜单和内嵌 frame 使用与 DOM 输入相同的命令注册表。Preload 和 Client 将获焦 iframe 的名称或浏览器 webview 租约与原生输入匹配;Client 拒绝旧配置消息。浏览器 guest 的输入监听在租约释放时结束,无需等待 guest 销毁完成。原生菜单选择是显式操作,不依赖快捷键绑定。双键组合没有原生菜单快捷键,其键位仍显示在快捷键速查中。Windows 和 macOS Desktop 之外的终端 Ctrl+W/R 保留为局部输入。显式原生菜单操作保留模态检查;键盘绑定使用对应设备的分发策略。 + +
+ +----- + + +## 进一步阅读 + +- [Web Client](../../../docs/subsystems/web-client.zh.md) +- [UI primitives](../ui-primitives/README.zh.md) +- [Web app bundle](../../bundle/web-app/README.zh.md) + +----- + + +## 模型体验 + +无;此包是浏览器侧 UI 插件,不注册面向模型的内容。 + +#### KV Cache 影响 + +无;此包不组装或发送模型提供方请求。 + +## 已知限制与延期工作 + + + +- 功能 owner 显式声明 Windows 和 macOS Web 的默认键位:Mod+Slash、Mod+Comma、Mod+Backslash 和 Control+Backquote 保持双键;其他不含 Alt 的默认键位使用 Mod+Alt,含 Alt 的使用 Mod+Shift,全屏除外,使用 Mod+Alt+Enter。macOS Web 的“刷新当前页”默认不绑定快捷键,刷新按钮和用户显式绑定仍然可用。Mod 在 macOS 上为 Command,在 Windows 上为 Control。Windows 和 macOS 的 Web、Desktop 均接受任意三个或四个不同修饰键的组合。Web 还接受上述组合及 Mod+Shift 组合;浏览器或系统能否送达按键需在对应平台测试。Linux Web 接受 Mod+Slash、Mod+Shift+Comma 和 Mod+Shift+Period。未送达窗口的操作系统快捷键无法被拦截。双键组合等待第二个键时,不延迟或消费第一个键;原生操作或焦点变化可能使组合无法完成。命令注册或注销会使打开的编辑草稿过期,即使保存的偏好未改变。Desktop 在每次命令目录更新时重新读取文件,不设置文件 watcher。 + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
+ +**Runtime invariant:** 不发布配套检查模块。目录和键位索引由私有注册表同步派生,没有独立维护、需要检查的运行时关系。 diff --git a/packages/client/shortcuts/package.json b/packages/client/shortcuts/package.json new file mode 100644 index 0000000000..7bd6786771 --- /dev/null +++ b/packages/client/shortcuts/package.json @@ -0,0 +1,67 @@ +{ + "name": "@deepseek-ai/dsh-client-shortcuts", + "description": "Application keyboard command registry and physical-key routing", + "version": "0.1.7-alpha.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/shortcuts" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json", + "./protocol": { + "types": "./lib/types/protocol.d.ts", + "default": "./lib/protocol.js" + } + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-locale" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-host-webserver": "workspace:^" + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/protocol.js", + "lib/types/**/*.d.ts" + ], + "dependencies": { + "@deepseek-ai/dsh-util-crypto": "workspace:^", + "@deepseek-ai/dsh-util-values": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^" + } +} diff --git a/packages/client/shortcuts/src/binding.ts b/packages/client/shortcuts/src/binding.ts new file mode 100644 index 0000000000..8a7bed818e --- /dev/null +++ b/packages/client/shortcuts/src/binding.ts @@ -0,0 +1,112 @@ +/** Physical-key protocol shared by browser commands and desktop adapters; no DOM or runtime state. */ +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Stable command identity owned by the registering feature. */ +export type ShortcutCommandId = Branded<'ShortcutCommandId'> +/** Operating system of the device receiving input. */ +export type ShortcutPlatform = 'macos' | 'windows' | 'linux' +/** Application shell selecting the default bindings. */ +export type ShortcutRuntime = 'desktop' | 'web' +/** Runtime and operating system selecting one explicit default and preference profile. */ +export type ShortcutProfile = `${ShortcutRuntime}:${ShortcutPlatform}` +/** Logical primary expands to Meta on macOS and Control elsewhere. */ +export type ShortcutModifier = 'primary' | 'control' | 'alt' | 'shift' | 'meta' +/** One or two distinct physical keys held together, plus an exact set of modifiers. */ +export interface ShortcutBinding { + readonly code: string + readonly secondCode?: string + readonly modifiers: readonly ShortcutModifier[] +} +/** Normalized modifier order is also the keycap order. */ +export interface NormalizedBinding { + readonly code: string + readonly secondCode?: string + readonly modifiers: readonly ('control' | 'alt' | 'shift' | 'meta')[] +} + +const keyNames: Readonly> = { + Slash: '/', Comma: ',', Period: '.', Backslash: '\\', Backquote: '`', Minus: '-', Equal: '=', + BracketLeft: '[', BracketRight: ']', Semicolon: ';', Quote: "'", Enter: 'Enter', + Escape: 'Esc', Space: 'Space', Tab: 'Tab', Backspace: 'Backspace', Delete: 'Delete', + ArrowUp: '↑', ArrowDown: '↓', ArrowLeft: '←', ArrowRight: '→', +} +const modifierOrder = ['control', 'alt', 'shift', 'meta'] as const + +/** + * Expand logical modifiers, deduplicate, and validate the physical code. + * @param binding - declared binding. + * @param platform - receiving device platform. + * @returns canonical binding; unsupported codes throw during registration. + */ +export function normalizeBinding(binding: ShortcutBinding, platform: ShortcutPlatform): NormalizedBinding { + const codes: [string, ...string[]] = [binding.code, ...binding.secondCode === undefined ? [] : [binding.secondCode]] + codes.sort() + for (const code of codes) { + if (!/^(Key[A-Z]|Digit[0-9]|F([1-9]|1[0-9]|2[0-4]))$/u.test(code) + && !Object.hasOwn(keyNames, code)) throw new Error(`Unsupported shortcut code: ${code}`) + } + if (codes.length === 2 && codes[0] === codes[1]) throw new Error('Shortcut keys must be distinct') + const modifiers = new Set(binding.modifiers.map(value => value === 'primary' + ? platform === 'macos' ? 'meta' : 'control' : value)) + return { code: codes[0], ...(codes[1] === undefined ? {} : { secondCode: codes[1] }), + modifiers: modifierOrder.filter(value => modifiers.has(value)) } +} + +/** + * Produce an exact-match index from a normalized binding. + * @param binding - normalized physical key and modifiers. + * @returns stable index used for both matching and conflict checks. + */ +export function bindingKey(binding: NormalizedBinding): string { + const codes = binding.secondCode === undefined ? [binding.code] : [binding.code, binding.secondCode].sort() + return [...binding.modifiers, ...codes].join('+') +} + +/** + * Format keycaps and ARIA; Windows separates modifiers with plus signs, while chord keys remain adjacent. + * @param binding - normalized binding, or null for an unbound command. + * @param platform - receiving device platform. + * @returns visible keycaps; two-key chords omit ARIA shortcuts, which only support one non-modifier key. + */ +export function presentBinding(binding: NormalizedBinding | null, platform: ShortcutPlatform): { + keys: readonly string[] + aria: string | undefined +} { + if (binding === null) return { keys: [], aria: undefined } + const key = keyNames[binding.code] ?? binding.code.replace(/^(Key|Digit)/u, '') + const symbols = platform === 'macos' + ? { control: '⌃', alt: '⌥', shift: '⇧', meta: '⌘' } + : { control: 'Ctrl', alt: 'Alt', shift: 'Shift', meta: 'Meta' } + const ariaNames = { control: 'Control', alt: 'Alt', shift: 'Shift', meta: 'Meta' } + const ariaKey = binding.code === 'Space' ? 'Space' : binding.code === 'Escape' ? 'Escape' + : binding.code.startsWith('Arrow') ? binding.code : key + const second = binding.secondCode === undefined ? [] + : [keyNames[binding.secondCode] ?? binding.secondCode.replace(/^(Key|Digit)/u, '')] + const keys = [...binding.modifiers.map(value => symbols[value]), key] + return { keys: [...platform === 'windows' ? keys.flatMap((label, index) => index === 0 ? [label] : ['+', label]) : keys, ...second], + aria: binding.secondCode === undefined ? [...binding.modifiers.map(value => ariaNames[value]), ariaKey].join('+') : undefined } +} + +/** + * Check Web combinations: Windows and macOS also admit any three or four modifiers; Linux retains the limited set. + * @param binding - normalized candidate. + * @param platform - receiving device platform. + * @returns whether this combination is admitted; admission does not guarantee browser or system delivery. + */ +export function isWebBindingAllowed(binding: NormalizedBinding, platform: ShortcutPlatform): boolean { + if (binding.secondCode !== undefined) return false + if (platform === 'windows' || platform === 'macos') { + if (binding.modifiers.length >= 3) return true + const primary = platform === 'macos' ? 'meta' : 'control' + if (binding.modifiers.length === 1 + && ((['Comma', 'Backslash'].includes(binding.code) && binding.modifiers[0] === primary) + || (binding.code === 'Backquote' && binding.modifiers[0] === 'control'))) return true + if (binding.modifiers.length === 2 && binding.modifiers.includes(primary) + && (binding.modifiers.includes('alt') || binding.modifiers.includes('shift'))) return true + } + return [ + { code: 'Slash', modifiers: ['primary'] as const }, + { code: 'Comma', modifiers: ['primary', 'shift'] as const }, + { code: 'Period', modifiers: ['primary', 'shift'] as const }, + ].some(candidate => bindingKey(binding) === bindingKey(normalizeBinding(candidate, platform))) +} diff --git a/packages/client/shortcuts/src/client/dom.ts b/packages/client/shortcuts/src/client/dom.ts new file mode 100644 index 0000000000..83f9641370 --- /dev/null +++ b/packages/client/shortcuts/src/client/dom.ts @@ -0,0 +1,118 @@ +/** Main-document keyboard adapter; local controls arbitrate before window bubbling. */ +import type { ShortcutContext, ShortcutFixedInput } from './types.ts' +import type { ShortcutRegistry } from './registry.ts' +import type { ShortcutPlatform, ShortcutRuntime } from '../protocol.ts' + +/** + * Detect the visiting device, never the server operating system. + * @param document - product document, marked by Electron preload when present. + * @param navigator - browser device identification. + * @returns explicit runtime and platform for default resolution. + */ +export function detectEnvironment(document: Document, navigator: Navigator): { + runtime: ShortcutRuntime + platform: ShortcutPlatform +} { + const desktop = document.documentElement.dataset.platform + const device = desktop ?? navigator.platform + return { runtime: desktop === undefined ? 'web' : 'desktop', + platform: /darwin|mac|iphone|ipad/iu.test(device) ? 'macos' : /win/iu.test(device) ? 'windows' : 'linux' } +} + +/** + * Install document composition tracking and application dispatch after local handlers. + * @param window - input window owned by the client plugin. + * @param shortcuts - command registry for this window. + * @param fixed - optional fixed-sequence consumer after local controls. + * @param native - native input owns configurable bindings; DOM delivery only feeds fixed actions. + * @returns disposer releasing every listener. + */ +export function installKeyboard(window: Window, shortcuts: Pick, + fixed?: (input: ShortcutFixedInput) => void, native = false): () => void { + const document = window.document + let pending = false + let pendingTimer: number | undefined + const reset = (): void => { fixed?.({ type: 'reset' }) } + let composing = false + let compositionEnded = false + let deadKey = false + const start = (): void => { composing = true; reset() } + const end = (): void => { composing = false; compositionEnded = true; reset() } + const release = (): void => { compositionEnded = false } + const blur = (): void => { composing = false; compositionEnded = false; deadKey = false; reset() } + const modalSelector = '[role="dialog"][aria-modal="true"], [role="menu"]' + const containsModal = (node: Node): boolean => node instanceof Element + && (node.matches(modalSelector) || node.querySelector(modalSelector) !== null) + const changedModals = (records: MutationRecord[]): void => { + if (records.some(record => record.type === 'attributes' + ? record.oldValue === 'dialog' || record.oldValue === 'true' || containsModal(record.target) + : [...record.addedNodes, ...record.removedNodes].some(containsModal))) reset() + } + const observer = fixed === undefined ? undefined : new MutationObserver(changedModals) + observer?.observe(document.documentElement, { childList: true, subtree: true, + attributes: true, attributeFilter: ['role', 'aria-modal'], attributeOldValue: true }) + const capture = (): void => { + if (pending) reset() + window.clearTimeout(pendingTimer) + if (observer !== undefined) changedModals(observer.takeRecords()) + pending = true + // Native event listeners can yield a microtask checkpoint before bubbling. + pendingTimer = window.setTimeout(() => { + pending = false + pendingTimer = undefined + reset() + }, 0) + } + const keydown = (event: KeyboardEvent): void => { + window.clearTimeout(pendingTimer) + pendingTimer = undefined + pending = false + const target = event.composedPath().find(value => value instanceof Element) + const element = target instanceof Element ? target : document.activeElement + const region = element?.closest('.xterm') ? 'terminal' + : element?.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]') ? 'editable' : 'page' + const dialogs = document.querySelectorAll(modalSelector) + const top = [...dialogs].at(-1) + const context: ShortcutContext = { region, modal: top === undefined ? null : top.dataset.shortcutModal ?? 'other', target: element } + // oxlint-disable-next-line typescript/no-deprecated -- IME 229 covers engines without isComposing. + const guarded = composing || compositionEnded || deadKey || event.isComposing || event.keyCode === 229 + || event.getModifierState('AltGraph') + const isDead = event.key === 'Dead' + // macOS can report Option+Command+N as Dead outside input-method composition. + const commandDeadKey = isDead && shortcuts.runtime === 'web' && shortcuts.platform === 'macos' + && event.code === 'KeyN' && event.metaKey && event.altKey && !event.ctrlKey && !event.shiftKey + compositionEnded = false + deadKey = isDead + const gesture = { code: event.code, control: event.ctrlKey, alt: event.altKey, shift: event.shiftKey, + meta: event.metaKey, repeat: event.repeat, composing: guarded || isDead, defaultPrevented: event.defaultPrevented } + const consume = (): void => { + event.preventDefault() + if (commandDeadKey) deadKey = false + } + fixed?.({ type: 'keydown', gesture, context, consume }) + if (native) return + shortcuts.dispatch({ ...gesture, composing: guarded || (isDead && !commandDeadKey), + defaultPrevented: event.defaultPrevented }, context, consume) + } + document.addEventListener('compositionstart', start, true) + document.addEventListener('compositionend', end, true) + document.addEventListener('focusin', reset, true) + document.addEventListener('pointerdown', reset, true) + window.addEventListener('keydown', capture, true) + window.addEventListener('keydown', keydown) + window.addEventListener('keyup', release) + window.addEventListener('blur', blur) + return () => { + pending = false + window.clearTimeout(pendingTimer) + observer?.disconnect() + document.removeEventListener('compositionstart', start, true) + document.removeEventListener('compositionend', end, true) + document.removeEventListener('focusin', reset, true) + document.removeEventListener('pointerdown', reset, true) + window.removeEventListener('keydown', capture, true) + window.removeEventListener('keydown', keydown) + window.removeEventListener('keyup', release) + window.removeEventListener('blur', blur) + } +} diff --git a/packages/client/shortcuts/src/client/index.ts b/packages/client/shortcuts/src/client/index.ts new file mode 100644 index 0000000000..6947690abe --- /dev/null +++ b/packages/client/shortcuts/src/client/index.ts @@ -0,0 +1,152 @@ +/** Browser command service, with one keyboard adapter per plugin lifetime. */ +import { Service } from '@deepseek-ai/cordis' +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import { ShortcutRegistry } from './registry.ts' +import { detectEnvironment, installKeyboard } from './dom.ts' +import { bindingIssue, initialShortcutConfig, normalizeBinding, overlappingBindings, presentBinding } from '../protocol.ts' +import type { DesktopKeyboardApi, DesktopShortcutsApi, ShortcutSaveResult } from '../protocol.ts' +import { desktopShortcutStorage, webShortcutStorage } from './storage.ts' +import type { Shortcuts } from './types.ts' +import type { ShortcutFixedInput } from './types.ts' +import { installNativeKeyboard } from './native.ts' +import { Config } from '../config.ts' + +export type { ShortcutCatalogEntry, ShortcutCommand, ShortcutContext, ShortcutGesture, Shortcuts } from './types.ts' +export type { ShortcutFixedInput, ShortcutFixedCommand, ShortcutFixedCatalogEntry } from './types.ts' +export type { ShortcutBinding, ShortcutCommandId, ShortcutPlatform, ShortcutRuntime } from '../protocol.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Window-local application commands and effective keycap catalog. */ + shortcuts: Shortcuts + } +} + +/** Cordis keyboard provider; Desktop startup requires its native keyboard bridge. */ +export default class ShortcutsService extends Service implements Shortcuts { + static inject = ['locale'] + readonly runtime: Shortcuts['runtime'] + readonly platform: Shortcuts['platform'] + readonly catalog: Shortcuts['catalog'] + readonly config: Shortcuts['config'] + readonly fixedCatalog: Shortcuts['fixedCatalog'] + readonly stopSequenceMs: number + private readonly fixedListeners = new Set<(input: ShortcutFixedInput) => void>() + private readonly adapter: DesktopShortcutsApi | undefined + private active = true + private connected = false + private readonly registry: ShortcutRegistry + + constructor(ctx: Context) { + const environment = detectEnvironment(document, navigator) + const keyboard = environment.runtime === 'desktop' + ? (window as Window & { dshDesktop?: { keyboard?: DesktopKeyboardApi } }).dshDesktop?.keyboard : undefined + if (environment.runtime === 'desktop' && keyboard === undefined) throw new Error('Desktop keyboard bridge unavailable') + super(ctx, 'shortcuts') + this.runtime = environment.runtime + this.platform = environment.platform + const config = Config((globalThis as { __DSH_SHORTCUTS_CONFIG__?: unknown }).__DSH_SHORTCUTS_CONFIG__ ?? {}) + this.stopSequenceMs = config.stopSequenceMs + this.registry = new ShortcutRegistry(this.runtime, this.platform, initialShortcutConfig()) + this.catalog = this.registry.catalog + this.config = this.registry.config + this.fixedCatalog = this.registry.fixedCatalog + const publish = (snapshot: ReturnType): void => { + if (this.active && this.connected && snapshot.sequence >= this.config.getSnapshot().sequence) this.registry.configure(snapshot) + } + const web = this.runtime === 'web' ? webShortcutStorage(window, this.platform, publish) : undefined + this.adapter = web ?? desktopShortcutStorage(window) + if (keyboard !== undefined) ctx.effect(() => installNativeKeyboard(window, keyboard, this.registry, + () => this.config.getSnapshot(), () => { this.fixedInput({ type: 'reset' }) }), 'shortcuts: native keyboard') + ctx.effect(() => { + const off = this.adapter?.subscribe(publish) + return () => { this.active = false; off?.(); web?.dispose() } + }, 'shortcuts: preferences') + this.syncDefinitions() + ctx.effect(() => { + const off = installKeyboard(window, this.registry, (input) => { this.fixedInput(input) }, + keyboard !== undefined && (this.platform === 'macos' || this.platform === 'windows')) + return () => { off(); this.fixedListeners.clear() } + }, 'shortcuts: keyboard') + ctx.effect(() => ctx.locale.subscribe(() => { this.registry.refreshLabels() }), 'shortcuts: locale') + } + + register(command: Parameters[0]): () => void { + const off = this.registry.register(command) + this.syncDefinitions() + return () => { off(); this.syncDefinitions() } + } + + registerFixed(command: Parameters[0]): () => void { + const off = this.registry.registerFixed(command) + this.syncDefinitions() + return () => { off(); this.syncDefinitions() } + } + + observeFixedInput(listener: (input: ShortcutFixedInput) => void): () => void { + this.fixedListeners.add(listener) + return () => { this.fixedListeners.delete(listener) } + } + + private fixedInput(input: ShortcutFixedInput): void { + let consumed = false + for (const listener of [...this.fixedListeners]) { + if (!this.fixedListeners.has(listener)) continue + try { + listener(input.type === 'reset' ? input : { ...input, + gesture: { ...input.gesture, defaultPrevented: input.gesture.defaultPrevented || consumed }, + consume: () => { consumed = true; input.consume() } }) + } catch (error) { + console.error('Fixed shortcut handler failed', error) + } + } + } + + describeBinding(binding: Parameters[0]): ReturnType { + const normalized = binding === null ? null : normalizeBinding(binding, this.platform) + return { binding: normalized, + keys: presentBinding(normalized, this.platform).keys, + issue: normalized === null ? null : bindingIssue(normalized, this.runtime, this.platform), + conflicts: normalized === null ? [] : [...this.catalog.getSnapshot().filter(row => row.binding !== null + && overlappingBindings(row.binding, normalized)).map(row => row.id), + ...this.fixedCatalog.getSnapshot().filter(row => row.bindings.some(binding => overlappingBindings(binding, normalized))) + .map(row => row.id)] } + } + + private syncDefinitions(): void { + if (!this.active) return + if (this.adapter === undefined) { this.failRead(); return } + void this.adapter.get(this.registry.definitions()).then((snapshot) => { + this.connected = true + if (this.active && snapshot.sequence >= this.config.getSnapshot().sequence) this.registry.configure(snapshot) + }, () => { this.failRead() }) + } + + private failRead(): void { + if (this.active) this.registry.configure({ ...this.config.getSnapshot(), status: 'unreadable', error: 'read' }) + } + + /** + * Persist one reviewed operation while retaining accepted bindings on failure. + * @param args - edit and expected revision supplied by the editor. + * @returns classified save outcome and accepted snapshot. + */ + async edit(...args: Parameters): ReturnType { + if (this.adapter === undefined) return { status: 'unreadable', snapshot: this.config.getSnapshot() } + let result: ShortcutSaveResult + try { + result = await this.adapter.edit(...args) + } catch (error) { + if (this.active) console.error('Shortcut preference save failed', error) + return { status: 'write-failed', snapshot: this.config.getSnapshot() } + } + if (this.active && result.snapshot.sequence >= this.config.getSnapshot().sequence) this.registry.configure(result.snapshot) + return result + } + + async recording(active: boolean): Promise { + if (this.adapter === undefined) throw new Error('Desktop shortcuts bridge unavailable') + await this.adapter.recording(active) + } +} diff --git a/packages/client/shortcuts/src/client/native.ts b/packages/client/shortcuts/src/client/native.ts new file mode 100644 index 0000000000..faf162f63c --- /dev/null +++ b/packages/client/shortcuts/src/client/native.ts @@ -0,0 +1,44 @@ +/** Desktop gestures use live focus, modal state, and verified embedding ownership. */ +import type { DesktopKeyboardApi, ShortcutConfigSnapshot } from '../protocol.ts' +import type { ShortcutRegistry } from './registry.ts' + +/** + * Route native menu, main-frame, and embedded-frame input through the shared command registry. + * @param window - trusted product document. + * @param keyboard - top-frame preload capability. + * @param registry - window-local command owner. + * @param snapshot - latest accepted configuration. + * @param reset - clears pending fixed sequences when native input bypasses DOM delivery. + * @returns disposer releasing native input. + */ +export function installNativeKeyboard(window: Window, keyboard: DesktopKeyboardApi, registry: ShortcutRegistry, + snapshot: () => ShortcutConfigSnapshot, reset?: () => void): () => void { + return keyboard.subscribe((input) => { + if (input.revision !== snapshot().revision) return + reset?.() + let target = window.document.activeElement + while (target?.shadowRoot?.activeElement != null && !target.matches('webview[data-sidebar-browser-frame]')) { + target = target.shadowRoot.activeElement + } + const top = [...window.document.querySelectorAll('[role="dialog"][aria-modal="true"], [role="menu"]')].at(-1) + const region = target?.closest('.xterm') ? 'terminal' as const + : target?.matches('input, textarea, select, [contenteditable="true"], [contenteditable=""]') ? 'editable' as const : 'page' as const + const context = { target, region, modal: top?.dataset.shortcutModal ?? (top === undefined ? null : 'other') } + if (input.kind === 'menu') { registry.invoke(input.commandId, context); return } + if (input.kind === 'keyboard') { + registry.dispatch({ ...input, composing: false, defaultPrevented: false }, context, () => {}) + return + } + // Renderer focus and embedding identity may change after preload verification. + if (input.kind === 'webview') { + if (target?.matches('webview[data-sidebar-browser-frame]') !== true || !target.isConnected + || input.frameName === '' || target.getAttribute('name') !== input.frameName) return + registry.dispatch({ ...input, composing: false, defaultPrevented: false }, { ...context, source: 'webview' }, () => {}) + return + } + if (!(target instanceof HTMLIFrameElement) || !target.isConnected + || !target.matches('iframe[data-sidebar-browser-frame], iframe[data-html-preview]') + || input.frameName === '' || target.name !== input.frameName) return + registry.dispatch({ ...input, composing: false, defaultPrevented: false }, { ...context, source: 'iframe' }, () => {}) + }) +} diff --git a/packages/client/shortcuts/src/client/registry.ts b/packages/client/shortcuts/src/client/registry.ts new file mode 100644 index 0000000000..b0c3be724d --- /dev/null +++ b/packages/client/shortcuts/src/client/registry.ts @@ -0,0 +1,180 @@ +/** Command registration, normalized default bindings, and synchronous dispatch. */ +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { bindingIssue, bindingKey, effectiveShortcuts, initialShortcutConfig, isWebBindingAllowed, normalizeBinding, overlappingBindings, presentBinding, resolveShortcutDefault } from '../protocol.ts' +import type { ShortcutCommandId, ShortcutConfigSnapshot, ShortcutDefinition, ShortcutPlatform, ShortcutRuntime } from '../protocol.ts' +import type { ShortcutCatalogEntry, ShortcutCommand, ShortcutContext, ShortcutGesture, + ShortcutFixedCommand, ShortcutFixedCatalogEntry } from './types.ts' + +/** Dispatch consumption is independent of later business-operation success. */ +type ShortcutDispatch = { status: 'handled'; commandId: ShortcutCommandId } + | { status: 'blocked'; commandId: ShortcutCommandId; reason: string } + | { status: 'pass' } + +/** Application command registry; adapters own event listeners, feature plugins own actions. */ +export class ShortcutRegistry { + private readonly commands = new Map() + private readonly fixedCommands = new Map() + private readonly conflicts = new Map() + private readonly bindings = new Map() + private readonly state + /** Effective localized rows derived from the accepted configuration. */ + readonly catalog + /** Accepted preferences and visible read diagnostics. */ + readonly config + /** Fixed local operations whose owning plugins are mounted. */ + readonly fixedCatalog = createSnapshotStore([]) + + constructor(readonly runtime: ShortcutRuntime, readonly platform: ShortcutPlatform, + config: ShortcutConfigSnapshot = { ...initialShortcutConfig(), status: 'ready' }) { + this.state = createSnapshotStore<{ catalog: readonly ShortcutCatalogEntry[]; config: ShortcutConfigSnapshot }>({ catalog: [], config }) + this.catalog = { getSnapshot: () => this.state.getSnapshot().catalog, + subscribe: (listener: () => void) => this.state.subscribe(listener) } + this.config = { getSnapshot: () => this.state.getSnapshot().config, + subscribe: (listener: () => void) => this.state.subscribe(listener) } + } + + /** + * Return the serializable active catalog for storage validation. + * @returns definitions without callbacks or localized labels. + */ + definitions(): readonly ShortcutDefinition[] { + return [...[...this.commands.values()].map(({ id, defaults }) => ({ id, defaults })), + ...[...this.fixedCommands.values()].map(({ id, bindings }) => ({ id, defaults: {}, fixed: bindings }))] + } + + /** + * Register a read-only input action whose keys cannot be assigned to editable commands. + * @param command - owner-localized action and readable sequence. + * @returns idempotent disposer removing its reference row. + */ + registerFixed(command: ShortcutFixedCommand): () => void { + if (this.fixedCommands.has(command.id) || this.commands.has(command.id)) throw new Error(`Duplicate shortcut command: ${command.id}`) + for (const binding of command.bindings) normalizeBinding(binding, this.platform) + this.fixedCommands.set(command.id, command) + this.refreshLabels() + return () => { + if (this.fixedCommands.get(command.id) !== command) return + this.fixedCommands.delete(command.id) + this.refreshLabels() + } + } + + private refreshFixedLabels(): void { + this.fixedCatalog.set([...this.fixedCommands.values()].map(command => ({ + id: command.id, label: command.label(), keys: command.keys, group: command.group, + bindings: command.bindings.map(binding => normalizeBinding(binding, this.platform)), + }))) + } + + /** + * Publish accepted preferences and all derived labels atomically. + * @param config - storage owner's latest accepted snapshot. + */ + configure(config: ShortcutConfigSnapshot): void { + const current = this.config.getSnapshot() + if (config.revision === current.revision && config.status === current.status && config.error === current.error) return + this.refreshLabels(config) + } + + /** + * Register atomically after checking defaults for all supported platforms and shells. + * @param command - feature-owned command definition. + * @returns idempotent disposer removing both matching and catalog entries. + */ + register(command: ShortcutCommand): () => void { + if (this.commands.has(command.id) || this.fixedCommands.has(command.id)) throw new Error(`Duplicate shortcut command: ${command.id}`) + for (const runtime of ['desktop', 'web'] as const) { + for (const platform of ['macos', 'windows', 'linux'] as const) { + const candidate = resolveShortcutDefault(command, runtime, platform) + if (candidate === undefined) continue + const binding = normalizeBinding(candidate, platform) + if (runtime === 'web' && !isWebBindingAllowed(binding, platform)) { + throw new Error(`Unsupported Web shortcut: ${command.id}`) + } + if (bindingIssue(binding, runtime, platform) !== null) throw new Error(`Reserved shortcut default: ${command.id}`) + for (const existing of this.commands.values()) { + const other = resolveShortcutDefault(existing, runtime, platform) + if (other !== undefined && overlappingBindings(binding, normalizeBinding(other, platform))) { + throw new Error(`Conflicting shortcut defaults: ${command.id} and ${existing.id} (${runtime}:${platform})`) + } + } + } + } + this.commands.set(command.id, command) + this.refreshLabels() + return () => { + if (this.commands.get(command.id) !== command) return + this.commands.delete(command.id) + this.refreshLabels() + } + } + + /** + * Recompute effective bindings when preferences, commands, or locale change. + * @param config - accepted configuration, defaulting to the current snapshot. + */ + refreshLabels(config: ShortcutConfigSnapshot = this.config.getSnapshot()): void { + this.bindings.clear() + this.conflicts.clear() + const rows = effectiveShortcuts(this.definitions(), config.document, this.runtime, this.platform) + const catalog = rows.map((row) => { + const command = this.commands.get(row.id) as ShortcutCommand + const enabled = config.status !== 'loading' && row.issue === null && row.conflicts.length === 0 + if (enabled && row.binding !== null) this.bindings.set(bindingKey(row.binding), command) + if (config.status !== 'loading' && row.issue === null && row.conflicts.length > 0 && row.binding !== null) { + this.conflicts.set(bindingKey(row.binding), command) + } + return { ...row, label: command.label(), aliases: command.aliases, + ...presentBinding(row.binding, this.platform), + aria: enabled ? presentBinding(row.binding, this.platform).aria : undefined } + }) + this.state.set({ config, catalog }) + this.refreshFixedLabels() + } + + /** + * Invoke a native menu selection independently of its optional key binding. + * @param id - registered product command. + * @param context - live input owner and modal state. + */ + invoke(id: ShortcutCommandId, context: ShortcutContext): void { + const command = this.commands.get(id) + if (command === undefined || (context.modal !== null && !command.modals.includes(context.modal))) return + const result = command.resolve({ ...context, source: 'menu' }) + if (result.status === 'handled') result.run() + } + + /** + * Windows/macOS Desktop bindings override local regions and modal controls independently of their configuration source. + * @param gesture - normalized DOM/native input facts. + * @param context - synchronous input and modal owner. + * @param consume - adapter's preventDefault, called before business execution. + * @returns handled, blocked with a reason, or pass for local/system input. + */ + dispatch(gesture: ShortcutGesture, context: ShortcutContext, consume: () => void): ShortcutDispatch { + if (gesture.defaultPrevented || gesture.composing) return { status: 'pass' } + const modifiers = (['control', 'alt', 'shift', 'meta'] as const).filter(value => gesture[value]) + const pairKey = bindingKey({ code: gesture.code, modifiers, + ...(gesture.secondCode === undefined ? {} : { secondCode: gesture.secondCode }) }) + const key = this.bindings.has(pairKey) || this.conflicts.has(pairKey) ? pairKey : bindingKey({ code: gesture.code, modifiers }) + const command = this.bindings.get(key) ?? this.conflicts.get(key) + const priority = this.runtime === 'desktop' && (this.platform === 'windows' || this.platform === 'macos') + if (command === undefined || (!priority && !command.regions.includes(context.region))) return { status: 'pass' } + if (!priority && context.region === 'terminal' && gesture.control && !gesture.meta && !gesture.alt && !gesture.shift + && (gesture.code === 'KeyW' || gesture.code === 'KeyR')) return { status: 'pass' } + if (!this.bindings.has(key)) { + consume() + return { status: 'blocked', commandId: command.id, reason: 'conflict' } + } + if (!priority && context.modal !== null && !command.modals.includes(context.modal)) { + consume() + return { status: 'blocked', commandId: command.id, reason: 'modal' } + } + const resolution = command.resolve(context) + if (resolution.status === 'pass') return resolution + consume() + if (resolution.status === 'blocked') return { ...resolution, commandId: command.id } + if (!gesture.repeat) resolution.run() + return { status: 'handled', commandId: command.id } + } +} diff --git a/packages/client/shortcuts/src/client/storage.ts b/packages/client/shortcuts/src/client/storage.ts new file mode 100644 index 0000000000..2810833fee --- /dev/null +++ b/packages/client/shortcuts/src/client/storage.ts @@ -0,0 +1,40 @@ +/** Explicit browser and Desktop preference adapters; Desktop never falls back to browser storage. */ +import { ShortcutPersistence } from '../protocol.ts' +import type { DesktopShortcutsApi, ShortcutConfigSnapshot, ShortcutDefinition, ShortcutPlatform } from '../protocol.ts' + +/** Browser-profile and origin-local preference key. */ +export const SHORTCUT_STORAGE_KEY = 'dsh.keybindings.v1' + +/** + * Connect localStorage and same-origin external updates to the shared transaction coordinator. + * @param window - owning browser window. + * @param platform - visiting device platform. + * @param publish - accepts complete configuration snapshots. + * @returns adapter and lifecycle disposal. + */ +export function webShortcutStorage(window: Window, platform: ShortcutPlatform, + publish: (snapshot: ShortcutConfigSnapshot) => void): DesktopShortcutsApi & { dispose(): void } { + const persistence = new ShortcutPersistence({ + read: () => window.localStorage.getItem(SHORTCUT_STORAGE_KEY), + write: (raw) => { window.localStorage.setItem(SHORTCUT_STORAGE_KEY, raw) }, + }, 'web', platform, true, publish) + const changed = (event: StorageEvent): void => { + if (event.key === null || event.key === SHORTCUT_STORAGE_KEY) void persistence.readCurrent() + } + window.addEventListener('storage', changed) + return { + get: (definitions: readonly ShortcutDefinition[]) => { persistence.setDefinitions(definitions); return persistence.readCurrent() }, + edit: (edit, revision) => persistence.edit(edit, revision), + subscribe: () => () => {}, recording: async () => {}, + dispose: () => { window.removeEventListener('storage', changed); persistence.dispose() }, + } +} + +/** + * Read the origin-scoped preload capability; a missing bridge is an explicit configuration failure. + * @param window - product window. + * @returns the restricted Desktop API, or undefined while the preload is unavailable. + */ +export function desktopShortcutStorage(window: Window): DesktopShortcutsApi | undefined { + return (window as Window & { dshDesktop?: { shortcuts?: DesktopShortcutsApi } }).dshDesktop?.shortcuts +} diff --git a/packages/client/shortcuts/src/client/types.ts b/packages/client/shortcuts/src/client/types.ts new file mode 100644 index 0000000000..d374b89618 --- /dev/null +++ b/packages/client/shortcuts/src/client/types.ts @@ -0,0 +1,130 @@ +/** Command contributions and immutable catalog values. */ +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { BindingIssue, NormalizedBinding, ShortcutConfigSnapshot, ShortcutEdit, ShortcutRevision, ShortcutSaveResult, ShortcutBinding, ShortcutCommandId, ShortcutPlatform, ShortcutProfile, ShortcutRuntime } from '../protocol.ts' + +/** Local input owner resolved before an application command. */ +export type ShortcutRegion = 'page' | 'editable' | 'terminal' +/** Synchronous DOM context; owners capture business targets in resolve(). */ +export interface ShortcutContext { + readonly source?: 'keyboard' | 'menu' | 'iframe' | 'webview' + readonly region: ShortcutRegion + readonly modal: string | null + /** Original input element, or null when no document target is available. */ + readonly target: Element | null +} +/** A resolved action captures its target; failures remain the owner's responsibility. */ +export type ShortcutResolution = { status: 'handled'; run(): void } + | { status: 'blocked'; reason: string } + | { status: 'pass' } +/** One command's labels, defaults, input ownership, and target resolver. */ +export interface ShortcutCommand { + readonly id: ShortcutCommandId + readonly label: () => string + readonly aliases: readonly string[] + readonly defaults: Readonly>> + readonly regions: readonly ShortcutRegion[] + /** Modal identifiers in which this command may resolve; others block it. */ + readonly modals: readonly string[] + resolve(context: ShortcutContext): ShortcutResolution +} +/** JSON-compatible catalog row, shared by reference, tooltips, and controls. */ +export interface ShortcutCatalogEntry { + readonly id: ShortcutCommandId + readonly label: string + readonly aliases: readonly string[] + readonly keys: readonly string[] + readonly aria: string | undefined + readonly binding: NormalizedBinding | null + readonly modified: boolean + readonly conflicts: readonly ShortcutCommandId[] + readonly issue: BindingIssue | null +} +/** Keyboard adapter input, with composition and local consumption already resolved. */ +export interface ShortcutGesture { + readonly code: string + /** Second physical key currently held with code; matching ignores pair order. */ + readonly secondCode?: string + readonly control: boolean + readonly alt: boolean + readonly shift: boolean + readonly meta: boolean + readonly repeat: boolean + readonly composing: boolean + readonly defaultPrevented: boolean +} +/** Local fixed input delivered after controls, or a sequence-invalidating interaction. */ +export type ShortcutFixedInput = { + readonly type: 'keydown' + readonly gesture: ShortcutGesture + readonly context: ShortcutContext + consume(): void +} | { readonly type: 'reset' } +/** Read-only input action contributed while its owning feature is mounted. */ +export interface ShortcutFixedCommand { + readonly id: ShortcutCommandId + readonly label: () => string + readonly keys: readonly string[] + /** One or more physical combinations reserved by this action, including individual steps of a sequence. */ + readonly bindings: readonly [ShortcutBinding, ...ShortcutBinding[]] + readonly group: 'input' | 'menus' | 'approval' +} +/** Localized fixed-action row; its key sequence is not an editable binding. */ +export interface ShortcutFixedCatalogEntry { + readonly id: ShortcutCommandId + readonly label: string + readonly keys: readonly string[] + readonly bindings: readonly NormalizedBinding[] + readonly group: ShortcutFixedCommand['group'] +} +/** Feature-facing keyboard registry. */ +export interface Shortcuts { + readonly runtime: ShortcutRuntime + readonly platform: ShortcutPlatform + readonly catalog: ObservableSnapshot + readonly config: ObservableSnapshot + readonly fixedCatalog: ObservableSnapshot + readonly stopSequenceMs: number + /** + * Contribute one fixed action and reserve its keys against editable bindings. + * @param command - owner-localized name, group, readable keys, and reserved physical combinations. + * @returns idempotent disposer removing the fixed row. + */ + registerFixed(command: ShortcutFixedCommand): () => void + /** + * Observe locally arbitrated input and interaction resets for fixed sequences. + * @param listener - owner handler; composing or consumed input must not trigger actions. + * @returns disposer releasing the handler. + */ + observeFixedInput(listener: (input: ShortcutFixedInput) => void): () => void + /** + * Describe a candidate using the device's physical-key and reservation rules; invalid codes throw. + * @param binding - candidate combination, or null for an unbound command. + * @returns canonical binding, visible keys, rejection reason, and overlapping editable/fixed command IDs. + */ + describeBinding(binding: ShortcutBinding | null): { + binding: NormalizedBinding | null + keys: readonly string[] + issue: BindingIssue | null + conflicts: readonly ShortcutCommandId[] + } + /** + * Save a reviewed edit; failures preserve both accepted preferences and the caller's draft. + * @param edit - set, clear, reset, or reset-all operation. + * @param revision - accepted revision reviewed by the user. + * @returns persistence outcome and current accepted configuration. + * @throws when publishing the accepted command catalog fails. + */ + edit(edit: ShortcutEdit, revision: ShortcutRevision): Promise + /** + * Suppress native menu accelerators while the recording layer owns keyboard input; reject if Desktop cannot acknowledge it. + * @param active - whether the recording layer is mounted. + * @returns completion of the native interception update. + */ + recording(active: boolean): Promise + /** + * Register a command; duplicate ids and overlapping default bindings throw. + * @param command - feature-owned labels, defaults, and target resolver. + * @returns disposer removing the binding and catalog entry. + */ + register(command: ShortcutCommand): () => void +} diff --git a/packages/client/shortcuts/src/config.ts b/packages/client/shortcuts/src/config.ts new file mode 100644 index 0000000000..ca8c838d0c --- /dev/null +++ b/packages/client/shortcuts/src/config.ts @@ -0,0 +1,13 @@ +/** Window-local timing accepted by the Host and browser keyboard service. */ +import z from '@deepseek-ai/schemastery' + +/** Fixed shortcut sequence settings. */ +export interface Config { + /** Maximum interval between independent Escape presses for stopping a reply, in milliseconds. */ + stopSequenceMs: number +} + +/** Validated deployment settings for fixed keyboard sequences. */ +export const Config: z, Config> = z.object({ + stopSequenceMs: z.natural().min(1).max(2_147_483_646).default(500), +}) diff --git a/packages/client/shortcuts/src/configuration.ts b/packages/client/shortcuts/src/configuration.ts new file mode 100644 index 0000000000..9a63ec87e1 --- /dev/null +++ b/packages/client/shortcuts/src/configuration.ts @@ -0,0 +1,244 @@ +/** Validated preference documents and deterministic conflict resolution, without browser dependencies. */ +import { assertNever } from '@deepseek-ai/dsh-util-values' +import { bindingKey, isWebBindingAllowed, normalizeBinding } from './binding.ts' +import type { NormalizedBinding, ShortcutBinding, ShortcutCommandId, ShortcutModifier, ShortcutPlatform, ShortcutProfile, ShortcutRuntime } from './binding.ts' + +/** Overrides are platform-local; absent commands inherit defaults and null explicitly unbinds. */ +export interface ShortcutDocument { + readonly schemaVersion: 1 | 2 + readonly profiles: Readonly>>>> +} +/** Serializable command definitions accepted from the trusted product frame. */ +export interface ShortcutDefinition { + readonly id: ShortcutCommandId + readonly defaults: Readonly>> + /** Fixed actions reserve one or more combinations instead of exposing an editable default. */ + readonly fixed?: readonly [ShortcutBinding, ...ShortcutBinding[]] +} +/** Validation failures select localized copy in the UI. */ +export type BindingIssue = 'reserved' | 'unsupported-browser' | 'modifier-required' | 'unsupported-key' +/** A conflicting row keeps its requested binding visible but cannot execute it. */ +export interface EffectiveShortcut { + readonly id: ShortcutCommandId + readonly binding: NormalizedBinding | null + readonly modified: boolean + readonly conflicts: readonly ShortcutCommandId[] + readonly issue: BindingIssue | null +} +/** One revision-checked preference edit, limited to the current runtime and platform. */ +export type ShortcutEdit = { type: 'set'; id: ShortcutCommandId; binding: ShortcutBinding | null } + | { type: 'reset'; id: ShortcutCommandId } | { type: 'reset-all' } + +const record = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) +const commandPattern = /^[a-z][a-zA-Z0-9-]*(?:\.[a-zA-Z][a-zA-Z0-9-]*)+$/u + +/** + * Validate JSON binding fields before normalization; unknown fields are rejected to prevent lossy rewrites. + * @param value - file or IPC input. + * @returns a binding with a supported physical code, or null for explicit removal. + */ +export function parseBinding(value: unknown): ShortcutBinding | null { + if (value === null) return null + if (!record(value) || Object.keys(value).some(key => key !== 'code' && key !== 'secondCode' && key !== 'modifiers') + || typeof value.code !== 'string' || !Array.isArray(value.modifiers) + || (Object.hasOwn(value, 'secondCode') && typeof value.secondCode !== 'string') + || !value.modifiers.every((modifier: unknown): modifier is ShortcutModifier => + typeof modifier === 'string' && ['primary', 'control', 'alt', 'shift', 'meta'].includes(modifier))) { + throw new Error('Invalid shortcut binding') + } + const binding: ShortcutBinding = { + code: value.code, modifiers: value.modifiers, + ...(typeof value.secondCode === 'string' ? { secondCode: value.secondCode } : {}), + } + normalizeBinding(binding, 'windows') + return binding +} + +/** + * Decode the complete document while preserving dormant command overrides. + * @param raw - stored JSON, or null for a missing document. + * @returns the accepted document or a classified read failure. + */ +export function parseShortcutDocument(raw: string | null): ShortcutDocument | 'invalid' | 'future' { + if (raw === null) return { schemaVersion: 1, profiles: {} } + try { + const value: unknown = JSON.parse(raw) + if (!record(value)) return 'invalid' + if (typeof value.schemaVersion === 'number' && value.schemaVersion > 2) return 'future' + if ((value.schemaVersion !== 1 && value.schemaVersion !== 2) || !record(value.profiles) + || Object.keys(value).some(key => key !== 'schemaVersion' && key !== 'profiles')) return 'invalid' + const profiles: Record> = {} + for (const [profile, overrides] of Object.entries(value.profiles)) { + if (!/^(desktop|web):(macos|windows|linux)$/u.test(profile) || !record(overrides)) return 'invalid' + const bindings: Record = {} + for (const [id, binding] of Object.entries(overrides)) { + if (!commandPattern.test(id)) return 'invalid' + const parsed = parseBinding(binding) + if (value.schemaVersion === 1 && parsed?.secondCode !== undefined) return 'invalid' + bindings[id] = parsed + } + profiles[profile] = bindings + } + return { schemaVersion: value.schemaVersion, profiles } + } catch (_error) { + // Invalid JSON and invalid binding fields both preserve the original document. + return 'invalid' + } +} + +/** + * Check system, editor, and browser reservations using expanded physical modifiers. + * @param binding - normalized candidate. + * @param runtime - receiving application shell. + * @param platform - receiving device. + * @returns the rejection reason, or null when this combination is allowed. + */ +export function bindingIssue(binding: NormalizedBinding, runtime: ShortcutRuntime, platform: ShortcutPlatform): BindingIssue | null { + const { code, modifiers } = binding + if (runtime === 'desktop' && (platform === 'windows' || platform === 'macos')) return null + if (binding.secondCode !== undefined) return 'unsupported-key' + if (modifiers.length === 0 || modifiers.every(modifier => modifier === 'shift')) return 'modifier-required' + if ((platform === 'windows' || platform === 'macos') && modifiers.length >= 3) return null + if (runtime === 'web' && (platform === 'windows' || platform === 'macos') && isWebBindingAllowed(binding, platform)) return null + const primary = modifiers.includes(platform === 'macos' ? 'meta' : 'control') + if (['Escape', 'Tab', 'Space', 'Backspace', 'Delete', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(code) + || (code === 'Enter' && !modifiers.includes('alt')) + || (primary && ['KeyC', 'KeyV', 'KeyX', 'KeyZ', 'KeyY', 'KeyQ', 'KeyH'].includes(code)) + || (primary && code === 'KeyA' && !modifiers.includes('shift')) + || (platform !== 'macos' && (modifiers.includes('meta') || (modifiers.includes('alt') && ['F4', 'F2'].includes(code)))) + || (platform === 'macos' && modifiers.includes('control') && modifiers.includes('meta')) + || (platform === 'macos' && modifiers.includes('alt') && !primary)) return 'reserved' + if (runtime === 'web' && !isWebBindingAllowed(binding, platform)) return 'unsupported-browser' + return null +} + +/** + * Select the command owner's explicit default for one device profile. + * @param definition - command identity and per-profile defaults. + * @param runtime - receiving shell. + * @param platform - receiving device. + * @returns the declared physical binding, or undefined for an unbound action. + */ +export function resolveShortcutDefault(definition: ShortcutDefinition, runtime: ShortcutRuntime, + platform: ShortcutPlatform): ShortcutBinding | undefined { + return definition.defaults[`${runtime}:${platform}`] +} + +/** + * Resolve overrides and conflicts independently of registration order. Explicit overrides displace defaults. + * @param definitions - active commands. + * @param document - accepted preferences. + * @param runtime - receiving shell. + * @param platform - receiving device. + * @returns every active command, including unavailable conflicting bindings. + */ +export function effectiveShortcuts(definitions: readonly ShortcutDefinition[], document: ShortcutDocument, + runtime: ShortcutRuntime, platform: ShortcutPlatform): readonly EffectiveShortcut[] { + const overrides = document.profiles[`${runtime}:${platform}`] ?? {} + const fixed = definitions.flatMap(row => row.fixed?.map(binding => ({ id: row.id, binding: normalizeBinding(binding, platform) })) ?? []) + const rows = definitions.filter(row => row.fixed === undefined).map(({ id, defaults }) => { + const modified = Object.hasOwn(overrides, id) + const candidate = modified ? overrides[id] : resolveShortcutDefault({ id, defaults }, runtime, platform) + const binding = candidate == null ? null : normalizeBinding(candidate, platform) + return { id, binding, modified, issue: binding === null ? null : bindingIssue(binding, runtime, platform) } + }) + return rows.map((row) => { + const binding = row.binding + return { ...row, conflicts: binding === null ? [] : [...new Set([...rows.filter(other => other.id !== row.id + && other.binding !== null && other.issue === null && overlappingBindings(other.binding, binding) + && (!row.modified || other.modified)).map(other => other.id), + ...fixed.filter(other => overlappingBindings(other.binding, binding)).map(other => other.id)])] } + }) +} + +/** + * Detect identical combinations or a single key contained in a two-key chord. + * @param left - normalized candidate. + * @param right - normalized occupied binding. + * @returns whether both bindings require the same modifiers and overlap. + */ +export function overlappingBindings(left: NormalizedBinding, right: NormalizedBinding): boolean { + if (left.modifiers.join('+') !== right.modifiers.join('+')) return false + if (left.secondCode !== undefined && right.secondCode !== undefined) return bindingKey(left) === bindingKey(right) + return [left.code, left.secondCode].some(code => code !== undefined && (code === right.code || code === right.secondCode)) +} + +/** + * Apply an edit without modifying other profiles or dormant overrides. + * @param document - accepted document. + * @param edit - validated operation. + * @param runtime - current shell. + * @param platform - current device. + * @returns the candidate document, pending conflict checks and durable storage. + */ +export function editShortcutDocument(document: ShortcutDocument, edit: ShortcutEdit, + runtime: ShortcutRuntime, platform: ShortcutPlatform): ShortcutDocument { + const schemaVersion = runtime === 'desktop' && (platform === 'macos' || platform === 'windows') ? 2 : document.schemaVersion + const profile = `${runtime}:${platform}` as const + let overrides = { ...document.profiles[profile] } + switch (edit.type) { + case 'set': overrides[edit.id] = edit.binding; break + case 'reset': { + const { [edit.id]: _removed, ...remaining } = overrides + overrides = remaining + break + } + case 'reset-all': overrides = {}; break + /* v8 ignore next -- parseShortcutEdit validates this closed union before persistence. */ + default: return assertNever(edit, 'shortcut edit') + } + return { schemaVersion, profiles: { ...document.profiles, [profile]: overrides } } +} + +/** + * Validate a preference edit at the Desktop IPC boundary. + * @param value - untrusted renderer request. + * @returns the constrained operation; malformed requests throw. + */ +export function parseShortcutEdit(value: unknown): ShortcutEdit { + if (!record(value)) throw new Error('Invalid shortcut edit') + if (value.type === 'reset-all' && Object.keys(value).length === 1) return { type: value.type } + if (typeof value.id !== 'string' || !commandPattern.test(value.id)) throw new Error('Invalid shortcut command') + if (value.type === 'reset' && Object.keys(value).length === 2) return { type: 'reset', id: value.id as ShortcutCommandId } + if (value.type === 'set' && Object.keys(value).length === 3) return { type: 'set', id: value.id as ShortcutCommandId, binding: parseBinding(value.binding) } + throw new Error('Invalid shortcut edit') +} + +/** + * Validate the trusted product's serializable command catalog at IPC ingress. + * @param value - renderer-supplied active command definitions. + * @returns validated definitions; duplicate IDs, overlapping defaults, and unsupported combinations throw. + */ +export function parseShortcutDefinitions(value: unknown): readonly ShortcutDefinition[] { + if (!Array.isArray(value)) throw new Error('Invalid shortcut catalog') + const ids = new Set() + for (const entry of value) { + if (!record(entry) || typeof entry.id !== 'string' || !commandPattern.test(entry.id) || ids.has(entry.id) + || !record(entry.defaults) || Object.keys(entry).some(key => key !== 'id' && key !== 'defaults' && key !== 'fixed')) throw new Error('Invalid shortcut definition') + ids.add(entry.id) + if (Object.hasOwn(entry, 'fixed')) { + if (!Array.isArray(entry.fixed) || entry.fixed.length === 0 || Object.keys(entry.defaults).length > 0) throw new Error('Invalid fixed shortcut definition') + for (const binding of entry.fixed) if (parseBinding(binding) === null) throw new Error('Invalid fixed shortcut binding') + } + for (const [profile, candidate] of Object.entries(entry.defaults)) { + if (!/^(desktop|web):(macos|windows|linux)$/u.test(profile)) throw new Error('Invalid shortcut profile') + const binding = parseBinding(candidate) + if (binding === null) throw new Error('Invalid shortcut default') + } + } + const definitions = value as ShortcutDefinition[] + for (const runtime of ['desktop', 'web'] as const) { + for (const platform of ['macos', 'windows', 'linux'] as const) { + const bindings: NormalizedBinding[] = [] + for (const entry of definitions) { + const binding = resolveShortcutDefault(entry, runtime, platform) + if (binding === undefined) continue + const normalized = normalizeBinding(binding, platform) + if (bindings.some(other => overlappingBindings(other, normalized)) + || bindingIssue(normalized, runtime, platform) !== null) throw new Error('Conflicting or reserved shortcut default') + bindings.push(normalized) + } + } + } + return definitions +} diff --git a/packages/client/shortcuts/src/index.ts b/packages/client/shortcuts/src/index.ts new file mode 100644 index 0000000000..75fb62db79 --- /dev/null +++ b/packages/client/shortcuts/src/index.ts @@ -0,0 +1,20 @@ +/** Host configuration for window-local keyboard sequences. */ +// Host configuration injection stays with each plugin's validated Config and browser global. +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-host-webserver' +import type { Config } from './config.ts' + +export { Config } from './config.ts' + +/** + * Embed validated keyboard settings in product pages. + * @param ctx - Host context serving browser pages. + * @param config - sequence timing adopted when the page loads. + */ +export function apply(ctx: Context, config: Config): void { + ctx.on('webserver/index-inject', (table) => { + table.push({ kind: 'global', name: '__DSH_SHORTCUTS_CONFIG__', value: config }) + }) +} +/* jscpd:ignore-end */ diff --git a/packages/client/shortcuts/src/native.ts b/packages/client/shortcuts/src/native.ts new file mode 100644 index 0000000000..45e0817488 --- /dev/null +++ b/packages/client/shortcuts/src/native.ts @@ -0,0 +1,29 @@ +/** Trusted product-window input exchanged with Electron's top-frame preload. */ +import type { ShortcutRevision } from './persistence.ts' +import type { ShortcutCommandId } from './binding.ts' + +/** Native input carries the accepted configuration identity; frame names identify the focused iframe or browser guest embedding element. */ +export type DesktopShortcutInput = { + readonly revision: ShortcutRevision +} & ({ readonly kind: 'menu'; readonly commandId: ShortcutCommandId } + | { readonly kind: 'keyboard' | 'iframe' | 'webview' + readonly frameName: string + readonly code: string + readonly secondCode?: string + readonly control: boolean + readonly alt: boolean + readonly shift: boolean + readonly meta: boolean + readonly repeat: boolean }) + +/** Window-scoped capabilities exposed only to the product main frame. */ +export interface DesktopKeyboardApi { + /** Subscribe to verified native input. @param listener - current document consumer. @returns listener disposer. */ + subscribe(listener: (input: DesktopShortcutInput) => void): () => void + /** + * Close the owning window if its configuration is still current. + * @param revision - accepted configuration identity. + * @returns IPC completion. + */ + closeWindow(revision: ShortcutRevision): Promise +} diff --git a/packages/client/shortcuts/src/persistence.ts b/packages/client/shortcuts/src/persistence.ts new file mode 100644 index 0000000000..49335a4935 --- /dev/null +++ b/packages/client/shortcuts/src/persistence.ts @@ -0,0 +1,144 @@ +/** Serialized preference transactions; storage owners publish only accepted writes or read diagnostics. */ +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' +import type { Branded } from '@deepseek-ai/dsh-brand' +import { editShortcutDocument, effectiveShortcuts, parseShortcutDocument } from './configuration.ts' +import type { BindingIssue, ShortcutDefinition, ShortcutDocument, ShortcutEdit } from './configuration.ts' +import type { ShortcutCommandId, ShortcutPlatform, ShortcutRuntime } from './binding.ts' + +/** Opaque accepted-state identity, invalidated by configuration and catalog changes. */ +export type ShortcutRevision = Branded<'ShortcutRevision'> +/** Read state retains the last accepted document on failure; loading does not enable commands. */ +export interface ShortcutConfigSnapshot { + readonly revision: ShortcutRevision + /** Monotonic within one storage-owner lifetime; clients discard out-of-order IPC replies. */ + readonly sequence: number + readonly document: ShortcutDocument + readonly status: 'loading' | 'ready' | 'unreadable' + readonly error: 'read' | 'invalid' | 'future' | null + readonly usingDefaults: boolean +} +/** Save failures never replace effective preferences. */ +export interface ShortcutSaveResult { + readonly status: 'saved' | 'stale' | 'unreadable' | 'write-failed' | 'not-ready' | 'conflict' + readonly snapshot: ShortcutConfigSnapshot + readonly issue?: BindingIssue + readonly conflicts?: readonly ShortcutCommandId[] +} +/** Storage adapter owns path/origin isolation and complete replacement on successful writes. */ +export interface ShortcutStorage { + read(): string | null | Promise + write(raw: string): void | Promise +} +/** Restricted Desktop preload API; file paths and accelerators never cross from Renderer. */ +export interface DesktopShortcutsApi { + get(definitions: readonly ShortcutDefinition[]): Promise + edit(edit: ShortcutEdit, revision: ShortcutRevision): Promise + subscribe(listener: (snapshot: ShortcutConfigSnapshot) => void): () => void + recording(active: boolean): Promise +} + +/** + * Create a disabled initial snapshot for asynchronous adapter startup. + * @returns a fresh configuration with no accepted persisted state. + */ +export function initialShortcutConfig(): ShortcutConfigSnapshot { + return { revision: randomUUID() as ShortcutRevision, sequence: 0, document: { schemaVersion: 1, profiles: {} }, + status: 'loading', error: null, usingDefaults: true } +} + +/** Single-writer coordinator shared by localStorage and Electron's atomic file adapter. */ +export class ShortcutPersistence { + private snapshot = initialShortcutConfig() + private raw: string | null | undefined + private queue: Promise = Promise.resolve() + private definitions: readonly ShortcutDefinition[] | null = null + private active = true + + constructor(private readonly storage: ShortcutStorage, private readonly runtime: ShortcutRuntime, + private readonly platform: ShortcutPlatform, private readonly rereadBeforeWrite: boolean, + private readonly publish: (snapshot: ShortcutConfigSnapshot) => void) {} + + /** + * Install or revoke a product catalog and invalidate drafts from its previous lifetime. + * @param definitions - current trusted definitions, or null while the product is not ready. + */ + setDefinitions(definitions: readonly ShortcutDefinition[] | null): void { + this.definitions = definitions + this.accept({ ...this.snapshot }) + } + + /** Stop accepting edits or publishing late completions. */ + dispose(): void { this.active = false } + + /** + * Read the current file; failures retain the last accepted document and disable ordinary writes. + * @returns the accepted snapshot or diagnostic snapshot. + */ + readCurrent(): Promise { + return this.serialize(() => this.read()) + } + + /** + * Compare the draft revision, validate the complete candidate, then persist before publishing. + * @param edit - constrained preference operation. + * @param revision - state against which the user reviewed the edit. + * @returns a classified outcome and the currently accepted snapshot. + */ + edit(edit: ShortcutEdit, revision: ShortcutRevision): Promise { + return this.serialize(async () => { + if (this.rereadBeforeWrite) await this.read() + const result = (status: ShortcutSaveResult['status']): ShortcutSaveResult => ({ status, snapshot: this.snapshot }) + if (!this.active || this.definitions === null || this.snapshot.status === 'loading') return result('not-ready') + if (revision !== this.snapshot.revision) return result('stale') + if (this.snapshot.status === 'unreadable') return result('unreadable') + if ((edit.type === 'set' || edit.type === 'reset') && !this.definitions.some(row => row.id === edit.id && row.fixed === undefined)) return result('not-ready') + const document = editShortcutDocument(this.snapshot.document, edit, this.runtime, this.platform) + const rows = effectiveShortcuts(this.definitions, document, this.runtime, this.platform) + const invalid = rows.find(row => (edit.type === 'reset-all' || row.id === edit.id) + && (row.issue !== null || row.conflicts.length > 0)) + // An explicit edit must not silently disable another command's default binding. + const displaced = edit.type === 'set' ? rows.find(row => row.conflicts.includes(edit.id)) : undefined + if (invalid !== undefined || displaced !== undefined) return { ...result('conflict'), + ...(invalid?.issue ? { issue: invalid.issue } : {}), + conflicts: invalid?.conflicts.length ? invalid.conflicts : displaced === undefined ? [] : [displaced.id] } + try { + const raw = `${JSON.stringify(document, null, 2)}\n` + await this.storage.write(raw) + this.raw = raw + this.accept({ ...this.snapshot, document, status: 'ready', error: null, usingDefaults: false }) + return result('saved') + } catch (_error) { + // The adapter retains the previous complete document when a write fails. + return result('write-failed') + } + }) + } + + private serialize(operation: () => Promise): Promise { + const next = this.queue.then(operation) + this.queue = next.catch(() => undefined) + return next + } + + private accept(snapshot: ShortcutConfigSnapshot): void { + this.snapshot = { ...snapshot, sequence: this.snapshot.sequence + 1, revision: randomUUID() as ShortcutRevision } + if (!this.active) return + try { this.publish(this.snapshot) } catch (error) { console.error('Shortcut configuration subscriber failed:', error) } + } + + private async read(): Promise { + let raw: string | null + try { + raw = await this.storage.read() + } catch (_error) { + if (this.snapshot.error !== 'read') this.accept({ ...this.snapshot, status: 'unreadable', error: 'read' }) + return this.snapshot + } + if (raw === this.raw && this.snapshot.error !== 'read') return this.snapshot + this.raw = raw + const document = parseShortcutDocument(raw) + if (typeof document === 'string') this.accept({ ...this.snapshot, status: 'unreadable', error: document }) + else this.accept({ ...this.snapshot, document, status: 'ready', error: null, usingDefaults: raw === null }) + return this.snapshot + } +} diff --git a/packages/client/shortcuts/src/protocol.ts b/packages/client/shortcuts/src/protocol.ts new file mode 100644 index 0000000000..fad8ab60bd --- /dev/null +++ b/packages/client/shortcuts/src/protocol.ts @@ -0,0 +1,5 @@ +/** Physical-key and versioned preference protocol shared by Client and Desktop main. */ +export * from './binding.ts' +export * from './configuration.ts' +export * from './persistence.ts' +export * from './native.ts' diff --git a/packages/client/shortcuts/tests/config.host.spec.ts b/packages/client/shortcuts/tests/config.host.spec.ts new file mode 100644 index 0000000000..13ad7bc00a --- /dev/null +++ b/packages/client/shortcuts/tests/config.host.spec.ts @@ -0,0 +1,17 @@ +import { Context } from '@deepseek-ai/cordis' +import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' +import { expect, it } from 'vitest' +import { Config, apply } from '../src/index.ts' + +it('validates positive sequence timing and publishes it through the product page injection', async () => { + expect(Config({}).stopSequenceMs).toBe(500) + expect(Config({ stopSequenceMs: 2_147_483_646 }).stopSequenceMs).toBe(2_147_483_646) + for (const stopSequenceMs of [0, -1, 1.5, 2_147_483_647, Infinity, NaN]) expect(() => Config({ stopSequenceMs })).toThrow() + const ctx = new Context() + try { + apply(ctx, Config({ stopSequenceMs: 800 })) + const table: IndexInjection[] = [] + await ctx.parallel('webserver/index-inject', table) + expect(table).toEqual([{ kind: 'global', name: '__DSH_SHORTCUTS_CONFIG__', value: { stopSequenceMs: 800 } }]) + } finally { await ctx.fiber.dispose() } +}) diff --git a/packages/client/shortcuts/tests/configuration.client.spec.ts b/packages/client/shortcuts/tests/configuration.client.spec.ts new file mode 100644 index 0000000000..63d49dc38e --- /dev/null +++ b/packages/client/shortcuts/tests/configuration.client.spec.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest' +import { bindingIssue, editShortcutDocument, effectiveShortcuts, normalizeBinding, parseBinding, parseShortcutDefinitions, + parseShortcutDocument, parseShortcutEdit } from '../src/protocol.ts' +import type { ShortcutCommandId, ShortcutDefinition, ShortcutDocument } from '../src/protocol.ts' + +const id = (value: string) => value as ShortcutCommandId +it('reserves the macOS Web control-command combination', () => { + expect(bindingIssue({ code: 'KeyB', modifiers: ['control', 'meta'] }, 'web', 'macos')).toBe('reserved') +}) +const primary = (code: string) => ({ code, modifiers: ['primary'] as const }) +const definitions: readonly ShortcutDefinition[] = [ + { id: id('test.one'), defaults: { + 'desktop:macos': primary('KeyB'), + 'desktop:windows': primary('KeyB'), + 'desktop:linux': primary('KeyB'), + } }, + { id: id('test.two'), defaults: { + 'desktop:macos': primary('KeyJ'), + 'desktop:windows': primary('KeyJ'), + 'desktop:linux': primary('KeyJ'), + } }, +] + +describe('preference document validation and conflict resolution', () => { + it('distinguishes inherited, cleared, reset, and dormant preferences without touching other profiles', () => { + const document: ShortcutDocument = { schemaVersion: 1, profiles: { + 'desktop:macos': { 'dormant.command': primary('KeyU'), 'test.one': null }, + 'web:windows': { 'test.one': primary('Slash') }, + } } + expect(parseShortcutDocument(JSON.stringify(document))).toEqual(document) + expect(effectiveShortcuts(definitions, document, 'desktop', 'macos')[0]).toMatchObject({ modified: true, binding: null }) + const reset = editShortcutDocument(document, { type: 'reset', id: id('test.one') }, 'desktop', 'macos') + expect(reset.profiles['desktop:macos']).toEqual({ 'dormant.command': primary('KeyU') }) + expect(effectiveShortcuts(definitions, reset, 'desktop', 'macos')[0]).toMatchObject({ modified: false, binding: { code: 'KeyB' } }) + expect(editShortcutDocument(document, { type: 'reset-all' }, 'desktop', 'macos').profiles) + .toEqual({ 'desktop:macos': {}, 'web:windows': document.profiles['web:windows'] }) + }) + + it.each(['{', 'null', '[]', '{"schemaVersion":0,"profiles":{}}', '{"schemaVersion":1,"profiles":{"alien":{}}}', + '{"schemaVersion":1,"profiles":{"web:macos":{"bad":null}}}', '{"schemaVersion":1,"profiles":{},"extra":1}', + '{"schemaVersion":1,"profiles":{"web:macos":{"test.one":{"code":"KeyB","modifiers":["bogus"]}}}}'])('retains invalid documents: %s', (raw) => { + expect(parseShortcutDocument(raw)).toBe('invalid') + }) + it('refuses future versions without interpreting their fields', () => { + expect(parseShortcutDocument('{"schemaVersion":3,"profiles":17}')).toBe('future') + expect(parseShortcutDocument(null)).toEqual({ schemaVersion: 1, profiles: {} }) + expect(() => parseBinding({ code: 'KeyB', modifiers: ['primary'], extra: true })).toThrow() + expect(() => parseBinding({ code: 'Unknown', modifiers: [] })).toThrow() + }) + + it('lets explicit overrides displace new defaults, but disables both conflicting explicit overrides', () => { + const document: ShortcutDocument = { schemaVersion: 1, profiles: { 'desktop:windows': { 'test.one': primary('KeyJ') } } } + const rows = effectiveShortcuts(definitions, document, 'desktop', 'windows') + expect(rows.map(row => row.conflicts)).toEqual([[], [id('test.one')]]) + const both = editShortcutDocument(document, { type: 'set', id: id('test.two'), binding: { code: 'KeyJ', modifiers: ['control'] } }, 'desktop', 'windows') + expect(effectiveShortcuts([...definitions].reverse(), both, 'desktop', 'windows').map(row => row.conflicts)) + .toEqual([[id('test.one')], [id('test.two')]]) + }) + + it('keeps Linux desktop and browser reservations', () => { + const platform = 'linux' + const check = (code: string, modifiers: readonly ('primary' | 'control' | 'alt' | 'shift' | 'meta')[], runtime: 'web' | 'desktop' = 'desktop') => + bindingIssue(normalizeBinding({ code, modifiers }, platform), runtime, platform) + for (const code of ['KeyC', 'KeyV', 'KeyX', 'KeyZ', 'KeyY', 'KeyQ', 'Tab', 'Escape', 'Space', 'Enter']) expect(check(code, ['primary'])).toBe('reserved') + expect(check('KeyA', ['primary', 'shift'])).toBeNull() + expect(check('KeyA', ['primary'])).toBe('reserved') + expect(check('KeyJ', [])).toBe('modifier-required') + expect(check('KeyJ', ['shift'])).toBe('modifier-required') + expect(check('KeyJ', ['primary'])).toBeNull() + for (const code of ['KeyN', 'KeyP', 'KeyW', 'KeyR', 'KeyO', 'KeyJ']) expect(check(code, ['primary'], 'web')).toBe('unsupported-browser') + for (const code of ['Comma', 'Period']) expect(check(code, ['primary', 'shift'], 'web')).toBeNull() + expect(check('Slash', ['primary'], 'web')).toBeNull() + expect(check('F4', ['alt'], 'desktop')).toBe('reserved') + expect(check('KeyL', ['meta', 'control'], 'desktop')).toBe('reserved') + }) + + it.each(['macos', 'windows'] as const)('accepts %s desktop editing and system combinations, and single keys', (platform) => { + for (const [code, modifiers] of [ + ...['KeyC', 'KeyV', 'KeyX', 'KeyZ', 'KeyY', 'KeyA', 'KeyQ', 'KeyH', 'Tab', 'Space'].map(code => [code, ['primary']] as const), + ['KeyC', ['control']], ['KeyX', ['control']], ['KeyN', ['shift']], ['Tab', ['control']], ['Tab', ['shift']], + ['Escape', ['alt']], ['F4', ['alt']], ['KeyL', ['meta']], ['Delete', ['control', 'alt']], + ['KeyL', ['control', 'meta']], ['ArrowLeft', ['alt']], + ] as const) expect(bindingIssue(normalizeBinding({ code, modifiers }, platform), 'desktop', platform)).toBeNull() + expect(bindingIssue(normalizeBinding({ code: 'KeyC', modifiers: [] }, platform), 'desktop', platform)).toBeNull() + expect(bindingIssue(normalizeBinding(primary('KeyC'), platform), 'web', platform)).toBe('reserved') + let document: ShortcutDocument = { schemaVersion: 1, profiles: {} } + for (const entry of definitions) document = editShortcutDocument(document, { type: 'set', id: entry.id, binding: primary('KeyC') }, 'desktop', platform) + expect(effectiveShortcuts(definitions, document, 'desktop', platform).map(row => row.conflicts)) + .toEqual([[id('test.two')], [id('test.one')]]) + }) + + it.each([ + ['web', 'macos'], ['web', 'windows'], ['desktop', 'macos'], ['desktop', 'windows'], + ] as const)('accepts three and four distinct modifiers in %s on %s and still detects conflicts', (runtime, platform) => { + for (const modifiers of [ + ['control', 'alt', 'shift'], ['control', 'alt', 'meta'], ['control', 'shift', 'meta'], + ['alt', 'shift', 'meta'], ['control', 'alt', 'shift', 'meta'], + ] as const) { + for (const code of ['KeyN', 'KeyR', 'KeyX', 'Tab', 'F4']) { + expect(bindingIssue(normalizeBinding({ code, modifiers }, platform), runtime, platform)).toBeNull() + } + } + const binding = { code: 'KeyX', modifiers: ['primary', 'alt', 'shift'] as const } + let document: ShortcutDocument = { schemaVersion: 1, profiles: {} } + for (const entry of definitions) document = editShortcutDocument(document, { type: 'set', id: entry.id, binding }, runtime, platform) + expect(effectiveShortcuts(definitions, document, runtime, platform).map(row => row.conflicts)) + .toEqual([[id('test.two')], [id('test.one')]]) + expect(bindingIssue(normalizeBinding({ code: 'KeyN', modifiers: [] }, platform), runtime, platform)).toBe(runtime === 'desktop' ? null : 'modifier-required') + }) + + it('counts expanded distinct modifiers and keeps Linux reservations', () => { + expect(normalizeBinding({ code: 'KeyX', modifiers: ['primary', 'meta', 'shift'] }, 'macos').modifiers).toEqual(['shift', 'meta']) + expect(bindingIssue(normalizeBinding({ code: 'KeyN', modifiers: ['primary', 'control', 'control'] }, 'windows'), 'web', 'windows')).toBe('unsupported-browser') + expect(bindingIssue(normalizeBinding({ code: 'KeyN', modifiers: ['control', 'alt', 'shift'] }, 'linux'), 'web', 'linux')).toBe('unsupported-browser') + }) + + it('selects each owner-declared profile without inheriting or rewriting another profile', () => { + const entry: ShortcutDefinition = { id: id('page.refresh'), defaults: { + 'desktop:macos': primary('KeyR'), 'desktop:windows': primary('KeyR'), + 'web:macos': { code: 'KeyP', modifiers: ['primary', 'shift'] }, + 'web:windows': { code: 'KeyJ', modifiers: ['primary', 'alt'] }, + } } + const document: ShortcutDocument = { schemaVersion: 1, profiles: {} } + const effective = (runtime: 'desktop' | 'web', platform: 'macos' | 'windows' | 'linux', source = document) => + effectiveShortcuts([entry], source, runtime, platform)[0]?.binding + expect(effective('desktop', 'macos')).toEqual({ code: 'KeyR', modifiers: ['meta'] }) + expect(effective('desktop', 'windows')).toEqual({ code: 'KeyR', modifiers: ['control'] }) + expect(effective('web', 'macos')).toEqual({ code: 'KeyP', modifiers: ['shift', 'meta'] }) + expect(effective('web', 'windows')).toEqual({ code: 'KeyJ', modifiers: ['control', 'alt'] }) + expect(effective('desktop', 'linux')).toBeNull() + expect(effective('web', 'linux')).toBeNull() + const override = editShortcutDocument(document, { type: 'set', id: entry.id, binding: null }, 'web', 'macos') + expect(effective('web', 'macos', override)).toBeNull() + const reset = editShortcutDocument(override, { type: 'reset', id: entry.id }, 'web', 'macos') + expect(effective('web', 'macos', reset)).toEqual({ code: 'KeyP', modifiers: ['shift', 'meta'] }) + expect(() => parseShortcutDefinitions([entry])).not.toThrow() + expect(() => parseShortcutDefinitions([entry, { ...entry, id: id('other.refresh') }])).toThrow('Conflicting') + }) + + it('keeps browser admission separate from per-profile default selection', () => { + for (const platform of ['macos', 'windows'] as const) { + const primary = platform === 'macos' ? 'meta' : 'control' + for (const other of ['alt', 'shift'] as const) { + expect(bindingIssue(normalizeBinding({ code: 'KeyX', modifiers: [primary, other] }, platform), 'web', platform)).toBeNull() + } + } + expect(bindingIssue(normalizeBinding(primary('Backquote'), 'macos'), 'web', 'macos')).toBe('unsupported-browser') + expect(bindingIssue(normalizeBinding({ code: 'KeyB', modifiers: ['alt'] }, 'macos'), 'web', 'macos')).toBe('reserved') + expect(bindingIssue(normalizeBinding(primary('KeyN'), 'windows'), 'web', 'windows')).toBe('unsupported-browser') + }) + + it('validates IPC edits and active definitions rather than trusting renderer fields', () => { + expect(parseShortcutDefinitions(definitions)).toEqual(definitions) + expect(parseShortcutEdit({ type: 'set', id: 'test.one', binding: null })).toEqual({ type: 'set', id: 'test.one', binding: null }) + expect(parseShortcutEdit({ type: 'reset', id: 'test.one' }).type).toBe('reset') + expect(parseShortcutEdit({ type: 'reset-all' }).type).toBe('reset-all') + for (const value of [null, {}, { type: 'recover' }, { type: 'reset', id: '../x' }, { type: 'reset-all', path: '/tmp' }, { type: 'other', id: 'test.one' }]) expect(() => parseShortcutEdit(value)).toThrow() + for (const value of [null, [{ ...definitions[0], defaults: { + 'desktop:macos': null, + 'desktop:windows': null, + 'desktop:linux': null, + } }], [...definitions, definitions[0]], + [{ ...definitions[0], defaults: { other: primary('KeyJ') } }], [{ ...definitions[0], defaults: { + 'web:macos': primary('KeyN'), + 'web:windows': primary('KeyN'), + 'web:linux': primary('KeyN'), + } }], + [{ ...definitions[0], defaults: { + 'desktop:macos': primary('KeyC'), + 'desktop:windows': primary('KeyC'), + 'desktop:linux': primary('KeyC'), + } }]]) expect(() => parseShortcutDefinitions(value)).toThrow() + }) +}) diff --git a/packages/client/shortcuts/tests/desktop-chords.client.spec.ts b/packages/client/shortcuts/tests/desktop-chords.client.spec.ts new file mode 100644 index 0000000000..e926f5f0ac --- /dev/null +++ b/packages/client/shortcuts/tests/desktop-chords.client.spec.ts @@ -0,0 +1,119 @@ +/** Physical shortcut chords share normalization, conflicts, and persistence rules. */ +import { expect, it, onTestFinished, vi } from 'vitest' +import { bindingIssue, bindingKey, effectiveShortcuts, normalizeBinding, parseBinding, parseShortcutDocument, + parseShortcutDefinitions, presentBinding, ShortcutPersistence } from '../src/protocol.ts' +import type { ShortcutBinding, ShortcutCommandId, ShortcutDefinition } from '../src/protocol.ts' +import { ShortcutRegistry } from '../src/client/registry.ts' + +const id = 'test.action' as ShortcutCommandId +const other = 'test.other' as ShortcutCommandId +const pair = { code: 'KeyA', secondCode: 'KeyB', modifiers: [] } as const +const definitions = [{ id, defaults: {} }, { id: other, defaults: {} }] + +it.each(['macos', 'windows'] as const)('rejects overlapping %s defaults at registration and IPC ingress without disabling the active command', (platform) => { + const modifier = platform === 'macos' ? 'meta' : 'control' + for (const code of ['KeyA', 'KeyB']) { + const single: ShortcutBinding = { code, modifiers: ['primary'] } + const chord: ShortcutBinding = { ...pair, modifiers: [modifier] } + for (const [first, second] of [[single, chord], [chord, single]] as const) { + const catalog: ShortcutDefinition[] = [ + { id, defaults: { [`desktop:${platform}`]: first } }, + { id: other, defaults: { [`desktop:${platform}`]: second } }, + ] + expect(() => parseShortcutDefinitions(catalog)).toThrow('Conflicting') + const registry = new ShortcutRegistry('desktop', platform) + const run = vi.fn() + const register = (definition: ShortcutDefinition) => registry.register({ ...definition, label: () => definition.id, + aliases: [], regions: ['page'], modals: [], resolve: () => ({ status: 'handled', run }) }) + register(catalog[0]!) + const accepted = registry.catalog.getSnapshot() + expect(() => register(catalog[1]!)).toThrow('Conflicting') + expect(registry.catalog.getSnapshot()).toBe(accepted) + expect(registry.dispatch({ ...first, meta: platform === 'macos', control: platform === 'windows', + alt: false, shift: false, repeat: false, composing: false, defaultPrevented: false }, + { region: 'page', modal: null, target: null }, vi.fn()).status).toBe('handled') + expect(run).toHaveBeenCalledOnce() + expect(parseShortcutDefinitions([catalog[0], { id: other, defaults: { [`desktop:${platform}`]: { + ...second, modifiers: [modifier, 'shift'], + } } }])).toHaveLength(2) + } + } +}) + +it.each([['web', 'macos'], ['web', 'windows'], ['web', 'linux'], ['desktop', 'linux']] as const)( + 'rejects bare keys and chords in %s on %s', (runtime, platform) => { + for (const binding of [pair, { code: 'KeyA', modifiers: [] }, { ...pair, modifiers: ['control', 'alt', 'shift', 'meta'] }] as const) { + expect(bindingIssue(normalizeBinding(binding, platform), runtime, platform)).not.toBeNull() + } + }) + +it('canonicalizes unordered pairs, rejects invalid main keys, and omits unsupported ARIA accelerators', () => { + const reversed = normalizeBinding({ ...pair, code: 'KeyB', secondCode: 'KeyA' }, 'macos') + expect(reversed).toEqual(pair) + expect(bindingKey(reversed)).toBe('KeyA+KeyB') + expect(presentBinding(reversed, 'macos')).toEqual({ keys: ['A', 'B'], aria: undefined }) + expect(presentBinding(reversed, 'windows')).toEqual({ keys: ['A', 'B'], aria: undefined }) + expect(presentBinding({ ...reversed, modifiers: ['control', 'shift'] }, 'windows').keys) + .toEqual(['Ctrl', '+', 'Shift', '+', 'A', 'B']) + for (const input of [ + { ...pair, secondCode: 'KeyA' }, { ...pair, secondCode: 'ShiftLeft' }, { ...pair, thirdCode: 'KeyC' }, + { ...pair, secondCode: ['KeyB', 'KeyC'] }, { code: 'MetaLeft', modifiers: ['control'] }, { modifiers: ['meta'] }, + ]) expect(() => parseBinding(input)).toThrow() +}) + +it.each(['macos', 'windows'] as const)('migrates %s preferences on write and rejects both directions of single/chord conflicts', async (platform) => { + let raw = JSON.stringify({ schemaVersion: 1, profiles: { 'web:macos': { 'dormant.web': null } } }) + const original = raw + const store = new ShortcutPersistence({ read: () => raw, write: (next) => { raw = next } }, 'desktop', platform, false, () => {}) + onTestFinished(() => { store.dispose() }) + store.setDefinitions(definitions) + let state = await store.readCurrent() + expect(raw).toBe(original) + const saved = await store.edit({ type: 'set', id, binding: pair }, state.revision) + expect(saved.status).toBe('saved') + expect(JSON.parse(raw)).toEqual({ schemaVersion: 2, profiles: { 'web:macos': { 'dormant.web': null }, [`desktop:${platform}`]: { [id]: pair } } }) + expect(parseShortcutDocument(raw)).toEqual(saved.snapshot.document) + expect(parseShortcutDocument(raw.replace('"schemaVersion": 2', '"schemaVersion": 1'))).toBe('invalid') + state = saved.snapshot + for (const code of ['KeyA', 'KeyB']) { + const rejected = await store.edit({ type: 'set', id: other, binding: { code, modifiers: [] } }, state.revision) + expect(rejected).toMatchObject({ status: 'conflict', conflicts: [id] }) + } + const cleared = await store.edit({ type: 'reset-all' }, state.revision) + const single = await store.edit({ type: 'set', id, binding: { code: 'KeyB', modifiers: [] } }, cleared.snapshot.revision) + expect((await store.edit({ type: 'set', id: other, binding: pair }, single.snapshot.revision)).status).toBe('conflict') + const overlapping = { schemaVersion: 2 as const, profiles: { [`desktop:${platform}`]: { [id]: pair, [other]: { code: 'KeyA', modifiers: [] } } } } + expect(effectiveShortcuts(definitions, overlapping, 'desktop', platform).map(row => row.conflicts)).toEqual([[other], [id]]) +}) + +it.each(['macos', 'windows'] as const)('rejects direct %s writes that overlap fixed actions and disables previously saved conflicts', async (platform) => { + const fixedId = 'menu.dismiss' as ShortcutCommandId + const catalog = parseShortcutDefinitions([...definitions, + { id: fixedId, defaults: {}, fixed: [{ code: 'Escape', modifiers: [] }] }]) + const storage = { read: () => null, write: vi.fn() } + const store = new ShortcutPersistence(storage, 'desktop', platform, false, () => {}) + onTestFinished(() => { store.dispose() }) + store.setDefinitions(catalog) + const state = await store.readCurrent() + for (const binding of [{ code: 'Escape', modifiers: [] }, { code: 'KeyA', secondCode: 'Escape', modifiers: [] }] as const) { + expect(await store.edit({ type: 'set', id, binding }, state.revision)).toMatchObject({ status: 'conflict', conflicts: [fixedId] }) + expect(effectiveShortcuts(catalog, { schemaVersion: 2, profiles: { [`desktop:${platform}`]: { [id]: binding } } }, + 'desktop', platform).find(row => row.id === id)?.conflicts).toEqual([fixedId]) + } + expect(storage.write).not.toHaveBeenCalled() + expect((await store.edit({ type: 'set', id: fixedId, binding: null }, state.revision)).status).toBe('not-ready') + expect((await store.edit({ type: 'set', id, binding: { code: 'Escape', modifiers: ['control', 'alt'] } }, state.revision)).status).toBe('saved') + store.setDefinitions(definitions) + expect((await store.edit({ type: 'set', id, binding: { code: 'Escape', modifiers: [] } }, (await store.readCurrent()).revision)).status).toBe('saved') +}) + +it('rejects malformed fixed reservations at catalog IPC ingress', () => { + for (const fixed of [[], null, [null], [{ code: 'MetaLeft', modifiers: [] }], [{ code: 'Escape', modifiers: [], extra: true }]]) { + expect(() => parseShortcutDefinitions([{ id, defaults: {}, fixed }])).toThrow() + } + expect(() => parseShortcutDefinitions([{ id, defaults: { + 'desktop:macos': pair, + 'desktop:windows': pair, + 'desktop:linux': pair, + }, fixed: [{ code: 'Escape', modifiers: [] }] }])).toThrow() +}) diff --git a/packages/client/shortcuts/tests/dom.client.spec.ts b/packages/client/shortcuts/tests/dom.client.spec.ts new file mode 100644 index 0000000000..e8c93c6e61 --- /dev/null +++ b/packages/client/shortcuts/tests/dom.client.spec.ts @@ -0,0 +1,201 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from 'vitest' +import { fireEvent } from '@testing-library/dom' +import { installKeyboard, detectEnvironment } from '../src/client/dom.ts' +import { ShortcutRegistry } from '../src/client/registry.ts' +import type { ShortcutFixedInput } from '../src/client/types.ts' +import type { ShortcutCommandId } from '../src/protocol.ts' + +let dispose: (() => void) | undefined +afterEach(() => { dispose?.(); document.body.replaceChildren(); vi.restoreAllMocks() }) + +function mount(runtime: 'desktop' | 'web' = 'web', platform: 'macos' | 'windows' = 'windows') { + const registry = new ShortcutRegistry(runtime, platform) + const run = vi.fn() + registry.register({ id: 'shortcuts.open' as ShortcutCommandId, label: () => 'Shortcuts', aliases: [], + defaults: { + 'desktop:macos': { code: 'Slash', modifiers: ['primary'] }, + 'desktop:windows': { code: 'Slash', modifiers: ['primary'] }, + 'desktop:linux': { code: 'Slash', modifiers: ['primary'] }, + 'web:macos': { code: 'Slash', modifiers: ['primary'] }, + 'web:windows': { code: 'Slash', modifiers: ['primary'] }, + 'web:linux': { code: 'Slash', modifiers: ['primary'] }, + }, + regions: ['page', 'editable'], modals: ['shortcuts'], resolve: () => ({ status: 'handled', run }) }) + dispose = installKeyboard(window, registry) + const input = document.createElement('input'); document.body.append(input); input.focus() + return { registry, input, run } +} +const press = (target: EventTarget, extra: KeyboardEventInit = {}) => { + const event = new KeyboardEvent('keydown', { key: '/', code: 'Slash', ctrlKey: true, bubbles: true, cancelable: true, ...extra }) + target.dispatchEvent(event) + return event +} + +it.each(['macos', 'windows'] as const)('leaves configurable %s shortcuts to native input while retaining DOM fixed-action observation', (platform) => { + const { registry, input, run } = mount('desktop', platform) + dispose?.() + const fixed = vi.fn<(input: ShortcutFixedInput) => void>() + dispose = installKeyboard(window, registry, fixed, true) + expect(press(input, { ctrlKey: platform === 'windows', metaKey: platform === 'macos' }).defaultPrevented).toBe(false) + registry.configure({ ...registry.config.getSnapshot(), revision: 'pair' as never, + document: { schemaVersion: 2, profiles: { [`desktop:${platform}`]: { + 'shortcuts.open': { code: 'KeyF', secondCode: 'KeyG', modifiers: [] }, + } } } }) + press(input, { code: 'KeyF', key: 'f', ctrlKey: false }) + press(input, { code: 'KeyG', key: 'g', ctrlKey: false }) + expect(run).not.toHaveBeenCalled() + expect(fixed.mock.lastCall?.[0]).toMatchObject({ type: 'keydown', gesture: { code: 'KeyG' } }) +}) + +const optionCommandN = { key: 'Dead', code: 'KeyN', ctrlKey: false, altKey: true, metaKey: true } as const +const commandSlash = { ctrlKey: false, metaKey: true } as const +function mountMacNewSession() { + const fixture = mount('web', 'macos') + const newSession = vi.fn() + const unregister = fixture.registry.register({ id: 'session.new' as ShortcutCommandId, label: () => 'New session', aliases: [], + defaults: { + 'desktop:macos': { code: 'KeyN', modifiers: ['primary'] }, + 'desktop:windows': { code: 'KeyN', modifiers: ['primary'] }, + 'desktop:linux': { code: 'KeyN', modifiers: ['primary'] }, + 'web:macos': { code: 'KeyN', modifiers: ['primary', 'alt'] }, + }, regions: ['page', 'editable'], modals: [], + resolve: () => ({ status: 'handled', run: newSession }) }) + return { ...fixture, newSession, unregister } +} + +it('consumes bound macOS Web Option+Command+N without leaving dead-key state on repeats or subsequent commands', () => { + const { input, newSession, run } = mountMacNewSession() + expect(press(input, optionCommandN).defaultPrevented).toBe(true) + expect(press(input, { ...optionCommandN, repeat: true }).defaultPrevented).toBe(true) + expect(newSession).toHaveBeenCalledOnce() + expect(press(input, commandSlash).defaultPrevented).toBe(true) + expect(run).toHaveBeenCalledOnce() + expect(press(input, optionCommandN).defaultPrevented).toBe(true) + expect(newSession).toHaveBeenCalledTimes(2) +}) + +it('preserves an unbound Dead key and the following input', () => { + const { input, unregister, newSession, run } = mountMacNewSession() + unregister() + expect(press(input, optionCommandN).defaultPrevented).toBe(false) + expect(press(input, commandSlash).defaultPrevented).toBe(false) + expect(press(input, commandSlash).defaultPrevented).toBe(true) + expect(newSession).not.toHaveBeenCalled() + expect(run).toHaveBeenCalledOnce() +}) + +it('keeps real composition, composition-ending input, legacy IME, AltGraph and accent input guarded for Option+Command+N', () => { + const { input, newSession } = mountMacNewSession() + fireEvent.compositionStart(input) + expect(press(input, optionCommandN).defaultPrevented).toBe(false) + fireEvent.compositionEnd(input) + expect(press(input, optionCommandN).defaultPrevented).toBe(false) + for (const extra of [ + { isComposing: true }, + { keyCode: 229 }, + { metaKey: false }, { code: 'KeyE' }, { ctrlKey: true }, { shiftKey: true }, + ]) { + fireEvent.blur(window) + expect(press(input, { ...optionCommandN, ...extra }).defaultPrevented).toBe(false) + } + fireEvent.blur(window) + const altGraph = new KeyboardEvent('keydown', { ...optionCommandN, bubbles: true, cancelable: true }) + vi.spyOn(altGraph, 'getModifierState').mockReturnValue(true) + input.dispatchEvent(altGraph) + expect(altGraph.defaultPrevented).toBe(false) + fireEvent.blur(window) + expect(press(input, { ...optionCommandN, metaKey: false }).defaultPrevented).toBe(false) + expect(press(input, optionCommandN).defaultPrevented).toBe(false) + expect(newSession).not.toHaveBeenCalled() +}) + +it.each([['desktop', 'macos'], ['web', 'windows']] as const)('keeps Dead key protection in %s on %s', (runtime, platform) => { + const { input, run } = mount(runtime, platform) + const keys = { metaKey: platform === 'macos', ctrlKey: platform === 'windows' } + expect(press(input, { ...keys, key: 'Dead' }).defaultPrevented).toBe(false) + expect(press(input, keys).defaultPrevented).toBe(false) + expect(run).not.toHaveBeenCalled() + expect(press(input, keys).defaultPrevented).toBe(true) + expect(run).toHaveBeenCalledOnce() +}) + +it('detects the client OS and separates Web from the preload marker', () => { + expect(detectEnvironment(document, { platform: 'MacIntel' } as Navigator)).toEqual({ runtime: 'web', platform: 'macos' }) + expect(detectEnvironment(document, { platform: 'Win32' } as Navigator)).toEqual({ runtime: 'web', platform: 'windows' }) + const html = document.documentElement + const previous = html.dataset.platform + try { + html.dataset.platform = 'linux' + expect(detectEnvironment(document, navigator)).toEqual({ runtime: 'desktop', platform: 'linux' }) + } finally { + if (previous === undefined) delete html.dataset.platform + else html.dataset.platform = previous + } +}) + +it('passes browser navigation/editing and local consumption; disposes the listener', () => { + const { input, run } = mount() + for (const key of ['N', 'P', 'O', 'R', 'W', 'C', 'V', 'Z']) expect(press(input, { code: `Key${key}` }).defaultPrevented).toBe(false) + const local = (event: Event) => { event.preventDefault() } + input.addEventListener('keydown', local) + expect(press(input).defaultPrevented).toBe(true) + expect(run).not.toHaveBeenCalled() + input.removeEventListener('keydown', local) + expect(press(input).defaultPrevented).toBe(true) + expect(press(input, { repeat: true }).defaultPrevented).toBe(true) + expect(run).toHaveBeenCalledOnce() + dispose?.() + expect(press(input).defaultPrevented).toBe(false) +}) + +it('preserves composition, late closing keys, 229, AltGraph, and dead-key input', () => { + const { input, run } = mount() + fireEvent.compositionStart(input) + expect(press(input).defaultPrevented).toBe(false) + fireEvent.compositionEnd(input) + expect(press(input).defaultPrevented).toBe(false) + expect(press(input, { isComposing: true }).defaultPrevented).toBe(false) + // oxlint-disable-next-line typescript/no-deprecated -- Exercise legacy IME input. + expect(press(input, { keyCode: 229 }).defaultPrevented).toBe(false) + expect(press(input, { key: 'Dead' }).defaultPrevented).toBe(false) + const event = new KeyboardEvent('keydown', { code: 'Slash', ctrlKey: true, bubbles: true, cancelable: true }) + vi.spyOn(event, 'getModifierState').mockReturnValue(true) + input.dispatchEvent(event) + expect(event.defaultPrevented).toBe(false) + expect(run).not.toHaveBeenCalled() + fireEvent.keyUp(input) + expect(press(input).defaultPrevented).toBe(true) +}) + +it('blocks background commands during dialogs and gives terminal input ownership', () => { + const { input, run } = mount() + const dialog = document.createElement('div'); dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true') + document.body.append(dialog) + expect(press(input).defaultPrevented).toBe(true) + expect(run).not.toHaveBeenCalled() + dialog.dataset.shortcutModal = 'shortcuts' + expect(press(input).defaultPrevented).toBe(true) + expect(run).toHaveBeenCalledOnce() + const terminal = document.createElement('div'); terminal.className = 'xterm'; terminal.append(input); document.body.append(terminal) + expect(press(input).defaultPrevented).toBe(false) + fireEvent.blur(window) +}) + +it('routes body focus and window-delivered input through the same application command', () => { + const { input, run } = mount() + input.remove() + expect(press(document.body).defaultPrevented).toBe(true) + expect(press(window).defaultPrevented).toBe(true) + expect(run).toHaveBeenCalledTimes(2) +}) + +it('passes the original input element to a feature target resolver', () => { + const { registry, input } = mount() + const dispatch = vi.spyOn(registry, 'dispatch') + press(input) + expect(dispatch).toHaveBeenLastCalledWith(expect.anything(), { region: 'editable', modal: null, target: input }, expect.any(Function)) + input.remove() + press(window) + expect(dispatch).toHaveBeenLastCalledWith(expect.anything(), { region: 'page', modal: null, target: document.body }, expect.any(Function)) +}) diff --git a/packages/client/shortcuts/tests/fixed-input.client.spec.ts b/packages/client/shortcuts/tests/fixed-input.client.spec.ts new file mode 100644 index 0000000000..07b2b93692 --- /dev/null +++ b/packages/client/shortcuts/tests/fixed-input.client.spec.ts @@ -0,0 +1,150 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from 'vitest' +import { fireEvent } from '@testing-library/dom' +import { Context } from '@deepseek-ai/cordis' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { installKeyboard } from '../src/client/dom.ts' +import ShortcutsService from '../src/client/index.ts' +import { ShortcutRegistry } from '../src/client/registry.ts' +import type { ShortcutFixedCommand, ShortcutFixedInput } from '../src/client/types.ts' +import type { ShortcutCommandId } from '../src/protocol.ts' + +let dispose: (() => void) | undefined +afterEach(() => { dispose?.(); document.body.replaceChildren(); vi.restoreAllMocks(); vi.useRealTimers(); localStorage.clear() }) +const press = (target: EventTarget, extra: KeyboardEventInit = {}) => { + const event = new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true, cancelable: true, ...extra }) + target.dispatchEvent(event) + return event +} +function mount() { + const dispatch = vi.fn(() => ({ status: 'pass' as const })) + const fixed = vi.fn<(input: ShortcutFixedInput) => void>() + const input = document.createElement('input') + document.body.append(input) + input.focus() + dispose = installKeyboard(window, { dispatch, runtime: 'web', platform: 'windows' }, fixed) + return { input, dispatch, fixed } +} + +it('runs fixed handlers after local ownership and preserves their consumption for application dispatch', () => { + const { input, dispatch, fixed } = mount() + input.addEventListener('keydown', (event) => { event.preventDefault() }, { once: true }) + press(input) + expect(fixed.mock.lastCall?.[0]).toMatchObject({ type: 'keydown', gesture: { defaultPrevented: true } }) + fixed.mockImplementation((event) => { if (event.type === 'keydown') event.consume() }) + expect(press(input).defaultPrevented).toBe(true) + expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ defaultPrevented: true }), expect.anything(), expect.any(Function)) + fireEvent.compositionStart(input) + press(input) + expect(fixed.mock.lastCall?.[0]).toMatchObject({ type: 'keydown', gesture: { composing: true } }) + fireEvent.compositionEnd(input) + press(input) + expect(fixed.mock.lastCall?.[0]).toMatchObject({ type: 'keydown', gesture: { composing: true } }) +}) + +it('resets sequences when local controls stop propagation, including two inputs in the same task', async () => { + vi.useFakeTimers() + const { input, fixed } = mount() + input.addEventListener('keydown', (event) => { event.stopPropagation() }, { once: true }) + press(input) + expect(fixed).not.toHaveBeenCalled() + press(input) + expect(fixed.mock.calls.map(([event]) => event.type)).toEqual(['reset', 'keydown']) + input.addEventListener('keydown', (event) => { event.stopPropagation() }, { once: true }) + press(input) + await vi.runOnlyPendingTimersAsync() + expect(fixed).toHaveBeenLastCalledWith({ type: 'reset' }) +}) + +it('resets on focus, pointer, blur, and modal transitions even when focus stays unchanged', async () => { + const { input, fixed } = mount() + fireEvent.focusIn(input); fireEvent.pointerDown(input); fireEvent.blur(window) + expect(fixed.mock.calls.map(([event]) => event.type)).toEqual(['reset', 'reset', 'reset']) + const dialog = document.createElement('div') + dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true') + const wrapper = document.createElement('div'); wrapper.append(dialog); document.body.append(wrapper) + await Promise.resolve() + expect(fixed).toHaveBeenCalledTimes(4) + dialog.setAttribute('aria-modal', 'false') + await Promise.resolve() + expect(fixed).toHaveBeenCalledTimes(5) + dialog.setAttribute('aria-modal', 'true') + press(input) + expect(fixed.mock.calls.slice(-2).map(([event]) => event.type)).toEqual(['reset', 'keydown']) + wrapper.remove() + await Promise.resolve() + expect(fixed).toHaveBeenLastCalledWith({ type: 'reset' }) + fixed.mockClear() + document.body.append(document.createTextNode('stream')) + await Promise.resolve() + expect(fixed).not.toHaveBeenCalled() +}) + +it('releases listeners, modal observation, and pending local-input resets on disposal', async () => { + vi.useFakeTimers() + const { input, fixed } = mount() + input.addEventListener('keydown', (event) => { event.stopPropagation() }, { once: true }) + press(input) + dispose?.() + fireEvent.focusIn(input); fireEvent.pointerDown(input); fireEvent.blur(window); press(input) + const dialog = document.createElement('div'); dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true') + document.body.append(dialog) + await vi.runOnlyPendingTimersAsync() + expect(fixed).not.toHaveBeenCalled() +}) + +it('publishes fixed reservations and removes them with their owning registration', () => { + const registry = new ShortcutRegistry('web', 'windows') + let label = 'Stop reply' + const command: ShortcutFixedCommand = { id: 'response.stop' as ShortcutCommandId, label: () => label, keys: ['Esc', 'Esc'], bindings: [{ code: 'Escape', modifiers: [] }], group: 'input' } + const off = registry.registerFixed(command) + expect(registry.fixedCatalog.getSnapshot()).toEqual([{ id: 'response.stop', label, keys: ['Esc', 'Esc'], bindings: [{ code: 'Escape', modifiers: [] }], group: 'input' }]) + expect(registry.definitions()).toEqual([{ id: 'response.stop', defaults: {}, fixed: [{ code: 'Escape', modifiers: [] }] }]) + expect(() => registry.registerFixed(command)).toThrow('Duplicate') + expect(() => registry.register({ ...command, aliases: [], defaults: {}, regions: [], modals: [], resolve: () => ({ status: 'pass' }) })).toThrow('Duplicate') + label = '停止回复'; registry.refreshLabels() + expect(registry.fixedCatalog.getSnapshot()[0]?.label).toBe(label) + off(); off() + expect(registry.fixedCatalog.getSnapshot()).toEqual([]) + expect(registry.definitions()).toEqual([]) +}) + +it('contains observer failures and makes prior consumption visible to later observers', async () => { + const ctx = new Context() + ctx.provide('locale', new LocaleRuntime(ctx)) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const service = new ShortcutsService(ctx) + const offFixed = service.registerFixed({ id: 'response.stop' as ShortcutCommandId, label: () => 'Stop', keys: ['Esc', 'Esc'], bindings: [{ code: 'Escape', modifiers: [] }], group: 'input' }) + expect(service.stopSequenceMs).toBe(500) + expect(service.fixedCatalog.getSnapshot()).toHaveLength(1) + service.observeFixedInput(() => { throw new Error('owner failure') }) + const off = service.observeFixedInput((event) => { if (event.type === 'keydown') event.consume() }) + const last = vi.fn<(input: ShortcutFixedInput) => void>() + service.observeFixedInput(last) + expect(press(document.body).defaultPrevented).toBe(true) + expect(last.mock.lastCall?.[0]).toMatchObject({ gesture: { defaultPrevented: true } }) + expect(error).toHaveBeenCalled() + off(); offFixed() + expect(press(document.body).defaultPrevented).toBe(false) + await ctx.fiber.dispose() + last.mockClear(); press(document.body) + expect(last).not.toHaveBeenCalled() + } finally { await ctx.fiber.dispose() } +}) + +it('delivers resets and skips an observer removed earlier in the same dispatch', async () => { + const ctx = new Context() + ctx.provide('locale', new LocaleRuntime(ctx)) + try { + const service = new ShortcutsService(ctx) + let off = (): void => {} + const first = vi.fn(() => { off() }) + service.observeFixedInput(first) + const removed = vi.fn() + off = service.observeFixedInput(removed) + fireEvent.focusIn(document.body) + expect(first).toHaveBeenLastCalledWith({ type: 'reset' }) + expect(removed).not.toHaveBeenCalled() + } finally { await ctx.fiber.dispose() } +}) diff --git a/packages/client/shortcuts/tests/native.client.spec.ts b/packages/client/shortcuts/tests/native.client.spec.ts new file mode 100644 index 0000000000..03bd2d378a --- /dev/null +++ b/packages/client/shortcuts/tests/native.client.spec.ts @@ -0,0 +1,167 @@ +// @vitest-environment jsdom +/** Trusted native inputs preserve current configuration, frame identity, and modal arbitration. */ +import { afterEach, expect, it, vi } from 'vitest' +import { installNativeKeyboard } from '../src/client/native.ts' +import { ShortcutRegistry } from '../src/client/registry.ts' +import type { DesktopShortcutInput, ShortcutCommandId } from '../src/protocol.ts' + +afterEach(() => { document.body.replaceChildren() }) + +it.each(['macos', 'windows'] as const)('prioritizes %s custom native bindings across editable and terminal regions and modals with revision checks', (platform) => { + const registry = new ShortcutRegistry('desktop', platform) + const run = vi.fn() + registry.register({ id: 'session.new' as ShortcutCommandId, label: () => 'New', aliases: [], + defaults: { + 'desktop:macos': { code: 'KeyN', modifiers: ['primary'] }, + 'desktop:windows': { code: 'KeyN', modifiers: ['primary'] }, + 'desktop:linux': { code: 'KeyN', modifiers: ['primary'] }, + }, regions: ['page', 'editable'], modals: [], + resolve: () => ({ status: 'handled', run }) }) + registry.configure({ ...registry.config.getSnapshot(), revision: 'custom' as never, + document: { schemaVersion: 1, profiles: { [`desktop:${platform}`]: { 'session.new': { code: 'KeyC', modifiers: ['primary'] } } } } }) + let deliver: (input: DesktopShortcutInput) => void = () => {} + const reset = vi.fn() + const off = installNativeKeyboard(window, { subscribe: (listener) => { deliver = listener; return () => {} }, closeWindow: vi.fn() }, + registry, () => registry.config.getSnapshot(), reset) + const input: DesktopShortcutInput = { kind: 'keyboard', frameName: '', revision: registry.config.getSnapshot().revision, + code: 'KeyC', control: platform === 'windows', alt: false, shift: false, meta: platform === 'macos', repeat: false } + const target = document.createElement('textarea') + const terminal = document.createElement('div') + terminal.className = 'xterm'; terminal.append(target); document.body.append(terminal) + target.focus() + deliver(input) + expect(run).toHaveBeenCalledTimes(1) + expect(reset).toHaveBeenCalledTimes(1) + deliver({ ...input, repeat: true }) + deliver({ ...input, revision: 'old' as typeof input.revision }) + expect(run).toHaveBeenCalledTimes(1) + expect(reset).toHaveBeenCalledTimes(2) + terminal.className = '' + deliver(input) + expect(run).toHaveBeenCalledTimes(2) + terminal.setAttribute('role', 'dialog'); terminal.setAttribute('aria-modal', 'true') + deliver(input) + expect(run).toHaveBeenCalledTimes(3) + terminal.remove() + deliver(input) + expect(run).toHaveBeenCalledTimes(4) + off() +}) + +it('rejects old configuration and iframe identities, consumes repeats, and invokes unbound menu actions', () => { + const registry = new ShortcutRegistry('desktop', 'macos') + const run = vi.fn() + const offCommand = registry.register({ id: 'page.close' as ShortcutCommandId, label: () => 'Close', aliases: [], + defaults: { + 'desktop:macos': { code: 'KeyW', modifiers: ['primary'] }, + 'desktop:windows': { code: 'KeyW', modifiers: ['primary'] }, + 'desktop:linux': { code: 'KeyW', modifiers: ['primary'] }, + }, regions: ['page'], modals: [], + resolve: () => ({ status: 'handled', run }) }) + let deliver: (input: DesktopShortcutInput) => void = () => {} + const release = vi.fn() + const off = installNativeKeyboard(window, { + subscribe: (listener) => { deliver = listener; return release }, closeWindow: vi.fn() }, registry, + () => registry.config.getSnapshot()) + const revision = registry.config.getSnapshot().revision + const input = { kind: 'iframe' as const, revision, frameName: 'current', code: 'KeyW', meta: true, + control: false, alt: false, shift: false, repeat: false } + deliver(input) + const frame = document.createElement('iframe') + document.body.append(frame) + frame.focus() + deliver(input) + expect(run).not.toHaveBeenCalled() + frame.name = 'current' + deliver(input) + expect(run).not.toHaveBeenCalled() + frame.setAttribute('data-html-preview', '') + deliver({ ...input, revision: 'old' as typeof revision }) + deliver({ ...input, repeat: true }) + expect(run).not.toHaveBeenCalled() + deliver(input) + expect(run).toHaveBeenCalledTimes(1) + const modal = document.createElement('div') + modal.setAttribute('role', 'dialog'); modal.setAttribute('aria-modal', 'true') + document.body.append(modal) + deliver(input) + deliver({ kind: 'menu', revision, commandId: 'page.close' as ShortcutCommandId }) + expect(run).toHaveBeenCalledTimes(2) + modal.remove() + registry.configure({ ...registry.config.getSnapshot(), revision: 'unbound' as typeof revision, + document: { schemaVersion: 1, profiles: { 'desktop:macos': { 'page.close': null } } } }) + deliver({ ...input, revision: 'unbound' as typeof revision }) + expect(run).toHaveBeenCalledTimes(2) + deliver({ kind: 'menu', revision: 'unbound' as typeof revision, commandId: 'page.close' as ShortcutCommandId }) + expect(run).toHaveBeenCalledTimes(3) + offCommand() + deliver({ kind: 'menu', revision: 'unbound' as typeof revision, commandId: 'page.close' as ShortcutCommandId }) + expect(run).toHaveBeenCalledTimes(3) + off() + expect(release).toHaveBeenCalledTimes(1) +}) + +it.each(['macos', 'windows'] as const)('runs a custom %s chord across text fields and verified embedded pages, once per press', (platform) => { + const registry = new ShortcutRegistry('desktop', platform) + const run = vi.fn() + registry.register({ id: 'session.new' as ShortcutCommandId, label: () => 'New', aliases: [], defaults: {}, + regions: ['page'], modals: [], resolve: () => ({ status: 'handled', run }) }) + registry.configure({ ...registry.config.getSnapshot(), revision: 'chord' as never, + document: { schemaVersion: 2, profiles: { [`desktop:${platform}`]: { 'session.new': { code: 'KeyA', secondCode: 'KeyB', modifiers: [] } } } } }) + let deliver: (input: DesktopShortcutInput) => void = () => {} + const off = installNativeKeyboard(window, { subscribe: (listener) => { deliver = listener; return () => {} }, closeWindow: vi.fn() }, + registry, () => registry.config.getSnapshot()) + try { + const field = document.createElement('textarea'); document.body.append(field); field.focus() + const input: DesktopShortcutInput = { kind: 'keyboard', frameName: '', revision: registry.config.getSnapshot().revision, + code: 'KeyA', secondCode: 'KeyB', control: false, alt: false, shift: false, meta: false, repeat: false } + deliver(input); deliver({ ...input, repeat: true }) + expect(run).toHaveBeenCalledOnce() + const frame = document.createElement('iframe'); frame.name = 'browser'; frame.setAttribute('data-sidebar-browser-frame', '') + document.body.append(frame); frame.focus() + deliver({ ...input, kind: 'iframe', frameName: 'browser' }) + expect(run).toHaveBeenCalledTimes(2) + deliver({ ...input, kind: 'iframe', frameName: 'old' }) + expect(run).toHaveBeenCalledTimes(2) + } finally { off() } +}) + +it('checks the focused browser guest lease before dispatch and retains its embedding context across shadow focus', () => { + const registry = new ShortcutRegistry('desktop', 'macos') + const run = vi.fn() + const resolve = vi.fn(() => ({ status: 'handled' as const, run })) + registry.register({ id: 'browser.new' as ShortcutCommandId, label: () => 'Browser', aliases: [], + defaults: { 'desktop:macos': { code: 'KeyT', modifiers: ['primary'] } }, regions: ['page'], modals: [], resolve }) + let deliver: (input: DesktopShortcutInput) => void = () => {} + const off = installNativeKeyboard(window, { subscribe: (listener) => { deliver = listener; return () => {} }, closeWindow: vi.fn() }, + registry, () => registry.config.getSnapshot()) + const input: DesktopShortcutInput = { kind: 'webview', frameName: 'guest', revision: registry.config.getSnapshot().revision, + code: 'KeyT', control: false, alt: false, shift: false, meta: true, repeat: false } + const frame = document.createElement('webview') + frame.tabIndex = 0 + frame.setAttribute('name', 'guest') + document.body.append(frame) + frame.focus() + const shadow = document.createElement('div').attachShadow({ mode: 'open' }) + Object.defineProperty(shadow, 'activeElement', { value: document.createElement('input') }) + const shadowRoot = vi.spyOn(frame, 'shadowRoot', 'get').mockReturnValue(shadow) + try { + deliver(input) + expect(run).not.toHaveBeenCalled() + frame.setAttribute('data-sidebar-browser-frame', 'webview') + deliver({ ...input, frameName: '' }) + deliver({ ...input, frameName: 'old' }) + deliver({ ...input, revision: 'old' as typeof input.revision }) + expect(run).not.toHaveBeenCalled() + deliver(input) + expect(run).toHaveBeenCalledOnce() + expect(resolve).toHaveBeenCalledWith(expect.objectContaining({ source: 'webview', target: frame })) + deliver({ ...input, repeat: true }) + frame.remove() + deliver(input) + expect(run).toHaveBeenCalledOnce() + } finally { + shadowRoot.mockRestore() + off() + } +}) diff --git a/packages/client/shortcuts/tests/persistence.client.spec.ts b/packages/client/shortcuts/tests/persistence.client.spec.ts new file mode 100644 index 0000000000..41698ba551 --- /dev/null +++ b/packages/client/shortcuts/tests/persistence.client.spec.ts @@ -0,0 +1,144 @@ +import { describe, expect, it, vi } from 'vitest' +import { ShortcutPersistence } from '../src/protocol.ts' +import type { ShortcutCommandId, ShortcutConfigSnapshot, ShortcutDefinition, ShortcutEdit } from '../src/protocol.ts' + +const one = 'test.one' as ShortcutCommandId +it('rejects an unsupported Web binding without writing preferences', async () => { + const storage = { read: () => null, write: vi.fn() } + const store = new ShortcutPersistence(storage, 'web', 'linux', false, () => {}) + store.setDefinitions([{ id: one, defaults: {} }]) + const snapshot = await store.readCurrent() + expect(await store.edit({ type: 'set', id: one, binding: { code: 'KeyB', modifiers: ['control'] } }, snapshot.revision)) + .toMatchObject({ status: 'conflict', issue: 'unsupported-browser', conflicts: [] }) + expect(storage.write).not.toHaveBeenCalled() +}) +const two = 'test.two' as ShortcutCommandId +const binding = { code: 'KeyJ', modifiers: ['primary'] as const } +const definitions: readonly ShortcutDefinition[] = [{ id: one, defaults: { + 'desktop:macos': { ...binding, code: 'KeyB' }, + 'desktop:windows': { ...binding, code: 'KeyB' }, + 'desktop:linux': { ...binding, code: 'KeyB' }, +} }, +{ id: two, defaults: { + 'desktop:macos': { ...binding, code: 'Comma' }, + 'desktop:windows': { ...binding, code: 'Comma' }, + 'desktop:linux': { ...binding, code: 'Comma' }, +} }] +const edit: ShortcutEdit = { type: 'set', id: one, binding } + +function fixture(reread = false) { + let raw: string | null = null + let state!: ShortcutConfigSnapshot + const publish = vi.fn((snapshot: ShortcutConfigSnapshot) => { state = snapshot }) + const storage = { read: vi.fn(async () => raw), + write: vi.fn(async (next: string) => { raw = next }) } + const store = new ShortcutPersistence(storage, 'desktop', 'windows', reread, publish) + store.setDefinitions(definitions) + return { store, storage, publish, state: () => state, raw: () => raw, external: (value: string) => { raw = value } } +} + +describe('serialized preference persistence', () => { + it('publishes a save once and only after durable completion; rejects the competing old revision', async () => { + const f = fixture() + const initial = await f.store.readCurrent() + let release!: () => void + let started!: () => void + const writing = new Promise((resolve) => { started = resolve }) + const barrier = new Promise((resolve) => { release = resolve }) + f.storage.write.mockImplementationOnce(async () => { started(); await barrier }) + const first = f.store.edit(edit, initial.revision) + const second = f.store.edit({ type: 'reset-all' }, initial.revision) + await writing + f.publish.mockClear() + expect(f.state()).toBe(initial) + release() + expect((await first).status).toBe('saved') + expect((await second).status).toBe('stale') + expect(f.publish).toHaveBeenCalledOnce() + expect(f.state().document.profiles['desktop:windows']?.[one]).toEqual(binding) + }) + + it('keeps the previous configuration and revision on write failure, allowing the same draft to retry', async () => { + const f = fixture(); const initial = await f.store.readCurrent() + f.storage.write.mockRejectedValueOnce(new Error('disk full')) + expect((await f.store.edit(edit, initial.revision)).status).toBe('write-failed') + expect(f.state()).toBe(initial) + expect(f.raw()).toBeNull() + expect((await f.store.edit(edit, initial.revision)).status).toBe('saved') + const stored: unknown = JSON.parse(f.raw()!) + expect(stored).toMatchObject({ profiles: { 'desktop:windows': { [one]: binding } } }) + const restarted = fixture(); restarted.external(f.raw()!) + expect((await restarted.store.readCurrent()).document).toEqual(f.state().document) + }) + + it.each(['{broken', '{"schemaVersion":3,"profiles":{}}'])('retains accepted bindings and original bytes after reading %s', async (raw) => { + const f = fixture(); await f.store.readCurrent(); await f.store.edit(edit, f.state().revision) + const accepted = f.state().document + f.external(raw) + const bad = await f.store.readCurrent() + expect(bad.status).toBe('unreadable'); expect(bad.document).toBe(accepted) + for (const operation of [edit, { type: 'reset-all' }, { type: 'reset', id: one }] as ShortcutEdit[]) { + expect((await f.store.edit(operation, bad.revision)).status).toBe('unreadable') + } + expect(f.raw()).toBe(raw) + + }) + + it('uses defaults on the first bad read and prevents writes until the source becomes readable', async () => { + const f = fixture(); f.storage.read.mockRejectedValueOnce(new Error('denied')) + expect((await f.store.readCurrent()).usingDefaults).toBe(true) + f.storage.read.mockRejectedValueOnce(new Error('still denied')) + expect((await f.store.readCurrent()).error).toBe('read') + expect((await f.store.edit({ type: 'reset-all' }, f.state().revision)).status).toBe('unreadable') + expect(f.storage.write).not.toHaveBeenCalled() + f.external('{'); expect((await f.store.readCurrent()).error).toBe('invalid') + f.external('{"schemaVersion":1,"profiles":{}}'); expect((await f.store.readCurrent()).status).toBe('ready') + }) + + it('rereads before a browser write and requires an explicit review of external updates', async () => { + const f = fixture(true); const initial = await f.store.readCurrent() + f.external('{"schemaVersion":1,"profiles":{"web:macos":{"sleeping.command":null}}}') + expect((await f.store.edit(edit, initial.revision)).status).toBe('stale') + expect(f.storage.write).not.toHaveBeenCalled() + expect((await f.store.edit(edit, f.state().revision)).status).toBe('saved') + expect(f.state().document.profiles['web:macos']).toEqual({ 'sleeping.command': null }) + }) + + it('accepts Windows single keys and editing combinations but rejects explicit/default conflicts', async () => { + const f = fixture(); await f.store.readCurrent() + expect((await f.store.edit({ ...edit, binding: { code: 'KeyC', modifiers: ['control'] } }, f.state().revision)).status).toBe('saved') + expect((await f.store.edit({ ...edit, binding: { code: 'KeyC', modifiers: [] } }, f.state().revision)).status).toBe('saved') + expect((await f.store.edit({ ...edit, binding: { ...binding, code: 'Comma' } }, f.state().revision)).status).toBe('conflict') + await f.store.edit(edit, f.state().revision) + expect((await f.store.edit({ ...edit, id: two }, f.state().revision)).conflicts).toEqual([one]) + expect((await f.store.edit({ type: 'set', id: two, binding: { ...binding, code: 'KeyB' } }, f.state().revision)).status).toBe('saved') + expect((await f.store.edit({ type: 'reset', id: one }, f.state().revision)).status).toBe('conflict') + expect((await f.store.edit({ type: 'reset-all' }, f.state().revision)).status).toBe('saved') + }) + + it('rejects early/unknown/stale-owner edits and does not publish a late read after disposal', async () => { + const f = fixture() + expect((await f.store.edit(edit, f.state().revision)).status).toBe('not-ready') + await f.store.readCurrent() + expect((await f.store.edit({ ...edit, id: 'missing.command' as ShortcutCommandId }, f.state().revision)).status).toBe('not-ready') + f.store.setDefinitions(null) + expect((await f.store.edit(edit, f.state().revision)).status).toBe('not-ready') + f.store.dispose(); f.publish.mockClear(); f.external('{') + await f.store.readCurrent() + expect(f.publish).not.toHaveBeenCalled() + }) +}) + + +it('continues transactions after a rejected unsupported code and contains subscriber failures', async () => { + const f = fixture(); await f.store.readCurrent() + await expect(f.store.edit({ ...edit, binding: { ...binding, code: 'Unidentified' } }, f.state().revision)).rejects.toThrow('Unsupported') + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + f.publish.mockImplementationOnce(() => { throw new Error('subscriber failed') }) + const result = await f.store.edit(edit, f.state().revision) + expect(result.status).toBe('saved') + expect(error).toHaveBeenCalledWith('Shortcut configuration subscriber failed:', expect.any(Error)) + expect((await f.store.readCurrent()).document).toEqual(result.snapshot.document) + } finally { error.mockRestore() } +}) diff --git a/packages/client/shortcuts/tests/registry.client.spec.ts b/packages/client/shortcuts/tests/registry.client.spec.ts new file mode 100644 index 0000000000..b87f40c361 --- /dev/null +++ b/packages/client/shortcuts/tests/registry.client.spec.ts @@ -0,0 +1,205 @@ +import { describe, expect, it, vi } from 'vitest' +import { bindingKey, initialShortcutConfig, normalizeBinding, presentBinding } from '../src/protocol.ts' +import type { ShortcutCommandId } from '../src/protocol.ts' +import { ShortcutRegistry } from '../src/client/registry.ts' +import { isWebBindingAllowed } from '../src/binding.ts' +import type { ShortcutCommand, ShortcutGesture } from '../src/client/types.ts' + +const context = { region: 'page', modal: null, target: null } as const +it('admits modified Web combinations and refuses two-key chords', () => { + expect(isWebBindingAllowed({ code: 'KeyB', modifiers: ['control', 'alt', 'shift'] }, 'windows')).toBe(true) + expect(isWebBindingAllowed({ code: 'KeyB', secondCode: 'KeyJ', modifiers: [] }, 'macos')).toBe(false) +}) + +it('leaves Linux terminal control-W and control-R with the terminal while dispatching other bindings', () => { + for (const [code, modifiers, status] of [ + ['KeyW', ['control'], 'pass'], ['KeyR', ['control'], 'pass'], ['KeyB', ['control'], 'handled'], + ['KeyB', ['alt'], 'handled'], + ['KeyB', ['control', 'alt'], 'handled'], ['KeyB', ['control', 'shift'], 'handled'], + ] as const) { + const registry = new ShortcutRegistry('desktop', 'linux') + const run = vi.fn(), consume = vi.fn() + registry.register(command({ defaults: { 'desktop:linux': { code, modifiers } }, regions: ['terminal'], + resolve: () => ({ status: 'handled', run }) })) + expect(registry.dispatch({ ...gesture, code, control: modifiers.some(value => value === 'control'), + meta: false, alt: modifiers.some(value => value === 'alt'), + shift: modifiers.some(value => value === 'shift') }, { ...context, region: 'terminal' }, consume).status).toBe(status) + expect(run).toHaveBeenCalledTimes(status === 'handled' ? 1 : 0) + expect(consume).toHaveBeenCalledTimes(status === 'handled' ? 1 : 0) + } +}) + +it('dispatches a Windows Web terminal binding with Control, Meta, and Shift', () => { + const registry = new ShortcutRegistry('web', 'windows') + const run = vi.fn(), consume = vi.fn() + registry.register(command({ defaults: { 'web:windows': { code: 'KeyB', modifiers: ['control', 'meta', 'shift'] } }, + regions: ['terminal'], resolve: () => ({ status: 'handled', run }) })) + expect(registry.dispatch({ ...gesture, control: true, shift: true }, { ...context, region: 'terminal' }, consume).status).toBe('handled') + expect(run).toHaveBeenCalledOnce() + expect(consume).toHaveBeenCalledOnce() +}) + +it.each(['pass', 'blocked'] as const)('does not execute a menu command whose owner returns %s', (status) => { + const registry = new ShortcutRegistry('desktop', 'macos') + const resolve = vi.fn(() => status === 'pass' ? { status } : { status, reason: 'unavailable' }) + registry.register(command({ resolve })) + registry.invoke('test.toggle' as ShortcutCommandId, context) + expect(resolve).toHaveBeenCalledWith({ ...context, source: 'menu' }) +}) +const gesture: ShortcutGesture = { code: 'KeyB', control: false, alt: false, shift: false, meta: true, + repeat: false, composing: false, defaultPrevented: false } +function command(overrides: Partial = {}): ShortcutCommand { + return { id: 'test.toggle' as ShortcutCommandId, label: () => 'Toggle', aliases: ['toggle'], + defaults: { + 'desktop:macos': { code: 'KeyB', modifiers: ['primary'] }, + 'desktop:windows': { code: 'KeyB', modifiers: ['primary'] }, + 'desktop:linux': { code: 'KeyB', modifiers: ['primary'] }, + }, regions: ['page', 'editable'], modals: [], + resolve: () => ({ status: 'handled', run: () => {} }), ...overrides } +} + +describe('physical key registry', () => { + it('normalizes primary/control together on Windows and presents platform-specific keys', () => { + const binding = { code: 'KeyB', modifiers: ['primary', 'control', 'shift'] as const } + expect(bindingKey(normalizeBinding(binding, 'windows'))).toBe('control+shift+KeyB') + expect(presentBinding(normalizeBinding(binding, 'macos'), 'macos')).toEqual({ keys: ['⌃', '⇧', '⌘', 'B'], aria: 'Control+Shift+Meta+B' }) + expect(presentBinding(normalizeBinding({ code: 'Slash', modifiers: ['primary'] }, 'windows'), 'windows')) + .toEqual({ keys: ['Ctrl', '+', '/'], aria: 'Control+/' }) + expect(presentBinding(null, 'linux')).toEqual({ keys: [], aria: undefined }) + expect(() => normalizeBinding({ code: 'Unidentified', modifiers: [] }, 'macos')).toThrow('Unsupported') + }) + + it('rejects duplicate ids, cross-platform default conflicts, and unverified Web combinations atomically', () => { + const registry = new ShortcutRegistry('desktop', 'macos') + const off = registry.register(command()) + expect(() => registry.register(command())).toThrow('Duplicate') + expect(() => registry.register(command({ id: 'other.toggle' as ShortcutCommandId, + defaults: { + 'desktop:macos': { code: 'KeyB', modifiers: ['control'] }, + 'desktop:windows': { code: 'KeyB', modifiers: ['control'] }, + 'desktop:linux': { code: 'KeyB', modifiers: ['control'] }, + } }))).toThrow('windows') + expect(() => registry.register(command({ id: 'web.new' as ShortcutCommandId, + defaults: { + 'web:macos': { code: 'KeyN', modifiers: ['primary'] }, + 'web:windows': { code: 'KeyN', modifiers: ['primary'] }, + 'web:linux': { code: 'KeyN', modifiers: ['primary'] }, + } }))).toThrow('Unsupported Web') + expect(() => registry.register(command({ id: 'system.copy' as ShortcutCommandId, + defaults: { + 'desktop:macos': { code: 'KeyC', modifiers: ['primary'] }, + 'desktop:windows': { code: 'KeyC', modifiers: ['primary'] }, + 'desktop:linux': { code: 'KeyC', modifiers: ['primary'] }, + } }))).toThrow('Reserved') + expect(registry.catalog.getSnapshot()).toHaveLength(1) + const changed = vi.fn() + registry.catalog.subscribe(changed) + off(); off() + expect(changed).toHaveBeenCalledOnce() + expect(registry.catalog.getSnapshot()).toEqual([]) + expect(registry.dispatch(gesture, context, vi.fn())).toEqual({ status: 'pass' }) + }) + + it('consumes before executing, ignores repeats, and matches every modifier exactly', () => { + const registry = new ShortcutRegistry('desktop', 'macos') + const consumed = vi.fn() + const run = vi.fn(() => { expect(consumed).toHaveBeenCalled() }) + registry.register(command({ resolve: () => ({ status: 'handled', run }) })) + expect(registry.dispatch(gesture, context, consumed).status).toBe('handled') + expect(registry.dispatch({ ...gesture, repeat: true }, context, consumed).status).toBe('handled') + expect(run).toHaveBeenCalledOnce() + expect(consumed).toHaveBeenCalledTimes(2) + const passed = [{ shift: true }, { alt: true }, { control: true }, { meta: false }, { composing: true }, { defaultPrevented: true }] + for (const extra of passed) { + expect(registry.dispatch({ ...gesture, ...extra }, context, consumed).status).toBe('pass') + } + expect(consumed).toHaveBeenCalledTimes(2) + }) + + it('prioritizes desktop bindings in terminals and modals while retaining owner refusal reasons', () => { + const registry = new ShortcutRegistry('desktop', 'macos') + const consumed = vi.fn() + const resolve = vi.fn(() => ({ status: 'blocked', reason: 'No target' })) + registry.register(command({ resolve })) + expect(registry.dispatch(gesture, { ...context, modal: 'settings' }, consumed)).toMatchObject({ status: 'blocked', reason: 'No target' }) + expect(resolve).toHaveBeenCalledOnce() + expect(registry.dispatch(gesture, { region: 'terminal', modal: null, target: null }, consumed).status).toBe('blocked') + expect(registry.dispatch(gesture, context, consumed)).toMatchObject({ status: 'blocked', reason: 'No target' }) + resolve.mockReturnValue({ status: 'pass' }) + expect(registry.dispatch(gesture, context, consumed).status).toBe('pass') + expect(consumed).toHaveBeenCalledTimes(3) + }) + + it('publishes stable effective rows with Windows Web three-key defaults', () => { + const registry = new ShortcutRegistry('web', 'windows') + let label = 'Settings' + registry.register(command({ label: () => label, defaults: { 'web:windows': { code: 'KeyB', modifiers: ['primary', 'alt'] } } })) + const first = registry.catalog.getSnapshot() + expect(registry.catalog.getSnapshot()).toBe(first) + expect(first[0]?.keys).toEqual(['Ctrl', '+', 'Alt', '+', 'B']) + label = '设置' + registry.refreshLabels() + expect(registry.catalog.getSnapshot()[0]?.label).toBe('设置') + expect(registry.dispatch({ ...gesture, control: true, meta: false }, context, vi.fn()).status).toBe('pass') + }) +}) + +it.each(['Space', 'Escape', 'ArrowUp'])('uses standard ARIA names for %s', (code) => { + expect(presentBinding(normalizeBinding({ code, modifiers: [] }, 'windows'), 'windows').aria).toBe(code) +}) + + +it('keeps commands disabled during startup and publishes config/catalog together before dispatch changes', () => { + const config = initialShortcutConfig() + const registry = new ShortcutRegistry('desktop', 'macos', config) + const run = vi.fn() + registry.register(command({ resolve: () => ({ status: 'handled', run }) })) + expect(registry.dispatch(gesture, context, vi.fn()).status).toBe('pass') + const snapshot = { ...config, status: 'ready' as const, document: { schemaVersion: 1 as const, + profiles: { 'desktop:macos': { 'test.toggle': { code: 'KeyJ', modifiers: ['primary'] as const } } } } } + registry.catalog.subscribe(() => { + expect(registry.config.getSnapshot()).toBe(snapshot) + expect(registry.catalog.getSnapshot()[0]?.keys).toEqual(['⌘', 'J']) + }) + registry.configure(snapshot) + expect(registry.dispatch(gesture, context, vi.fn()).status).toBe('pass') + expect(registry.dispatch({ ...gesture, code: 'KeyJ' }, context, vi.fn()).status).toBe('handled') + expect(run).toHaveBeenCalledOnce() +}) + +it('blocks conflicting dormant overrides when their plugins return, then releases every row on unload', () => { + const registry = new ShortcutRegistry('desktop', 'macos') + registry.configure({ ...initialShortcutConfig(), status: 'ready', document: { schemaVersion: 1, + profiles: { 'desktop:macos': { 'test.toggle': { code: 'KeyJ', modifiers: ['primary'] }, 'other.toggle': { code: 'KeyJ', modifiers: ['primary'] } } } } }) + const run = vi.fn() + const off = registry.register(command({ resolve: () => ({ status: 'handled', run }) })) + expect(registry.dispatch({ ...gesture, code: 'KeyJ' }, context, vi.fn()).status).toBe('handled') + const other = registry.register(command({ id: 'other.toggle' as ShortcutCommandId, defaults: {}, resolve: () => ({ status: 'handled', run }) })) + const consumed = vi.fn() + expect(registry.dispatch({ ...gesture, code: 'KeyJ' }, context, consumed).status).toBe('blocked') + expect(consumed).toHaveBeenCalledOnce() + expect(run).toHaveBeenCalledOnce() + other(); off() + expect(registry.catalog.getSnapshot()).toEqual([]) + expect(registry.dispatch({ ...gesture, code: 'KeyJ' }, context, vi.fn()).status).toBe('pass') +}) + +it.each(['macos', 'windows'] as const)('keeps %s priority identical before saving, after saving the same default, and after resetting', (platform) => { + const registry = new ShortcutRegistry('desktop', platform) + const run = vi.fn() + registry.register(command({ regions: ['page'], modals: [], resolve: () => ({ status: 'handled', run }) })) + for (const [index, modified] of [false, true, false].entries()) { + registry.configure({ ...registry.config.getSnapshot(), revision: `revision-${index}` as never, + document: { schemaVersion: 2, profiles: modified ? { + [`desktop:${platform}`]: { 'test.toggle': { code: 'KeyB', modifiers: ['primary'] } }, + } : {} } }) + expect(registry.catalog.getSnapshot()[0]?.modified).toBe(modified) + const input = { ...gesture, meta: platform === 'macos', control: platform === 'windows' } + for (const region of ['page', 'editable', 'terminal'] as const) { + for (const modal of [null, 'settings']) { + expect(registry.dispatch(input, { region, modal, target: null }, vi.fn()).status).toBe('handled') + } + } + } + expect(run).toHaveBeenCalledTimes(18) +}) diff --git a/packages/client/shortcuts/tests/service.client.spec.ts b/packages/client/shortcuts/tests/service.client.spec.ts new file mode 100644 index 0000000000..8848d4b42c --- /dev/null +++ b/packages/client/shortcuts/tests/service.client.spec.ts @@ -0,0 +1,182 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import ShortcutsService from '../src/client/index.ts' +import { initialShortcutConfig } from '../src/protocol.ts' +import type { DesktopKeyboardApi, DesktopShortcutsApi, ShortcutConfigSnapshot, ShortcutCommandId } from '../src/protocol.ts' + +const disposers: (() => Promise)[] = [] +afterEach(async () => { + for (const dispose of disposers.splice(0)) await dispose() + delete document.documentElement.dataset.platform + Reflect.deleteProperty(window, 'dshDesktop') + localStorage.clear() + vi.restoreAllMocks() +}) +const snapshot = (sequence: number): ShortcutConfigSnapshot => ({ ...initialShortcutConfig(), sequence, status: 'ready' }) +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: Error) => void + const promise = new Promise((yes, no) => { resolve = yes; reject = no }) + return { promise, resolve, reject } +} +function desktop() { + document.documentElement.dataset.platform = 'darwin' + let publish!: (value: ShortcutConfigSnapshot) => void + const unsubscribe = vi.fn() + const api = { + get: vi.fn().mockResolvedValue(snapshot(1)), + edit: vi.fn().mockResolvedValue({ status: 'saved', snapshot: snapshot(3) }), + recording: vi.fn().mockResolvedValue(), + subscribe: vi.fn((listener) => { publish = listener; return unsubscribe }), + } + const keyboard = { + closeWindow: vi.fn().mockResolvedValue(), + subscribe: vi.fn(() => () => {}), + } + Object.defineProperty(window, 'dshDesktop', { configurable: true, value: { shortcuts: api, keyboard } }) + return { api, keyboard, publish: (value: ShortcutConfigSnapshot) => { publish(value) }, unsubscribe } +} +function mount() { + const ctx = new Context() + ctx.provide('locale', new LocaleRuntime(ctx)) + const dispose = async () => { await ctx.fiber.dispose() } + disposers.push(dispose) + const service = new ShortcutsService(ctx) + return { ctx, service, dispose } +} + +it('describes registered binding conflicts on Linux Desktop', async () => { + desktop() + document.documentElement.dataset.platform = 'linux' + const { service } = mount() + const off = service.register(command) + await vi.waitFor(() => { expect(service.config.getSnapshot().status).toBe('ready') }) + expect(service.describeBinding({ code: 'KeyB', modifiers: ['control'] }).conflicts).toEqual([command.id]) + off() + expect(service.describeBinding({ code: 'KeyB', modifiers: ['control'] }).conflicts).toEqual([]) +}) +const command = { id: 'test.toggle' as ShortcutCommandId, label: () => 'Toggle', aliases: [], + defaults: { + 'desktop:macos': { code: 'KeyB', modifiers: ['primary'] as const }, + 'desktop:windows': { code: 'KeyB', modifiers: ['primary'] as const }, + 'desktop:linux': { code: 'KeyB', modifiers: ['primary'] as const }, + }, regions: ['page'] as const, modals: [], + resolve: () => ({ status: 'handled' as const, run() {} }) } + +it('waits for the Desktop handshake and ignores older get, edit, and broadcast replies', async () => { + const f = desktop(), first = deferred() + f.api.get.mockReturnValueOnce(first.promise) + const { service } = mount() + f.publish(snapshot(20)) + expect(service.config.getSnapshot().status).toBe('loading') + const off = service.register(command) + await vi.waitFor(() => { expect(service.config.getSnapshot().status).toBe('ready') }) + f.publish(snapshot(10)); first.resolve(snapshot(1)); await first.promise + expect(service.config.getSnapshot().sequence).toBe(10) + f.publish(snapshot(9)) + await service.edit({ type: 'reset-all' }, service.config.getSnapshot().revision) + expect(service.config.getSnapshot().sequence).toBe(10) + f.api.edit.mockResolvedValueOnce({ status: 'saved', snapshot: snapshot(12) }) + await service.edit({ type: 'reset-all' }, service.config.getSnapshot().revision) + expect(service.config.getSnapshot().sequence).toBe(12) + await service.recording(true); expect(f.api.recording).toHaveBeenCalledWith(true) + off() + expect(f.api.get).toHaveBeenLastCalledWith([]) +}) + +it.each(['darwin', 'win32', 'linux'])('rejects %s Desktop startup without its native keyboard bridge', (platform) => { + const { api } = desktop() + document.documentElement.dataset.platform = platform + Object.defineProperty(window, 'dshDesktop', { configurable: true, value: { shortcuts: api } }) + const read = vi.spyOn(Storage.prototype, 'getItem'), write = vi.spyOn(Storage.prototype, 'setItem') + expect(() => mount()).toThrow('Desktop keyboard bridge unavailable') + expect(api.get).not.toHaveBeenCalled() + expect(read).not.toHaveBeenCalled(); expect(write).not.toHaveBeenCalled() +}) + +it('reports unavailable Desktop storage without reading or writing localStorage', async () => { + const { keyboard } = desktop() + Object.defineProperty(window, 'dshDesktop', { configurable: true, value: { keyboard } }) + const read = vi.spyOn(Storage.prototype, 'getItem'), write = vi.spyOn(Storage.prototype, 'setItem') + const { service } = mount() + expect(service.config.getSnapshot().status).toBe('unreadable') + expect((await service.edit({ type: 'reset-all' }, service.config.getSnapshot().revision)).status).toBe('unreadable') + await expect(service.recording(true)).rejects.toThrow('bridge unavailable') + expect(read).not.toHaveBeenCalled(); expect(write).not.toHaveBeenCalled() +}) + +it('retains accepted bindings when the Desktop bridge rejects reads and writes', async () => { + const f = desktop(); const { service } = mount() + await vi.waitFor(() => { expect(service.config.getSnapshot().status).toBe('ready') }) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const failure = new Error('IPC disconnected') + f.api.edit.mockRejectedValueOnce(failure) + const accepted = service.config.getSnapshot() + expect((await service.edit({ type: 'reset-all' }, accepted.revision)).status).toBe('write-failed') + expect(service.config.getSnapshot()).toBe(accepted) + expect(error).toHaveBeenCalledExactlyOnceWith('Shortcut preference save failed', failure) + f.api.get.mockRejectedValueOnce(new Error('handshake failed')) + service.register(command) + await vi.waitFor(() => { expect(service.config.getSnapshot().error).toBe('read') }) +}) + +it.each(['resolve', 'reject'] as const)('suppresses %s completions after disposal and releases the bridge subscription', async (outcome) => { + const f = desktop(), pending = deferred() + f.api.get.mockReturnValue(pending.promise) + f.api.edit.mockImplementation(async () => ({ status: 'saved', snapshot: await pending.promise })) + const { service, dispose } = mount() + const off = service.register(command), edit = service.edit({ type: 'reset-all' }, service.config.getSnapshot().revision) + const accepted = service.config.getSnapshot() + await dispose(); off(); f.publish(snapshot(50)) + if (outcome === 'resolve') pending.resolve(snapshot(30)) + else pending.reject(new Error('window closed')) + await edit + expect(service.config.getSnapshot()).toBe(accepted) + expect(f.unsubscribe).toHaveBeenCalledOnce() +}) + +it('persists Web edits and presents normalized bindings through the public service', async () => { + const { service, ctx } = mount() + await vi.waitFor(() => { expect(service.config.getSnapshot().status).toBe('ready') }) + expect(service.describeBinding(null)).toEqual({ binding: null, keys: [], issue: null, conflicts: [] }) + expect(service.describeBinding({ code: 'Slash', modifiers: ['primary'] })) + .toMatchObject({ issue: null, binding: { code: 'Slash', modifiers: ['control'] } }) + const off = service.register({ ...command, defaults: { + 'web:macos': { code: 'Slash', modifiers: ['primary'] }, + 'web:windows': { code: 'Slash', modifiers: ['primary'] }, + 'web:linux': { code: 'Slash', modifiers: ['primary'] }, + } }) + await vi.waitFor(() => { expect(service.config.getSnapshot().status).toBe('ready') }) + const changed = vi.fn(); service.catalog.subscribe(changed) + ctx.locale.setLocale('zh'); expect(changed).toHaveBeenCalled() + expect((await service.edit({ type: 'set', id: command.id, binding: null }, service.config.getSnapshot().revision)).status).toBe('saved') + expect(service.catalog.getSnapshot()[0]?.keys).toEqual([]) + await service.recording(true); off() +}) + +it('publishes fixed reservations to Desktop and reports their conflicts while their owner is mounted', () => { + const f = desktop(), { service } = mount() + const off = service.registerFixed({ id: 'menu.close' as ShortcutCommandId, label: () => 'Close', keys: ['Esc'], + bindings: [{ code: 'Escape', modifiers: [] }], group: 'menus' }) + expect(f.api.get).toHaveBeenLastCalledWith([{ id: 'menu.close', defaults: {}, fixed: [{ code: 'Escape', modifiers: [] }] }]) + expect(service.describeBinding({ code: 'Escape', modifiers: [] }).conflicts).toEqual(['menu.close']) + expect(service.describeBinding({ code: 'KeyA', secondCode: 'Escape', modifiers: [] }).conflicts).toEqual(['menu.close']) + expect(service.describeBinding({ code: 'Escape', modifiers: ['alt'] }).conflicts).toEqual([]) + off() + expect(service.describeBinding({ code: 'Escape', modifiers: [] }).conflicts).toEqual([]) + expect(f.api.get).toHaveBeenLastCalledWith([]) +}) + +it('propagates catalog publication failures after a successful preference save', async () => { + const f = desktop() + const { service } = mount() + const label = vi.fn(() => 'Toggle') + service.register({ ...command, label }) + await vi.waitFor(() => { expect(service.config.getSnapshot().status).toBe('ready') }) + label.mockImplementationOnce(() => { throw new Error('Command label unavailable') }) + await expect(service.edit({ type: 'reset-all' }, service.config.getSnapshot().revision)) + .rejects.toThrow('Command label unavailable') + expect(f.api.edit).toHaveBeenCalledOnce() +}) diff --git a/packages/client/shortcuts/tests/storage.client.spec.ts b/packages/client/shortcuts/tests/storage.client.spec.ts new file mode 100644 index 0000000000..1c4afce2a9 --- /dev/null +++ b/packages/client/shortcuts/tests/storage.client.spec.ts @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from 'vitest' +import { webShortcutStorage, SHORTCUT_STORAGE_KEY } from '../src/client/storage.ts' +import type { ShortcutCommandId, ShortcutConfigSnapshot } from '../src/protocol.ts' + +afterEach(() => { localStorage.clear(); vi.restoreAllMocks() }) +const id = 'shortcuts.open' as ShortcutCommandId +const definitions = [{ id, defaults: { + 'web:macos': { code: 'Slash', modifiers: ['primary'] as const }, + 'web:windows': { code: 'Slash', modifiers: ['primary'] as const }, + 'web:linux': { code: 'Slash', modifiers: ['primary'] as const }, +} }] + +it('syncs same-origin updates, rejects stale drafts, and releases the storage listener', async () => { + let latest!: ShortcutConfigSnapshot + const publish = vi.fn((value: ShortcutConfigSnapshot) => { latest = value }) + const adapter = webShortcutStorage(window, 'macos', publish) + try { + const initial = await adapter.get(definitions) + adapter.subscribe(publish)(); await adapter.recording(true) + publish.mockClear() + window.dispatchEvent(new StorageEvent('storage', { key: 'unrelated' })) + await Promise.resolve() + expect(publish).not.toHaveBeenCalled() + window.dispatchEvent(new StorageEvent('storage', { key: null })) + localStorage.setItem(SHORTCUT_STORAGE_KEY, JSON.stringify({ schemaVersion: 1, profiles: { 'web:macos': { [id]: null } } })) + window.dispatchEvent(new StorageEvent('storage', { key: SHORTCUT_STORAGE_KEY })) + await vi.waitFor(() => { expect(latest.document.profiles['web:macos']?.[id]).toBeNull() }) + expect((await adapter.edit({ type: 'reset', id }, initial.revision)).status).toBe('stale') + expect((await adapter.edit({ type: 'reset', id }, latest.revision)).status).toBe('saved') + const stored: unknown = JSON.parse(localStorage.getItem(SHORTCUT_STORAGE_KEY)!) + expect(stored).toMatchObject({ profiles: { 'web:macos': {} } }) + adapter.dispose(); publish.mockClear() + localStorage.removeItem(SHORTCUT_STORAGE_KEY) + window.dispatchEvent(new StorageEvent('storage', { key: null })) + await Promise.resolve() + expect(publish).not.toHaveBeenCalled() + } finally { adapter.dispose() } +}) + +it('retains drafts after quota failure and refuses to replace future data', async () => { + let latest!: ShortcutConfigSnapshot + const adapter = webShortcutStorage(window, 'windows', (value) => { latest = value }) + try { + await adapter.get(definitions) + const write = vi.spyOn(Storage.prototype, 'setItem').mockImplementationOnce(() => { throw new Error('quota') }) + expect((await adapter.edit({ type: 'set', id, binding: null }, latest.revision)).status).toBe('write-failed') + expect(localStorage.getItem(SHORTCUT_STORAGE_KEY)).toBeNull() + write.mockRestore() + const raw = '{"schemaVersion":9}' + localStorage.setItem(SHORTCUT_STORAGE_KEY, raw) + await adapter.get(definitions) + expect((await adapter.edit({ type: 'reset-all' }, latest.revision)).status).toBe('unreadable') + expect(localStorage.getItem(SHORTCUT_STORAGE_KEY)).toBe(raw) + + } finally { adapter.dispose() } +}) diff --git a/packages/client/shortcuts/tsconfig.client.json b/packages/client/shortcuts/tsconfig.client.json new file mode 100644 index 0000000000..f795e9fd2e --- /dev/null +++ b/packages/client/shortcuts/tsconfig.client.json @@ -0,0 +1,37 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" + }, + { + "path": "../locale/tsconfig.client.json" + }, + { + "path": "../store" + }, + { + "path": "../../util/crypto" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../util/values" + } + ], + "exclude": [ + "src/index.ts" + ] +} diff --git a/packages/client/shortcuts/tsconfig.host.json b/packages/client/shortcuts/tsconfig.host.json new file mode 100644 index 0000000000..21e5b991ed --- /dev/null +++ b/packages/client/shortcuts/tsconfig.host.json @@ -0,0 +1,37 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "include": [ + "src/protocol.ts", + "src/native.ts", + "src/binding.ts", + "src/configuration.ts", + "src/persistence.ts", + "src/index.ts", + "src/config.ts" + ], + "references": [ + { + "path": "../../util/brand" + }, + { + "path": "../../util/crypto" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../host/webserver" + }, + { + "path": "../../util/values" + } + ] +} diff --git a/packages/client/shortcuts/tsconfig.json b/packages/client/shortcuts/tsconfig.json new file mode 100644 index 0000000000..2eca820546 --- /dev/null +++ b/packages/client/shortcuts/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.host.json" + }, + { + "path": "./tsconfig.client.json" + } + ] +} diff --git a/packages/client/shortcuts/tsdown.config.ts b/packages/client/shortcuts/tsdown.config.ts new file mode 100644 index 0000000000..66ffe581c0 --- /dev/null +++ b/packages/client/shortcuts/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-shortcuts', ['lib/types/index.js', 'lib/types/protocol.js']) diff --git a/packages/client/ui-approval/README.i18n.yaml b/packages/client/ui-approval/README.i18n.yaml index d8a94812cb..b414c3e630 100644 --- a/packages/client/ui-approval/README.i18n.yaml +++ b/packages/client/ui-approval/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-approval/README.md -README.md: 1d5a4a56830c34693ee8cc93c6805ec8dd9a8191 -README.zh.md: f5f932ed59834cd9b805700fa58dd0f552bc8454 +README.md: ce2ccdbbecef629771d2f4b7e2391668c3c3e9a0 +README.zh.md: d7a94ae9156c61b8ec8a6e18ce261bedf0ede48d diff --git a/packages/client/ui-approval/README.md b/packages/client/ui-approval/README.md index 1d5a4a5683..ce2ccdbbec 100644 --- a/packages/client/ui-approval/README.md +++ b/packages/client/ui-approval/README.md @@ -18,6 +18,8 @@ Browser approval presentation over the Agent-scoped Remote Event waterfall. The ----- +Focus the approval detail region to approve with Enter or reject with Escape. The mounted plugin reserves both keys against editable shortcuts. Enter on the focused Reject button retains its native reject action. Input controls and IME candidates keep their own keys. Keyboard and pointer actions share one pending-request lock; a withdrawn or replaced request cannot accept another answer, and an earlier failed answer cannot unlock its replacement. + ## Model Experience diff --git a/packages/client/ui-approval/README.zh.md b/packages/client/ui-approval/README.zh.md index f5f932ed59..d7a94ae915 100644 --- a/packages/client/ui-approval/README.zh.md +++ b/packages/client/ui-approval/README.zh.md @@ -18,6 +18,8 @@ kind: "package-reference" ----- +聚焦审批详情区域后,Enter 批准,Esc 拒绝。插件挂载期间,这两个按键不能分配给可编辑快捷键。聚焦“拒绝”按钮后,Enter 保留按钮原生拒绝操作。输入控件与输入法候选保留各自的按键。键盘和指针操作共用同一待处理请求锁;已撤销或替换的请求不能再次作答,较早请求的失败也不会解锁替代请求。 + ## 模型体验 diff --git a/packages/client/ui-approval/package.json b/packages/client/ui-approval/package.json index acb0a0c9ed..bc7152279f 100644 --- a/packages/client/ui-approval/package.json +++ b/packages/client/ui-approval/package.json @@ -60,7 +60,8 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@types/react": "~18.3.1", - "react": "^18.2.0" + "react": "^18.2.0", + "@deepseek-ai/dsh-client-shortcuts": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-approval/src/client/ApprovalPanel.tsx b/packages/client/ui-approval/src/client/ApprovalPanel.tsx index a7c0b33477..9dce13573c 100644 --- a/packages/client/ui-approval/src/client/ApprovalPanel.tsx +++ b/packages/client/ui-approval/src/client/ApprovalPanel.tsx @@ -1,5 +1,5 @@ /** Composer takeover for one pending approval waterfall. */ -import { useState, type ReactNode } from 'react' +import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react' import { Button, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ApprovalComposerProps, PendingApproval } from './contract/slots.ts' import css from './ApprovalPanel.module.css' @@ -23,12 +23,43 @@ function ApprovalFlow({ pending, detail, t }: { t: ApprovalComposerProps['t'] }) { const [answered, setAnswered] = useState(false) + const waiting = useRef(false) + const active = useRef(true) + const composing = useRef(false) + const compositionEnded = useRef(false) + useEffect(() => { + active.current = true + return () => { active.current = false } + }, []) const answer = (outcome: 'allowed-once' | 'rejected'): void => { + if (waiting.current || !pending.answerable) return + waiting.current = true setAnswered(true) - void pending.answer(outcome).catch(() => { setAnswered(false) }) + void pending.answer(outcome).catch(() => { + if (!active.current || !pending.answerable) return + waiting.current = false + setAnswered(false) + }) + } + const keydown = (event: KeyboardEvent): void => { + const element = event.target as Element + if (event.defaultPrevented || !event.currentTarget.contains(document.activeElement) + || element.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]') !== null) return + if (event.key !== 'Enter' && event.key !== 'Escape') return + if (event.key === 'Enter' && element.closest('button, a[href], [role="button"]') !== null) return + if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return + event.preventDefault() + event.stopPropagation() + // oxlint-disable-next-line typescript/no-deprecated -- IME 229 covers engines without isComposing. + if (event.repeat || composing.current || compositionEnded.current || event.nativeEvent.isComposing || event.keyCode === 229) return + answer(event.key === 'Enter' ? 'allowed-once' : 'rejected') } return ( -
+
{ compositionEnded.current = false }} + onCompositionStartCapture={() => { composing.current = true }} + onCompositionEndCapture={() => { composing.current = false; compositionEnded.current = true }}>
{t('waiting')}
ctx.locale.register(NS, { zh, en }), 'ui-approval: dictionaries') + ctx.inject(['shortcuts'], (scope) => { + const t = ctx.locale.bind(NS) + scope.effect(() => scope.shortcuts.registerFixed({ + id: 'approval.allow' as ShortcutCommandId, label: () => t('allowOnce'), keys: ['Enter'], bindings: [{ code: 'Enter', modifiers: [] }], group: 'approval', + }), 'ui-approval: fixed allow reference') + scope.effect(() => scope.shortcuts.registerFixed({ + id: 'approval.reject' as ShortcutCommandId, label: () => t('reject'), keys: ['Esc'], bindings: [{ code: 'Escape', modifiers: [] }], group: 'approval', + }), 'ui-approval: fixed reject reference') + }) const registerPendingInteraction = ctx.uiSession.registerPendingInteraction( () => 0, ) diff --git a/packages/client/ui-approval/tests/ui-approval.client.spec.tsx b/packages/client/ui-approval/tests/ui-approval.client.spec.tsx index 7ae0dbc11a..6e18ace13c 100644 --- a/packages/client/ui-approval/tests/ui-approval.client.spec.tsx +++ b/packages/client/ui-approval/tests/ui-approval.client.spec.tsx @@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client' import type { ToolCallId } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { ApprovalPanel } from '../src/client/ApprovalPanel.tsx' import type { ApprovalComposerProps } from '../src/client/contract/slots.ts' @@ -40,7 +40,7 @@ interface PluginBench { } } -function setupPlugin(): PluginBench { +async function setupPlugin(): Promise { const ctx = new Context() let listener: ApprovalListener | undefined let registration: { @@ -84,7 +84,8 @@ function setupPlugin(): PluginBench { register: vi.fn(() => disposeLocale), } as never) - apply(ctx) + const fiber = ctx.plugin({ apply }) + await fiber.await() if (listener === undefined) throw new Error('approval listener was not registered') return { ctx, @@ -198,7 +199,7 @@ describe('PendingApproval', () => { describe('approval Remote Event consumer', () => { it('delegates an event that has no Agent scope', async () => { - const bench = setupPlugin() + const bench = await setupPlugin() const next = vi.fn(() => Promise.resolve<'unavailable'>('unavailable')) await expect(bench.listener.call(bench.ctx, { toolName: 'bash' }, next)) @@ -209,7 +210,7 @@ describe('approval Remote Event consumer', () => { }) it('publishes one scoped takeover, returns the answer, and keeps stable registrations', async () => { - const bench = setupPlugin() + const bench = await setupPlugin() const scope = createScope(bench.ctx, id('s1')) await scope.fiber.await() const controller = new AbortController() @@ -245,7 +246,7 @@ describe('approval Remote Event consumer', () => { }) it('propagates request cancellation after removing the pending object', async () => { - const bench = setupPlugin() + const bench = await setupPlugin() const scope = createScope(bench.ctx, id('s1')) await scope.fiber.await() const controller = new AbortController() @@ -265,7 +266,7 @@ describe('approval Remote Event consumer', () => { }) it('delegates an active request when its interaction domain unloads', async () => { - const bench = setupPlugin() + const bench = await setupPlugin() const scope = createScope(bench.ctx, id('s1')) await scope.fiber.await() const next = vi.fn(() => Promise.resolve<'unavailable'>('unavailable')) @@ -281,7 +282,7 @@ describe('approval Remote Event consumer', () => { }) it('publishes a scoped request without optional request metadata', async () => { - const bench = setupPlugin() + const bench = await setupPlugin() const scope = createScope(bench.ctx, id('s1')) await scope.fiber.await() const result = bench.listener.call(scope.ctx, { toolName: 'read' }, () => Promise.resolve('unavailable')) @@ -297,7 +298,7 @@ describe('approval Remote Event consumer', () => { }) it('removes stable registrations with the plugin lifetime', async () => { - const bench = setupPlugin() + const bench = await setupPlugin() await bench.ctx.fiber.dispose() expect(bench.disposeSlot).toHaveBeenCalledOnce() expect(bench.disposeLocale).toHaveBeenCalledOnce() @@ -361,6 +362,102 @@ describe('ApprovalPanel', () => { await expect(pending.result).resolves.toBe('allowed-once') }) + it.each([['Enter', 'allowed-once'], ['Escape', 'rejected']] as const)( + 'answers the focused container with %s through the pending request', async (key, outcome) => { + const pending = new PendingApproval(id('s1'), { toolName: 'bash' }) + render() + const group = screen.getByRole('group', { name: 'Approval details' }) + group.focus() + const bubbled = vi.fn() + window.addEventListener('keydown', bubbled) + try { + fireEvent.keyDown(group, { key, code: key }) + await expect(pending.result).resolves.toBe(outcome) + expect(bubbled).not.toHaveBeenCalled() + expect(pending.answerable).toBe(false) + } finally { window.removeEventListener('keydown', bubbled) } + }, + ) + + it('leaves button Enter to its native click and rejects from the reject button', async () => { + const pending = new PendingApproval(id('s1'), { toolName: 'bash' }) + render() + const reject = screen.getByRole('button', { name: 'Reject' }) + reject.focus() + expect(fireEvent.keyDown(reject, { key: 'Enter', code: 'Enter' })).toBe(true) + expect(pending.answerable).toBe(true) + // jsdom does not synthesize the browser's native button activation. + fireEvent.click(reject) + await expect(pending.result).resolves.toBe('rejected') + }) + + it('ignores unowned input, modified keys, repeats and IME candidate keys', async () => { + const pending = new PendingApproval(id('s1'), { toolName: 'bash', callId: 'call-1' as ToolCallId }) + const renderSlot = () => + render() + const group = screen.getByRole('group', { name: 'Approval details' }) + fireEvent.keyDown(group, { key: 'Enter', code: 'Enter' }) + expect(pending.answerable).toBe(true) + const input = screen.getByRole('textbox', { name: 'Approval input' }) + input.focus() + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }) + fireEvent.keyDown(input, { key: 'Escape', code: 'Escape' }) + group.focus() + expect(fireEvent.keyDown(group, { key: 'Tab', code: 'Tab' })).toBe(true) + fireEvent.keyDown(group, { key: 'Enter', code: 'Enter', ctrlKey: true }) + fireEvent.keyDown(group, { key: 'Escape', code: 'Escape', repeat: true }) + fireEvent.keyDown(group, { key: 'Enter', code: 'Enter', isComposing: true }) + fireEvent.keyDown(group, { key: 'Enter', code: 'Enter', keyCode: 229 }) + fireEvent.compositionStart(group) + fireEvent.keyDown(group, { key: 'Enter', code: 'Enter' }) + fireEvent.compositionEnd(group) + fireEvent.keyDown(group, { key: 'Escape', code: 'Escape' }) + expect(pending.answerable).toBe(true) + fireEvent.keyUp(group, { key: 'Escape', code: 'Escape' }) + fireEvent.keyDown(group, { key: 'Enter', code: 'Enter' }) + await expect(pending.result).resolves.toBe('allowed-once') + }) + + it('locks keyboard and click submissions synchronously while the answer is pending', async () => { + const pending = new PendingApproval(id('s1'), { toolName: 'bash' }) + const gate = Promise.withResolvers() + const answer = vi.spyOn(pending, 'answer').mockImplementation(() => gate.promise) + render() + const group = screen.getByRole('group', { name: 'Approval details' }) + group.focus() + fireEvent.keyDown(group, { key: 'Enter', code: 'Enter' }) + fireEvent.keyDown(group, { key: 'Escape', code: 'Escape' }) + fireEvent.click(screen.getByRole('button', { name: 'Reject' })) + expect(answer).toHaveBeenCalledExactlyOnceWith('allowed-once') + await act(async () => { gate.resolve(undefined); await gate.promise }) + pending.abort(new Error('test cleanup')) + await pending.result.catch(() => {}) + }) + + it('does not settle an aborted request or let its late rejection unlock its replacement', async () => { + const first = new PendingApproval(id('s1'), { toolName: 'bash' }) + const gate = Promise.withResolvers() + vi.spyOn(first, 'answer').mockImplementation(() => gate.promise) + const view = render() + let group = screen.getByRole('group', { name: 'Approval details' }) + group.focus() + fireEvent.keyDown(group, { key: 'Enter', code: 'Enter' }) + first.abort(new Error('request withdrawn')) + await first.result.catch(() => {}) + const second = new PendingApproval(id('s1'), { toolName: 'read' }) + view.rerender() + await act(async () => { gate.reject(new Error('late transport failure')); await gate.promise.catch(() => {}) }) + expect(screen.getByRole('button', { name: 'Reject' }).disabled).toBe(false) + second.abort(new Error('request withdrawn')) + await second.result.catch(() => {}) + const answer = vi.spyOn(second, 'answer') + group = screen.getByRole('group', { name: 'Approval details' }) + group.focus() + fireEvent.keyDown(group, { key: 'Enter', code: 'Enter' }) + fireEvent.click(screen.getByRole('button', { name: 'Reject' })) + expect(answer).not.toHaveBeenCalled() + }) + it('re-enables actions when answering fails', async () => { const pending = new PendingApproval(id('s1'), { toolName: 'bash' }) vi.spyOn(pending, 'answer').mockRejectedValue(new Error('transport closed')) diff --git a/packages/client/ui-approval/tsconfig.json b/packages/client/ui-approval/tsconfig.json index 6c10dfc080..5bad85d9c5 100644 --- a/packages/client/ui-approval/tsconfig.json +++ b/packages/client/ui-approval/tsconfig.json @@ -40,6 +40,9 @@ }, { "path": "../ui-slots" + }, + { + "path": "../shortcuts/tsconfig.client.json" } ] } diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 99eba4e710..62c359f0c7 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 48df40483632a7c63ec59d389f920d4dd663155b -README.zh.md: f5c34657264f97d8f0178859dcba5e3fc981f17d +README.md: 2c0e192c6a92ffdc7edba7cebe9632a1443556cf +README.zh.md: ba7b6d32630ac81be8ef52c9983284bf725a960a diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 48df404836..2c0e192c6a 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -27,6 +27,8 @@ English | [中文](README.zh.md) `UiConversation.events` is the single registry for event Definitions, and `UiConversation.views` is the single registry for target snapshot builders. Both registries reject duplicate keys, preserve registration order, return idempotent disposers, and rebuild existing bindings when their contribution roster changes. `UiConversation.binding(bindingOrSessionId)` returns one identity-stable Conversation binding for the current Session Controller binding. It does not open another event source. A View Definition may declare `toolCallFocus` to translate a tool-call id into its focus identity. Conversation supplies an Inspect callback only while such a target has a visible View entry; Chat consumes this callback without selecting a target. +`ctx.uiConversation.binding(binding).timeline` exposes the same immutable loaded Turn/Step timeline that target builders receive. Turn and Step lifecycle events publish synchronously, including when no View is active; unloading the Session binding detaches its source subscription. Consumers bind cancellation to the current Session and turn. + The adapter passes each `SessionEventLikeEntry` directly to the assembler. Its outer `type` distinguishes durable events from Client-only transient events, while its inner `event` always exposes `type`, `seq`, `time`, and `data`; Definitions receive that inner `SessionEventLike`. Replacement windows may include both entry variants, while historical prepends carry durable entries and live appends may carry either. Every Definition uses the same `match` and `update` methods for both event forms, while `start` receives only a durable event and the assembler rejects a transient start. Definitions that do not consume Assistant deltas return `null` for `assistant/live-chunk`. Replacement windows and revision gaps rebuild from the complete loaded window; contiguous append, prepend, and Assistant-settlement revisions use incremental assembly. Settlement removes only the named attempt's transient matches, applies its optional durable entry, and replays the affected Contexts and dependents without replacing unrelated target nodes. The assembler owns Context matching, Turn/Step locations, target node materialization, target activity, and stable target sources. `ConversationSnapshot` contains only target-neutral views and active-target facts; Session lifecycle state remains in `SessionSnapshot`. A target becomes active when shell selection resolves it or when its source receives a first subscriber. The assembler replaces that target from current Contexts once and keeps it active for later incremental flushes; creating a source does not activate it and unsubscription does not deactivate it. @@ -50,7 +52,7 @@ With Developer tools off, the shell hides only Trajectory; other plugin-contribu 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. -`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). 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. +`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. Claimed commands retain their identity and highlight when only their arguments and trailing separator are deleted; editing the command name releases the claim. The same rules apply to every command and locale, including `/goal`, `/目标`, `/plan`, and `/计划`. Command hints and ordinary placeholders remain hidden throughout IME composition and reappear only after the editor commits the final text and the corresponding input is empty. @@ -70,6 +72,8 @@ The width-handle indicator follows the pointer only during a captured drag; ordi The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text in a textarea that grows with its content, so line breaks survive re-editing; Enter saves, Shift+Enter breaks the line, and Escape cancels. Durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start. +Plain Enter uses the configured delivery mode, exactly Ctrl+Enter or Cmd+Enter uses its complement, and Shift+Enter inserts a line break. Enter with Alt, AltGraph, both Ctrl and Cmd, or Shift plus Ctrl/Cmd leaves the draft and command menu unchanged and keeps the DOM event available to application shortcuts. The Conversation plugin registers the fixed send, newline, complementary-delivery, command-menu, and reference-menu rows and reserves their bindings until it unloads. + Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; queue and history observation by that `rpcId` retires the echo once. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Where the browser shell exposes `__DSH_HOST_PATHS__` (the Desktop application), dropped or pasted folders and dropped, picked, or pasted non-image files with a real path become `@path` chips; images keep uploading. Drag-and-drop and paste identify directories through the browser entry API; when that API is absent or returns no entry, pasted items retain ordinary file handling. The file picker cannot select directories. References require the `ui-reference` plugin and the original paths to remain readable by the model's file tools. Paths inside the workspace are relative; others remain absolute. The whole batch is validated before insertion, retains source order and selected text, and uses whitespace-separated, closed-quote mentions. A browser without the bridge refuses dropped or pasted folders; a Desktop folder without a reported path is rejected separately. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id. Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A failed submission removes its echo and displays an error; the composer restores the failed draft when it is empty or still contains the previous automatic restoration, preserving subsequently typed text. @@ -80,6 +84,8 @@ File chips and editable skill references share a whole-reference hover backgroun When another writer owns the Session, the send-error toast asks the user to quit other running DSH instances and retry. +Two independent Escape presses in the focused Chat or Composer stop its current running turn and preserve queued messages. The interval comes from the shortcuts plugin’s `stopSequenceMs` configuration (500 ms by default). A menu, approval, modal, terminal, embedded webpage, composition, repeated key, changed input region, Session or turn breaks the sequence. The shortcut uses the same scoped cancellation as the Stop button. Its fixed-action registration reserves plain Escape against editable shortcuts and supplies the `Esc Esc` sequence shown in the Stop button’s hover and keyboard-focus tooltip. + ## Temporary composer entries diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index f5c3465726..ba7b6d3263 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -27,6 +27,8 @@ kind: "package-reference" `UiConversation.events` 是 event Definition 的唯一 registry,`UiConversation.views` 是 target snapshot builder 的唯一 registry。两者都拒绝重复 key、保持注册顺序、返回幂等 disposer,并在 contribution roster 变化时重建现有 binding。`UiConversation.binding(bindingOrSessionId)` 为当前 Session Controller binding 返回 identity 稳定的 Conversation binding,不会另开事件源。 View Definition 可以声明 `toolCallFocus`,将工具调用 id 转换为自身的焦点标识。仅当此目标拥有可见的 View 条目时,Conversation 才提供 Inspect 回调;Chat 直接使用回调,不选择目标。 +`ctx.uiConversation.binding(binding).timeline` 暴露与目标构建器相同的不可变已加载轮次/步骤时间线。轮次和步骤生命周期事件同步发布,即使没有活动视图也是如此;卸载会话绑定会解除源订阅。消费者将取消操作绑定到当前会话和轮次。 + 适配器把每个 `SessionEventLikeEntry` 直接交给 assembler。外层 `type` 区分持久事件与 Client-only transient event,内部 `event` 则统一公开 `type`、`seq`、`time` 与 `data`;Definition 接收这个内部 `SessionEventLike`。replacement window 可以包含两种 entry,历史 prepend 携带持久 entry,实时 append 则可以携带任一种。两种事件都使用 Definition 的同一组 `match` 与 `update` 方法,`start` 只接收持久 event,assembler 会拒绝 transient start。不消费 Assistant delta 的 Definition 对 `assistant/live-chunk` 返回 `null`。replace window 或 revision 断档从完整已加载窗口重建;连续 revision 的 append、prepend 与 Assistant settlement 使用增量组装。settlement 只删除具名 attempt 的 transient match,应用可选持久 entry,并重放受影响的 Context 及其 dependent,不替换无关 target node。assembler 拥有 Context 匹配、Turn/Step location、target node 物化、target activity 和稳定 target source。`ConversationSnapshot` 只包含与 target 无关的 View 与 active-target 事实;Session lifecycle 状态仍属于 `SessionSnapshot`。 shell 选择解析出 target 或 target source 收到首个 subscriber 时,该 target 进入 active 状态。assembler 从当前 Context 对它执行一次 replace,并使它参与后续增量 flush;创建 source 不会激活 target,取消订阅也不会停用 target。 @@ -50,7 +52,7 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map, 输入框注册「文件」命令动作,负责其标题、可用性和原生文件选择器回调。菜单可用性与实际调用都读取已挂载输入框当前的附件接收策略。输入框卸载或锁定后该动作不可用,插件 dispose(资源释放)时移除注册。回调绑定留在输入模块内部。 -`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)。这一拆分不支持同一 Session 同时挂载多个可编辑 root;[两阶段隔离提案](../../../.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.zh.md) 定义剩余工作。 +`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) 定义剩余工作。 已认领的命令在仅删除参数和末尾分隔空格时保留身份与高亮,改动命令名才会释放认领。所有命令和语言使用相同规则,包括 `/goal`、`/目标`、`/plan` 和 `/计划`。输入法组合输入期间,命令提示和普通占位文字持续隐藏,直到编辑器提交最终文字且对应输入为空时才重新显示。 @@ -70,6 +72,8 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染 常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态在可随内容增高的 textarea 中展示字面发送文本,因此重新编辑不会丢失换行;Enter 保存,Shift+Enter 换行,Escape 取消。持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。 +普通 Enter 使用已配置的投递模式,严格的 Ctrl+Enter 或 Cmd+Enter 使用互补模式,Shift+Enter 插入换行。带 Alt、AltGraph、同时带 Ctrl 与 Cmd,或 Shift 与 Ctrl/Cmd 的 Enter 保持草稿和指令菜单不变,并将 DOM 事件留给应用快捷键。Conversation 插件注册发送、换行、互补投递、命令菜单和引用菜单的固定条目,并在卸载前保留这些键位。 + 默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;queue 或历史以同一 `rpcId` 被观察后,回显只退休一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。浏览器 shell 暴露 `__DSH_HOST_PATHS__` 时(桌面应用),拖入或粘贴的文件夹以及拖入、选择或粘贴的带真实路径的非图片文件会成为 `@路径` chip;图片仍然上传。拖放和粘贴通过浏览器 entry API 识别目录;该 API 不可用或没有返回 entry 时,粘贴项沿用普通文件处理。文件选择器不能选择目录。引用需要启用 `ui-reference` 插件,原路径也必须仍可由模型的文件工具读取。工作区内的路径使用相对形式,其他路径保留绝对形式。整批文件先校验再插入,保留来源顺序和已选中的文字,引用之间有空白分隔,含空格的路径使用闭合引号。没有该桥的浏览器会拒绝拖入或粘贴的文件夹,桌面端无法获取文件夹路径时单独报错。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。 排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到提示词确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。 @@ -80,6 +84,8 @@ Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切 当会话被其他写句柄占用时,发送失败的 toast 提示用户退出其他正在运行的 DSH 后重试。 +在获得焦点的 Chat 或 Composer 中连续独立按下两次 Esc,可停止当前运行轮次并保留排队消息。间隔由 shortcuts 插件的 `stopSequenceMs` 配置决定,默认 500 ms。菜单、审批、模态层、终端、内嵌网页、输入法、重复按键,以及输入区域、Session 或轮次变化会清空序列。快捷键与 Stop 按钮调用同一作用域取消操作。固定操作注册使普通 Esc 不能分配给可编辑快捷键,并为 Stop 按钮的悬停和键盘聚焦提示提供 `Esc Esc` 序列。 + ## 临时 composer entry diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 37093c371d..1207106728 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -95,7 +95,8 @@ "@lexical/plain-text": "^0.49.0", "@lexical/history": "^0.49.0", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-client-shortcuts": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index f4a5ff4780..737cee8329 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -11,6 +11,7 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +import type { ShortcutCommandId, ShortcutFixedCommand } from '@deepseek-ai/dsh-client-shortcuts/client' import { UiConversation } from './conversation/assembly.ts' import type { ViewTab } from './contract/views.ts' import type { @@ -38,6 +39,7 @@ import { ConversationHeader } from './skeleton/ConversationHeader.tsx' import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx' import { InputBar } from './skeleton/InputBar.tsx' import { todoDockEntry } from './skeleton/TodoPanel.tsx' +import { installStopShortcut } from './stop-shortcut.ts' import { DEVELOPER_TOOLS_VIEW_ID, resolveActiveView } from './view-selection.ts' import { en, NS, zh, type ConversationKey } from './locales.ts' import { CONVERSATION_SETTINGS_NAMESPACE, type ConversationSettings } from '../submission-settings.ts' @@ -225,6 +227,42 @@ export function apply(ctx: Context, config: Config = Config({})): void { } }, 'ui-conversation: View selection') + const stop = (sessionId: SessionId): void => { + scopedConversation(sessions, sessionId).cancel().catch((_error: unknown) => { + // Stop failure is published through Session promptError. + }) + } + const stopShortcut = createSnapshotStore([]) + ctx.inject(['shortcuts'], (scope) => { + const fixedInputs: readonly ShortcutFixedCommand[] = [ + { id: 'fixed.send' as ShortcutCommandId, label: () => t('input.send'), keys: ['Enter'], + bindings: [{ code: 'Enter', modifiers: [] }], group: 'input' }, + { id: 'fixed.newline' as ShortcutCommandId, label: () => t('shortcut.newline'), + keys: scope.shortcuts.describeBinding({ code: 'Enter', modifiers: ['shift'] }).keys, + bindings: [{ code: 'Enter', modifiers: ['shift'] }], group: 'input' }, + { id: 'fixed.complementary' as ShortcutCommandId, label: () => t('shortcut.complementary'), + keys: scope.shortcuts.describeBinding({ code: 'Enter', modifiers: ['primary'] }).keys, + bindings: [{ code: 'Enter', modifiers: ['control'] }, { code: 'Enter', modifiers: ['meta'] }], group: 'input' }, + { id: 'fixed.slash' as ShortcutCommandId, label: () => t('shortcut.slash'), keys: ['/'], + bindings: [{ code: 'Slash', modifiers: [] }], group: 'input' }, + { id: 'fixed.mention' as ShortcutCommandId, label: () => t('shortcut.mention'), keys: ['@'], + bindings: [{ code: 'Digit2', modifiers: ['shift'] }], group: 'input' }, + ] + for (const command of fixedInputs) { + scope.effect(() => scope.shortcuts.registerFixed(command), `ui-conversation: ${command.id}`) + } + scope.effect(() => installStopShortcut(scope.shortcuts, sessions, uiConversation, ctx.uiSession, stop), + 'ui-conversation: fixed stop input') + scope.effect(() => { + const command: ShortcutFixedCommand = { + id: 'response.stop' as ShortcutCommandId, label: () => t('input.stop'), keys: ['Esc', 'Esc'], bindings: [{ code: 'Escape', modifiers: [] }], group: 'input', + } + const dispose = scope.shortcuts.registerFixed(command) + stopShortcut.set(command.keys) + return () => { stopShortcut.set([]); dispose() } + }, 'ui-conversation: fixed stop reference') + }) + const inputHub = new InputHub(ctx, t) const composerBlocks = new ComposerBlockRegistry() @@ -402,6 +440,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { toggleCommandMenu: undefined, stop: undefined, hooks: { + stopShortcut, busyEnter: submissionPolicy.busyEnter, fileUploads: ABSENT_FILE_UPLOADS, notices: ABSENT_NOTICES, @@ -475,12 +514,9 @@ export function apply(ctx: Context, config: Config = Config({})): void { span: { ...selection, draftRev: snapshot.draftRev }, }) }, - stop: () => { - scopedConversation(sessions, sessionId).cancel().catch(() => { - // Stop failure is published through Session promptError. - }) - }, + stop: () => { stop(sessionId) }, hooks: { + stopShortcut, busyEnter: submissionPolicy.busyEnter, fileUploads: conversation.fileUploads, notices: shell.notices, diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 001becdfde..61f7d8ff8b 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -391,6 +391,8 @@ export interface ComposerBarInjected { toggleCommandMenu: ((selection: EditSelection) => void) | undefined stop: (() => void) | undefined hooks: { + /** Readable sequence while the fixed Stop command is registered. */ + stopShortcut: ObservableSnapshot /** * Live busy-state submission preference: the delivery mode plain Enter * and the primary Send button use while the addressed agent is busy. diff --git a/packages/client/ui-conversation/src/client/conversation/assembler.ts b/packages/client/ui-conversation/src/client/conversation/assembler.ts index d51216de32..c94fac7c75 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembler.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembler.ts @@ -6,7 +6,7 @@ import type { ConversationContextReader, ConversationLocationData, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, ConversationPreviousContext, ConversationLocationDataScope, ConversationPublication, ConversationViewBuilder, - ConversationStartMatch, + ConversationStartMatch, ConversationTimelineSnapshot, ConversationViewDefinition, ConversationViewNode, ConversationViewSnapshotMap, ConversationViewSnapshotStore, } from '../contract/conversation.ts' @@ -209,6 +209,12 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore this.resetViewBuilders() } + /** + * Read the assembled Turn/Step timeline without activating a presentation target. + * @returns the immutable timeline shared with target builders. + */ + timeline(): ConversationTimelineSnapshot { return this.locationIndex.snapshot() } + /** * Replace the complete loaded window after open, resync, or gap repair. * @param entries - complete contiguous window. diff --git a/packages/client/ui-conversation/src/client/conversation/assembly.ts b/packages/client/ui-conversation/src/client/conversation/assembly.ts index fe4d49505c..3107c194f8 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembly.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembly.ts @@ -10,7 +10,7 @@ import { createSnapshotStore, type ObservableSnapshot, type SnapshotStore, } from '@deepseek-ai/dsh-client-store' import type { - ConversationPublication, ConversationViewSnapshotMap, + ConversationPublication, ConversationTimelineSnapshot, ConversationViewSnapshotMap, ConversationViewSnapshotStore, } from '../contract/conversation.ts' import type { ConversationSnapshot } from '../contract/snapshot.ts' @@ -28,6 +28,8 @@ import { ConversationGroupRegistry } from './group-registry.ts' /** Observable faces published for one Session's Conversation assembly. */ export interface ConversationBinding { readonly snapshot: ObservableSnapshot + /** Loaded Turn/Step timeline; lifecycle events publish synchronously even without an active View. */ + readonly timeline: ObservableSnapshot /** * Add one selected target to the Session's monotonic active set. * @param target - registered or subsequently registered Conversation target. @@ -47,6 +49,7 @@ export interface ConversationBinding { class BoundConversation implements ConversationBinding { readonly snapshot: SnapshotStore + readonly timeline: SnapshotStore private readonly viewStore: ConversationViewSnapshotStore private readonly targetSources = new Map>() private revision = -1 @@ -59,6 +62,7 @@ class BoundConversation implements ConversationBinding { ) { this.viewStore = assembler this.snapshot = createSnapshotStore(this.currentSnapshot()) + this.timeline = createSnapshotStore(assembler.timeline()) this.replace(feed.getSnapshot()) this.disposeFeed = feed.subscribe(() => { this.accept(feed.getSnapshot()) @@ -86,6 +90,7 @@ class BoundConversation implements ConversationBinding { activate(target: string): void { if (this.assembler.activateTarget(target)) this.snapshot.set(this.currentSnapshot()) + this.timeline.set(this.assembler.timeline()) } rebuild(): void { this.publish(this.assembler.rebuildRegistry()) } @@ -157,6 +162,7 @@ class BoundConversation implements ConversationBinding { private flush(): void { if (this.assembler.flush()) this.snapshot.set(this.currentSnapshot()) + this.timeline.set(this.assembler.timeline()) } private currentSnapshot(): ConversationSnapshot { diff --git a/packages/client/ui-conversation/src/client/input/editor/keymap.ts b/packages/client/ui-conversation/src/client/input/editor/keymap.ts index 7291f2c25a..e105a1d6c0 100644 --- a/packages/client/ui-conversation/src/client/input/editor/keymap.ts +++ b/packages/client/ui-conversation/src/client/input/editor/keymap.ts @@ -32,7 +32,7 @@ export interface ComposerKeymapHandlers { dismissPopup(): void /** Whether Enter may submit right now (locked/busy states refuse). */ canSubmit(): boolean - /** The Enter gesture after every guard passed; `accelerated` = Ctrl/Cmd held. */ + /** Plain Enter submits; exactly Ctrl+Enter or Cmd+Enter selects accelerated delivery. */ submit(accelerated: boolean): void /** * Pasted files with directory metadata supplied by the clipboard entry API. @@ -133,8 +133,11 @@ export function registerComposerKeymap(editor: LexicalEditor, handlers: Composer return false }, COMMAND_PRIORITY_CRITICAL), editor.registerCommand(KEY_ENTER_COMMAND, (event) => { - // Shift+Enter is the native line break UNCONDITIONALLY — decided before - // the IME guard so a composition-closing Shift+Enter still breaks the line. + // Returning true stops Lexical's fallback line break without consuming + // the DOM event that application shortcuts need. + if (event !== null && (event.altKey || event.getModifierState('AltGraph') + || (event.ctrlKey && event.metaKey) || (event.shiftKey && (event.ctrlKey || event.metaKey)))) return true + // Plain Shift+Enter keeps its native line break, including during composition. if (event?.shiftKey === true) return false if (event !== null && isComposingEvent(event, recentlyComposing)) { // The IME consumes this Enter (candidate pick); neither submit nor diff --git a/packages/client/ui-conversation/src/client/input/editor/runtime.ts b/packages/client/ui-conversation/src/client/input/editor/runtime.ts index f59a6d947d..cf83316fea 100644 --- a/packages/client/ui-conversation/src/client/input/editor/runtime.ts +++ b/packages/client/ui-conversation/src/client/input/editor/runtime.ts @@ -3,7 +3,8 @@ import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { LexicalEditor, NodeKey } from 'lexical' import { $addUpdateTag, $createParagraphNode, $createTextNode, $getRoot, $getSelection, $isRangeSelection, - CLEAR_HISTORY_COMMAND, createEditor, HISTORY_MERGE_TAG, PASTE_TAG, + BLUR_COMMAND, CLEAR_HISTORY_COMMAND, COMMAND_PRIORITY_CRITICAL, createEditor, HISTORY_MERGE_TAG, PASTE_TAG, + RootNode, SELECTION_CHANGE_COMMAND, SKIP_DOM_SELECTION_TAG, } from 'lexical' import { registerPlainText } from '@lexical/plain-text' import { createEmptyHistoryState, registerHistory } from '@lexical/history' @@ -65,8 +66,23 @@ export class DraftEditorRuntime { * @returns unregister callback that also detaches the editor root. */ register(): () => void { + // A retained draft selection must not move keyboard focus back from another control. + const preserveExternalSelection = (): false => { + const root = this.editor.getRootElement() + if (root !== null && !root.contains(root.ownerDocument.activeElement)) { + $addUpdateTag(SKIP_DOM_SELECTION_TAG) + } + return false + } const unregister = mergeRegister( registerPlainText(this.editor), + this.editor.registerCommand(BLUR_COMMAND, () => { + // Finish this batch before an explicit focus can restore its updated selection. + this.editor.update(preserveExternalSelection, { discrete: true }) + return false + }, COMMAND_PRIORITY_CRITICAL), + this.editor.registerCommand(SELECTION_CHANGE_COMMAND, preserveExternalSelection, COMMAND_PRIORITY_CRITICAL), + this.editor.registerNodeTransform(RootNode, preserveExternalSelection), registerReferenceActivation(this.editor, (source, reference) => this.deps.openReference(source, reference)), registerHistory(this.editor, createEmptyHistoryState(), HISTORY_MERGE_DELAY_MS), diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index aab4735316..cf5c846800 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -9,6 +9,11 @@ const PLAN_NEXT_ACTION_EN = 'describe your task to generate plan' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { + 'shortcut.hint': '{label} {keys}', + 'shortcut.newline': '换行', + 'shortcut.complementary': '使用互补的 Queue/Steer 方式', + 'shortcut.slash': '打开命令菜单', + 'shortcut.mention': '打开引用菜单', 'hint.plan': PLAN_NEXT_ACTION_ZH, 'hint.goal': '输入目标,智能体将持续执行', 'hint.goal.active': '当前目标进行中。可输入 edit 修改 / pause 暂停 / resume 继续 / clear 清除', @@ -361,6 +366,11 @@ export type ConversationKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { + 'shortcut.hint': '{label} {keys}', + 'shortcut.newline': 'New line', + 'shortcut.complementary': 'Use the complementary Queue / Steer action', + 'shortcut.slash': 'Open command menu', + 'shortcut.mention': 'Open reference menu', 'hint.plan': PLAN_NEXT_ACTION_EN, 'hint.goal': 'describe the objective for a long-running task', 'hint.goal.active': 'goal active — edit / pause / resume / clear', diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx index adace1c113..23c1c3042d 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx @@ -174,7 +174,7 @@ export function ConversationContent(props: ConversationContentProps) { // on the fallback alone would leave a business-owned takeover at the content // end off-screen when the user is not pinned to the floor. const composerSeat = ( -
+
{composer}
) @@ -184,6 +184,8 @@ export function ConversationContent(props: ConversationContentProps) { ref={setBody} className={clsx(css.body, props.variant === 'embedded' && css.embeddedBody)} data-conversation-content="" + data-conversation-session={sessionId} + data-conversation-region="chat" data-content-phase={phase} >
diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index dea8a86934..9139aa9ac8 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -46,7 +46,7 @@ export const InputBar = memo(function InputBar({ useSession, useInput, inputActions, keyboard, addFiles, removeAttachment, resolveDraftAttachments, retryFileUpload, toggleCommandMenu, stop, t, - renderSlot, useBusyEnter, useFileUploads, useNotices, useLexicon, useMenuLauncher, + renderSlot, useBusyEnter, useFileUploads, useNotices, useLexicon, useMenuLauncher, useStopShortcut, useProjection, sessionId, variant, disabled: inert = false, blocked, workspacePickerOpen = false, onRequestWorkspace, placeholder, accessory, @@ -54,6 +54,7 @@ export const InputBar = memo(function InputBar({ const input = useInput(s => s) const notice = useNotices(s => s) const busyEnter = useBusyEnter(s => s) + const stopKeys = useStopShortcut(keys => keys) 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) @@ -310,6 +311,7 @@ export const InputBar = memo(function InputBar({ : running && steeringAvailable && !disabled && !uploadsPending && plainMessageDraft ? t(primarySubmitMode === 'steer' ? 'input.send.steer' : 'input.send.queue') : t('input.send') + const stopHint = stopKeys.length ? t('shortcut.hint', { label: t('input.stop'), keys: stopKeys.join(' ') }) : t('input.stop') const onPrimary = (): void => { if (primaryStops) { stop?.() @@ -457,7 +459,7 @@ export const InputBar = memo(function InputBar({ {renderSlot('conversation.input.activity', { locked, onActiveChange: setActivity })}
} {interruptible && ( - + {(canCloseTab?.(tab.id) ?? true) && ( - - + + + )} {menu?.tabId === tabId && ( {!(callbacks.hideSplitWhenBlocked && block !== undefined) && ( - - + + )} {/* The embedder's surface-wide controls, in the top-right pane only: the @@ -416,6 +423,7 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode {
{ if (state.activePaneId !== pane.id) callbacks.onFocusPane(pane.id) diff --git a/packages/client/ui-dockkit/src/contract/adapter.ts b/packages/client/ui-dockkit/src/contract/adapter.ts index 81239e816e..b35fbb18d7 100644 --- a/packages/client/ui-dockkit/src/contract/adapter.ts +++ b/packages/client/ui-dockkit/src/contract/adapter.ts @@ -19,12 +19,20 @@ export interface DockLabels { readonly emptyPane: string /** The split control, while splitting is allowed. */ readonly splitPane: string + /** Effective localized action and shortcut tooltip, when supplied. */ + readonly splitPaneTooltip?: string + /** ARIA combination from the effective binding, when bound. */ + readonly splitPaneShortcut?: string | undefined /** The split control, once the pane budget is spent. */ readonly splitPaneDisabled: string /** The split control, while the pane is too narrow for two working halves. */ readonly splitPaneNarrow: string /** Destroy a tab: the chip's close control and the menu's close item. */ readonly closeTab: string + /** Effective close label and keycaps supplied by the embedder. */ + readonly closeTabTooltip?: string + /** Effective close combination, when bound. */ + readonly closeTabShortcut?: string | undefined /** The strip's add control, which seats the embedder's seeded tab. */ readonly addTab: string /** Send a floating panel back into the docked tree. */ diff --git a/packages/client/ui-dockkit/tests/components.client.spec.tsx b/packages/client/ui-dockkit/tests/components.client.spec.tsx index 85768ebb55..33d0d67130 100644 --- a/packages/client/ui-dockkit/tests/components.client.spec.tsx +++ b/packages/client/ui-dockkit/tests/components.client.spec.tsx @@ -256,7 +256,7 @@ describe('DockSurface', () => { renderSurface(controller, intents, false) const disabled = screen.getByRole('button', { name: TEST_LABELS.splitPane }) expect(disabled.hasAttribute('disabled')).toBe(true) - expect(disabled.getAttribute('title')).toBe(TEST_LABELS.splitPaneDisabled) + expect(disabled.parentElement?.getAttribute('aria-label')).toBe(TEST_LABELS.splitPaneDisabled) expect(disabled.getAttribute('data-dockkit-split-blocked')).toBe('budget') }) @@ -336,7 +336,7 @@ describe('DockSurface', () => { expect(narrowButton).toBeNull() } else { expect(narrowButton?.hasAttribute('disabled')).toBe(true) - expect(narrowButton?.getAttribute('title')).toBe(TEST_LABELS.splitPaneNarrow) + expect(narrowButton?.parentElement?.getAttribute('aria-label')).toBe(TEST_LABELS.splitPaneNarrow) expect(narrowButton?.getAttribute('data-dockkit-split-blocked')).toBe('width') } }) @@ -815,6 +815,64 @@ describe('DockSurface', () => { expect(screen.queryByRole('menu')).toBeNull() }) + it.each(['tab', 'item'])('Escape dismisses a reopened menu from the %s without closing its tab', (focus) => { + const intents = spyIntents() + const { unmount } = renderSurface(seededController(), intents) + const tab = screen.getByRole('tab') + for (let attempt = 0; attempt < 2; attempt++) { + fireEvent.contextMenu(tab) + const target = focus === 'tab' ? tab : screen.getByRole('menuitem') + target.focus() + expect(fireEvent.keyDown(target, { key: 'Escape', code: 'Escape' })).toBe(false) + expect(screen.queryByRole('menu')).toBeNull() + expect(document.activeElement).toBe(tab) + expect(intents.closeTab).not.toHaveBeenCalled() + } + unmount() + expect(fireEvent.keyDown(document.body, { key: 'Escape', code: 'Escape' })).toBe(true) + }) + + it('keeps the menu during composition, modified Escape and key repeat', () => { + renderSurface(seededController(), spyIntents()) + const tab = screen.getByRole('tab') + fireEvent.contextMenu(tab) + for (const modifier of ['ctrlKey', 'altKey', 'metaKey', 'shiftKey', 'isComposing']) { + expect(fireEvent.keyDown(tab, { key: 'Escape', [modifier]: true })).toBe(true) + expect(screen.getByRole('menu')).toBeDefined() + } + fireEvent.compositionStart(tab) + fireEvent.keyDown(tab, { key: 'Escape' }) + expect(screen.getByRole('menu')).toBeDefined() + fireEvent.compositionEnd(tab) + fireEvent.keyDown(tab, { key: 'Escape' }) + expect(screen.getByRole('menu')).toBeDefined() + fireEvent.keyUp(tab, { key: 'Escape' }) + expect(fireEvent.keyDown(tab, { key: 'Escape', repeat: true })).toBe(false) + expect(screen.getByRole('menu')).toBeDefined() + fireEvent.keyDown(tab, { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() + }) + + it('yields Escape to a foreground modal and consumes it before a parent handler', () => { + renderSurface(seededController(), spyIntents()) + const tab = screen.getByRole('tab') + fireEvent.contextMenu(tab) + const modal = document.createElement('div') + modal.setAttribute('role', 'dialog') + modal.setAttribute('aria-modal', 'true') + document.body.append(modal) + onTestFinished(() => { modal.remove() }) + expect(fireEvent.keyDown(modal, { key: 'Escape' })).toBe(true) + expect(screen.getByRole('menu')).toBeDefined() + modal.remove() + const parent = vi.fn((event: KeyboardEvent) => { expect(event.defaultPrevented).toBe(true) }) + document.addEventListener('keydown', parent) + onTestFinished(() => { document.removeEventListener('keydown', parent) }) + fireEvent.keyDown(tab, { key: 'Escape' }) + expect(parent).toHaveBeenCalledOnce() + expect(screen.queryByRole('menu')).toBeNull() + }) + it('hangs the menu below the chip\'s left edge, or from its right edge when it would run off the viewport', () => { const controller = seededController() let left = 10 @@ -1118,6 +1176,18 @@ describe('keyboard tabs', () => { expect(intents.focusTab).toHaveBeenCalledTimes(2) }) + it('leaves modified and composing keys available to their owners', () => { + const { intents, chip } = threeChips() + const tab = chip('a.txt') + tab.focus() + for (const modifiers of [{ ctrlKey: true, altKey: true }, { metaKey: true, altKey: true }, { shiftKey: true }, { isComposing: true }]) { + expect(fireEvent.keyDown(tab, { key: 'Enter', ...modifiers })).toBe(true) + expect(fireEvent.keyDown(tab, { key: 'ArrowRight', ...modifiers })).toBe(true) + expect(document.activeElement).toBe(tab) + } + expect(intents.focusTab).not.toHaveBeenCalled() + }) + it('records nothing for Enter on the active pane\'s selected chip, and leaves keys on the close control alone', () => { const { intents, chip } = threeChips() fireEvent.keyDown(chip('b.txt'), { key: 'Enter' }) @@ -1483,7 +1553,7 @@ describe('surface chrome', () => { const role = (child: Element): string => { if (child.hasAttribute('data-dockkit-strip-tabs')) return 'tabs' if (child.hasAttribute('data-dockkit-add-tab')) return 'add' - if (child.hasAttribute('data-dockkit-split-button')) return 'split' + if (child.hasAttribute('data-dockkit-split-button') || child.querySelector('[data-dockkit-split-button]')) return 'split' if (child.hasAttribute('data-dockkit-strip-chrome')) return 'chrome' return 'fill' } diff --git a/packages/client/ui-layout/README.i18n.yaml b/packages/client/ui-layout/README.i18n.yaml index 57e2a79c7a..329f825ce9 100644 --- a/packages/client/ui-layout/README.i18n.yaml +++ b/packages/client/ui-layout/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-layout/README.md -README.md: b26b182f5ea2c727411910cc5f85819b9dfbcb2c -README.zh.md: 58ef066ab05389898dc912500cd6b37f1a07824d +README.md: c3747fd855fe8bd9a622b4859e49f7746ad56147 +README.zh.md: 169a9c250da3fd4f3c4a43b5c76ffdfe45d16f0c diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index b26b182f5e..c3747fd855 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -25,6 +25,8 @@ This package provides the Web GUI's three-column AppFrame, edge-column widths, a ## Use this package +Desktop Mod+B toggles the left sidebar through the same layout action used by its controls. The shortcut command is unavailable behind modal dialogs and yields to terminal input. + The root slot composes the sidebar, main content, and right column. The sidebar spans 264–420px, defaults to 280px, and retains a 56px rail when collapsed; below 1024px it collapses automatically, and opening the right panel collapses a manually expanded sidebar. The right panel first opens at 45% of the viewport, then retains the user's pixel preference, capped at 70%. To protect 400px for the center, the frame first reduces the right panel to 300px, then reports insufficient room so its occupant closes it, and only then compresses the center further. Dragging has no transition delay; the right handle is absent while closed or fullscreen. Global panels occupy the root-scoped `main` keyed slot; `conversation` is the reserved key for the Conversation. `ctx.layout.selectPanel(id)` selects a registered panel, and `null` selects the Conversation without changing the current Session. No global panel is registered by the shipped composition. @@ -49,7 +51,7 @@ The presenter consumes resolved theme snapshots and projects them onto the docum `selectPanel(id)` checks the live `main` registry before changing selection; an absent key throws and leaves the current panel intact. `beginNavigation()` returns an abort signal for an asynchronous UI navigation. A later call, a valid panel selection (including repeated selection), or layout disposal aborts that signal without cancelling underlying Session creation. Consumers check the signal before committing navigation or moving drafts. -One registration declares five child slots and binds `ctx.layout` methods `selectPanel`, `toggleSidebar`, `openRightbar(track, fullscreen)`, and `closeRightbar`. One root store separates `panelInfo` selection from `layoutInfo` measurements, width preferences, and presentation reports. `usePanelInfo` subscribes to the stable selection object; AppFrame subscribes to the stable layout object. The `rightbar` owner supplies actual `width`, `viewportWidth`, and normal-presentation eligibility `canShow`; insufficient room causes a deterministic close, never automatic reopening on widening. Fullscreen hides the width handle without releasing a track the occupant retains. AppFrame keeps the column containers mounted. The right column's root controller renders `rightbar.session` through `SessionProvider` only while the Conversation is selected; its unmount report releases the track. The independent title component uses the selected Session title only while the Conversation is visible, with the build-configured product title or localized `common.brand.localBuild` as its fallback; locale revisions update that fallback. The theme presenter is a second effect: pure DOM writes from resolved snapshots — initial state through the getter once, then event-driven only, with no React path. It applies palette, font-size, and token variables before measuring the rendered background as the single color authority. Fullscreen presentation suppresses grid and handle transitions; its occupant reports the new columns only after covering the frame. Fullscreen exit keeps transitions suppressed while the frame installs its destination geometry: close removes the right track, and restore retains it. Subsequent normal geometry actions restore ordinary transitions. +One registration declares five child slots and binds `ctx.layout` methods `selectPanel`, `toggleSidebar`, `openRightbar(track, fullscreen)`, and `closeRightbar`. One root store separates `panelInfo` selection from `layoutInfo` measurements, width preferences, and presentation reports. `ctx.layout.panelInfo` and the standard `usePanelInfo` hook share the same selection source; AppFrame subscribes to the stable layout object. The `rightbar` owner supplies actual `width`, `viewportWidth`, and normal-presentation eligibility `canShow`; insufficient room causes a deterministic close, never automatic reopening on widening. Fullscreen hides the width handle without releasing a track the occupant retains. AppFrame keeps the column containers mounted. The right column's root controller renders `rightbar.session` through `SessionProvider` only while the Conversation is selected; its unmount report releases the track. The independent title component uses the selected Session title only while the Conversation is visible, with the build-configured product title or localized `common.brand.localBuild` as its fallback; locale revisions update that fallback. The theme presenter is a second effect: pure DOM writes from resolved snapshots — initial state through the getter once, then event-driven only, with no React path. It applies palette, font-size, and token variables before measuring the rendered background as the single color authority. Fullscreen presentation suppresses grid and handle transitions; its occupant reports the new columns only after covering the frame. Fullscreen exit keeps transitions suppressed while the frame installs its destination geometry: close removes the right track, and restore retains it. Subsequent normal geometry actions restore ordinary transitions. diff --git a/packages/client/ui-layout/README.zh.md b/packages/client/ui-layout/README.zh.md index 58ef066ab0..169a9c250d 100644 --- a/packages/client/ui-layout/README.zh.md +++ b/packages/client/ui-layout/README.zh.md @@ -25,6 +25,8 @@ kind: "package-reference" ## 使用本包 +桌面 Mod+B 通过与控件共用的布局操作切换左侧栏。模态弹窗打开时此快捷键不可用;终端输入优先。 + 本插件在 root slot 中组合侧边栏、主内容和右栏。侧边栏宽度为 264~420px,默认为 280px,收起后保留 56px 控制栏;窗口宽度低于 1024px 时自动收起,打开右侧面板也会收起手动展开的侧边栏。右侧面板首次打开时使用视口宽度的 45%,之后保留用户的像素宽度偏好,上限为 70%。为给中栏保留 400px,框架先将右侧面板缩减至 300px,再报告空间不足,使占用方将其关闭,最后才进一步压缩中栏。拖动没有过渡延迟;右侧手柄在关闭或全屏时不显示。 全局面板占据 root 作用域的 `main` keyed slot;`conversation` 是为会话界面保留的 key。`ctx.layout.selectPanel(id)` 选中已注册面板,`null` 则选中会话界面,但不改变当前会话。默认组合不注册任何全局面板。 @@ -49,7 +51,7 @@ Windows Electron 的 `data-windows-titlebar` 标记在所有列上方预留顶 `selectPanel(id)` 在改变选中态前检查实时 `main` 注册表;缺失的 key 会抛错并保留当前面板。`beginNavigation()` 为异步 UI 导航返回 abort signal。后续调用、有效面板选择(包括重复选择)或布局释放会中止该 signal,但不取消底层会话创建。消费方在提交导航或搬移草稿前检查 signal。 -一次注册声明五个子 slot,并绑定 `ctx.layout` 的 `selectPanel`、`toggleSidebar`、`openRightbar(track, fullscreen)` 与 `closeRightbar`。同一个 root 存储把 `panelInfo` 选中态与 `layoutInfo` 测量、宽度偏好、呈现报告分开。`usePanelInfo` 订阅引用稳定的选中态对象,AppFrame 订阅引用稳定的布局对象。`rightbar` owner 提供实际 `width`、`viewportWidth`,以及表示能否以普通模式呈现的 `canShow`;占用方在空间不足时执行确定性的收起,变宽不自行重新展开。全屏隐藏宽度手柄,但不自行释放占用方要求保留的轨道。AppFrame 保持各列容器挂载。右栏的 root 控制器仅在选中会话界面时,经 `SessionProvider` 渲染 `rightbar.session`;内容卸载时的报告释放轨道。独立的标题组件仅在会话界面可见时使用所选会话标题,以构建配置的产品标题或本地化 `common.brand.localBuild` 为回退值;语言变化会更新该回退值。主题呈现器是第二个 effect:从解析后的快照做纯 DOM 写入——初始状态经 getter 读取一次,此后仅事件驱动,不经过 React。它先应用调色板、字号与 token 变量,再把渲染出的背景测量为唯一的颜色依据。全屏呈现禁用网格和手柄过渡;占用方完全覆盖框架后才报告新的列布局。退出全屏时,框架先保持无过渡并安装目标布局:关闭移除右轨道,恢复保留右轨道。后续普通几何操作恢复正常过渡。 +一次注册声明五个子 slot,并绑定 `ctx.layout` 的 `selectPanel`、`toggleSidebar`、`openRightbar(track, fullscreen)` 与 `closeRightbar`。同一个 root 存储把 `panelInfo` 选中态与 `layoutInfo` 测量、宽度偏好、呈现报告分开。`ctx.layout.panelInfo` 与标准 `usePanelInfo` hook 共用同一个选中态来源;AppFrame 订阅引用稳定的布局对象。`rightbar` owner 提供实际 `width`、`viewportWidth`,以及表示能否以普通模式呈现的 `canShow`;占用方在空间不足时执行确定性的收起,变宽不自行重新展开。全屏隐藏宽度手柄,但不自行释放占用方要求保留的轨道。AppFrame 保持各列容器挂载。右栏的 root 控制器仅在选中会话界面时,经 `SessionProvider` 渲染 `rightbar.session`;内容卸载时的报告释放轨道。独立的标题组件仅在会话界面可见时使用所选会话标题,以构建配置的产品标题或本地化 `common.brand.localBuild` 为回退值;语言变化会更新该回退值。主题呈现器是第二个 effect:从解析后的快照做纯 DOM 写入——初始状态经 getter 读取一次,此后仅事件驱动,不经过 React。它先应用调色板、字号与 token 变量,再把渲染出的背景测量为唯一的颜色依据。全屏呈现禁用网格和手柄过渡;占用方完全覆盖框架后才报告新的列布局。退出全屏时,框架先保持无过渡并安装目标布局:关闭移除右轨道,恢复保留右轨道。后续普通几何操作恢复正常过渡。 diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index 3ae2159164..1c4c7ea22b 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -31,7 +31,8 @@ "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-session", - "@deepseek-ai/dsh-client-ui-theme" + "@deepseek-ai/dsh-client-ui-theme", + "@deepseek-ai/dsh-client-shortcuts" ], "platform": "web" } @@ -54,7 +55,8 @@ "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "react": "^18.2.0", + "@deepseek-ai/dsh-client-shortcuts": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-layout/src/client/index.ts b/packages/client/ui-layout/src/client/index.ts index 045fcb8a39..410c8b0977 100644 --- a/packages/client/ui-layout/src/client/index.ts +++ b/packages/client/ui-layout/src/client/index.ts @@ -17,6 +17,8 @@ import type { PanelInfo } from './service.ts' import { AppFrame } from './AppFrame.tsx' import { createLayoutStore } from './stores.ts' import { LayoutController } from './service.ts' +import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client' +import { en, zh } from './shortcut-locales.ts' import { ThemePresenter } from './theme-presenter.ts' // Contract exports only (export-convergence rule: cross-package consumers @@ -38,6 +40,11 @@ declare module '@deepseek-ai/cordis' { } declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Layout keyboard command labels. */ + 'shortcuts.layout': keyof typeof zh + } + interface GlobalStandardProps { /** Subscribe to the selected main panel independently of parent renders. */ usePanelInfo: UsePanelInfo @@ -133,7 +140,7 @@ export interface RightbarOwnerProps { } /** Required services (cordis fiber inject — the loader passes all module exports as an object plugin). */ -export const inject = ['slots', 'theme', 'locale'] +export const inject = ['slots', 'theme', 'locale', 'shortcuts'] /** * Client plugin body: provide ctx.layout, then one register() call — AppFrame @@ -142,12 +149,13 @@ export const inject = ['slots', 'theme', 'locale'] * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register('shortcuts.layout', { zh, en }), 'layout: command labels') + const t = ctx.locale.bind('shortcuts.layout') + ctx.effect(() => { const handle = createLayoutStore() const instance = handle.create() const store: typeof handle = { ...handle, create: () => instance } - const layout = new LayoutController(instance.actions, id => - ctx.slots.entries('main').some(entry => entry.options.key === id)) const retainMainPanels = (): void => { instance.actions.retainMainPanels(ctx.slots.entries('main').flatMap(entry => entry.options.key === undefined ? [] : [entry.options.key])) @@ -156,7 +164,9 @@ export function apply(ctx: ClientContext): void { getSnapshot: () => instance.getSnapshot().panelInfo, subscribe: listener => instance.subscribe(listener), } - const disposePanelInfo = ctx.slots.provideRoot({ hooks: { panelInfo } }) + const layout = new LayoutController(instance.actions, id => + ctx.slots.entries('main').some(entry => entry.options.key === id), panelInfo) + const disposePanelInfo = ctx.slots.provideRoot({ hooks: { panelInfo: layout.panelInfo } }) const disposeService = ctx.reflect.provide('layout', layout) const disposeRegistration = ctx.slots.register({ name: 'root', @@ -170,9 +180,22 @@ export function apply(ctx: ClientContext): void { }, store, }, AppFrame) + const disposeShortcut = ctx.shortcuts.register({ + id: 'sidebar.left.toggle' as ShortcutCommandId, label: () => t('toggle'), aliases: ['sidebar', 'toggle left sidebar'], + defaults: { + 'desktop:macos': { code: 'KeyB', modifiers: ['primary'] }, + 'desktop:windows': { code: 'KeyB', modifiers: ['primary'] }, + 'desktop:linux': { code: 'KeyB', modifiers: ['primary'] }, + 'web:macos': { code: 'KeyB', modifiers: ['primary', 'alt'] }, + 'web:windows': { code: 'KeyB', modifiers: ['primary', 'alt'] }, + }, + regions: ['page', 'editable'], modals: [], + resolve: () => ({ status: 'handled', run: () => { layout.toggleSidebar() } }), + }) const disposePanels = ctx.slots.subscribe('main', retainMainPanels) retainMainPanels() return () => { + disposeShortcut() layout.dispose() disposePanels() disposeRegistration() diff --git a/packages/client/ui-layout/src/client/service.ts b/packages/client/ui-layout/src/client/service.ts index 14a3924d63..22dd77a4e6 100644 --- a/packages/client/ui-layout/src/client/service.ts +++ b/packages/client/ui-layout/src/client/service.ts @@ -8,7 +8,7 @@ * right-panel show/hide from ui-sidebar-right) — writes stay inside the * store's declared action set, shared with the root registration. */ -import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' +import type { BoundActions, HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type { Branded } from '@deepseek-ai/dsh-brand' import type { createLayoutStore } from './stores.ts' @@ -26,6 +26,8 @@ export type PanelActions = BoundActions> /** Panel navigation and geometry actions exposed through ctx.layout. */ export interface ILayout { + /** Selected central panel from the same root store used by `usePanelInfo`. */ + readonly panelInfo: HostObservable /** * Select a global central panel without changing the current Session. * @param panelId - registered main key, or null to show the Conversation. @@ -58,10 +60,12 @@ export class LayoutController implements ILayout { /** * @param panels - actions of the instance shared with the root entry. * @param hasMainPanel - checks the live main-slot registry for a panel id. + * @param panelInfo - root store's shared central-panel selection source. */ constructor( private readonly panels: PanelActions, private readonly hasMainPanel: (id: MainPanelId) => boolean, + readonly panelInfo: HostObservable, ) {} /** Select a global panel or return to the Conversation. */ diff --git a/packages/client/ui-layout/src/client/shortcut-locales.ts b/packages/client/ui-layout/src/client/shortcut-locales.ts new file mode 100644 index 0000000000..7c90e1ae9a --- /dev/null +++ b/packages/client/ui-layout/src/client/shortcut-locales.ts @@ -0,0 +1,4 @@ +/** Layout command labels. */ +export const zh = { toggle: '展开/收起左侧栏' } +/** English labels for the same layout commands. */ +export const en: Record = { toggle: 'Toggle left sidebar' } diff --git a/packages/client/ui-layout/tests/apply.client.spec.ts b/packages/client/ui-layout/tests/apply.client.spec.ts index 0e29721622..c38719c68d 100644 --- a/packages/client/ui-layout/tests/apply.client.spec.ts +++ b/packages/client/ui-layout/tests/apply.client.spec.ts @@ -1,5 +1,6 @@ // @vitest-environment jsdom +import ShortcutsService from '../../shortcuts/src/client/index.ts' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { Context, type Fiber } from '@deepseek-ai/cordis' import { stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' @@ -58,6 +59,7 @@ async function bench() { // Theme registers its Appearance settings row and requires the connection // seam for persistence; model this bench as a remote, memory-only browser. ctx.provide('locale', new LocaleRuntime(ctx)) + await ctx.plugin(ShortcutsService).await() ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) // ui-theme's Appearance row binds a durable scope through these two. ctx.provide('remote', { $on: () => () => {} } as never) @@ -77,7 +79,7 @@ async function bench() { describe('ui-layout client apply', () => { it('declares its service dependencies', () => { - expect(inject).toEqual(['slots', 'theme', 'locale']) + expect(inject).toEqual(['slots', 'theme', 'locale', 'shortcuts']) }) it('provides ctx.layout and declares the five root-scoped frame slots', async () => { @@ -109,6 +111,7 @@ describe('ui-layout client apply', () => { const host = rendererHost() expect(host.storeOf(entry, undefined)).toBe(instance) const panelInfo = host.root.getSnapshot().hooks.panelInfo! + expect(panelInfo).toBe(layout.panelInfo) expect(panelInfo.getSnapshot()).toBe(instance.getSnapshot().panelInfo) const panelId = 'panel-a' as MainPanelId const disposePanel = slots.register({ name: 'main', key: panelId }, () => null) diff --git a/packages/client/ui-layout/tests/service.client.spec.ts b/packages/client/ui-layout/tests/service.client.spec.ts index 7ccadb5c3c..b3e3680d5c 100644 --- a/packages/client/ui-layout/tests/service.client.spec.ts +++ b/packages/client/ui-layout/tests/service.client.spec.ts @@ -1,3 +1,4 @@ +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { describe, expect, it, vi } from 'vitest' import { LayoutController } from '../src/client/service.ts' import type { MainPanelId, PanelActions } from '../src/client/service.ts' @@ -18,7 +19,7 @@ function fakePanels(): PanelActions { describe('LayoutController', () => { it('forwards right column transitions to the constructor-supplied actions', () => { const panels = fakePanels() - const service = new LayoutController(panels, () => true) + const service = new LayoutController(panels, () => true, createSnapshotStore({ activePanelId: null })) service.openRightbar(true, false) service.openRightbar(true, true) @@ -35,7 +36,7 @@ describe('LayoutController', () => { it('can toggle the sidebar immediately after construction', () => { const panels = fakePanels() - const service = new LayoutController(panels, () => true) + const service = new LayoutController(panels, () => true, createSnapshotStore({ activePanelId: null })) service.toggleSidebar() @@ -45,7 +46,7 @@ describe('LayoutController', () => { it('forwards panel selection and returning to the Conversation without changing geometry', () => { const panels = fakePanels() - const service = new LayoutController(panels, () => true) + const service = new LayoutController(panels, () => true, createSnapshotStore({ activePanelId: null })) const panelId = 'panel-a' as MainPanelId service.selectPanel(panelId) service.selectPanel(panelId) @@ -61,8 +62,8 @@ describe('LayoutController', () => { it('keeps separately constructed controllers bound to their own instances', () => { const first = fakePanels() const second = fakePanels() - const firstService = new LayoutController(first, () => true) - const secondService = new LayoutController(second, () => true) + const firstService = new LayoutController(first, () => true, createSnapshotStore({ activePanelId: null })) + const secondService = new LayoutController(second, () => true, createSnapshotStore({ activePanelId: null })) firstService.toggleSidebar() expect(first.toggleSidebar).toHaveBeenCalledTimes(1) expect(second.toggleSidebar).not.toHaveBeenCalled() @@ -74,7 +75,7 @@ describe('LayoutController', () => { it('rejects an absent main entry without changing selection or cancelling pending navigation', () => { const panels = fakePanels() const present = new Set(['panel-a']) - const service = new LayoutController(panels, id => present.has(id)) + const service = new LayoutController(panels, id => present.has(id), createSnapshotStore({ activePanelId: null })) service.selectPanel('panel-a' as MainPanelId) const navigation = service.beginNavigation() present.delete('panel-a') @@ -86,7 +87,7 @@ describe('LayoutController', () => { }) it('supersedes asynchronous navigation on another request, any valid selection, and disposal', () => { - const service = new LayoutController(fakePanels(), () => true) + const service = new LayoutController(fakePanels(), () => true, createSnapshotStore({ activePanelId: null })) const first = service.beginNavigation() const second = service.beginNavigation() expect(first.aborted).toBe(true) diff --git a/packages/client/ui-layout/tsconfig.json b/packages/client/ui-layout/tsconfig.json index 8893b1800d..b2daaaf054 100644 --- a/packages/client/ui-layout/tsconfig.json +++ b/packages/client/ui-layout/tsconfig.json @@ -34,6 +34,9 @@ }, { "path": "../ui-session" + }, + { + "path": "../shortcuts/tsconfig.client.json" } ] } diff --git a/packages/client/ui-open-in-app/README.i18n.yaml b/packages/client/ui-open-in-app/README.i18n.yaml index b0fa75db3e..e426bd97fe 100644 --- a/packages/client/ui-open-in-app/README.i18n.yaml +++ b/packages/client/ui-open-in-app/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-open-in-app/README.md -README.md: 138be128c291fc4a0611295c54768a95f33f3ae1 -README.zh.md: b81f62f9dbf8ccfbc1822b8a72df95e7169228c0 +README.md: 4a00f11998b2b3761c561508cd3f409d6c4cd3a8 +README.zh.md: 72475f9deb34d1ee65711b00f0069255a4d9a08f diff --git a/packages/client/ui-open-in-app/README.md b/packages/client/ui-open-in-app/README.md index 138be128c2..4a00f11998 100644 --- a/packages/client/ui-open-in-app/README.md +++ b/packages/client/ui-open-in-app/README.md @@ -31,6 +31,8 @@ Mount this plugin in the Web composition beside [`dsh-host-open-in-app`](../../h The Session header and document header use the same 24px-high split button with 9px corners. Both headers show only the icon; their tooltip names the default application or the reveal action. Both show the default action’s icon, mark its application with “(default)” in the menu, disable while their own gesture runs, and report failures through a transient toast. The directory adapter uses the existing cross-platform application catalog and remembers the last successful choice in `dsh.open-in-app.choice`; a missing choice falls back to the first available application. +The **Open locally** shortcut captures the main Session's directory and the same remembered application as the header button. The effective binding appears in the button tooltip and `aria-keyshortcuts`; Web follows the [shortcut service’s platform defaults](../shortcuts/README.md). The command runs only while the Conversation is selected and the header button has a directory and installed application to open. An in-flight launch also blocks the command. Pointer and keyboard gestures share the controller's launch state, so repeated gestures cannot launch twice or change the remembered choice mid-launch. + File menus list the discovered associated applications without a separate “Open in default app” row. “Show file location” stays in a fixed footer separated from the scrolling applications. A successful query with only one available action renders a single button without a dropdown. Initial file-association loading uses a gray skeleton icon. When no default application is identified, that row reads “Show file location (default)” and the main button reveals the file. Directory menus have no reveal row. Choosing a file application does not change the system default. An unpreviewable file uses the same menu in a 40px-high split button with an icon and action text; its label is “Open” or “Show file location” according to the default action. Mounted controls with the same association reader and file share one query and its results; opening either menu refreshes all of them. Releasing the last control cancels the query and discards its state. Cancelled queries cannot replace another file’s results. A failed query displays a menu message and leaves reveal available as the default action. File-association discovery follows [native-command](../../util/native-command/README.md); an empty result, including a platform without a discovery adapter, uses the same reveal fallback without platform branches in the control. @@ -43,7 +45,7 @@ Mounted controls with the same association reader and file share one query and i
Implementation internals — click to expand -The plugin registers the split button on `conversation.session.header.utilities` through the standard slot/inject currency and registers the `open-in-app` dictionaries as one effect. A page-lifetime controller ([`src/client/controller.ts`](src/client/controller.ts)) owns the once-per-page availability read, the persisted choice snapshot store, and the launch POST; the component receives both stores through the inject `hooks` compartment, so every Session header shares one truth. Document-relative route forms and wire payload types come from the host package's browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. In-flight launches are guarded by a ref — repeat clicks and menu picks during a launch are ignored whole (a pick would otherwise persist a choice the gesture never opened) — and the busy/error dress is timer-driven around the `launch` promise. +The plugin registers the split button on `conversation.session.header.utilities` through the standard slot/inject currency and registers the `open-in-app` dictionaries as one effect. A page-lifetime controller ([`src/client/controller.ts`](src/client/controller.ts)) owns the once-per-page availability read, the persisted choice snapshot store, and the launch POST; the component receives the shared sources through the inject `hooks` compartment, so every Session header shares one truth. Document-relative route forms and wire payload types come from the host package's browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. The controller guards in-flight launches and publishes their captured directory and status; header controls derive delayed busy and transient error visuals from that source. The directory and file adapters supply application metadata and operations to [`OpenTargetButton`](src/client/OpenTargetButton.tsx), which owns menu ordering, default markers, icons, sizing, and gesture feedback. The file header and empty state share `FileOpenTarget`, while `OpenPathInjected.applications` queries `session.workspacePathApplications` through [`open-path.ts`](src/client/open-path.ts). Opening uses `session.openWorkspacePath`; the Host revalidates an explicitly selected handler before launch. The directory adapter keeps the existing catalog routes. `FileRouteAction` supplies the same control to delivery cards and change review through `deliverables.file.actions` and `deliverables.review.file.actions`; their authenticated routes retain Session file authorization. A failed or unavailable file query therefore needs no platform-specific UI implementation. diff --git a/packages/client/ui-open-in-app/README.zh.md b/packages/client/ui-open-in-app/README.zh.md index b81f62f9db..72475f9deb 100644 --- a/packages/client/ui-open-in-app/README.zh.md +++ b/packages/client/ui-open-in-app/README.zh.md @@ -31,6 +31,8 @@ kind: "package-reference" 会话标题栏和文档标题栏共用同一个高 24px、圆角 9px 的分体按钮。两个标题栏都只显示图标,悬停提示显示默认应用名称或文件定位动作。两者都显示默认动作的图标,在菜单的默认应用后标注“(默认)”,仅在自己的操作执行期间禁用,并通过短暂提示报告失败。目录适配器使用已有的跨平台应用列表,将最后一次成功选择保存在 `dsh.open-in-app.choice` 中;原选择不可用时回退到第一个可用应用。 +**在本地打开**快捷键捕获主会话的目录,并使用与头部按钮相同的已记住应用。按钮 tooltip 和 `aria-keyshortcuts` 显示当前有效绑定;Web 遵循[快捷键服务的平台默认值](../shortcuts/README.zh.md)。只有选中会话界面、且头部按钮有可打开的目录和已安装应用时,命令才执行。启动尚未结束时也会阻止命令。鼠标与键盘操作共享 controller 的启动状态,重复操作不会启动两次,也不会在启动期间更改已记住的选择。 + 文件菜单列出已发现的关联应用,不单列“用默认应用打开”。“显示文件位置”固定在菜单底部,通过分隔线与滚动的应用列表分开。查询成功且只有一个可用操作时显示单按钮,不再显示下拉箭头。文件关联首次加载时使用灰色骨架图标。未识别到默认应用时,该项显示为“显示文件位置(默认)”,主按钮也执行文件定位。目录菜单不包含定位项。选择文件应用不修改系统默认应用。无法预览文件时,空态使用同一菜单,按钮增大到 40px 高并显示图标和动作文字;根据默认动作显示“打开”或“显示文件位置”。 使用同一查询函数和文件的已挂载控件共用查询与结果,打开任一菜单会刷新所有相关控件。最后一个控件释放后取消查询并清除状态。已取消的查询不会覆盖其他文件的结果。查询失败时菜单显示提示,并以文件定位作为默认动作。文件关联查询的平台支持范围见 [native-command](../../util/native-command/README.zh.md);查询结果为空时,包括平台尚无查询适配器的情况,控件统一使用定位动作,不按操作系统分支处理。 @@ -43,7 +45,7 @@ kind: "package-reference"
实现内幕——点击展开 -插件通过标准 slot/inject 机制把分体按钮注册到 `conversation.session.header.utilities`,并以一个 effect 注册 `open-in-app` 词典。一个页面生命周期的 controller([`src/client/controller.ts`](src/client/controller.ts))拥有每页一次的可用性读取、持久化选择的 snapshot store 与启动 POST;组件经 inject 的 `hooks` 隔间接收两个 store,因此所有会话头部共享同一份事实。文档相对的路由形式与 wire 载荷类型来自主机包的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared`。飞行中的启动由 ref 守卫——启动期间的重复点击与菜单选择被整体忽略(否则会持久化一个该手势从未打开的选择)——busy/error 视觉由围绕 `launch` promise 的定时器驱动。 +插件通过标准 slot/inject 机制把分体按钮注册到 `conversation.session.header.utilities`,并以一个 effect 注册 `open-in-app` 词典。一个页面生命周期的 controller([`src/client/controller.ts`](src/client/controller.ts))拥有每页一次的可用性读取、持久化选择的 snapshot store 与启动 POST;组件经 inject 的 `hooks` 隔间接收共享源,因此所有会话头部共享同一份事实。文档相对的路由形式与 wire 载荷类型来自主机包的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared`。controller 守卫执行中的启动,并发布所捕获的目录与状态;头部控件从该源派生延迟出现的等待态和短暂错误态。 目录和文件适配器把应用信息与操作交给 [`OpenTargetButton`](src/client/OpenTargetButton.tsx),由它统一管理菜单顺序、默认标记、图标、尺寸和操作反馈。文件标题栏和空态共用 `FileOpenTarget`,`OpenPathInjected.applications` 通过 [`open-path.ts`](src/client/open-path.ts) 查询 `session.workspacePathApplications`。打开操作使用 `session.openWorkspacePath`,Host 在启动前重新验证指定的关联应用。`FileRouteAction` 通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 为交付卡片和变更对比页提供同一控件,其认证路由保留会话文件校验。目录适配器继续使用已有的应用列表路由,文件查询失败或不可用时无需增加平台专用的界面实现。 diff --git a/packages/client/ui-open-in-app/package.json b/packages/client/ui-open-in-app/package.json index 1d4d53f0de..8ebf61173f 100644 --- a/packages/client/ui-open-in-app/package.json +++ b/packages/client/ui-open-in-app/package.json @@ -42,7 +42,9 @@ "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-shortcuts", "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-layout", "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-session", "@deepseek-ai/dsh-client-ui-sidebar-documentpreview" @@ -62,6 +64,7 @@ "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", @@ -72,6 +75,7 @@ "@types/react": "~18.3.1", "react": "^18.2.0", "@deepseek-ai/dsh-client-ui-deliverables": "workspace:^", - "@deepseek-ai/dsh-native-command": "workspace:^" + "@deepseek-ai/dsh-native-command": "workspace:^", + "@deepseek-ai/dsh-client-shortcuts": "workspace:^" } } diff --git a/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx b/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx index a58ed63933..532508c557 100644 --- a/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx @@ -1,7 +1,10 @@ import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' -import { NS, type OpenInAppKey } from './locales.ts' +import { APP_LABEL_KEY } from './applications.ts' +import type { OpenInAppLaunchState } from './controller.ts' +import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client' +import { NS } from './locales.ts' import { OpenTargetButton } from './OpenTargetButton.tsx' /** Browser operations and state injected into the Session Header contribution. */ @@ -9,6 +12,8 @@ export interface OpenInAppActionInjected { hooks: { openInAppApps: ObservableSnapshot openInAppChoice: ObservableSnapshot + openInAppLaunch: ObservableSnapshot + shortcuts: ObservableSnapshot } launch: (appId: string, path: string) => Promise choose: (appId: string) => void @@ -21,47 +26,6 @@ export type OpenInAppActionProps = & PropsLocale & InjectFace -/** - * Label keys per catalog id: the browser renders only ids it can name, so a - * host catalog extension without a matching dictionary entry stays invisible - * instead of showing a raw id. - */ -const APP_LABEL_KEY: Record = { - finder: 'app.finder', - explorer: 'app.explorer', - filemanager: 'app.filemanager', - cursor: 'app.cursor', - vscode: 'app.vscode', - vscodeinsiders: 'app.vscodeinsiders', - windsurf: 'app.windsurf', - zed: 'app.zed', - sublimetext: 'app.sublimetext', - xcode: 'app.xcode', - androidstudio: 'app.androidstudio', - intellij: 'app.intellij', - pycharm: 'app.pycharm', - webstorm: 'app.webstorm', - phpstorm: 'app.phpstorm', - goland: 'app.goland', - rider: 'app.rider', - rustrover: 'app.rustrover', - fork: 'app.fork', - sourcetree: 'app.sourcetree', - github: 'app.github', - tower: 'app.tower', - gitkraken: 'app.gitkraken', - smartgit: 'app.smartgit', - sublimemerge: 'app.sublimemerge', - ghostty: 'app.ghostty', - warp: 'app.warp', - iterm: 'app.iterm', - kitty: 'app.kitty', - terminal: 'app.terminal', - windowsterminal: 'app.windowsterminal', - gitbash: 'app.gitbash', - gnometerminal: 'app.gnometerminal', - konsole: 'app.konsole', -} /** * Adapt the installed directory catalog to the shared opening control. @@ -73,6 +37,8 @@ export function OpenInAppAction(props: OpenInAppActionProps): React.JSX.Element const cwd = useSessions(state => state.byId[sessionId]?.cwd) const available = useOpenInAppApps(apps => apps) const choice = useOpenInAppChoice(id => id) + const operation = props.useOpenInAppLaunch(value => value) + const shortcut = props.useShortcuts(rows => rows.find(row => row.id === 'workspace.openLocal')) const apps = (available ?? []).flatMap((id) => { const key = APP_LABEL_KEY[id] return key === undefined ? [] : [{ id, name: t(key), icon: props.iconUrl(id) }] @@ -82,6 +48,7 @@ export function OpenInAppAction(props: OpenInAppActionProps): React.JSX.Element return ( { const id = operation.kind === 'application' ? operation.id : preferred.id try { diff --git a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx index 9546c13153..87195baa7d 100644 --- a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx @@ -4,6 +4,7 @@ import type { ReactNode } from 'react' import { IconChevronDownOutlineRegular, IconFolderOpenOutlineRegular, IconRightUpOutlineRegular, Menu, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client' import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import type { OpenInAppPathFailure } from './open-path.ts' import { useOpenFailureToast } from './open-failure-toast.tsx' @@ -22,6 +23,7 @@ export type OpenTargetOperation = { readonly kind: 'default' } | { readonly kind /** Inputs shared by both target adapters and the file empty-state action. */ export interface OpenTargetButtonProps { + readonly shortcut?: Pick | undefined readonly kind: 'file' | 'directory' readonly applications: readonly OpenTargetApplication[] readonly defaultId: string | undefined @@ -117,8 +119,9 @@ export function OpenTargetButton(props: OpenTargetButtonProps): ReactNode { anchor={(
- +
) @@ -307,6 +317,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI walkIndex.current = null return } + const composition = observeComposition(document) const onPointerDown = (e: PointerEvent) => { if (!(e.target instanceof Node)) return // The portaled list is outside the anchor subtree; check both. @@ -315,12 +326,15 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI onClose() } const onKeyDown = (e: KeyboardEvent) => { + if (composition.guards(e) || isBehindModal(rootRef.current) || e.defaultPrevented || e.ctrlKey || e.altKey || e.metaKey) return // Where the keyboard is, computed once: the menu owns it when it holds a // row or sits on its anchor region. const focused = document.activeElement const insideList = listRef.current?.contains(focused) === true const anchored = rootRef.current?.contains(focused) === true || insideList - if (e.key === 'Escape') { + if (e.key === 'Escape' && !e.shiftKey) { + e.preventDefault() + if (e.repeat) return // Closing hands the keyboard back when the menu had it — and, as this // primitive always did for autoFocus menus, when it held the keyboard // and lost it again (a row that unmounted under it). @@ -389,11 +403,16 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI if (document.activeElement instanceof HTMLIFrameElement) onClose() } document.addEventListener('pointerdown', onPointerDown) - document.addEventListener('keydown', onKeyDown) + const onEscape = (event: KeyboardEvent): void => { if (event.key === 'Escape') onKeyDown(event) } + const onOtherKey = (event: KeyboardEvent): void => { if (event.key !== 'Escape') onKeyDown(event) } + document.addEventListener('keydown', onOtherKey) + document.addEventListener('keydown', onEscape, true) window.addEventListener('blur', onWindowBlur) return () => { + composition.dispose() document.removeEventListener('pointerdown', onPointerDown) - document.removeEventListener('keydown', onKeyDown) + document.removeEventListener('keydown', onOtherKey) + document.removeEventListener('keydown', onEscape, true) window.removeEventListener('blur', onWindowBlur) } }, [open, onClose, autoFocus]) @@ -432,6 +451,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI role="menuitem" className={clsx(css.item, selected && (selection === 'fill' ? css.selectedFill : css.selected), entry.danger === true && css.danger)} disabled={entry.disabled} + aria-keyshortcuts={entry.shortcut?.aria} aria-haspopup={hasSub ? 'menu' : undefined} aria-expanded={hasSub ? subOpen : undefined} onFocus={hasSub ? () => { setOpenSubmenuId(entry.id) } : undefined} @@ -445,6 +465,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI > {entry.icon !== undefined && {entry.icon}} {entry.label} + {entry.shortcut !== undefined && } {/* Selection marker is a trailing check (figma .Menu_cell) unless the fill mode carries it. */} {selected && selection === 'check' && } @@ -457,10 +478,12 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI role="menuitem" className={css.item} disabled={sub.disabled} - onClick={() => { onSelect?.(sub.id) }} + aria-keyshortcuts={sub.shortcut?.aria} + onClick={() => { onSelect?.(sub.id); refocusAfterSelection() }} > {sub.icon !== undefined && {sub.icon}} {sub.label} + {sub.shortcut !== undefined && } ))}
diff --git a/packages/client/ui-primitives/src/Modal.module.css b/packages/client/ui-primitives/src/Modal.module.css index 8611494214..977bc6372c 100644 --- a/packages/client/ui-primitives/src/Modal.module.css +++ b/packages/client/ui-primitives/src/Modal.module.css @@ -4,6 +4,7 @@ Cards size against this padding box: consumers cap growth with `max-height: 100%`, never their own viewport calc. */ .root { + pointer-events: auto; position: fixed; inset: 0; z-index: 1000; diff --git a/packages/client/ui-primitives/src/Modal.tsx b/packages/client/ui-primitives/src/Modal.tsx index 58438873a8..430e44adfa 100644 --- a/packages/client/ui-primitives/src/Modal.tsx +++ b/packages/client/ui-primitives/src/Modal.tsx @@ -1,8 +1,9 @@ -import { useEffect } from 'react' +import { useRef } from 'react' import type { ReactNode } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx' import { IconCloseOutlineRegular } from './icons/index.tsx' +import { useModalLayer } from './useModalLayer.ts' import css from './Modal.module.css' interface ModalBaseProps { @@ -14,6 +15,7 @@ interface ModalBaseProps { footer?: ReactNode className?: string contentClassName?: string + shortcutModal?: string } type ModalProps = ModalBaseProps & ( @@ -32,21 +34,17 @@ type ModalProps = ModalBaseProps & ( * @param props.children - body (inputs, etc.). * @param props.footer - action row (Cancel / Create). * @param props.contentClassName - optional class for a scrollable content region. + * @param props.shortcutModal - command scope allowed by shortcut owners; unnamed + * dialogs block application commands unless their owner allows the "other" scope. * @param props.headless - render children directly in the card (no default * header/close/body chrome); mask, card, Escape, and aria-label remain. * @returns null when closed; otherwise the overlay tree. */ export function Modal({ - open, onClose, title, closeLabel, description, children, footer, className, contentClassName, headless = false, + open, onClose, title, closeLabel, description, children, footer, className, contentClassName, headless = false, shortcutModal, }: ModalProps) { - useEffect(() => { - if (!open) return - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose() - } - document.addEventListener('keydown', onKeyDown) - return () => { document.removeEventListener('keydown', onKeyDown) } - }, [open, onClose]) + const dialog = useRef(null) + useModalLayer(dialog, open, onClose) if (!open) return null @@ -54,6 +52,9 @@ export function Modal({
) } render() const trigger = screen.getByRole('button', { name: 'trigger' }) - expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['Alpha', 'Publish']) + expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['Alpha', 'PublishCtrlP']) // The component row starts a group: one hairline, between the data row and it. const publishWrap = screen.getByRole('menuitem', { name: 'Publish' }).parentElement expect(screen.getByRole('separator').nextElementSibling).toBe(screen.getByRole('menuitem', { name: 'Publish' })) expect(publishWrap?.contains(screen.getByRole('separator'))).toBe(true) const publish = screen.getByRole('menuitem', { name: 'Publish' }) + expect(publish.getAttribute('aria-keyshortcuts')).toBe('Control+P') trigger.focus() fireEvent.keyDown(trigger, { key: 'End' }) expect(document.activeElement).toBe(publish) @@ -438,7 +439,7 @@ describe('Menu', () => { id: 'new', label: 'New Workspace', submenu: [ - { id: 'ok', label: 'Create ok', icon: }, + { id: 'ok', label: 'Create ok', icon: , shortcut: { keys: ['⌘', 'N'], aria: 'Meta+N' } }, ], }, ]} @@ -455,6 +456,8 @@ describe('Menu', () => { fireEvent.focus(parent) fireEvent.mouseEnter(wrap) expect(screen.getByTestId('sub-ic')).toBeDefined() + expect(screen.getByRole('menuitem', { name: 'Create ok' }).getAttribute('aria-keyshortcuts')).toBe('Meta+N') + expect(screen.getByRole('menuitem', { name: 'Create ok' }).querySelectorAll('kbd')).toHaveLength(2) fireEvent.click(screen.getByRole('menuitem', { name: 'Create ok' })) expect(onSelect).toHaveBeenCalledWith('ok') fireEvent.mouseLeave(wrap) diff --git a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx index a71922c828..abedb0edb1 100644 --- a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { HoverCard } from '@deepseek-ai/dsh-client-ui-primitives' +import { HoverCard, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import { POINTER_GRACE_MS } from '../src/pointer-grace.ts' afterEach(cleanup) @@ -54,6 +54,47 @@ function installClipboard(writeText: (text: string) => Promise): () => voi } describe('HoverCard', () => { + it.each(['disabled', 'unmounted'] as const)('restores its preview after a nested tooltip is %s', (released) => { + const markup = (state: 'active' | 'disabled' | 'unmounted') => ( + row{state !== 'unmounted' && ( + + )}
} + content={
card body
} + copyLabel="Copy" + copiedLabel="Copied" + /> + ) + const view = render(markup('active')) + fireEvent.pointerEnter(screen.getByText('row').parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(500) }) + fireEvent.mouseEnter(screen.getByRole('button', { name: 'action' })) + expect(screen.getByRole('tooltip').textContent).toBe('Create') + expect(screen.queryByText('card body')).toBeNull() + + view.rerender(markup(released)) + expect(screen.queryByRole('tooltip')).toBeNull() + expect(screen.getByText('card body')).toBeTruthy() + }) + + it('keeps card content mounted while its own tooltip is visible', () => { + render(row} + content={
card body
} + copyLabel="Copy" + copiedLabel="Copied" + />) + fireEvent.pointerEnter(screen.getByText('row').parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByText('card body') + fireEvent.mouseEnter(screen.getByRole('button', { name: 'card action' })) + expect(card.isConnected).toBe(true) + expect(screen.getByRole('tooltip').textContent).toBe('Details') + act(() => { vi.advanceTimersByTime(1000) }) + expect(card.isConnected).toBe(true) + expect(screen.getByRole('tooltip').textContent).toBe('Details') + }) + it('opens after the dwell delay, positioned right of the anchor', () => { const { wrapper } = mount() fireEvent.pointerEnter(wrapper) diff --git a/packages/client/ui-primitives/tests/modal-layer.client.spec.tsx b/packages/client/ui-primitives/tests/modal-layer.client.spec.tsx new file mode 100644 index 0000000000..416b137490 --- /dev/null +++ b/packages/client/ui-primitives/tests/modal-layer.client.spec.tsx @@ -0,0 +1,125 @@ +// @vitest-environment jsdom +import { useState } from 'react' +import { afterEach, expect, it } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { Modal } from '../src/Modal.tsx' +import { isBehindModal } from '../src/useModalLayer.ts' +import { Menu } from '../src/Menu.tsx' + +afterEach(cleanup) +function Nested() { + const [settings, setSettings] = useState(false) + const [reference, setReference] = useState(false) + const [menu, setMenu] = useState(false) + return <> + + { setSettings(false) }}> + + + { setReference(false) }}> + + { setMenu(true) }}>Menu} + items={[{ id: 'item', label: 'Item' }]} onSelect={() => { setMenu(false) }} onClose={() => { setMenu(false) }} /> + + +} +const escape = (init = {}) => fireEvent.keyDown(document.activeElement ?? document, { key: 'Escape', code: 'Escape', ...init }) +it('closes one layer per Escape, honors local menus and IME, and restores the real opener', () => { + expect(isBehindModal(null)).toBe(false) + render() + const settings = screen.getByRole('button', { name: 'Settings' }); settings.focus(); fireEvent.click(settings) + const reference = screen.getByRole('button', { name: 'Reference' }); reference.focus(); fireEvent.click(reference) + expect(document.activeElement).toBe(screen.getByRole('textbox')) + fireEvent.compositionStart(screen.getByRole('textbox')) + escape() + fireEvent.compositionEnd(screen.getByRole('textbox')) + escape() + escape({ isComposing: true }); escape({ ctrlKey: true }); escape({ repeat: true }) + expect(screen.getAllByRole('dialog')).toHaveLength(2) + fireEvent.click(screen.getByRole('button', { name: 'Menu' })) + fireEvent.compositionStart(document.activeElement!) + fireEvent.compositionEnd(document.activeElement!) + escape() + expect(screen.getByRole('menu')).toBeTruthy() + fireEvent.keyUp(document.activeElement!, { key: 'Escape' }) + escape() + expect(screen.queryByRole('menu')).toBeNull() + expect(screen.getAllByRole('dialog')).toHaveLength(2) + escape() + expect(screen.getAllByRole('dialog')).toHaveLength(1) + expect(document.activeElement).toBe(reference) + escape() + expect(screen.queryByRole('dialog')).toBeNull() + expect(document.activeElement).toBe(settings) +}) + +it('keeps Tab within the top dialog and releases listeners after unmount', () => { + const view = render() + fireEvent.click(screen.getByRole('button', { name: 'Settings' })) + const last = screen.getByRole('button', { name: 'Reference' }); last.focus() + fireEvent.keyDown(last, { key: 'Tab' }) + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Close settings' })) + fireEvent.keyDown(document.activeElement!, { key: 'Tab', shiftKey: true }) + expect(document.activeElement).toBe(last) + view.unmount() + const event = new KeyboardEvent('keydown', { key: 'Escape', cancelable: true }) + document.dispatchEvent(event) + expect(event.defaultPrevented).toBe(false) +}) + +it('keeps focus in a headless empty dialog and leaves portaled menu traversal to its owner', () => { + render( {}} />) + const dialog = screen.getByRole('dialog') + expect(document.activeElement).toBe(dialog) + fireEvent.keyDown(dialog, { key: 'Tab' }) + expect(document.activeElement).toBe(dialog) + fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true }) + expect(document.activeElement).toBe(dialog) + const menu = document.createElement('div') + menu.setAttribute('role', 'menu') + const button = document.createElement('button') + menu.append(button); document.body.append(menu) + try { + button.focus() + expect(fireEvent.keyDown(button, { key: 'Tab' })).toBe(true) + fireEvent.blur(window) + } finally { menu.remove() } +}) + +it('does not steal focus when a lower layer disappears and restores a remaining parent after opener removal', () => { + const tree = (parent: boolean, child: boolean, opener: boolean) => <> + {}}> + {opener && } + + {}}> + + const view = render(tree(true, false, true)) + screen.getByRole('button', { name: 'Child opener' }).focus() + view.rerender(tree(true, true, true)) + view.rerender(tree(true, true, false)) + view.rerender(tree(true, false, false)) + expect(document.activeElement).toBe(screen.getByRole('dialog', { name: 'Parent' })) + view.rerender(tree(true, false, true)) + screen.getByRole('button', { name: 'Child opener' }).focus() + view.rerender(tree(true, true, true)) + const input = screen.getByRole('textbox') + view.rerender(tree(false, true, true)) + expect(document.activeElement).toBe(input) + view.rerender(tree(false, false, true)) + expect(screen.queryByRole('dialog')).toBeNull() +}) + +it('gives a new modal Escape ahead of an older menu', () => { + let menuClosed = false + let childClosed = false + render(<> + {}}> + Menu} items={[{ id: 'item', label: 'Item' }]} + onSelect={() => {}} onClose={() => { menuClosed = true }} /> + + { childClosed = true }}> + ) + escape() + expect(menuClosed).toBe(false) + expect(childClosed).toBe(true) +}) diff --git a/packages/client/ui-settings-account/README.i18n.yaml b/packages/client/ui-settings-account/README.i18n.yaml index a6b62d6056..4d7f0242be 100644 --- a/packages/client/ui-settings-account/README.i18n.yaml +++ b/packages/client/ui-settings-account/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-account/README.md -README.md: be38bcd68ab7affe4aa840499ef79009ba25b779 -README.zh.md: 9429d21cbd988a538c18320acbbfcb65dd6acfc0 +README.md: 9c11291d598b8a0292bb8f89ad28a8b17c42e25e +README.zh.md: 6ac220493610d773a35f14dd6fe60576dccf254a diff --git a/packages/client/ui-settings-account/README.md b/packages/client/ui-settings-account/README.md index be38bcd68a..9c11291d59 100644 --- a/packages/client/ui-settings-account/README.md +++ b/packages/client/ui-settings-account/README.md @@ -32,7 +32,7 @@ Desktop usage and top-up actions open an isolated native Platform view below a 4 The account menu's Feedback entry opens the Feishu questionnaire in the system browser. It supplies the available build version, UI locale and physical screen resolution as prefill_* parameters, with hide_*=1 for every context field; the account UID, tokens and contact details are excluded. Configure contactFormUrl on the ui-settings-account plugin to select another HTTPS form. contactSource defaults to empty until the questionnaire supports a Harness source option; OS and device fields remain unfilled, matching the Web implementation. -The sidebar account menu uses the shared Menu surface, backdrop blur, spacing, and row typography; feature styles only size its launcher. +The sidebar account menu uses the shared Menu surface, backdrop blur, spacing, and row typography; feature styles only size its launcher. Its Settings row shows the effective key combination supplied by the shell. Closing Settings returns focus to the sidebar account launcher. The account card’s More account information link opens `https://platform.deepseek.com` in the system browser. diff --git a/packages/client/ui-settings-account/README.zh.md b/packages/client/ui-settings-account/README.zh.md index 9429d21cbd..6ac2204936 100644 --- a/packages/client/ui-settings-account/README.zh.md +++ b/packages/client/ui-settings-account/README.zh.md @@ -32,7 +32,7 @@ Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Pl 账号菜单的“意见反馈”入口在系统浏览器中打开飞书问卷。链接通过 prefill_* 参数填写已有的构建版本、界面语言和屏幕物理分辨率,并为所有上下文字段设置 hide_*=1;不传账号 UID、token 或联系方式。可在 ui-settings-account 插件配置 contactFormUrl,切换到另一个 HTTPS 问卷。问卷支持 Harness 来源选项前,contactSource 默认为空;OS 和设备字段沿用 Web 实现,保持未填写。 -侧边栏账号菜单使用公共 Menu 的表面、背景模糊、间距和菜单项排版;功能样式仅设置入口布局。 +侧边栏账号菜单使用公共 Menu 的表面、背景模糊、间距和菜单项排版;功能样式仅设置入口布局。设置菜单项显示外壳传入的有效组合键。关闭设置后,焦点回到侧栏账号入口。 账号卡片的“更多账号信息”链接通过系统浏览器打开 `https://platform.deepseek.com`。 diff --git a/packages/client/ui-settings-account/src/client/AccountMenu.tsx b/packages/client/ui-settings-account/src/client/AccountMenu.tsx index 606312ae2d..49d8027acb 100644 --- a/packages/client/ui-settings-account/src/client/AccountMenu.tsx +++ b/packages/client/ui-settings-account/src/client/AccountMenu.tsx @@ -1,5 +1,5 @@ /** Sidebar account launcher and locally authoritative sign-out action. */ -import { useState } from 'react' +import { useRef, useState } from 'react' import { Menu, IconPaperPlaneOutlineMedium, IconSettingsOutlineMedium, IconUserOutlineMedium } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { AccountSectionInjected } from './AccountSection.tsx' @@ -16,7 +16,7 @@ export type AccountMenuProps = PropsRuntime<'settings.launcher'> & PropsLocale<' * @returns account menu launcher. */ export function AccountMenu({ - wide, openSettings, openOnboarding, useAccount, useTheme, signOut, contactUs, showLogin, start, cancel, t, + wide, settingsShortcut, openSettings, openOnboarding, useAccount, useTheme, signOut, contactUs, showLogin, start, cancel, t, }: AccountMenuProps) { const account = useAccount(state => state) const colorScheme = useTheme(snapshot => snapshot.active.colorScheme) @@ -25,6 +25,7 @@ export function AccountMenu({ const label = profile === undefined ? null : profile.status === 'ready' ? profile.value.name ?? profile.value.contact ?? t('signedIn') : t('signedIn') const [open, setOpen] = useState(false) + const trigger = useRef(null) const [busy, setBusy] = useState(false) const [logoutFailed, setLogoutFailed] = useState(false) const logout = async () => { @@ -38,20 +39,21 @@ export function AccountMenu({ const beginSignIn = (): void => { setOpen(false); void start().catch(() => undefined) } return
{ setOpen(value => !value) }}> {wide && {signedIn ? label : t('signedOut')}} } items={[ - { id: 'settings', label: t('settings'), icon: }, + { id: 'settings', label: t('settings'), icon: , + ...(settingsShortcut === undefined ? {} : { shortcut: settingsShortcut }) }, { id: 'contact', label: t('contactUs'), icon: }, ...(signedIn ? [{ id: 'signout', label: t('signOut'), icon: , disabled: busy }] : [{ id: 'signin', label: t('signIn'), icon: }]), ]} onClose={() => { setOpen(false) }} onSelect={(id) => { - if (id === 'settings') { setOpen(false); openSettings() } + if (id === 'settings') { setOpen(false); trigger.current?.focus(); openSettings() } else if (id === 'contact') { setOpen(false); contactUs() } else if (id === 'signin') beginSignIn() else void logout() diff --git a/packages/client/ui-settings-account/tests/account.client.spec.tsx b/packages/client/ui-settings-account/tests/account.client.spec.tsx index 4e4351f411..0547dc7827 100644 --- a/packages/client/ui-settings-account/tests/account.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/account.client.spec.tsx @@ -6,6 +6,7 @@ import type { AccountDetails, AccountView, SignInAttemptId } from '@deepseek-ai/ import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' import type { PlatformBridge } from '../src/client/PlatformOverlay.tsx' import { AccountSection, type AccountSectionInjected, type AccountSnapshot } from '../src/client/AccountSection.tsx' +import type { AccountMenuProps } from '../src/client/AccountMenu.tsx' import type {} from '../src/client/index.ts' import { en, zh, type AccountKey } from '../src/client/locales.ts' @@ -90,6 +91,34 @@ it.each([en, zh])('opens settings and signs out from the sidebar account menu', expect(screen.queryByRole('menu')).toBeNull() }) +it.each([en, zh])('updates the Settings menu keycaps and accessible combination from its owner', async (copy) => { + const operations = mount({ status: 'signed-out', attempt: null }, copy) + cleanup() + const { AccountMenu } = await import('../src/client/AccountMenu.tsx') + const props: AccountMenuProps = { + ...({} as GlobalStandardProps), ...operations, wide: true, + useAccount: selector => selector(operations.hooks.account.getSnapshot()), + openSettings: vi.fn(() => { expect(document.activeElement).toBe(screen.getByRole('button', { name: copy.menu })) }), + openOnboarding: vi.fn(), + t: key => key in copy ? copy[key as AccountKey] : key, + } + const view = render() + fireEvent.click(screen.getByRole('button', { name: copy.menu })) + const settings = screen.getByRole('menuitem', { name: copy.settings }) + expect(settings.getAttribute('aria-keyshortcuts')).toBe('Meta+,') + expect([...settings.querySelectorAll('kbd')].map(key => key.textContent)).toEqual(['⌘', ',']) + + view.rerender() + expect(settings.getAttribute('aria-keyshortcuts')).toBe('Control+Shift+S') + expect([...settings.querySelectorAll('kbd')].map(key => key.textContent)).toEqual(['Ctrl', 'Shift', 'S']) + + view.rerender() + expect(settings.hasAttribute('aria-keyshortcuts')).toBe(false) + expect(settings.querySelector('kbd')).toBeNull() + fireEvent.click(settings) + expect(props.openSettings).toHaveBeenCalledOnce() +}) + it('reports a failed start in the login dialog, not as a sidebar alert', async () => { const operations = mount({ status: 'signed-out', attempt: null }) cleanup() diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index d0491ebef3..4ffadd18b4 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md -README.md: 36d3fd7b260f95cffb67977e5a6f37852bf195aa -README.zh.md: 9df8fe8bc3a39fceeccfc8b08a8e18cc5eeaf752 +README.md: d98ddc336852becbe2a6f4b80de487d9a493b995 +README.zh.md: c89f804b4927cdc1bd90de48879e5f7b610462db diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index 36d3fd7b26..d98ddc3368 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -33,6 +33,8 @@ When the section navigation exceeds the panel's available height, the list scrol In Desktop, the account-row update control shows availability, progress, verification, readiness, and persistent retry feedback. It shares the connection indicator’s 28px height, 13px corners, 14px icon slot, 4px icon gap, and 12px medium text with an 18px line height; update-state border, fill, and text colors remain independently defined. Retry text and its dot use the same brand blue as other update labels; numeric download progress has no ellipsis. The preload carries semantic phase, version, progress, and classified failures; the component resolves every visible and accessible string from the active `settings` locale, including after an in-application language change. Selecting an available update starts downloading; installation requires a separate shell-owned confirmation. A collapsed sidebar shows the same status as a brand-blue dot on its top expand button, including failures. Connection feedback takes priority except during shell-reported installation, when the expected backend disconnect must not hide update status. Failure restores connection feedback. Both controls share one carrier subscription; browser code cannot choose packages or authorize installation. [Desktop updates](../../../apps/desktop/README.md) owns the release workflow. +Settings visibility and section selection live in the shell owner store. The shell supplies the effective Settings binding to the contributed launcher for menu keycaps and `aria-keyshortcuts`; the fallback button uses the same binding for hover and keyboard-focus hints and `aria-keyshortcuts`. Desktop Mod+, and the sidebar control open the same dialog; repeated commands keep it open. Settings and the shortcut reference use body portals at the same modal layer. The last opened dialog appears above the other and owns keyboard focus and Escape; closing it restores its invoking control. + ### The General section The current release version appears at the bottom of General Settings in Web and Desktop, using the build’s `DSH_CLIENT_VERSION` metadata and the active language. Partial builds without version metadata omit the row. diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 9df8fe8bc3..c89f804b49 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -33,6 +33,8 @@ kind: "package-reference" 在 Desktop 中,账户行更新控件显示可用版本、进度、验证、就绪和持久重试反馈。它与连接指示器共用 28px 高度、13px 圆角、14px 图标占位、4px 图文间距,以及 12px 中等字重文字和 18px 行高;更新状态的边框、填充和文字颜色独立定义。重试文字及其圆点与其他更新文案使用相同的品牌蓝色;数字下载进度不带省略号。预加载仅传递语义化阶段、版本、进度和失败类别;组件从当前 `settings` locale 解析全部可见与无障碍文案,并在应用内语言变化后同步更新。选择可用更新即开始下载;安装需要独立的壳拥有的确认。侧栏收起时,其顶部展开按钮以品牌蓝色圆点显示相同状态,包括失败状态。连接反馈通常优先展示,但壳报告正在安装时,预期的后端断开不能遮盖更新状态;失败后恢复连接反馈。两个控件共用一次载体订阅;浏览器代码不能选择安装包或授权安装。[Desktop 更新](../../../apps/desktop/README.zh.md)负责发布流程。 +设置的可见状态和分区选择由 shell owner store 保存。 外壳将设置命令的有效绑定传给入口贡献,用于菜单键帽和 `aria-keyshortcuts`;回退按钮使用相同绑定提供悬停、键盘聚焦提示和 `aria-keyshortcuts`。桌面 Mod+, 与侧栏控件打开同一弹窗;重复命令保持其打开。设置和快捷键速查通过 body portal 使用同一模态层级。最后打开的弹窗显示在另一弹窗上方,并拥有键盘焦点和 Esc;关闭后恢复触发控件的焦点。 + ### 「通用」分区 Web 与桌面端的通用设置底部显示当前发布版本,使用构建注入的 `DSH_CLIENT_VERSION` 元数据和当前语言。缺少版本元数据的局部构建不显示该行。 diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 5791636067..be1a4cf59d 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -35,7 +35,8 @@ "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-api-remotes", - "@deepseek-ai/dsh-client-ui-sidebar" + "@deepseek-ai/dsh-client-ui-sidebar", + "@deepseek-ai/dsh-client-shortcuts" ], "platform": "web" } @@ -66,9 +67,12 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-remote-mock": "workspace:^", "@types/react": "~18.3.1", + "@types/react-dom": "~18.3.0", "clsx": "^2.0.0", "react": "^18.2.0", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/dsh-settings": "workspace:^", + "react-dom": "^18.2.0", + "@deepseek-ai/dsh-client-shortcuts": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-settings-general/src/client/SettingsRoot.tsx b/packages/client/ui-settings-general/src/client/SettingsRoot.tsx index 7a1f4fb920..66c1a0ae5d 100644 --- a/packages/client/ui-settings-general/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings-general/src/client/SettingsRoot.tsx @@ -5,15 +5,16 @@ * close label, sections) arrives from registrants through slots; accessible * names resolve from localized content (trigger: shell locale; dialog: * aria-labelledby the title node; close: visually-hidden slot text). Modal - * open state and the active section id are component-local viewing state; + * open state and the active section id belong to the declared owner store; * the onboarding coordinator mounts exactly one ordered registrant while the * sessions-derived empty-Hero fact is active. Visible dialog chrome belongs * to the step, so a mounted-but-deciding step paints nothing here. */ import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react' import clsx from 'clsx' +import { createPortal } from 'react-dom' import { - ConnectionIndicator, + ConnectionIndicator, Tooltip, useModalLayer, IconAgentPresetOutlineMedium, IconArchiveOutlineMedium, IconCloseOutlineRegular, IconDataOutlineMedium, IconPersonalizationOutlineMedium, IconSettingsOutlineMedium, IconUserOutlineMedium, } from '@deepseek-ai/dsh-client-ui-primitives' @@ -46,7 +47,7 @@ type PanelProps = { } /** - * The modal layer: full-viewport mask + centered panel. Close paths: the + * Body-portaled modal layer: full-viewport mask + centered panel. Close paths: the * header button, a mask click, and document-level Escape (mounted only while * open, so the listener lifetime is the panel's). */ @@ -56,22 +57,13 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP const active = rows.find(r => r.id === activeId)?.id ?? rows[0]?.id const titleId = useId() - useEffect(() => { - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose() - } - document.addEventListener('keydown', onKeyDown) - return () => { document.removeEventListener('keydown', onKeyDown) } - }, [onClose]) + const panel = useRef(null) + useModalLayer(panel, true, onClose) - // Entering the dialog focuses the close button; the root restores its trigger on close. - const closeButton = useRef(null) - useEffect(() => { closeButton.current?.focus() }, []) - - return ( + return createPortal((