diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4bdb7d64f..b898d575a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,6 +92,10 @@ jobs: --exclude 'packages/renderer-extension/test/**' --exclude 'tools/gate-claude-code/run.test.mjs' + # Real Launcher + Node service, with isolated HOME/data and fake Harnesses. + - name: Test independent Host lifecycle + run: npm run test:shared-host + # Rust formatting is already covered by the Linux x64 formatting step. - name: Check Rust run: > @@ -146,6 +150,8 @@ jobs: run: npm ci --prefer-offline # Linux x64 owns TS tests; ARM64 verifies native code and the installed package. + - name: Test independent Host lifecycle + run: npm run test:shared-host - name: Check Rust run: > diff --git a/.gitignore b/.gitignore index 50003d2c8..959629564 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ # Dependencies and tool caches node_modules/ +.work/ +.dsh-build/ +/apps/web-ui/**/lib/ .npm/ .cache/ .wix/ diff --git a/.prettierignore b/.prettierignore index ebadfaa33..101680903 100644 --- a/.prettierignore +++ b/.prettierignore @@ -15,3 +15,8 @@ package-lock.json Cargo.lock # Generated models.dev price snapshot. packages/host-runtime/src/model-prices.generated.ts +# Pinned upstream browser protocol codecs. +apps/web-ui/**/generated/ +# Imported browser build outputs and local diagnostics. +apps/web-ui/**/lib/ +.work/ diff --git a/AGENTS.md b/AGENTS.md index 3ed051185..04a4edb06 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,7 +83,8 @@ Existing Harness integration and Desktop update audit skills remain applicable t ## Testing & Completion - Choose build and validation commands from `package.json`, not a copied command catalog. For focused tests, use the repository configuration in `tests/vitest.config.js` or `tests/e2e/playwright.config.js`, and the owning `Cargo.toml` for Rust test features. -- To build and launch from source, run `npm start` at the repository root; `npm start -- --no-build` reuses existing artifacts. On macOS/Windows this stops running Codex Desktop processes before launch. Use it when launching is intended, not as a routine validation command; implementation is in `tools/dev-desktop/run.mjs`. +- To build and launch from source, run `npm start` at the repository root for Desktop or `npm start web` for Web; both restart the corresponding interface after building, reusing an unchanged shared Host. A changed Host restarts automatically only when the other interface is closed; otherwise a post-build prompt defaults to keeping Host (non-interactive runs keep it and warn). `--restart-host` / `--keep-host` override this choice; no running Host is left to the interface's `host ensure`. `--no-build` reuses existing artifacts and applies the same Host decision. Restarting Host interrupts its tasks. `npm start web` rebuilds Web UI sources on edit and swaps them into open pages by default; `--no-watch` disables this, while explicit `--watch` remains supported. `--no-build` does not disable watching. On macOS/Windows the Desktop command stops running Codex Desktop processes before launch. Use it when launching is intended, not as a routine validation command; implementation is in `tools/dev-desktop/run.mjs`. +- `npm stop` stops the current user's identifiable local codexhost services across worktrees (Web/watchers, managed Desktop, shared Hosts and Console), interrupting tasks and Web terminals without deleting data. It signals verified service processes and waits for native supervisors to finish cleanup, without using the invoking checkout's build. Run only when stopping is intended, from a separate terminal; never use it as routine validation or from a Host-owned task. Implementation: `tools/dev-desktop/stop.mjs`. - Small, low-risk changes do not require tests. For high-risk or cross-package changes, or when explicitly requested, add focused tests for changed behavior and boundary conditions; do not run full test suites by default. - Do not claim a check passed unless it was executed. Report skipped or blocked checks and the reason. - A change is complete only when implementation, contracts, tests, and affected documentation agree. diff --git a/README.md b/README.md index 21019d14c..f124de9ff 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,10 @@ https://github.com/user-attachments/assets/c48192d7-23ff-4f6e-b61a-6345a655bb76 Claude Code, Pi, Grok Build, and Oh My Pi sessions running in Codex Desktop, with Diff review, Fork, Worktree, and Agent switching +## Standalone Web development + +This checkout also contains a Web/PWA application. Run `npm ci`, then `npm start web` to build and start an authenticated Web client with Web UI hot reload enabled by default. Use `npm start web -- --no-watch` to disable hot reload. The development command restarts this checkout's Web service on the selected port and reuses an unchanged shared Host. If the Host changes, it restarts automatically when Desktop is closed; otherwise a post-build prompt defaults to keeping it (non-interactive runs keep it and warn). Restarting Host interrupts its tasks. Web and Desktop can each be used alone, share canonical external Threads when both are open, and close without stopping the other or its tasks. Existing saved GUI project metadata remains read-only; no GUI projects are created. This does not launch or restart Desktop. A running older Desktop-owned Host is not taken over; update/restart it separately to transition. See [the Web guide](docs/product/standalone-web.md) for connection requirements, plugin distribution and current limitations. + ## Quick Start **Option 1: npm** (macOS / Windows / Linux) @@ -84,7 +88,7 @@ Run `codexhost update` to check for the latest release and prepare an upgrade fr **codexhost does not start, or Codex opens without codexhost features** -Run `codexhost console` (Windows: Start Menu → "codexhost console") to open the local console at `http://127.0.0.1:4399/`. It shows why the last start failed, the Codex Desktop version, Host Runtime logs, and can update codexhost while Codex is not running. It starts together with codexhost: installer launches open it in your browser, and terminal launches print its address. +Run `codexhost console` (Windows: Start Menu → "codexhost console") to open the local console at `http://127.0.0.1:4399/`. It shows why the last start failed, the Codex Desktop version, Host Runtime logs, and can update codexhost while Codex is not running. It starts together with codexhost: installer launches open it in your browser only when startup or Codex UI integration fails, and terminal launches print its address only when startup fails. **macOS: "App can't be verified" on first launch** @@ -296,6 +300,21 @@ npm ci npm start ``` +| Command | Purpose | +| --- | --- | +| `npm start` | Build and start Desktop. | +| `npm start web` | Build and start Web with Web UI hot reload (default). | +| `npm start web -- --no-watch` | Build and start Web without hot reload. | +| `npm stop` | Stop the current user's local codexhost services across worktrees. | + +Explicit `--watch` remains supported; it cannot be combined with `--no-watch`. `--no-build` skips the initial full build but does not disable watching. + +Web UI hot reload swaps rebuilt client plugins in open pages; shell changes reload the page. Plugin React state may reset. Changes to Host, Web server or the CodexHost settings page still require restarting. See [Web UI hot reload](docs/product/standalone-web.md#前端开发热更新) for details. + +`npm start` rebuilds and restarts Desktop; `npm start web` rebuilds and restarts Web. Both reuse an unchanged shared Host. A changed Host is restarted if the other interface is closed; otherwise a post-build prompt defaults to keeping it. `--restart-host` / `--keep-host` override that decision. `--no-build` skips building but applies the same decision. Restarting Host interrupts its tasks. + +Run `npm stop` from a separate terminal to stop the current user's local codexhost services across worktrees, including managed Desktop, Web/watchers, shared Hosts and Console. It interrupts tasks and Web terminals but preserves data and build artifacts, and requires no build in the invoking checkout. See [cross-worktree shutdown](docs/product/standalone-web.md#跨工作树停机) for scope and limitations; `npm stop -- --help` is read-only. + To run the current source with a specific runtime version, use `npm start 0.12.0` (prereleases such as `0.13.0-rc.1` are also accepted). Host status, console version and update-check version reporting use this value. Without a version argument the version remains `-dev`; `npm start 0.12.0 -- --no-build` reuses artifacts after building once. The override lasts for this launch and does not modify version files or publish packages. Source installations still lack packaged self-update resources; remote npm installation downloads the published package with that version, not the local source. diff --git a/apps/web-ui/LICENSE b/apps/web-ui/LICENSE new file mode 100644 index 000000000..c1f7a78e8 --- /dev/null +++ b/apps/web-ui/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 DeepSeek + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/web-ui/README.md b/apps/web-ui/README.md new file mode 100644 index 000000000..8bc346bb0 --- /dev/null +++ b/apps/web-ui/README.md @@ -0,0 +1,15 @@ +# CodexHost Web UI + +Private browser workspaces imported from the MIT-licensed `deepseek-harness` 0.2.1-alpha.1 Web UI and the local `codexhost-web` fork. The import keeps the selected client plugins, their browser runtime and protocol dependencies, and local CodexHost/mobile changes. It does not include the upstream CLI, release machinery, independent agent execution, or a second Git repository. + +The source roster is in `import-manifest.json`. `packages/` and `vendor/` are npm workspaces of the parent CodexHost repository; install dependencies once at that repository root. `npm run build:web:ui` compiles plugin factories with the imported CSS/module-loader preset and builds `apps/web` with Vite. `lib/` and `dist/` are generated and ignored. All Harness execution belongs to the parent repository's Adapters and Web Server, not these browser packages. + +The `generated/remote.js` and matching declarations in selected packages are pinned DSH protocol codecs from the imported fork's generated Host descriptors. They are protocol source snapshots, not copies of Harness SDKs or bundled applications. Keep them aligned with the Web Server's DSH protocol compatibility layer; do not generate them from the Desktop Host. The full upstream frontend type-check and test programs are not imported yet; the current integration uses source compilation and the Web Server's browser regression tests. + +The right Sidebar also embeds upstream host services: `packages/fs/fs`, `packages/fs/fs-local`, `packages/util/timeout`, `packages/sandbox/sandbox-policy`, `packages/core/scope`, `packages/subprocess/*`, `packages/util/http-proxy`, `packages/util/lazy-require`, and `packages/document/office-to-pdf` are the imported local-filesystem, sandbox-policy, subprocess/PTY, and Office-conversion sources. `packages/host/dsh-web-backend` is the CodexHost adapter that mounts the Typert registry, local filesystem, Connection, gateway, workspace files, Office conversion, subprocess, and terminal services for `packages/web-server`; it supplies only a local Thread's working directory, the sandbox policy's workspace root, and the four Agent fields the terminal controller reads. The imported client plugins are the file tree, document preview, terminal, and sandboxed browser tabs; `packages/test-support/remote-mock` supports their client tests. The one upstream source adaptation in the gateway is `packages/api/gateway/src/index.ts`, which imports the runtime `OperatorPeer` from `src/operator-peer.ts` rather than the Connection plugin root so the embedded backend does not pull the upstream browser-auth and credentials chain; `subprocess-local/src/runner-launch.ts` additionally honors `CODEXHOST_SUBPROCESS_RUNNER` so a packed distribution can run its bundled runner. Dependency patches for FortuneSheet, ExcelJS, and node-pty are copied to the repository `patches/` directory and applied by `scripts/apply-dependency-patches.mjs` after install. `tests/dsh` holds the imported workspace file and terminal-controller tests; they run under Vitest with the same standard-decorator pre-transform the upstream repository uses. + +`packages/client/hmr` is the browser half of the upstream `@deepseek-ai/dsh-client-hmr` plugin; its DSH Host half is replaced by `packages/web-server/src/web-hmr.ts`, and the plugin is composed only when the Web Server runs with `--dev-web-ui` (`npm start web` enables this by default and runs `scripts/build.ts --watch`; `--no-watch` disables it). Its one local adaptation is a `shell` frame that reloads the page, because the statically linked shell cannot be swapped. + +The upstream MIT license is retained in `LICENSE`, framework package licenses remain under `vendor/`, and upstream dependency notices are retained in `THIRD_PARTY_NOTICES.md`. Local import adaptations include private npm manifests, source exports, the selected source build, and removal of the upstream standalone Web Worker preview entry. The root LGPL license does not replace these notices. + +See [the Web feature document](../../docs/product/standalone-web.md) for runtime isolation, commands and supported behavior. diff --git a/apps/web-ui/THIRD_PARTY_NOTICES.md b/apps/web-ui/THIRD_PARTY_NOTICES.md new file mode 100644 index 000000000..3602ce87c --- /dev/null +++ b/apps/web-ui/THIRD_PARTY_NOTICES.md @@ -0,0 +1,297 @@ + + +# Third-Party Notices + +DeepSeek Harness is licensed under [MIT](LICENSE). It depends on the third-party software listed below. Each project remains under its own license; nothing in this file changes those terms. + +This file lists **direct** dependencies declared by the workspace, the explicitly disclosed official Claude Code platform payload closure, and the Bundled Python distributions. It is generated by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check. + +The complete npm transitive closure, including the Landlock launcher workspace, is recorded with exact pinned versions in [`pnpm-lock.yaml`](pnpm-lock.yaml) — inspect it with `pnpm licenses list`. The Python SDK closure is recorded separately in [`python/sdk/uv.lock`](python/sdk/uv.lock). + +## Vendored source (`vendor/`) + +The Cordis framework and its foundation libraries are source-vendored into this repository rather than consumed from npm, and republished under the `@deepseek-ai` scope. All are MIT-licensed; each directory preserves its upstream `LICENSE` file. Exact upstream commits and local modifications are recorded in [`vendor/README.md`](vendor/README.md). + +| Package | Upstream name | Source | License | +| --- | --- | --- | --- | +| `@deepseek-ai/cosmokit` | `cosmokit` | [vendor/cosmokit](vendor/cosmokit/) | MIT | +| `@deepseek-ai/schemastery` | `schemastery` | [vendor/schemastery](vendor/schemastery/) | MIT | +| `@deepseek-ai/cordis` | `cordis` | [vendor/cordis](vendor/cordis/) | MIT | +| `@deepseek-ai/cordis-plugin-loader` | `@cordisjs/plugin-loader` | [vendor/loader](vendor/loader/) | MIT | +| `@deepseek-ai/cordis-plugin-include` | `@cordisjs/plugin-include` | [vendor/include](vendor/include/) | MIT | +| `@deepseek-ai/cordis-plugin-group` | `@cordisjs/plugin-group` | [vendor/group](vendor/group/) | MIT | +| `@deepseek-ai/cordis-plugin-timer` | `@cordisjs/plugin-timer` | [vendor/timer](vendor/timer/) | MIT | +| `@deepseek-ai/cordis-plugin-hmr` | `@cordisjs/plugin-hmr` | [vendor/hmr](vendor/hmr/) | MIT | +| `@deepseek-ai/cordis-plugin-logger-console` | `@cordisjs/plugin-logger-console` | [vendor/logger-console](vendor/logger-console/) | MIT | + +## Runtime npm dependencies + +External packages installed for runtime use or distributed inside the prebuilt browser artifacts. Browser inputs are resolved through the shipping tsdown and Vite configurations, independently of npm dependency sections. The tier covers every plugin a user can mount from `cordis.yml` — not only what the `dsh` CLI, Web UI, and Python SDK runtime load by default. + +| Package | License | +| --- | --- | +| [`@agentclientprotocol/sdk`](https://github.com/agentclientprotocol/typescript-sdk) | Apache-2.0 | +| [`@anthropic-ai/claude-agent-sdk`](https://github.com/anthropics/claude-agent-sdk-typescript) | SEE LICENSE IN README.md | +| [`@anthropic-ai/sdk`](https://github.com/anthropics/anthropic-sdk-typescript) | MIT | +| [`@babel/code-frame`](https://github.com/babel/babel) | MIT | +| [`@browserbasehq/stagehand`](https://github.com/browserbase/stagehand) | MIT | +| [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-ai/dsh-libreoffice-kit) | MPL-2.0 | +| [`@earendil-works/pi-ai`](https://github.com/earendil-works/pi) | MIT | +| [`@eslint-community/regexpp`](https://github.com/eslint-community/regexpp) | MIT | +| [`@fortune-sheet/core`](https://github.com/ruilisi/fortune-sheet) | MIT | +| [`@fortune-sheet/react`](https://github.com/ruilisi/fortune-sheet) | MIT | +| [`@joplin/turndown-plugin-gfm`](https://github.com/laurent22/joplin-turndown-plugin-gfm) | MIT | +| [`@jridgewell/gen-mapping`](https://github.com/jridgewell/sourcemaps) | MIT | +| [`@js-temporal/polyfill`](https://github.com/js-temporal/temporal-polyfill) | ISC | +| [`@lexical/history`](https://github.com/facebook/lexical) | MIT | +| [`@lexical/plain-text`](https://github.com/facebook/lexical) | MIT | +| [`@lexical/text`](https://github.com/facebook/lexical) | MIT | +| [`@lexical/utils`](https://github.com/facebook/lexical) | MIT | +| [`@modelcontextprotocol/client`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT | +| [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT | +| [`@noble/hashes`](https://github.com/paulmillr/noble-hashes) | MIT | +| [`@octokit/webhooks`](https://github.com/octokit/webhooks.js) | MIT | +| [`@openai/codex`](https://github.com/openai/codex) | Apache-2.0 | +| [`@opentelemetry/api`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | +| [`@opentelemetry/api-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | +| [`@opentelemetry/core`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | +| [`@opentelemetry/otlp-exporter-base`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | +| [`@opentelemetry/otlp-transformer`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | +| [`@opentelemetry/resources`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | +| [`@opentelemetry/sdk-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | +| [`@playwright/mcp`](https://github.com/microsoft/playwright-mcp) | Apache-2.0 | +| [`@puppeteer/browsers`](https://github.com/puppeteer/puppeteer/tree/main/packages/browsers) | Apache-2.0 | +| [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT | +| [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT | +| [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT | +| [`@trycua/cua-driver`](https://github.com/trycua/cua) | MIT | +| [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT | +| [`@xterm/addon-fit`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-fit) | MIT | +| [`@xterm/addon-serialize`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-serialize) | MIT | +| [`@xterm/headless`](https://github.com/xtermjs/xterm.js) | MIT | +| [`@xterm/xterm`](https://github.com/xtermjs/xterm.js) | MIT | +| [`@yarnpkg/parsers`](https://github.com/yarnpkg/berry) | BSD-2-Clause | +| [`acorn`](https://github.com/acornjs/acorn) | MIT | +| [`ajv`](https://github.com/ajv-validator/ajv) | MIT | +| [`anser`](https://github.com/IonicaBizau/anser) | MIT | +| [`big.js`](https://github.com/MikeMcl/big.js) | MIT | +| [`buffer`](https://github.com/feross/buffer) | MIT | +| [`chokidar`](https://github.com/paulmillr/chokidar) | MIT | +| [`chrome-devtools-frontend`](https://github.com/ChromeDevTools/devtools-frontend) | BSD-3-Clause | +| [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp) | Apache-2.0 | +| [`clsx`](https://github.com/lukeed/clsx) | MIT | +| [`commander`](https://github.com/tj/commander.js) | MIT | +| [`compression`](https://github.com/expressjs/compression) | MIT | +| [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause | +| [`dompurify`](https://github.com/cure53/DOMPurify) | (MPL-2.0 OR Apache-2.0) | +| [`electron-updater`](https://github.com/electron-userland/electron-builder) | MIT | +| [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT | +| [`exceljs`](https://github.com/exceljs/exceljs) | MIT | +| [`execa`](https://github.com/sindresorhus/execa) | MIT | +| [`fast-xml-parser`](https://github.com/NaturalIntelligence/fast-xml-parser) | MIT | +| [`fflate`](https://github.com/101arrowz/fflate) | MIT | +| [`got`](https://github.com/sindresorhus/got) | MIT | +| [`immer`](https://github.com/immerjs/immer) | MIT | +| [`ipaddr.js`](https://github.com/whitequark/ipaddr.js) | MIT | +| [`js-yaml`](https://github.com/nodeca/js-yaml) | MIT | +| [`katex`](https://github.com/KaTeX/KaTeX) | MIT | +| [`koffi`](https://github.com/Koromix/koffi) | MIT | +| [`lexical`](https://github.com/facebook/lexical) | MIT | +| [`mdast-util-from-markdown`](https://github.com/syntax-tree/mdast-util-from-markdown) | MIT | +| [`mdast-util-gfm`](https://github.com/syntax-tree/mdast-util-gfm) | MIT | +| [`mdast-util-math`](https://github.com/syntax-tree/mdast-util-math) | MIT | +| [`micromark-core-commonmark`](https://github.com/micromark/micromark/tree/main/packages/micromark-core-commonmark) | MIT | +| [`micromark-extension-gfm`](https://github.com/micromark/micromark-extension-gfm) | MIT | +| [`micromark-extension-math`](https://github.com/micromark/micromark-extension-math) | MIT | +| [`micromark-factory-space`](https://github.com/micromark/micromark/tree/main/packages/micromark-factory-space) | MIT | +| [`micromark-util-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-character) | MIT | +| [`micromark-util-classify-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-classify-character) | MIT | +| [`micromark-util-sanitize-uri`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-sanitize-uri) | MIT | +| [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT | +| [`mime-types`](https://github.com/jshttp/mime-types) | MIT | +| [`negotiator`](https://github.com/jshttp/negotiator) | MIT | +| [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT | +| [`node-pty`](https://github.com/microsoft/node-pty) | MIT | +| [`open`](https://github.com/sindresorhus/open) | MIT | +| [`papaparse`](https://github.com/mholt/PapaParse) | MIT | +| [`pdfjs-dist`](https://github.com/mozilla/pdf.js) | Apache-2.0 | +| [`picomatch`](https://github.com/micromatch/picomatch) | MIT | +| [`react`](https://github.com/facebook/react) | MIT | +| [`react-dom`](https://github.com/facebook/react) | MIT | +| [`readable-stream`](https://github.com/nodejs/readable-stream) | MIT | +| [`resolve.exports`](https://github.com/lukeed/resolve.exports) | MIT | +| [`semver`](https://github.com/npm/node-semver) | ISC | +| [`serve-static`](https://github.com/expressjs/serve-static) | MIT | +| [`sharp`](https://github.com/lovell/sharp) | Apache-2.0 | +| [`sherpa-onnx-node`](https://github.com/csukuangfj/sherpa-onnx) | Apache-2.0 | +| [`shiki`](https://github.com/shikijs/shiki) | MIT | +| [`simple-icons`](https://github.com/simple-icons/simple-icons) | CC0-1.0 | +| [`supports-color`](https://github.com/chalk/supports-color) | MIT | +| [`tsx`](https://github.com/privatenumber/tsx) | MIT | +| [`turndown`](https://github.com/mixmark-io/turndown) | MIT | +| [`typescript`](https://github.com/microsoft/TypeScript) | Apache-2.0 | +| [`undici`](https://github.com/nodejs/undici) | MIT | +| [`use-sync-external-store`](https://github.com/facebook/react) | MIT | +| [`ws`](https://github.com/websockets/ws) | MIT | +| [`xlsx`](https://git.sheetjs.com/SheetJS/sheetjs) | Apache-2.0 | +| [`yaml`](https://github.com/eemeli/yaml) | ISC | +| [`zod`](https://github.com/colinhacks/zod) | MIT | +| [`zustand`](https://github.com/pmndrs/zustand) | MIT | + +pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification: + +- `@earendil-works/pi-ai@0.87.1` — [`patches/@earendil-works__pi-ai@0.87.1.patch`](patches/@earendil-works__pi-ai@0.87.1.patch) +- `@electron/osx-sign@1.3.3` — [`patches/@electron__osx-sign@1.3.3.patch`](patches/@electron__osx-sign@1.3.3.patch) +- `@fortune-sheet/core@1.0.4` — [`patches/@fortune-sheet__core@1.0.4.patch`](patches/@fortune-sheet__core@1.0.4.patch) +- `@fortune-sheet/react@1.0.4` — [`patches/@fortune-sheet__react@1.0.4.patch`](patches/@fortune-sheet__react@1.0.4.patch) +- `@yao-pkg/pkg@6.21.0` — [`patches/@yao-pkg__pkg@6.21.0.patch`](patches/@yao-pkg__pkg@6.21.0.patch) +- `exceljs@4.4.0` — [`patches/exceljs@4.4.0.patch`](patches/exceljs@4.4.0.patch) +- `node-pty@1.2.0-beta.15` — [`patches/node-pty@1.2.0-beta.15.patch`](patches/node-pty@1.2.0-beta.15.patch) + +## Chrome DevTools frontend + +The optional experimental Inspector distributes a locally compiled copy of [chrome-devtools-frontend 1.0.1638082](https://www.npmjs.com/package/chrome-devtools-frontend/v/1.0.1638082), from upstream revision [0e1186138ed519d9659c1874bf6375eca4483c72](https://chromium.googlesource.com/devtools/devtools-frontend/+/0e1186138ed519d9659c1874bf6375eca4483c72). The build includes the Chromium [BSD-3-Clause license](packages/experimental/inspector/assets/devtools/LICENSE) and the third-party license and notice files supplied by the npm source. The Chromium root license does not replace those dependencies' licenses. + + +## Official Claude Code platform payloads + +The project owner authorizes distribution of every version of the official `@anthropic-ai/claude-agent-sdk` package and the official Claude Code CLI/platform payloads that each version declares through `optionalDependencies`. This identity-scoped authorization does not classify their declared terms as permissive and does not cover any unrelated runtime package; version, declared-license, and payload-set changes still require the ordinary dependency, lockfile, compatibility, terms, and notices review. + +The installed SDK 0.3.263 declares the following optional platform packages. Each carries the official Claude Code 2.1.263 executable; the package identities and versions come from the SDK manifest, while the declared license field is verified against the platform payload installed for the current host. + +| Optional platform package | Version | Declared license | +| --- | --- | --- | +| [`@anthropic-ai/claude-agent-sdk-darwin-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-arm64) | 0.3.263 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-darwin-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-x64) | 0.3.263 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64) | 0.3.263 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-arm64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64-musl) | 0.3.263 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64) | 0.3.263 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-x64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64-musl) | 0.3.263 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-win32-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-arm64) | 0.3.263 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-win32-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-x64) | 0.3.263 | SEE LICENSE IN LICENSE.md | + + +## LibreOffice conversion kit + +`@deepseek-ai/libreoffice-kit`, `@deepseek-ai/libreoffice-kit-wasm`, `@deepseek-ai/libreoffice-kit-darwin-arm64`, `@deepseek-ai/libreoffice-kit-darwin-x64`, `@deepseek-ai/libreoffice-kit-win32-arm64`, `@deepseek-ai/libreoffice-kit-win32-x64` declare MPL-2.0, which remains outside the permissive-license allowlist; the notices check accepts only these package identities at those terms. The [distribution decision](.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md) records the source obligations. + +The [kit repository](https://github.com/deepseek-ai/dsh-libreoffice-kit) supplies the corresponding LibreOffice source pin, modifications, build instructions, Node API, and artifact validation. Its engine packages retain their license and third-party notices; the Node API retains its MPL-2.0 declaration and NOTICE. Recipients must have access to those corresponding sources and notices. + + +## Development-only npm dependencies + +External packages **directly declared** for development, tests, types, or tooling, without a runtime installation or browser-build relationship. A package here may still be pulled in transitively by a runtime dependency — `pnpm-lock.yaml` is the authority on that full closure. + +| Package | License | +| --- | --- | +| [`@braintree/sanitize-url`](https://github.com/braintree/sanitize-url) | MIT | +| [`@electron/get`](https://github.com/electron/get) | MIT | +| [`@electron/notarize`](https://github.com/electron/notarize) | MIT | +| [`@lexical/headless`](https://github.com/facebook/lexical) | MIT | +| [`@modelcontextprotocol/node`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT | +| [`@modelcontextprotocol/server`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT | +| [`@modelcontextprotocol/server-everything`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 | +| [`@modelcontextprotocol/server-filesystem`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 | +| [`@panzoom/panzoom`](https://github.com/timmywil/panzoom) | MIT | +| [`@stylistic/eslint-plugin`](https://github.com/eslint-stylistic/eslint-stylistic) | MIT | +| [`@testing-library/dom`](https://github.com/testing-library/dom-testing-library) | MIT | +| [`@testing-library/react`](https://github.com/testing-library/react-testing-library) | MIT | +| [`@types/babel__code-frame`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/big.js`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/compression`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/mime-types`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/negotiator`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/node`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/papaparse`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/picomatch`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/react`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/react-dom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/readable-stream`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/semver`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/serve-static`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/spdx-expression-parse`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/turndown`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/use-sync-external-store`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/ws`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@vitejs/plugin-react`](https://github.com/vitejs/vite-plugin-react) | MIT | +| [`@vitest/coverage-v8`](https://github.com/vitest-dev/vitest) | MIT | +| [`@vitest/spy`](https://github.com/vitest-dev/vitest) | MIT | +| [`@yao-pkg/pkg`](https://github.com/yao-pkg/pkg) | MIT | +| [`@yarnpkg/cli-dist`](https://github.com/yarnpkg/berry) | BSD-2-Clause | +| [`app-builder-lib`](https://github.com/electron-userland/electron-builder) | MIT | +| [`cos-nodejs-sdk-v5`](https://github.com/tencentyun/cos-nodejs-sdk-v5) | ISC | +| [`cytoscape`](https://github.com/cytoscape/cytoscape.js) | MIT | +| [`cytoscape-cose-bilkent`](https://github.com/cytoscape/cytoscape.js-cose-bilkent) | MIT | +| [`dayjs`](https://github.com/iamkun/dayjs) | MIT | +| [`debug`](https://github.com/debug-js/debug) | MIT | +| [`electron`](https://github.com/electron/electron) | MIT | +| [`electron-builder`](https://github.com/electron-userland/electron-builder) | MIT | +| [`esbuild`](https://github.com/evanw/esbuild) | MIT | +| [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only | +| [`extract-zip`](https://github.com/maxogden/extract-zip) | BSD-2-Clause | +| [`fast-check`](https://github.com/dubzzz/fast-check) | MIT | +| [`http-server`](https://github.com/http-party/http-server) | MIT | +| [`istanbul-lib-report`](https://github.com/istanbuljs/istanbuljs) | BSD-3-Clause | +| [`jscpd`](https://github.com/kucherenko/jscpd) | MIT | +| [`jsdom`](https://github.com/jsdom/jsdom) | MIT | +| [`lefthook`](https://github.com/evilmartians/lefthook) | MIT | +| [`lightningcss`](https://github.com/parcel-bundler/lightningcss) | MPL-2.0 | +| [`mermaid`](https://github.com/mermaid-js/mermaid) | MIT | +| [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT | +| [`oxlint`](https://github.com/oxc-project/oxc) | MIT | +| [`oxlint-tsgolint`](https://github.com/oxc-project/tsgolint) | MIT | +| [`playwright`](https://github.com/microsoft/playwright) | Apache-2.0 | +| [`pnpm`](https://github.com/pnpm/pnpm) | MIT | +| [`publint`](https://github.com/publint/publint) | MIT | +| [`smol-toml`](https://github.com/squirrelchat/smol-toml) | BSD-3-Clause | +| [`spdx-expression-parse`](https://github.com/jslicense/spdx-expression-parse.js) | MIT | +| [`tar`](https://github.com/isaacs/node-tar) | BlueOak-1.0.0 | +| [`tsdown`](https://github.com/rolldown/tsdown) | MIT | +| [`typescript-language-server`](https://github.com/typescript-language-server/typescript-language-server) | Apache-2.0 | +| [`vite`](https://github.com/vitejs/vite) | MIT | +| [`vite-tsconfig-paths`](https://github.com/aleclarson/vite-tsconfig-paths) | MIT | +| [`vitepress`](https://github.com/vuejs/vitepress) | MIT | +| [`vitepress-plugin-mermaid`](https://github.com/emersonbottero/vitepress-plugin-mermaid) | MIT | +| [`vitest`](https://github.com/vitest-dev/vitest) | MIT | +| [`vue`](https://github.com/vuejs/core) | MIT | + +`eslint-plugin-sonarjs` (LGPL-3.0-only) and `lightningcss` (MPL-2.0) run only as development tooling; their code is not linked into or distributed with any DeepSeek Harness artifact. + +## Python SDK dependencies (`python/`) + +Direct dependencies of the `pyproject.toml` manifests, plus `uv` as the development workflow tool. + +| Package | License | Role | +| --- | --- | --- | +| [`hatchling`](https://github.com/pypa/hatch) | MIT | build backend | +| [`pydantic`](https://github.com/pydantic/pydantic) | MIT | runtime dependency of `deepseek-harness-sdk` | +| [`pytest`](https://github.com/pytest-dev/pytest) | MIT | test-only | +| [`uv`](https://github.com/astral-sh/uv) | MIT / Apache-2.0 | development workflow tool | + +## Bundled Python distributions + +The [shared runtime lock](scripts/primary-runtime/lock.json) records each distribution version and the wheel download hashes. The table includes every entry in `pythonPackages`, including transitive dependencies. Wheel extraction preserves distribution metadata and the license and notice files supplied by each archive. Project licenses below do not enumerate the separate licenses of native libraries bundled inside wheels. + +| Distribution | Locked version | Project license | +| --- | --- | --- | +| [`et-xmlfile`](https://foss.heptapod.net/openpyxl/et_xmlfile) | 2.0.0 | MIT | +| [`lxml`](https://github.com/lxml/lxml) | 6.1.3 | BSD-3-Clause | +| [`numpy`](https://github.com/numpy/numpy) | 2.3.5 | BSD-3-Clause | +| [`openpyxl`](https://foss.heptapod.net/openpyxl/openpyxl) | 3.1.5 | MIT | +| [`pandas`](https://github.com/pandas-dev/pandas) | 3.0.1 | BSD-3-Clause | +| [`pillow`](https://github.com/python-pillow/Pillow) | 12.3.0 | MIT-CMU | +| [`python-dateutil`](https://github.com/dateutil/dateutil) | 2.9.0.post0 | Apache-2.0 OR BSD-3-Clause | +| [`python-docx`](https://github.com/python-openxml/python-docx) | 1.2.0 | MIT | +| [`python-pptx`](https://github.com/scanny/python-pptx) | 1.0.2 | MIT | +| [`six`](https://github.com/benjaminp/six) | 1.17.0 | MIT | +| [`typing-extensions`](https://github.com/python/typing_extensions) | 4.16.0 | PSF-2.0 | +| [`tzdata`](https://github.com/python/tzdata) | 2025.2 | Apache-2.0 | +| [`xlsxwriter`](https://github.com/jmcnamara/XlsxWriter) | 3.2.9 | BSD-2-Clause | + +## First-party native packages + +`@deepseek-ai/node-addon-system` (and its platform packages) is built and released from this repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party. diff --git a/apps/web-ui/apps/web/.npmignore b/apps/web-ui/apps/web/.npmignore new file mode 100644 index 000000000..46af539c1 --- /dev/null +++ b/apps/web-ui/apps/web/.npmignore @@ -0,0 +1 @@ +*.map \ No newline at end of file diff --git a/apps/web-ui/apps/web/index.html b/apps/web-ui/apps/web/index.html new file mode 100644 index 000000000..6b3458d2d --- /dev/null +++ b/apps/web-ui/apps/web/index.html @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + DSH Local Build + + +
+ + + diff --git a/apps/web-ui/apps/web/package.json b/apps/web-ui/apps/web/package.json new file mode 100644 index 000000000..2fd3e24a3 --- /dev/null +++ b/apps/web-ui/apps/web/package.json @@ -0,0 +1,18 @@ +{ + "name": "@codexhost/web-ui-shell", + "version": "0.0.0", + "private": true, + "type": "module", + "license": "MIT", + "scripts": { + "build": "vite build" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-web": "*", + "@deepseek-ai/dsh-client-ui-theme": "*", + "@vitejs/plugin-react": "^4.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "vite": "^6.0.0" + } +} diff --git a/apps/web-ui/apps/web/product-isolation.ts b/apps/web-ui/apps/web/product-isolation.ts new file mode 100644 index 000000000..6b54d4258 --- /dev/null +++ b/apps/web-ui/apps/web/product-isolation.ts @@ -0,0 +1,121 @@ +/** Vite adapters for recording product chunk, asset, CSS, and worker inputs. */ + +import type { Plugin } from "vite"; +import { WebProductBundleIsolation } from "../../scripts/web-product-bundle-isolation.ts"; + +const DEPENDENCY_ANALYSIS_PLUGIN = "dsh-browser-dependency-analysis"; + +/** + * Identify a dependency-disclosure walk that deliberately leaves third-party imports external. + * @returns Analysis intent that refuses builds which can write output. + */ +export function browserDependencyAnalysis(): Plugin { + return { + name: DEPENDENCY_ANALYSIS_PLUGIN, + apply: "build", + configResolved(config) { + requireNonWritingAnalysis(config.build.write); + }, + }; +} + +function requireNonWritingAnalysis(write: boolean): void { + if (write !== false) throw new Error("browser dependency analysis requires build.write: false"); +} + +/** + * Reject experimental inputs reachable from the built default Web page. + * @param repository - repository root supplying package ownership. + * @param webRoot - Vite root containing index.html and public assets. + * @returns Build plugins that preserve Vite's output and fail before publication on a violation. + */ +export function productWebBundleIsolation(repository: string, webRoot: string): Plugin[] { + const inputs = new WebProductBundleIsolation(repository, webRoot); + let dependencyAnalysis = false; + return [ + { + name: "dsh-product-web-chunk-inputs", + apply: "build", + generateBundle: { + order: "pre", + handler(_options, bundle) { + if (!dependencyAnalysis) inputs.captureChunks(bundle); + }, + }, + }, + { + name: "dsh-product-web-bundle-isolation", + apply: "build", + enforce: "post", + config(config) { + const renderBuiltUrl = config.experimental?.renderBuiltUrl; + const workerPlugins = config.worker?.plugins; + return { + experimental: { + renderBuiltUrl(filename, context) { + inputs.assetReference(filename, context.hostId, context.type); + return renderBuiltUrl?.(filename, context); + }, + }, + worker: { + plugins: () => [ + ...(workerPlugins?.() ?? []), + { + name: "dsh-worker-build-inputs", + generateBundle: { + order: "post", + handler(_options, bundle) { + inputs.workerBundle(bundle, this.getWatchFiles()); + }, + }, + }, + ], + }, + }; + }, + configResolved(config) { + dependencyAnalysis = config.plugins.some( + (plugin) => plugin.name === DEPENDENCY_ANALYSIS_PLUGIN, + ); + if (dependencyAnalysis) { + requireNonWritingAnalysis(config.build.write); + return; + } + const cssPlugins = config.plugins.filter((plugin) => plugin.name === "vite:css"); + const css = cssPlugins[0]; + if (cssPlugins.length !== 1 || css?.transform === undefined) { + throw new Error("Web product isolation: Vite CSS input instrumentation is unavailable"); + } + const transform = + typeof css.transform === "function" ? css.transform : css.transform.handler; + css.transform = { + ...(typeof css.transform === "function" ? {} : css.transform), + handler(code, id, options) { + inputs.cssTransform(id); + const context = new Proxy(this, { + get(target, property) { + if (property === "addWatchFile") + return (file: string): void => { + inputs.cssDependency(id, file); + target.addWatchFile(file); + }; + const value: unknown = Reflect.get(target, property, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }); + return transform.call(context, code, id, options); + }, + }; + }, + buildStart() { + inputs.reset(); + }, + generateBundle: { + order: "post", + handler(_options, bundle) { + if (!dependencyAnalysis) inputs.verify(bundle, (id) => this.getModuleInfo(id)); + }, + }, + }, + ]; +} diff --git a/apps/web-ui/apps/web/public/apple-touch-icon.png b/apps/web-ui/apps/web/public/apple-touch-icon.png new file mode 100644 index 000000000..8f3593311 Binary files /dev/null and b/apps/web-ui/apps/web/public/apple-touch-icon.png differ diff --git a/apps/web-ui/apps/web/public/favicon-dark.svg b/apps/web-ui/apps/web/public/favicon-dark.svg new file mode 100644 index 000000000..70ac67657 --- /dev/null +++ b/apps/web-ui/apps/web/public/favicon-dark.svg @@ -0,0 +1 @@ + diff --git a/apps/web-ui/apps/web/public/favicon.svg b/apps/web-ui/apps/web/public/favicon.svg new file mode 100644 index 000000000..9d2c7bd33 --- /dev/null +++ b/apps/web-ui/apps/web/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/apps/web-ui/apps/web/public/icon-192.png b/apps/web-ui/apps/web/public/icon-192.png new file mode 100644 index 000000000..a51963c11 Binary files /dev/null and b/apps/web-ui/apps/web/public/icon-192.png differ diff --git a/apps/web-ui/apps/web/public/icon-512.png b/apps/web-ui/apps/web/public/icon-512.png new file mode 100644 index 000000000..d5bae8560 Binary files /dev/null and b/apps/web-ui/apps/web/public/icon-512.png differ diff --git a/apps/web-ui/apps/web/public/manifest.webmanifest b/apps/web-ui/apps/web/public/manifest.webmanifest new file mode 100644 index 000000000..8a5e08237 --- /dev/null +++ b/apps/web-ui/apps/web/public/manifest.webmanifest @@ -0,0 +1,15 @@ +{ + "name": "CodexHost", + "short_name": "CodexHost", + "description": "Run Claude Code, Pi and other coding agents from any browser.", + "start_url": "./", + "scope": "./", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#ffffff", + "icons": [ + { "src": "favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }, + { "src": "icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } + ] +} diff --git a/apps/web-ui/apps/web/public/sw.js b/apps/web-ui/apps/web/public/sw.js new file mode 100644 index 000000000..7643251df --- /dev/null +++ b/apps/web-ui/apps/web/public/sw.js @@ -0,0 +1,58 @@ +/* CodexHost service worker: Web Push notifications and notification clicks. */ + +self.addEventListener("install", () => { + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener("push", (event) => { + let message = {}; + try { + message = event.data ? event.data.json() : {}; + } catch { + message = { title: "CodexHost", body: event.data ? event.data.text() : "" }; + } + event.waitUntil( + (async () => { + // Skip the notification when the user is already looking at CodexHost. + const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); + const visible = windows.some( + (client) => client.visibilityState === "visible" && client.focused, + ); + if (visible && message.kind === "turn") return; + await self.registration.showNotification(message.title || "CodexHost", { + body: message.body || "", + tag: message.tag || undefined, + renotify: Boolean(message.tag), + icon: "icon-192.png", + badge: "icon-192.png", + data: { url: message.url || "./", sessionId: message.sessionId }, + requireInteraction: message.kind === "approval" || message.kind === "question", + }); + })(), + ); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + const target = new URL(event.notification.data?.url || "./", self.registration.scope).href; + event.waitUntil( + (async () => { + const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); + for (const client of windows) { + if (client.url.startsWith(self.registration.scope)) { + await client.focus(); + client.postMessage({ + type: "codexhost/open-session", + sessionId: event.notification.data?.sessionId, + }); + return; + } + } + await self.clients.openWindow(target); + })(), + ); +}); diff --git a/apps/web-ui/apps/web/src/main.ts b/apps/web-ui/apps/web/src/main.ts new file mode 100644 index 000000000..8cca8a3e2 --- /dev/null +++ b/apps/web-ui/apps/web/src/main.ts @@ -0,0 +1,61 @@ +/** Browser entry for the Web client. */ +import "@deepseek-ai/dsh-client-ui-theme/brand-font.css"; +import { AppWebEntry, applyIndexInjections } from "@deepseek-ai/dsh-client-web"; + +interface DesktopBootGlobal { + dshDesktopBoot?: { + failed(message: string): Promise; + ready(): Promise<{ + injections: Parameters[0]; + streamBaseUrl: string; + }>; + }; +} +const desktop = (globalThis as DesktopBootGlobal).dshDesktopBoot; +const reportFailure = (reason: unknown): void => { + if (desktop === undefined) throw reason; + void desktop + .failed(reason instanceof Error ? reason.message : String(reason)) + .catch(console.error); +}; + +try { + const el = document.getElementById("root"); + if (el === null) throw new Error("web app: missing #root"); + const entry = new AppWebEntry(el); + if (desktop !== undefined) { + const gate = (globalThis as { __DSH_BOOT_READY__?: PromiseWithResolvers }) + .__DSH_BOOT_READY__; + if (gate === undefined) throw new Error("desktop web: boot readiness is missing"); + void desktop + .ready() + .then(async ({ injections, streamBaseUrl }) => { + const transport = globalThis as { + __DSH_TRANSPORT__?: { ownsHost: boolean; streamBaseUrl: string }; + }; + transport.__DSH_TRANSPORT__ = { ownsHost: true, streamBaseUrl }; + await applyIndexInjections( + injections, + (src) => + new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = src; + script.onload = () => { + resolve(); + }; + script.onerror = () => { + reject(new Error(`desktop web: failed to load ${src}`)); + }; + document.head.append(script); + }), + ); + gate.resolve(); + }) + .catch((error: unknown) => { + gate.reject(error); + }); + } + void entry.run(desktop === undefined ? undefined : reportFailure); +} catch (reason) { + reportFailure(reason); +} diff --git a/apps/web-ui/apps/web/src/node-module-stub.ts b/apps/web-ui/apps/web/src/node-module-stub.ts new file mode 100644 index 000000000..bffb6e247 --- /dev/null +++ b/apps/web-ui/apps/web/src/node-module-stub.ts @@ -0,0 +1,12 @@ +/** + * Browser stand-in for `node:module`. `createRequire` is unreachable in the + * configured loader path and fails loud if that assumption changes. + */ + +/** Fail if browser boot reaches Node's module loader. */ +export const createRequire = (): never => { + throw new Error("node:module is not available in the browser"); +}; + +/** Type-only peer for the vendored loader. */ +export type LoadHookContext = never; diff --git a/apps/web-ui/apps/web/src/preview.ts b/apps/web-ui/apps/web/src/preview.ts new file mode 100644 index 000000000..07fcecc7d --- /dev/null +++ b/apps/web-ui/apps/web/src/preview.ts @@ -0,0 +1,16 @@ +/** + * Worker-preview bootstrap: the one module preview.html adds ahead of the + * stock entry tag. The runtime's optional source stage owns the pre-Cordis + * chooser; the unchanged Host connector then owns the Worker handshake. + * Everything after those calls is the served startup chain verbatim. + */ +import DshWorker from "@deepseek-ai/dsh-experimental-webworker-runtime/worker?worker"; +import { + chooseWorkerHostSource, + connectWorkerHost, + IMAGE_FILE_NAME, +} from "@deepseek-ai/dsh-experimental-webworker-runtime/client"; + +const image = `preview/${IMAGE_FILE_NAME}`; +const source = await chooseWorkerHostSource({ image }); +await connectWorkerHost(new DshWorker({ name: "dsh-host" }), { image, overlays: source.overlays }); diff --git a/apps/web-ui/apps/web/src/vite-env.d.ts b/apps/web-ui/apps/web/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/apps/web-ui/apps/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/web-ui/apps/web/stress-tests/reasoning-chunks.stress.ts b/apps/web-ui/apps/web/stress-tests/reasoning-chunks.stress.ts new file mode 100644 index 000000000..0fe35acad --- /dev/null +++ b/apps/web-ui/apps/web/stress-tests/reasoning-chunks.stress.ts @@ -0,0 +1,269 @@ +/** + * Opt-in browser stress reproduction for reasoning-stream renderer stalls. + * A test-owned model adapter emits 100,000 individual chunks through the real + * Host, Gateway, and browser carriers; the test measures event-loop and + * scheduled-interaction delay while the assembled React surface keeps a + * collapsed Think row live. + */ +import type { Browser, Page } from "playwright"; +import { chromium } from "playwright"; +import { expect, it, onTestFailed } from "vitest"; +import { LlmAdapter, type GenerateOptions, type StreamChunk } from "@deepseek-ai/dsh-llm"; +import type {} from "@deepseek-ai/dsh-agent-default-model"; +import { launchWebScaffold, watchConsole, type WebScaffold } from "../tests/scaffold.ts"; +import { + connectFreshWorkspace, + newEnglishPage, + saveFailureShot, + writeComposerDraft, +} from "../tests/support.ts"; + +const CHUNK_COUNT = 100_000; +const CHUNKS_PER_INTERVAL = 128; +const CHUNK_INTERVAL_MS = 16; +const MAIN_THREAD_DELAY_BUDGET_MS = 250; +const PROVIDER = "reasoning-stress-test"; +const MODEL = "reasoning-storm"; +const MARKER = `REASONING_STRESS_COMPLETE:${String(CHUNK_COUNT)}`; + +interface ReasoningChunkStormState { + chunkCount: number; + chunksPerInterval: number; + intervalMs: number; + emitted: number; + marker: string; + emitting: boolean; +} + +/** Externally paced model stream whose counters are observed outside the browser. */ +class ReasoningStressAdapter extends LlmAdapter { + private finishStream!: () => void; + private resolveStarted!: () => void; + private resolveCompleted!: () => void; + private releaseBatch: (() => void) | undefined; + private resolveBatch: (() => void) | undefined; + private batchInFlight = false; + private finishRequested = false; + private readonly finishGate = new Promise((resolve) => { + this.finishStream = resolve; + }); + readonly started = new Promise((resolve) => { + this.resolveStarted = resolve; + }); + readonly completed = new Promise((resolve) => { + this.resolveCompleted = resolve; + }); + private emitted = 0; + private emitting = false; + + finish(): void { + this.finishRequested = true; + this.releaseBatch?.(); + this.finishStream(); + } + + async emitNextBatch(): Promise { + if (!this.emitting || this.releaseBatch === undefined) { + throw new Error("reasoning stress adapter has no batch awaiting release"); + } + if (this.batchInFlight) + throw new Error("reasoning stress adapter accepts one batch release at a time"); + this.batchInFlight = true; + const delivered = new Promise((resolve) => { + this.resolveBatch = resolve; + }); + const release = this.releaseBatch; + this.releaseBatch = undefined; + release(); + await delivered; + } + + state(): ReasoningChunkStormState { + return { + chunkCount: CHUNK_COUNT, + chunksPerInterval: CHUNKS_PER_INTERVAL, + intervalMs: CHUNK_INTERVAL_MS, + emitted: this.emitted, + marker: MARKER, + emitting: this.emitting, + }; + } + + override async *stream(options: GenerateOptions): AsyncIterable { + if (this.emitting || this.emitted !== 0) + throw new Error("reasoning stress adapter accepts one model call"); + this.emitting = true; + const parts: string[] = []; + yield { type: "block-start", index: 0, blockType: "reasoning" }; + while (this.emitted < CHUNK_COUNT) { + await new Promise((resolve) => { + this.releaseBatch = resolve; + if (this.emitted === 0) this.resolveStarted(); + if (this.finishRequested) resolve(); + }); + if (this.finishRequested) break; + options.signal?.throwIfAborted(); + const end = Math.min(this.emitted + CHUNKS_PER_INTERVAL, CHUNK_COUNT); + while (this.emitted < end) { + const text = + this.emitted === CHUNK_COUNT - 1 + ? `\n${MARKER}` + : this.emitted % 64 === 63 + ? "推理\n" + : "推理"; + parts.push(text); + yield { type: "reasoning-delta", index: 0, text }; + this.emitted += 1; + } + this.batchInFlight = false; + this.resolveBatch?.(); + this.resolveBatch = undefined; + } + this.emitting = false; + if (this.emitted === CHUNK_COUNT) this.resolveCompleted(); + await this.finishGate; + options.signal?.throwIfAborted(); + yield { type: "block-end", index: 0, block: { type: "reasoning", text: parts.join("") } }; + yield { type: "finish", reason: { kind: "stop" } }; + } +} + +interface StressProbe { + intervalId: number; + intervalMs: number; + lastTickAt: number; + maxDelayMs: number; + samples: number; + interactionDueAt: number; + interactionHandledAt: number | null; +} + +interface StressWindow extends Window { + __reasoningStressProbe?: StressProbe; +} + +it("keeps the browser responsive while rendering 100,000 reasoning chunks", async () => { + let scaffold: WebScaffold | undefined; + let browser: Browser | undefined; + let page: Page | undefined; + const adapter = new ReasoningStressAdapter(); + try { + scaffold = await launchWebScaffold(); + scaffold.ctx.effect( + () => scaffold!.ctx.llm.registerAdapter([PROVIDER], adapter), + "reasoning stress adapter", + ); + await scaffold.ctx.agentDefaultModel.saveSelection({ provider: PROVIDER, model: MODEL }); + browser = await chromium.launch({ headless: process.env.DSH_WEB_STRESS_HEADFUL !== "1" }); + page = await newEnglishPage(browser); + const activePage = page; + const tripwire = watchConsole(activePage); + onTestFailed(() => saveFailureShot(activePage, "web-stress-reasoning-chunks")); + await activePage.goto(scaffold.authenticatedUrl, { waitUntil: "load" }); + await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 }); + await connectFreshWorkspace(activePage, scaffold.workspaceCwd); + + await activePage.evaluate(() => { + const intervalMs = 50; + const now = performance.now(); + const probe: StressProbe = { + intervalId: 0, + intervalMs, + lastTickAt: now, + maxDelayMs: 0, + samples: 0, + interactionDueAt: now + 1_000, + interactionHandledAt: null, + }; + probe.intervalId = window.setInterval(() => { + const tickAt = performance.now(); + probe.maxDelayMs = Math.max(probe.maxDelayMs, tickAt - probe.lastTickAt - intervalMs); + probe.lastTickAt = tickAt; + probe.samples++; + }, intervalMs); + document.body.addEventListener( + "reasoning-stress-interaction", + () => { + probe.interactionHandledAt = performance.now(); + }, + { once: true }, + ); + window.setTimeout(() => { + document.body.dispatchEvent(new CustomEvent("reasoning-stress-interaction")); + }, 1_000); + (window as StressWindow).__reasoningStressProbe = probe; + }); + + const settled = scaffold.whenTurnSettled(540_000); + const input = activePage.locator("[data-composer-input]").first(); + await writeComposerDraft(activePage, input, `Render ${String(CHUNK_COUNT)} reasoning chunks.`); + await input.press("Enter"); + await adapter.started; + + await adapter.emitNextBatch(); + const liveThink = activePage.locator('[data-variant="think"][data-state="running"]').last(); + await liveThink.waitFor({ timeout: 60_000 }); + await activePage.evaluate( + (intervalMs) => + new Promise((resolve) => { + window.setTimeout(resolve, intervalMs); + }), + CHUNK_INTERVAL_MS, + ); + while (adapter.state().emitted < CHUNK_COUNT) { + await adapter.emitNextBatch(); + await activePage.evaluate( + (intervalMs) => + new Promise((resolve) => { + window.setTimeout(resolve, intervalMs); + }), + CHUNK_INTERVAL_MS, + ); + } + await adapter.completed; + await expect + .poll(() => liveThink.textContent(), { timeout: 60_000, interval: 100 }) + .toContain(MARKER); + + const browserReport = await activePage.evaluate(() => { + const win = window as StressWindow; + const probe = win.__reasoningStressProbe; + if (probe === undefined) throw new Error("reasoning stress metrics unavailable"); + window.clearInterval(probe.intervalId); + const interactionDelayMs = + probe.interactionHandledAt === null + ? null + : probe.interactionHandledAt - probe.interactionDueAt; + return { + maxMainThreadDelayMs: Math.max(0, probe.maxDelayMs), + interactionDelayMs, + heartbeatSamples: probe.samples, + }; + }); + const report = { ...adapter.state(), ...browserReport }; + process.stdout.write(`reasoning-chunk stress report: ${JSON.stringify(report)}\n`); + + expect(report).toMatchObject({ + chunkCount: CHUNK_COUNT, + chunksPerInterval: CHUNKS_PER_INTERVAL, + intervalMs: CHUNK_INTERVAL_MS, + emitted: CHUNK_COUNT, + }); + expect(report.heartbeatSamples).toBeGreaterThan(0); + const interactionDelayMs = report.interactionDelayMs; + if (interactionDelayMs === null) + throw new Error(`scheduled interaction was not handled: ${JSON.stringify(report)}`); + expect(report.maxMainThreadDelayMs, JSON.stringify(report)).toBeLessThan( + MAIN_THREAD_DELAY_BUDGET_MS, + ); + expect(interactionDelayMs, JSON.stringify(report)).toBeLessThan(MAIN_THREAD_DELAY_BUDGET_MS); + expect(tripwire.pageErrors).toEqual([]); + expect(tripwire.warnings).toEqual([]); + adapter.finish(); + await settled; + } finally { + adapter.finish(); + await browser?.close(); + await scaffold?.close(); + } +}, 600_000); diff --git a/apps/web-ui/apps/web/tsconfig.json b/apps/web-ui/apps/web/tsconfig.json new file mode 100644 index 000000000..310434a3e --- /dev/null +++ b/apps/web-ui/apps/web/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/main.ts", "src/node-module-stub.ts"] +} diff --git a/apps/web-ui/apps/web/vite.config.ts b/apps/web-ui/apps/web/vite.config.ts new file mode 100644 index 000000000..005109f16 --- /dev/null +++ b/apps/web-ui/apps/web/vite.config.ts @@ -0,0 +1,274 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; +import type { Plugin } from "vite"; +import react from "@vitejs/plugin-react"; +import { clientBuildEnvironmentDefines } from "../../scripts/client-build-environment.ts"; +import { productWebBundleIsolation } from "./product-isolation.ts"; + +const src = (rel: string): string => fileURLToPath(new URL(rel, import.meta.url)); +const STANDALONE_ERROR = + "apps/web is not a standalone application: bare Vite cannot inject window.__DSH_BOOT__. " + + "From a repository checkout, run `pnpm dsh web`; an installed package uses `dsh web`. " + + "For client-plugin HMR, run `pnpm run dev:web`, which starts `dsh web` and the rebuild watchers together."; +const DEFAULT_CLIENT_TITLE = "DSH Local Build"; + +/** Escape build-time text before placing it in the HTML title element. */ +function escapeHtmlText(value: string): string { + return value.replace(/&/g, "&").replace(//g, ">"); +} + +/** Project the public build title into the initial HTML document. */ +function clientDocumentTitle(): Plugin { + const title = escapeHtmlText(process.env.DSH_CLIENT_TITLE ?? DEFAULT_CLIENT_TITLE); + return { + name: "dsh-client-document-title", + transformIndexHtml(html) { + return html.replace("DSH Local Build", `${title}`); + }, + }; +} + +/** Keep the redistribution license beside the bundled brand font. */ +function brandFontLicense(): Plugin { + return { + name: "dsh-brand-font-license", + async generateBundle() { + this.emitFile({ + type: "asset", + fileName: "assets/fonts/Montserrat-OFL.txt", + source: await readFile(src("../../packages/client/ui-theme/src/styles/Montserrat-OFL.txt")), + }); + }, + }; +} + +/** Fail before a Vite dev or preview server can expose the boot-manifest-free shell. */ +function rejectStandaloneServe(): Plugin { + return { + name: "dsh-reject-standalone-web-serve", + config(_config, env) { + if (env.command === "serve") throw new Error(STANDALONE_ERROR); + }, + }; +} + +/** + * Emit preview.html beside index.html: the built index page with one module + * script — the worker bootstrap entry — spliced ahead of its entry tag. Both + * pages share every chunk; the extra tag is the only difference, so the + * static worker deployment ships the served page verbatim plus its + * bootstrap. + */ +function emitPreviewPage(): Plugin { + let bootstrapFile: string | undefined; + let write = true; + let written = false; + let outputDirectory = ""; + return { + name: "dsh-emit-preview-page", + configResolved(config) { + write = config.build.write; + outputDirectory = resolve(config.root, config.build.outDir); + }, + buildStart() { + bootstrapFile = undefined; + written = false; + }, + generateBundle(_options, bundle) { + if (!write) return; + for (const item of Object.values(bundle)) { + if (item.type === "chunk" && item.isEntry && item.name === "bootstrap") + bootstrapFile = item.fileName; + } + if (bootstrapFile === undefined) + throw new Error("vite: preview bootstrap entry missing from the bundle"); + }, + writeBundle() { + written = true; + }, + async closeBundle() { + if (!write || !written || bootstrapFile === undefined) return; + const page = await readFile(resolve(outputDirectory, "index.html"), "utf8"); + const anchor = page.indexOf('`; + await writeFile( + resolve(outputDirectory, "preview.html"), + `${page.slice(0, anchor)}${tag}${page.slice(anchor)}`, + ); + }, + }; +} + +/** + * Vendor-chunk membership, by exact npm package name — the heavy render + * families (math, highlight, markdown) that change only on dependency bumps. + * Only packages workspace code imports DIRECTLY need listing: their private + * transitive dependencies (oniguruma machinery, character tables, …) are + * imported solely by these and rollup's chunk coloring pulls them into + * vendor automatically. A dependency shared with index-side code falls back + * to index — a few kB of dilution, never a correctness problem. Anything not + * listed (react family, the vendored cordis workspace, tiny helpers like + * anser/clsx, all workspace code) stays in the default `index` chunk, so + * editing shell code re-hashes only index and returning clients keep the + * cached vendor chunk. + * + * Every member must be React-free. A package that + * imports react/jsx-runtime must never be listed — rollup folds a module + * shared between the entry and a manual chunk into the manual chunk, so one + * react-importing member would drag the single shared react copy into + * vendor. The React side of markdown/math rendering is workspace code and + * rides index. + */ +const VENDOR_PACKAGES: ReadonlySet = new Set([ + // math + "katex", + // syntax highlight (@shikijs/langs is handled separately below — + // lazy grammars must not land here) + "shiki", + // markdown parse pipeline (micromark/mdast; the incremental React renderer + // over it is workspace code) + "mdast-util-from-markdown", + "mdast-util-gfm", + "mdast-util-math", + "micromark-core-commonmark", + "micromark-extension-gfm", + "micromark-extension-math", + "micromark-factory-space", + "micromark-util-character", + "micromark-util-classify-character", + "micromark-util-sanitize-uri", + "micromark-util-symbol", + "micromark-util-types", +]); + +/** + * Boot grammars statically imported by ui-primitives' highlight.ts + * (`@shikijs/langs/typescript` → `dist/typescript.mjs`, etc.). They live in + * the same package as the lazy read-card grammars, but unlike those they are + * part of the initial load and belong in the vendor chunk; the lazy ones must + * stay unassigned so each keeps its own on-demand chunk. + */ +const BOOT_GRAMMAR_FILES: readonly string[] = [ + "dist/typescript.mjs", + "dist/shellscript.mjs", + "dist/json.mjs", +]; + +/** Font asset extensions routed to assets/fonts/ (KaTeX's woff2/woff/ttf faces). */ +const FONT_EXTENSIONS: readonly string[] = [".woff2", ".woff", ".ttf"]; + +/** + * npm package name of a resolved module id: the segment after the last + * `node_modules/`. pnpm nests the real package under an inner node_modules. + */ +function npmPackageOf(id: string): string | undefined { + const parts = id.split("/node_modules/"); + if (parts.length === 1) return undefined; + const [first, second] = parts[parts.length - 1].split("/"); + if (first.startsWith(".")) return undefined; // .pnpm store segment, not a package + if (first.startsWith("@")) return second === undefined ? undefined : `${first}/${second}`; + return first; +} + +export default defineConfig({ + // Relative asset URLs: preview.html mounts the same output under any base + // directory, and the served index resolves identically from the site root. + base: "./", + plugins: [ + rejectStandaloneServe(), + clientDocumentTitle(), + brandFontLicense(), + react(), + productWebBundleIsolation(src("../.."), src(".")), + ], + build: { + // The worker bootstrap holds its page at top-level await; Vite's default + // `modules` target (es2020-era) rejects that syntax. + target: "es2022", + sourcemap: true, + rollupOptions: { + input: { + index: src("./index.html"), + // Standalone entry, not an index.html script tag: Vite folds every + // module tag of one page into a single synthetic entry, and only a + // separate input keeps the shared page chunks bootstrap-free. + }, + output: { + // The worker-preview surface groups under dist/preview/ (the page + // itself stays at dist/preview.html), so the published payload can + // exclude it as one directory. + entryFileNames(chunk): string { + return chunk.name === "bootstrap" + ? "preview/[name]-[hash].js" + : "assets/[name]-[hash].js"; + }, + // Output layout: the two main chunks stay at assets/ root; lazy + // @shikijs/langs grammar chunks group under assets/langs/; fonts + // (all KaTeX faces referenced by vendor.css) group under + // assets/fonts/. Sourcemaps need no arrangement: rollup writes each + // .map next to its js and references it by bare relative filename. + chunkFileNames(chunk): string { + // Grammar chunks are recognized by their member modules, not the + // facade: shared embedded-grammar chunks (e.g. html+javascript, + // split out because php/ruby/mdx embed them) have no facade at all. + // index and vendor are excluded by name — vendor legitimately + // carries the three boot grammars. + if (chunk.name === "index" || chunk.name === "vendor") return "assets/[name]-[hash].js"; + const isLangChunk = chunk.moduleIds.some((id) => + id.includes("/node_modules/@shikijs/langs/"), + ); + return isLangChunk ? "assets/langs/[name]-[hash].js" : "assets/[name]-[hash].js"; + }, + assetFileNames(asset): string { + const fileName = asset.names[0] ?? ""; + const isFont = FONT_EXTENSIONS.some((ext) => fileName.endsWith(ext)); + return isFont ? "assets/fonts/[name]-[hash][extname]" : "assets/[name]-[hash][extname]"; + }, + manualChunks(id: string): string | undefined { + const pkg = npmPackageOf(id); + if (pkg === undefined) return undefined; // workspace + vendored cordis: index + if (pkg === "@shikijs/langs") { + return BOOT_GRAMMAR_FILES.some((file) => id.endsWith(`/${file}`)) + ? "vendor" + : undefined; + } + return VENDOR_PACKAGES.has(pkg) ? "vendor" : undefined; + }, + }, + }, + }, + worker: { + // The preview worker rides dist/preview/ with the rest of that surface. + rollupOptions: { output: { entryFileNames: "preview/[name]-[hash].js" } }, + }, + resolve: { + // One instance per shared npm identity: a bare specifier otherwise resolves + // from the importer's directory, so a diverging range ships a second React + // and splits hook and element identity. Entries are package ids — they cover + // react/jsx-runtime and react-dom/client — and resolve from this package's + // node_modules, so react must stay a devDependency here and any watcher must + // run vite from this directory (scripts/dev-web.ts). Workspace packages need + // no entry: pnpm links each of them to a single directory. + dedupe: ["react", "react-dom"], + // Workspace packages are consumed as built lib products: each resolves + // through its own package.json exports from the importer's directory, and + // CSS still rides Vite's pipeline because the client build preset emits it + // beside the bundle. Plugin packages never enter this graph; they arrive as + // runtime bundles through the client module system. The remaining alias + // browserizes the vendored Cordis Loader's only Node import. + alias: [{ find: /^node:module$/, replacement: src("./src/node-module-stub.ts") }], + }, + define: { + ...clientBuildEnvironmentDefines(process.env), + // vendored loader internal.ts: fromInternal() probes the Node major — + // "0.0.0" takes neither branch, returning undefined (exactly the empty + // internal slot the shell boot fills with the client module loader). + "process.versions.node": '"0.0.0"', + "process.execArgv": "[]", + // vendored loader index.ts: envData falls to its default branch. + "process.env.CORDIS_SHARED": "undefined", + }, +}); diff --git a/apps/web-ui/import-manifest.json b/apps/web-ui/import-manifest.json new file mode 100644 index 000000000..bfaf57ba0 --- /dev/null +++ b/apps/web-ui/import-manifest.json @@ -0,0 +1,161 @@ +{ + "source": "deepseek-harness (codexhost-web fork)", + "version": "0.2.1-alpha.1", + "plugins": [ + "@deepseek-ai/dsh-api-gateway", + "@deepseek-ai/dsh-client-modules", + "@deepseek-ai/dsh-client-ui-theme", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-shortcuts", + "@deepseek-ai/dsh-client-ui-shortcuts", + "@deepseek-ai/dsh-client-ui-layout", + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-session", + "@deepseek-ai/dsh-client-resources", + "@deepseek-ai/dsh-client-ui-sidebar", + "@deepseek-ai/dsh-client-ui-sidebar-right", + "@deepseek-ai/dsh-client-ui-sidebar-files", + "@deepseek-ai/dsh-client-ui-sidebar-documentpreview", + "@deepseek-ai/dsh-client-ui-sidebar-terminal", + "@deepseek-ai/dsh-client-ui-sidebar-browser", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-ui-settings-general", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-approval", + "@deepseek-ai/dsh-client-ui-chat", + "@deepseek-ai/dsh-client-ui-brand-official", + "@deepseek-ai/dsh-client-ui-attachment", + "@deepseek-ai/dsh-client-ui-tool", + "@deepseek-ai/dsh-client-ui-workspace", + "@deepseek-ai/dsh-client-ui-input-trigger", + "@deepseek-ai/dsh-client-ui-commands", + "@deepseek-ai/dsh-client-ui-subagent", + "@deepseek-ai/dsh-client-ui-model-selection", + "@deepseek-ai/dsh-client-ui-permission-presets", + "@deepseek-ai/dsh-client-ui-plan", + "@deepseek-ai/dsh-client-ui-user-questions", + "@deepseek-ai/dsh-typert-registry", + "@deepseek-ai/dsh-client-file-upload", + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-api-session-controller", + "@deepseek-ai/dsh-api-terminal-controller", + "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-api-workspace-controller", + "@deepseek-ai/dsh-client-ui-directory-picker-browse", + "@deepseek-ai/dsh-client-ui-codexhost", + "@deepseek-ai/dsh-api-workspace-files", + "@deepseek-ai/dsh-client-hmr" + ], + "packages": [ + "@deepseek-ai/cordis", + "@deepseek-ai/cordis-plugin-loader", + "@deepseek-ai/cosmokit", + "@deepseek-ai/dsh-agent-preset-registry", + "@deepseek-ai/dsh-api-account-controller", + "@deepseek-ai/dsh-api-gateway", + "@deepseek-ai/dsh-api-job-controller", + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-api-session-controller", + "@deepseek-ai/dsh-api-settings-controller", + "@deepseek-ai/dsh-api-terminal-controller", + "@deepseek-ai/dsh-api-workspace-controller", + "@deepseek-ai/dsh-api-workspace-files", + "@deepseek-ai/dsh-brand", + "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-client-file-upload", + "@deepseek-ai/dsh-client-hmr", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-modules", + "@deepseek-ai/dsh-client-product-analytics", + "@deepseek-ai/dsh-client-resources", + "@deepseek-ai/dsh-client-shortcuts", + "@deepseek-ai/dsh-client-store", + "@deepseek-ai/dsh-client-ui-approval", + "@deepseek-ai/dsh-client-ui-attachment", + "@deepseek-ai/dsh-client-ui-brand-official", + "@deepseek-ai/dsh-client-ui-chat", + "@deepseek-ai/dsh-client-ui-codexhost", + "@deepseek-ai/dsh-client-ui-commands", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-directory-picker-browse", + "@deepseek-ai/dsh-client-ui-dockkit", + "@deepseek-ai/dsh-client-ui-input-trigger", + "@deepseek-ai/dsh-client-ui-layout", + "@deepseek-ai/dsh-client-ui-model-selection", + "@deepseek-ai/dsh-client-ui-permission-presets", + "@deepseek-ai/dsh-client-ui-plan", + "@deepseek-ai/dsh-client-ui-plugin-manager", + "@deepseek-ai/dsh-client-ui-primitives", + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-session", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-ui-settings-general", + "@deepseek-ai/dsh-client-ui-shortcuts", + "@deepseek-ai/dsh-client-ui-sidebar", + "@deepseek-ai/dsh-client-ui-sidebar-right", + "@deepseek-ai/dsh-client-ui-slots", + "@deepseek-ai/dsh-client-ui-subagent", + "@deepseek-ai/dsh-client-ui-theme", + "@deepseek-ai/dsh-client-ui-tool", + "@deepseek-ai/dsh-client-ui-user-questions", + "@deepseek-ai/dsh-client-ui-workspace", + "@deepseek-ai/dsh-client-web", + "@deepseek-ai/dsh-command-feedback", + "@deepseek-ai/dsh-commands", + "@deepseek-ai/dsh-cordis-host-runner", + "@deepseek-ai/dsh-deque", + "@deepseek-ai/dsh-file-reference", + "@deepseek-ai/dsh-goal", + "@deepseek-ai/dsh-host-plugin-inventory", + "@deepseek-ai/dsh-llm", + "@deepseek-ai/dsh-message-feedback", + "@deepseek-ai/dsh-office-to-pdf", + "@deepseek-ai/dsh-output-retention", + "@deepseek-ai/dsh-permission-presets", + "@deepseek-ai/dsh-plugin-manager", + "@deepseek-ai/dsh-schedule", + "@deepseek-ai/dsh-session", + "@deepseek-ai/dsh-session-reference", + "@deepseek-ai/dsh-spill-policy", + "@deepseek-ai/dsh-subagent", + "@deepseek-ai/dsh-token-meter", + "@deepseek-ai/dsh-typert-protocol", + "@deepseek-ai/dsh-typert-registry", + "@deepseek-ai/dsh-user-questions", + "@deepseek-ai/dsh-util-code-language", + "@deepseek-ai/dsh-util-crypto", + "@deepseek-ai/dsh-util-values", + "@deepseek-ai/dsh-util-workspace-path", + "@deepseek-ai/schemastery", + "@deepseek-ai/dsh-client-ui-sidebar-terminal", + "@deepseek-ai/dsh-client-ui-sidebar-documentpreview", + "@deepseek-ai/dsh-client-ui-sidebar-browser" + ], + "generatedCodecs": [ + "@deepseek-ai/dsh-agent-preset-registry", + "@deepseek-ai/dsh-api-account-controller", + "@deepseek-ai/dsh-api-job-controller", + "@deepseek-ai/dsh-api-session-controller", + "@deepseek-ai/dsh-api-settings-controller", + "@deepseek-ai/dsh-api-terminal-controller", + "@deepseek-ai/dsh-api-workspace-controller", + "@deepseek-ai/dsh-api-workspace-files", + "@deepseek-ai/dsh-client-file-upload", + "@deepseek-ai/dsh-client-product-analytics", + "@deepseek-ai/dsh-client-ui-plugin-manager", + "@deepseek-ai/dsh-command-feedback", + "@deepseek-ai/dsh-commands", + "@deepseek-ai/dsh-cordis-host-runner", + "@deepseek-ai/dsh-goal", + "@deepseek-ai/dsh-host-plugin-inventory", + "@deepseek-ai/dsh-llm", + "@deepseek-ai/dsh-message-feedback", + "@deepseek-ai/dsh-office-to-pdf", + "@deepseek-ai/dsh-permission-presets", + "@deepseek-ai/dsh-plugin-manager", + "@deepseek-ai/dsh-schedule", + "@deepseek-ai/dsh-session-reference", + "@deepseek-ai/dsh-subagent", + "@deepseek-ai/dsh-user-questions" + ] +} diff --git a/apps/web-ui/package.json b/apps/web-ui/package.json new file mode 100644 index 000000000..342cbc05b --- /dev/null +++ b/apps/web-ui/package.json @@ -0,0 +1,19 @@ +{ + "name": "@codexhost/web-ui", + "version": "0.0.0", + "private": true, + "type": "module", + "license": "MIT", + "scripts": { + "build": "node --import tsx scripts/build.ts" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.0.0", + "vite": "^6.0.0", + "lightningcss": "^1.32.0", + "immer": "^10.1.1", + "zustand": "^4.4.7", + "tsdown": "^0.22.2", + "tsx": "4.22.4" + } +} diff --git a/apps/web-ui/packages/api/account-controller/generated/remote.d.ts b/apps/web-ui/packages/api/account-controller/generated/remote.d.ts new file mode 100644 index 000000000..2b084287a --- /dev/null +++ b/apps/web-ui/packages/api/account-controller/generated/remote.d.ts @@ -0,0 +1,43 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import type { + RemoteResult, + RemoteStreamHandle, + TypertRemoteContribution, +} from '@deepseek-ai/dsh-typert-protocol' +import type { AccountBonusBatch, AccountBonusOrderId, AccountClientMetadata, AccountDetails, AccountUserId, AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertRemoteNamespace$6163636f756e74 { + ackBonusNotified: (accountId: AccountUserId, orderId: AccountBonusOrderId, client: AccountClientMetadata) => Promise> + cancelSignIn: (attemptId: SignInAttemptId) => Promise> + getBalance: (client: AccountClientMetadata) => Promise> + getProfile: (client: AccountClientMetadata) => Promise> + getState: () => Promise> + getUnnotifiedBonuses: (client: AccountClientMetadata) => Promise> + hasRunningAccountTasks: () => Promise> + signOut: (client: AccountClientMetadata) => Promise> + startSignIn: (client: AccountClientMetadata, callbackOrigin: string, loginSource: 'web' | 'desktop') => Promise> + watch: (signal?: AbortSignal) => RemoteStreamHandle + watchExpiry: (signal?: AbortSignal) => RemoteStreamHandle<'session-expired', never> + } + interface TypertRemoteMap { + 'account/ackBonusNotified': (accountId: AccountUserId, orderId: AccountBonusOrderId, client: AccountClientMetadata) => Promise> + 'account/cancelSignIn': (attemptId: SignInAttemptId) => Promise> + 'account/getBalance': (client: AccountClientMetadata) => Promise> + 'account/getProfile': (client: AccountClientMetadata) => Promise> + 'account/getState': () => Promise> + 'account/getUnnotifiedBonuses': (client: AccountClientMetadata) => Promise> + 'account/hasRunningAccountTasks': () => Promise> + 'account/signOut': (client: AccountClientMetadata) => Promise> + 'account/startSignIn': (client: AccountClientMetadata, callbackOrigin: string, loginSource: 'web' | 'desktop') => Promise> + 'account/watch': (signal?: AbortSignal) => RemoteStreamHandle + 'account/watchExpiry': (signal?: AbortSignal) => RemoteStreamHandle<'session-expired', never> + } + interface TypertRemoteNamespaceMap { + 'account': TypertRemoteNamespace$6163636f756e74 + } +} + +export declare const TYPERT_REMOTE: TypertRemoteContribution +export default TYPERT_REMOTE +//# sourceMappingURL=typert.remote-client.d.ts.map diff --git a/apps/web-ui/packages/api/account-controller/generated/remote.js b/apps/web-ui/packages/api/account-controller/generated/remote.js new file mode 100644 index 000000000..2cb9a6b39 --- /dev/null +++ b/apps/web-ui/packages/api/account-controller/generated/remote.js @@ -0,0 +1,456 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import { z } from 'zod' + +let _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_0$schema$value +const _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_0$schema = () => (_deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_1$schema$value +const _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_1$schema = () => (_deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_1$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_2$schema$value +const _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_2$schema = () => (_deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_2$schema$value ??= z.object({ + 'version': z.string().readonly(), + 'locale': z.string().readonly(), + 'timezoneOffsetSeconds': z.number().readonly(), +})) +let _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_result$schema$value +const _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_result$schema = () => (_deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_result$schema$value ??= z.boolean()) +let _deepseek_ai_dsh_api_account_controller_account_cancelSignIn_parameter_0$schema$value +const _deepseek_ai_dsh_api_account_controller_account_cancelSignIn_parameter_0$schema = () => (_deepseek_ai_dsh_api_account_controller_account_cancelSignIn_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_account_controller_account_cancelSignIn_result$schema$value +const _deepseek_ai_dsh_api_account_controller_account_cancelSignIn_result$schema = () => (_deepseek_ai_dsh_api_account_controller_account_cancelSignIn_result$schema$value ??= z.object({ + 'status': z.union([z.literal("signed-out"), z.literal("credential-stored")]).readonly(), + 'links': z.object({ + 'usageUrl': z.string().readonly(), + 'topUpUrl': z.string().readonly(), +}).readonly(), + 'attempt': z.union([z.literal(null), z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'phase': z.union([z.literal("initializing"), z.literal("waiting-browser"), z.literal("exchanging"), z.literal("committing"), z.literal("succeeded"), z.literal("cancelled"), z.literal("expired"), z.literal("failed")]).readonly(), + 'authorizeUrl': z.string().readonly().optional(), + 'expiresAt': z.number().readonly().optional(), + 'errorCode': z.union([z.literal("expired"), z.literal("no-response"), z.literal("network"), z.literal("protocol"), z.literal("storage")]).readonly().optional(), +})]).readonly(), +})) +let _deepseek_ai_dsh_api_account_controller_account_getBalance_parameter_0$schema$value +const _deepseek_ai_dsh_api_account_controller_account_getBalance_parameter_0$schema = () => (_deepseek_ai_dsh_api_account_controller_account_getBalance_parameter_0$schema$value ??= z.object({ + 'version': z.string().readonly(), + 'locale': z.string().readonly(), + 'timezoneOffsetSeconds': z.number().readonly(), +})) +let _deepseek_ai_dsh_api_account_controller_account_getBalance_result$schema$value +const _deepseek_ai_dsh_api_account_controller_account_getBalance_result$schema = () => (_deepseek_ai_dsh_api_account_controller_account_getBalance_result$schema$value ??= z.union([z.literal(null), z.object({ + 'status': z.literal("ready").readonly(), + 'value': z.array(z.object({ + 'currency': z.union([z.literal("CNY"), z.literal("USD")]).readonly(), + 'balance': z.string().readonly(), +})).readonly(), + 'bonusWallets': z.array(z.object({ + 'currency': z.union([z.literal("CNY"), z.literal("USD")]).readonly(), + 'balance': z.string().readonly(), +})).readonly(), +}), z.object({ + 'status': z.literal("failed").readonly(), +})])) +let _deepseek_ai_dsh_api_account_controller_account_getProfile_parameter_0$schema$value +const _deepseek_ai_dsh_api_account_controller_account_getProfile_parameter_0$schema = () => (_deepseek_ai_dsh_api_account_controller_account_getProfile_parameter_0$schema$value ??= z.object({ + 'version': z.string().readonly(), + 'locale': z.string().readonly(), + 'timezoneOffsetSeconds': z.number().readonly(), +})) +let _deepseek_ai_dsh_api_account_controller_account_getProfile_result$schema$value +const _deepseek_ai_dsh_api_account_controller_account_getProfile_result$schema = () => (_deepseek_ai_dsh_api_account_controller_account_getProfile_result$schema$value ??= z.union([z.literal(null), z.object({ + 'status': z.literal("ready").readonly(), + 'value': z.object({ + 'id': z.union([z.literal(null), z.intersection(z.string(), z.unknown())]).readonly(), + 'name': z.union([z.literal(null), z.string()]).readonly(), + 'contact': z.union([z.literal(null), z.string()]).readonly(), + 'avatarUrl': z.union([z.literal(null), z.string()]).readonly().optional(), +}).readonly(), +}), z.object({ + 'status': z.literal("failed").readonly(), +})])) +let _deepseek_ai_dsh_api_account_controller_account_getState_result$schema$value +const _deepseek_ai_dsh_api_account_controller_account_getState_result$schema = () => (_deepseek_ai_dsh_api_account_controller_account_getState_result$schema$value ??= z.object({ + 'status': z.union([z.literal("signed-out"), z.literal("credential-stored")]).readonly(), + 'links': z.object({ + 'usageUrl': z.string().readonly(), + 'topUpUrl': z.string().readonly(), +}).readonly(), + 'attempt': z.union([z.literal(null), z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'phase': z.union([z.literal("initializing"), z.literal("waiting-browser"), z.literal("exchanging"), z.literal("committing"), z.literal("succeeded"), z.literal("cancelled"), z.literal("expired"), z.literal("failed")]).readonly(), + 'authorizeUrl': z.string().readonly().optional(), + 'expiresAt': z.number().readonly().optional(), + 'errorCode': z.union([z.literal("expired"), z.literal("no-response"), z.literal("network"), z.literal("protocol"), z.literal("storage")]).readonly().optional(), +})]).readonly(), +})) +let _deepseek_ai_dsh_api_account_controller_account_getUnnotifiedBonuses_parameter_0$schema$value +const _deepseek_ai_dsh_api_account_controller_account_getUnnotifiedBonuses_parameter_0$schema = () => (_deepseek_ai_dsh_api_account_controller_account_getUnnotifiedBonuses_parameter_0$schema$value ??= z.object({ + 'version': z.string().readonly(), + 'locale': z.string().readonly(), + 'timezoneOffsetSeconds': z.number().readonly(), +})) +let _deepseek_ai_dsh_api_account_controller_account_getUnnotifiedBonuses_result$schema$value +const _deepseek_ai_dsh_api_account_controller_account_getUnnotifiedBonuses_result$schema = () => (_deepseek_ai_dsh_api_account_controller_account_getUnnotifiedBonuses_result$schema$value ??= z.union([z.literal(null), z.object({ + 'accountId': z.intersection(z.string(), z.unknown()).readonly(), + 'bonuses': z.array(z.object({ + 'orderId': z.intersection(z.string(), z.unknown()).readonly(), + 'campaign': z.string().readonly(), + 'amount': z.string().readonly(), + 'currency': z.union([z.literal("CNY"), z.literal("USD")]).readonly(), + 'grantedAt': z.string().readonly(), + 'expiresAt': z.string().readonly(), + 'message': z.string().readonly(), +})).readonly(), +})])) +let _deepseek_ai_dsh_api_account_controller_account_hasRunningAccountTasks_result$schema$value +const _deepseek_ai_dsh_api_account_controller_account_hasRunningAccountTasks_result$schema = () => (_deepseek_ai_dsh_api_account_controller_account_hasRunningAccountTasks_result$schema$value ??= z.boolean()) +let _deepseek_ai_dsh_api_account_controller_account_signOut_parameter_0$schema$value +const _deepseek_ai_dsh_api_account_controller_account_signOut_parameter_0$schema = () => (_deepseek_ai_dsh_api_account_controller_account_signOut_parameter_0$schema$value ??= z.object({ + 'version': z.string().readonly(), + 'locale': z.string().readonly(), + 'timezoneOffsetSeconds': z.number().readonly(), +})) +let _deepseek_ai_dsh_api_account_controller_account_signOut_result$schema$value +const _deepseek_ai_dsh_api_account_controller_account_signOut_result$schema = () => (_deepseek_ai_dsh_api_account_controller_account_signOut_result$schema$value ??= z.object({ + 'status': z.union([z.literal("signed-out"), z.literal("credential-stored")]).readonly(), + 'links': z.object({ + 'usageUrl': z.string().readonly(), + 'topUpUrl': z.string().readonly(), +}).readonly(), + 'attempt': z.union([z.literal(null), z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'phase': z.union([z.literal("initializing"), z.literal("waiting-browser"), z.literal("exchanging"), z.literal("committing"), z.literal("succeeded"), z.literal("cancelled"), z.literal("expired"), z.literal("failed")]).readonly(), + 'authorizeUrl': z.string().readonly().optional(), + 'expiresAt': z.number().readonly().optional(), + 'errorCode': z.union([z.literal("expired"), z.literal("no-response"), z.literal("network"), z.literal("protocol"), z.literal("storage")]).readonly().optional(), +})]).readonly(), +})) +let _deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_0$schema$value +const _deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_0$schema = () => (_deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_0$schema$value ??= z.object({ + 'version': z.string().readonly(), + 'locale': z.string().readonly(), + 'timezoneOffsetSeconds': z.number().readonly(), +})) +let _deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_1$schema$value +const _deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_1$schema = () => (_deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_1$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_2$schema$value +const _deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_2$schema = () => (_deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_2$schema$value ??= z.union([z.literal("web"), z.literal("desktop")])) +let _deepseek_ai_dsh_api_account_controller_account_startSignIn_result$schema$value +const _deepseek_ai_dsh_api_account_controller_account_startSignIn_result$schema = () => (_deepseek_ai_dsh_api_account_controller_account_startSignIn_result$schema$value ??= z.object({ + 'status': z.union([z.literal("signed-out"), z.literal("credential-stored")]).readonly(), + 'links': z.object({ + 'usageUrl': z.string().readonly(), + 'topUpUrl': z.string().readonly(), +}).readonly(), + 'attempt': z.union([z.literal(null), z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'phase': z.union([z.literal("initializing"), z.literal("waiting-browser"), z.literal("exchanging"), z.literal("committing"), z.literal("succeeded"), z.literal("cancelled"), z.literal("expired"), z.literal("failed")]).readonly(), + 'authorizeUrl': z.string().readonly().optional(), + 'expiresAt': z.number().readonly().optional(), + 'errorCode': z.union([z.literal("expired"), z.literal("no-response"), z.literal("network"), z.literal("protocol"), z.literal("storage")]).readonly().optional(), +})]).readonly(), +})) +let _deepseek_ai_dsh_api_account_controller_account_watch_result$schema$value +const _deepseek_ai_dsh_api_account_controller_account_watch_result$schema = () => (_deepseek_ai_dsh_api_account_controller_account_watch_result$schema$value ??= z.object({ + 'status': z.union([z.literal("signed-out"), z.literal("credential-stored")]).readonly(), + 'links': z.object({ + 'usageUrl': z.string().readonly(), + 'topUpUrl': z.string().readonly(), +}).readonly(), + 'attempt': z.union([z.literal(null), z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'phase': z.union([z.literal("initializing"), z.literal("waiting-browser"), z.literal("exchanging"), z.literal("committing"), z.literal("succeeded"), z.literal("cancelled"), z.literal("expired"), z.literal("failed")]).readonly(), + 'authorizeUrl': z.string().readonly().optional(), + 'expiresAt': z.number().readonly().optional(), + 'errorCode': z.union([z.literal("expired"), z.literal("no-response"), z.literal("network"), z.literal("protocol"), z.literal("storage")]).readonly().optional(), +})]).readonly(), +})) +let _deepseek_ai_dsh_api_account_controller_account_watchExpiry_result$schema$value +const _deepseek_ai_dsh_api_account_controller_account_watchExpiry_result$schema = () => (_deepseek_ai_dsh_api_account_controller_account_watchExpiry_result$schema$value ??= z.literal("session-expired")) + +export const TYPERT_REMOTE = { + package: '@deepseek-ai/dsh-api-account-controller', + descriptors: [ + { + id: '@deepseek-ai/dsh-api-account-controller#account/ackBonusNotified', + service: 'accountController', + namespace: 'account', + method: 'ackBonusNotified', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'accountId', + wire: 'accountId', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountUserId', + create: _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_0$schema, + }, + }, + { + name: 'orderId', + wire: 'orderId', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountBonusOrderId', + create: _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_1$schema, + }, + }, + { + name: 'client', + wire: 'client', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountClientMetadata', + create: _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_2$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-account-controller#account/ackBonusNotified:result', + create: _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_result$schema, + }, + sourceLocation: {"file":"packages/api/account-controller/src/index.ts","line":55,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-account-controller#account/cancelSignIn', + service: 'accountController', + namespace: 'account', + method: 'cancelSignIn', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'attemptId', + wire: 'attemptId', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#SignInAttemptId', + create: _deepseek_ai_dsh_api_account_controller_account_cancelSignIn_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountView', + create: _deepseek_ai_dsh_api_account_controller_account_cancelSignIn_result$schema, + }, + sourceLocation: {"file":"packages/api/account-controller/src/index.ts","line":75,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-account-controller#account/getBalance', + service: 'accountController', + namespace: 'account', + method: 'getBalance', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'client', + wire: 'client', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountClientMetadata', + create: _deepseek_ai_dsh_api_account_controller_account_getBalance_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-account-controller#account/getBalance:result', + create: _deepseek_ai_dsh_api_account_controller_account_getBalance_result$schema, + }, + sourceLocation: {"file":"packages/api/account-controller/src/index.ts","line":35,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-account-controller#account/getProfile', + service: 'accountController', + namespace: 'account', + method: 'getProfile', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'client', + wire: 'client', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountClientMetadata', + create: _deepseek_ai_dsh_api_account_controller_account_getProfile_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-account-controller#account/getProfile:result', + create: _deepseek_ai_dsh_api_account_controller_account_getProfile_result$schema, + }, + sourceLocation: {"file":"packages/api/account-controller/src/index.ts","line":26,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-account-controller#account/getState', + service: 'accountController', + namespace: 'account', + method: 'getState', + invocation: { kind: 'direct' }, + parameters: [ + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountView', + create: _deepseek_ai_dsh_api_account_controller_account_getState_result$schema, + }, + sourceLocation: {"file":"packages/api/account-controller/src/index.ts","line":19,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-account-controller#account/getUnnotifiedBonuses', + service: 'accountController', + namespace: 'account', + method: 'getUnnotifiedBonuses', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'client', + wire: 'client', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountClientMetadata', + create: _deepseek_ai_dsh_api_account_controller_account_getUnnotifiedBonuses_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-account-controller#account/getUnnotifiedBonuses:result', + create: _deepseek_ai_dsh_api_account_controller_account_getUnnotifiedBonuses_result$schema, + }, + sourceLocation: {"file":"packages/api/account-controller/src/index.ts","line":44,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-account-controller#account/hasRunningAccountTasks', + service: 'accountController', + namespace: 'account', + method: 'hasRunningAccountTasks', + invocation: { kind: 'direct' }, + parameters: [ + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-account-controller#account/hasRunningAccountTasks:result', + create: _deepseek_ai_dsh_api_account_controller_account_hasRunningAccountTasks_result$schema, + }, + sourceLocation: {"file":"packages/api/account-controller/src/index.ts","line":81,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-account-controller#account/signOut', + service: 'accountController', + namespace: 'account', + method: 'signOut', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'client', + wire: 'client', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountClientMetadata', + create: _deepseek_ai_dsh_api_account_controller_account_signOut_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountView', + create: _deepseek_ai_dsh_api_account_controller_account_signOut_result$schema, + }, + sourceLocation: {"file":"packages/api/account-controller/src/index.ts","line":90,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-account-controller#account/startSignIn', + service: 'accountController', + namespace: 'account', + method: 'startSignIn', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'client', + wire: 'client', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountClientMetadata', + create: _deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_0$schema, + }, + }, + { + name: 'callbackOrigin', + wire: 'callbackOrigin', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-account-controller#account/startSignIn:callbackOrigin', + create: _deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_1$schema, + }, + }, + { + name: 'loginSource', + wire: 'loginSource', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-account-controller#account/startSignIn:loginSource', + create: _deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_2$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountView', + create: _deepseek_ai_dsh_api_account_controller_account_startSignIn_result$schema, + }, + sourceLocation: {"file":"packages/api/account-controller/src/index.ts","line":66,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-account-controller#account/watch', + service: 'accountController', + namespace: 'account', + method: 'watch', + mode: 'stream', + invocation: { kind: 'direct' }, + parameters: [ + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountView', + create: _deepseek_ai_dsh_api_account_controller_account_watch_result$schema, + }, + sourceLocation: {"file":"packages/api/account-controller/src/index.ts","line":119,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-account-controller#account/watchExpiry', + service: 'accountController', + namespace: 'account', + method: 'watchExpiry', + mode: 'stream', + invocation: { kind: 'direct' }, + parameters: [ + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-account-controller#account/watchExpiry:result', + create: _deepseek_ai_dsh_api_account_controller_account_watchExpiry_result$schema, + }, + sourceLocation: {"file":"packages/api/account-controller/src/index.ts","line":97,"column":10}, + }, + ], +} + +export default TYPERT_REMOTE diff --git a/apps/web-ui/packages/api/account-controller/package.json b/apps/web-ui/packages/api/account-controller/package.json new file mode 100644 index 000000000..c866d38d5 --- /dev/null +++ b/apps/web-ui/packages/api/account-controller/package.json @@ -0,0 +1,30 @@ +{ + "name": "@deepseek-ai/dsh-api-account-controller", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./types": { + "types": "./src/types.ts", + "default": "./src/types.ts" + }, + "./src/*": "./src/*", + "./package.json": "./package.json", + "./typert": { + "types": "./src/typert.host.ts", + "default": "./src/typert.host.ts" + }, + "./remote": { + "types": "./generated/remote.d.ts", + "default": "./generated/remote.js" + } + }, + "dependencies": { + "zod": "^4.4.3" + } +} diff --git a/apps/web-ui/packages/api/account-controller/src/index.ts b/apps/web-ui/packages/api/account-controller/src/index.ts new file mode 100644 index 000000000..9b2ca1dcd --- /dev/null +++ b/apps/web-ui/packages/api/account-controller/src/index.ts @@ -0,0 +1,156 @@ +/** Authenticated Remote operations for account UI consumers. */ +import { Context } from "@deepseek-ai/cordis"; +import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol"; +import { isRunningAccountTask } from "@deepseek-ai/dsh-deepseek-account"; +import type {} from "@deepseek-ai/dsh-agent"; +import type { + AccountBonusBatch, + AccountBonusOrderId, + AccountClientMetadata, + AccountDetails, + AccountUserId, +} from "@deepseek-ai/dsh-deepseek-account/types"; +import type { AccountView, SignInAttemptId } from "./types.ts"; + +/** Account commands and reconnect-safe state stream. */ +export class AccountController extends TypertRemoteService { + static inject = ["deepseekAccount", "agents"]; + /** @param ctx - Host with the account provider mounted. */ + constructor(ctx: Context) { + super(ctx, "accountController", { namespace: "account" }); + } + /** + * Read the safe account projection. + * @returns current account and attempt state. + */ + @Remote + getState(): Promise { + return this.ctx.deepseekAccount.getState(); + } + /** + * Query display-safe Platform profile data. + * @param client - identity of the requesting UI; the Host derives Platform request headers from it. + * @returns profile outcome, or null when the account grant is absent or changed. + */ + @Remote + getProfile(client: AccountClientMetadata): Promise { + return this.ctx.deepseekAccount.getProfile(client); + } + /** + * Query Platform recharge-wallet balances. + * @param client - identity of the requesting UI; the Host derives Platform request headers from it. + * @returns balance outcome, or null when the account grant is absent or changed. + */ + @Remote + getBalance(client: AccountClientMetadata): Promise { + return this.ctx.deepseekAccount.getBalance(client); + } + /** + * Query the granted bonuses Platform has not yet recorded as displayed. + * @param client - identity of the requesting UI; its language selects the server-authored message. + * @returns bonuses with their account, or null when the account grant is absent or changed. + */ + @Remote + getUnnotifiedBonuses(client: AccountClientMetadata): Promise { + return this.ctx.deepseekAccount.getUnnotifiedBonuses(client); + } + /** + * Record one displayed bonus as notified for the account it belongs to. + * @param accountId - account the notification was read for. + * @param orderId - granted bonus order the user saw. + * @param client - identity of the requesting UI; the Host derives Platform request headers from it. + * @returns true once Platform records the acknowledgement; false when the account is absent or changed. + */ + @Remote + ackBonusNotified( + accountId: AccountUserId, + orderId: AccountBonusOrderId, + client: AccountClientMetadata, + ): Promise { + return this.ctx.deepseekAccount.ackBonusNotified(accountId, orderId, client); + } + /** + * Begin browser sign-in. + * @param client - identity of the requesting UI, captured by a new attempt. + * @param callbackOrigin - browser-accessible loopback HTTP origin. + * @param loginSource - initiating UI, used to return from a failed exchange. + * @returns a new or already-active login attempt. + */ + @Remote + startSignIn( + client: AccountClientMetadata, + callbackOrigin: string, + loginSource: "web" | "desktop", + ): Promise { + return this.ctx.deepseekAccount.startSignIn(client, callbackOrigin, loginSource); + } + /** + * Cancel the named local attempt. + * @param attemptId - attempt to cancel. + * @returns settled cancellation or commit state. + */ + @Remote + cancelSignIn(attemptId: SignInAttemptId): Promise { + return this.ctx.deepseekAccount.cancelSignIn(attemptId); + } + /** + * Inspect the latest logged request providers of running tasks, including tools and retries. + * @returns whether running work has a latest request context on the account route. + */ + @Remote + hasRunningAccountTasks(): boolean { + return this.ctx.agents.list().some(isRunningAccountTask); + } + /** + * Remove the local account grant and revoke it through Platform in the background, without deleting API keys. + * @param client - identity of the requesting UI, captured for the background revocation retries. + * @returns state after removing the local account grant. + */ + @Remote + signOut(client: AccountClientMetadata): Promise { + return this.ctx.deepseekAccount.signOut(client); + } + /** + * Subscribe to credential expiry without replaying prior notifications. + * @param signal - stream lifetime. + * @returns notifications emitted while subscribed. + */ + @Remote({ mode: "stream" }) + async *watchExpiry(signal: AbortSignal): AsyncIterable<"session-expired"> { + let pending = 0; + let wake: (() => void) | undefined; + const stop = this.ctx.on("deepseek-account/session-expired", () => { + pending++; + wake?.(); + }); + const abort = (): void => { + wake?.(); + }; + signal.addEventListener("abort", abort, { once: true }); + try { + while (!signal.aborted) { + if (pending > 0) { + pending--; + yield "session-expired"; + continue; + } + await new Promise((resolve) => { + wake = resolve; + }); + } + } finally { + stop(); + signal.removeEventListener("abort", abort); + } + } + /** + * Stream the safe account projection. + * @param signal - stream lifetime. + * @returns initial snapshot and subsequent changes. + */ + @Remote({ mode: "stream" }) + watch(signal: AbortSignal): AsyncIterable { + return this.ctx.deepseekAccount.watch(signal); + } +} +export default AccountController; diff --git a/apps/web-ui/packages/api/account-controller/src/types.ts b/apps/web-ui/packages/api/account-controller/src/types.ts new file mode 100644 index 000000000..dc358c025 --- /dev/null +++ b/apps/web-ui/packages/api/account-controller/src/types.ts @@ -0,0 +1,12 @@ +/** Account Remote values contain no credential payloads. */ +export type { + AccountBonusBatch, + AccountBonusNotification, + AccountBonusOrderId, + AccountClientMetadata, + AccountDetails, + AccountProfile, + AccountUserId, + AccountView, + SignInAttemptId, +} from "@deepseek-ai/dsh-deepseek-account/types"; diff --git a/apps/web-ui/packages/api/gateway/package.json b/apps/web-ui/packages/api/gateway/package.json new file mode 100644 index 000000000..8ea02634c --- /dev/null +++ b/apps/web-ui/packages/api/gateway/package.json @@ -0,0 +1,46 @@ +{ + "name": "@deepseek-ai/dsh-api-gateway", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "scripts": { + "test": "node --import tsx --test test/*.test.ts" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./client": { + "types": "./src/client/index.ts", + "default": "./lib/client.js" + }, + "./types": { + "types": "./src/types.ts", + "default": "./src/types.ts" + }, + "./src/*": "./src/*", + "./package.json": "./package.json", + "./stream-protocol": { + "types": "./src/stream-protocol.ts", + "default": "./src/stream-protocol.ts" + } + }, + "dependencies": { + "@deepseek-ai/cordis": "*", + "@deepseek-ai/dsh-typert-protocol": "*", + "@deepseek-ai/dsh-deque": "*", + "@deepseek-ai/dsh-util-crypto": "*" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-typert-registry", + "@deepseek-ai/dsh-client-connection" + ], + "platform": "web", + "immediately": true + } + } +} diff --git a/apps/web-ui/packages/api/gateway/src/client/index.ts b/apps/web-ui/packages/api/gateway/src/client/index.ts new file mode 100644 index 000000000..d98bf0b68 --- /dev/null +++ b/apps/web-ui/packages/api/gateway/src/client/index.ts @@ -0,0 +1,969 @@ +/** + * Client projection of generated Typert Remote descriptors. Contributions + * install traced `remote.` services; no JavaScript Proxy + * participates in method lookup, invocation, or type exposure. + */ + +import { Service } from "@deepseek-ai/cordis"; +import { RemoteError, isRemoteUplinkItem, remoteErrorOf } from "@deepseek-ai/dsh-typert-protocol"; +export type { TypertGatewayFaultDetails } from "../remote-error-codes.ts"; +import type { Context } from "@deepseek-ai/cordis"; +import type { ConnectionHandle } from "@deepseek-ai/dsh-client-connection/client"; +import type { + InvocationDescriptor, + TypertClientEventListener, + TypertClientRemote, + RemoteFailure, + RemoteResult, + RemoteStreamHandle, + TypertCodec, + TypertDisposer, + TypertRemoteContribution, + TypertRemoteEvent, +} from "@deepseek-ai/dsh-typert-protocol"; +import { + ClientUplinkQueue, + RemoteStreamCarrierError, + RemoteStreamMuxClient, +} from "./stream-client.ts"; +import { ClientRemoteEvents } from "./remote-events.ts"; +import { RemoteStream, type RemoteStreamOptions } from "./remote-stream.ts"; + +export { RemoteStreamCarrierError } from "./stream-client.ts"; +export { RemoteJournalStream } from "./journal-stream.ts"; +export type { + RemoteJournalChange, + RemoteJournalFrame, + RemoteJournalStreamOptions, + RemoteStreamFactory, +} from "./journal-stream.ts"; +export { RemoteStream } from "./remote-stream.ts"; +export type { RemoteStreamItem, RemoteStreamOptions } from "./remote-stream.ts"; +export { RemoteSnapshotStream } from "./snapshot-stream.ts"; +export type { RemoteSnapshotStreamOptions } from "./snapshot-stream.ts"; + +interface MountToken { + active: boolean; + readonly abort: AbortController; +} + +interface ScopedProjection { + readonly context: string; + readonly wire: string; + readonly parameterIndex?: number; +} + +interface DirectMethod { + readonly descriptor: InvocationDescriptor; + readonly token: MountToken; +} + +interface ScopedMethod extends DirectMethod { + readonly projection: ScopedProjection; +} + +interface RemoteMethodRecord { + direct?: DirectMethod; + scoped?: ScopedMethod; +} + +interface BoundContextIdentity { + readonly value: unknown; +} + +interface PreparedClientInvocation { + readonly endpoint: string; + readonly args: Readonly>; + readonly signal: AbortSignal; +} + +interface RemoteNamespaceHandle { + readonly service: RemoteNamespaceService; + readonly dispose: TypertDisposer; +} + +interface LoaderReadiness { + await(): Promise; +} + +/** One descriptor's mounted variants, for the group disposer to unwind. */ +interface InstalledMethod { + readonly descriptor: InvocationDescriptor; + readonly token: MountToken; + direct: boolean; + scoped: boolean; +} + +/** Typed Remote service augmented by generated direct namespaces and Gateway stream supervision. */ +export interface ClientRemote extends TypertClientRemote { + /** + * Create one independently cancellable, reconnecting logical stream. + * @param options - domain-owned opener and generation-end classification. + * @returns a single-consumer stream annotated with physical generation ids. + */ + $stream(options: RemoteStreamOptions): RemoteStream; + /** + * Fixed Host facts as plain reads: no store, no subscription, no generation + * counter. `home` stays undefined until the first ready frame and reflects + * the latest one afterwards. + */ + readonly $host: RemoteHostFacts; +} + +/** The fixed Host facts exposed on `ctx.remote.$host`. */ +export interface RemoteHostFacts { + /** Host home directory from the ready frame, undefined before it. */ + readonly home: string | undefined; + /** Whether the carrier connects to the local Host. */ + readonly isLoopback: boolean; +} + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Generated Remote namespaces selected by the Client assembly. */ + remote: ClientRemote; + } +} + +/** Required Client services: the Typert registry and the existing Connection carrier. */ +export const inject = ["typert", "connection"]; + +/** + * Install the typed Client Remote service. + * @param ctx - Client Cordis root. + */ +export function apply(ctx: Context): void { + new ClientRemoteService(ctx); +} + +class ClientRemoteService extends Service implements ClientRemote { + private readonly ownerCtx: Context; + private readonly connection: ConnectionHandle; + private readonly namespaces = new Map(); + private hostFacts: RemoteHostFacts | undefined; + private readonly streams = new RemoteStreamMuxClient(); + private readonly events: ClientRemoteEvents; + private mutations = Promise.resolve(); + + constructor(ctx: Context) { + super(ctx, "remote"); + this.ownerCtx = ctx; + const connection = ctx.get("connection") as ConnectionHandle; + this.connection = connection; + this.events = new ClientRemoteEvents(ctx, connection, (endpoint, payload, signal) => + this.openRemoteStream(endpoint, payload, signal), + ); + if (connection.rpc.open === undefined) this.streams.start(); + let disposed = false; + let loop: ReturnType | undefined; + const start = (): void => { + if (disposed) return; + if (connection.rpc.open === undefined) this.streams.start(); + loop = connection.start({ + onConnected: () => { + this.ownerCtx.emit("connection/reset"); + }, + onReconnectRequested: () => { + if (connection.rpc.open === undefined) this.streams.reconnect(); + }, + }); + }; + const loader = ctx.get("loader") as LoaderReadiness | undefined; + if (loader === undefined) start(); + else void loader.await().then(start, () => {}); + ctx.effect( + () => async () => { + disposed = true; + loop?.stop(); + await this.events.dispose(); + await this.streams.close(); + }, + "api-gateway.client.transport", + ); + } + + $stream(options: RemoteStreamOptions): RemoteStream { + return new RemoteStream(this.connection, options); + } + + get $host(): RemoteHostFacts { + // Identity-stable: readers (useSyncExternalStore snapshots, memo inputs) + // compare by reference, so a fresh object is minted only when the fact + // itself changed. isLoopback is fixed for the page lifetime. + const home = this.connection.generation.getSnapshot()?.host.home; + if (this.hostFacts === undefined || this.hostFacts.home !== home) { + this.hostFacts = { home, isLoopback: this.connection.isLoopback }; + } + return this.hostFacts; + } + + async $mount(contribution: TypertRemoteContribution): ReturnType { + const callerCtx = this.ctx; + const owned = callerCtx.effect( + async () => { + const dispose = await this.enqueue(() => this.mountContribution(callerCtx, contribution)); + return () => this.enqueue(dispose); + }, + `api-gateway.client.$mount(${JSON.stringify(contribution.package)})`, + ); + await owned; + return async () => { + await owned(); + }; + } + + $on( + event: Event, + listener: TypertClientEventListener, + ): () => void { + return this.events.subscribe(this.ctx, event, listener); + } + + /** Open one Remote stream and normalize a worker-local carrier's structural failures. */ + private openRemoteStream( + endpoint: string, + payload: unknown, + signal: AbortSignal, + uplink?: AsyncIterable, + noConnection = `client api: ${endpoint} has no active Connection`, + ): AsyncIterable { + const connection = this.ownerCtx.get("connection") as ConnectionHandle | undefined; + if (connection === undefined) throw new Error(noConnection); + const local = connection.rpc.open?.("/api", endpoint, payload, signal, uplink); + return local === undefined + ? this.streams.open(endpoint, payload, signal, uplink) + : normalizeConnectionStream(local); + } + + private enqueue(operation: () => T | Promise): Promise { + const result = this.mutations.then(operation, operation); + this.mutations = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + private async mountContribution( + callerCtx: Context, + contribution: TypertRemoteContribution, + ): Promise { + this.validateContribution(contribution); + const disposeRemote = callerCtx.typert.remotes.register(contribution); + const groups = new Map(); + for (const descriptor of contribution.descriptors) { + const group = groups.get(descriptor.namespace); + if (group === undefined) groups.set(descriptor.namespace, [descriptor]); + else group.push(descriptor); + } + const installed: TypertDisposer[] = []; + try { + for (const [namespace, descriptors] of groups) { + installed.push(await this.installNamespace(namespace, descriptors)); + } + } catch (error) { + for (const dispose of installed.reverse()) await dispose(); + await disposeRemote(); + throw error; + } + return async () => { + for (const dispose of installed.reverse()) await dispose(); + await disposeRemote(); + }; + } + + private validateContribution(contribution: TypertRemoteContribution): void { + const direct = new Map>(); + const scoped = new Map>(); + const add = ( + table: Map>, + descriptor: InvocationDescriptor, + kind: "direct" | "scoped", + ): void => { + const methods = table.get(descriptor.namespace) ?? new Set(); + if (methods.has(descriptor.method)) { + throw new Error( + `client api: contribution repeats ${kind} method ${endpointOf(descriptor)}`, + ); + } + methods.add(descriptor.method); + table.set(descriptor.namespace, methods); + const namespace = this.namespaces.get(descriptor.namespace)?.service; + if (namespace?.has(kind, descriptor.method) === true) { + throw new Error(`client api: ${kind} method ${endpointOf(descriptor)} is already mounted`); + } + }; + for (const descriptor of contribution.descriptors) { + requireStrictInputs(descriptor); + if (descriptor.invocation.kind === "direct") add(direct, descriptor, "direct"); + if (scopedProjection(descriptor) !== undefined) add(scoped, descriptor, "scoped"); + } + const namespaces = new Set([...direct.keys(), ...scoped.keys()]); + for (const namespace of namespaces) { + const service = this.namespaces.get(namespace)?.service; + if (service === undefined) { + if (namespace in this) { + throw new Error( + `client api: namespace ${JSON.stringify(namespace)} conflicts with the Remote service`, + ); + } + const serviceKey = remoteServiceKey(namespace); + const property = this.ownerCtx.reflect.props[serviceKey]; + if (property?.type === "accessor" || this.ownerCtx.get(serviceKey) !== undefined) { + throw new Error( + `client api: namespace ${JSON.stringify(namespace)} conflicts with an existing Remote namespace`, + ); + } + } + for (const method of new Set([ + ...(direct.get(namespace) ?? []), + ...(scoped.get(namespace) ?? []), + ])) { + if (service === undefined) RemoteNamespaceService.assertMethodAvailable(namespace, method); + else service.assertMethodAvailable(method); + } + } + } + + /** + * Mount one namespace's descriptor group with no visibility gap: a fresh + * namespace installs its whole group synchronously inside its fiber's + * apply, so a plugin parked on the namespace service never observes it + * without the methods the same contribution carries; an existing namespace + * takes the group in one synchronous step. + * @param name - Remote namespace. + * @param descriptors - Every contribution descriptor naming that namespace. + * @returns disposer unmounting the group and the namespace once empty. + */ + private async installNamespace( + name: string, + descriptors: readonly InvocationDescriptor[], + ): Promise { + let namespace = this.namespaces.get(name); + let installed: InstalledMethod[]; + if (namespace === undefined) { + ({ namespace, installed } = await this.createNamespace(name, descriptors)); + } else { + installed = installMethods(namespace.service, descriptors); + } + const handle = namespace; + return async () => { + for (const method of [...installed].reverse()) { + /* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */ + if (!method.token.active) continue; + method.token.active = false; + method.token.abort.abort(); + if (method.scoped) handle.service.remove("scoped", method.descriptor.method, method.token); + if (method.direct) handle.service.remove("direct", method.descriptor.method, method.token); + } + await this.disposeNamespace(name, handle); + }; + } + + private async createNamespace( + name: string, + descriptors: readonly InvocationDescriptor[], + ): Promise<{ namespace: RemoteNamespaceHandle; installed: InstalledMethod[] }> { + let service: RemoteNamespaceService | undefined; + let installed: InstalledMethod[] | undefined; + const fiber = this.ownerCtx.plugin({ + name: remoteServiceKey(name), + apply: (ctx: Context) => { + service = new RemoteNamespaceService(ctx, name, (direct, scoped, caller, args) => + this.invokeMethod(direct, scoped, caller, args), + ); + // Same synchronous window as the service registration: a dependent the + // new service unparks runs only after the methods exist. + installed = installMethods(service, descriptors); + }, + }); + try { + await fiber; + } catch (error) { + await fiber.dispose(); + throw error; + } + /* v8 ignore next 3 -- a settled namespace fiber synchronously constructs its Service and installs the group. */ + if (service === undefined || installed === undefined) { + throw new Error(`client api: namespace ${JSON.stringify(name)} did not start`); + } + const namespace = { service, dispose: fiber.dispose }; + this.namespaces.set(name, namespace); + return { namespace, installed }; + } + + private async disposeNamespace(name: string, namespace: RemoteNamespaceHandle): Promise { + if (!namespace.service.empty || this.namespaces.get(name) !== namespace) return; + this.namespaces.delete(name); + await namespace.dispose(); + } + + private invokeMethod( + direct: DirectMethod | undefined, + scoped: ScopedMethod | undefined, + callerCtx: Context, + values: readonly unknown[], + ): Promise> | RemoteStreamHandle { + if (scoped !== undefined) { + const adapter = this.ownerCtx.typert.contexts.getClient(scoped.projection.context); + const identity = adapter?.identity(callerCtx); + if (identity !== undefined) { + return this.invokeSelected( + scoped.descriptor, + scoped.projection, + scoped.token, + callerCtx, + values, + { value: identity }, + ); + } + } + if (direct !== undefined) { + return this.invokeSelected(direct.descriptor, undefined, direct.token, callerCtx, values); + } + if (scoped !== undefined) { + return this.invokeSelected( + scoped.descriptor, + scoped.projection, + scoped.token, + callerCtx, + values, + ); + } + throw new Error("client api: Remote method is no longer mounted"); + } + + private invokeSelected( + descriptor: InvocationDescriptor, + projection: ScopedProjection | undefined, + token: MountToken, + callerCtx: Context, + values: readonly unknown[], + boundIdentity?: BoundContextIdentity, + ): Promise> | RemoteStreamHandle { + if (descriptor.mode !== undefined) { + return this.invokeStream(descriptor, projection, token, callerCtx, values, boundIdentity); + } + return this.invoke(descriptor, projection, token, callerCtx, values, boundIdentity); + } + + private async invoke( + descriptor: InvocationDescriptor, + projection: ScopedProjection | undefined, + token: MountToken, + callerCtx: Context, + values: readonly unknown[], + boundIdentity?: BoundContextIdentity, + ): Promise> { + const endpoint = endpointOf(descriptor); + if (!token.active) return withdrawn(endpoint); + const prepared = this.prepareInvocation( + descriptor, + projection, + token, + callerCtx, + values, + boundIdentity, + ); + const connection = this.ownerCtx.get("connection") as ConnectionHandle | undefined; + if (connection === undefined) + throw new Error(`client api: ${endpoint} has no active Connection`); + try { + const result = await connection.rpc.call( + "/api", + endpoint, + { args: prepared.args }, + prepared.signal, + ); + if (!mountActive(token)) return withdrawn(endpoint); + prepared.signal.throwIfAborted(); + if (!result.ok) return { ok: false, error: rebuiltFailure(result.error) }; + const value = + descriptor.result.mode === "strict" && descriptor.result.decode !== undefined + ? descriptor.result.decode(result.value) + : result.value; + return { ok: true, value }; + } catch (error) { + // Carrier throws (offline or abort) are outcomes of the call, not assembly + // faults, so they join the same error branch. A caller-aborted call is a + // cancellation even when the local throw wins the race against the wire + // round-trip, so it gets the same code the Host would have produced. + if (prepared.signal.aborted) return cancelledFailure(endpoint, error); + return carrierFailure(endpoint, error); + } + } + + /** Open the logical stream now and hand back its handle; `send()` before the first read queues behind the `open` frame. */ + private invokeStream( + descriptor: InvocationDescriptor, + projection: ScopedProjection | undefined, + token: MountToken, + callerCtx: Context, + values: readonly unknown[], + boundIdentity?: BoundContextIdentity, + ): RemoteStreamHandle { + const endpoint = endpointOf(descriptor); + if (!token.active) throw new Error(withdrawn(endpoint).error.message); + const prepared = this.prepareInvocation( + descriptor, + projection, + token, + callerCtx, + values, + boundIdentity, + ); + const generation = new AbortController(); + const uplink = new ClientUplinkQueue(endpoint); + const downlink = this.openRemoteStream( + endpoint, + { args: prepared.args }, + AbortSignal.any([prepared.signal, generation.signal]), + uplink, + ); + return new ClientStreamHandle(endpoint, downlink, uplink, generation, token); + } + + private prepareInvocation( + descriptor: InvocationDescriptor, + projection: ScopedProjection | undefined, + token: MountToken, + callerCtx: Context, + values: readonly unknown[], + boundIdentity?: BoundContextIdentity, + ): PreparedClientInvocation { + const endpoint = endpointOf(descriptor); + const expected = + descriptor.parameters.length - (projection?.parameterIndex === undefined ? 0 : 1); + const hasCallerSignal = descriptor.cancellation !== undefined && values.length === expected + 1; + if (values.length !== expected && !hasCallerSignal) { + const contract = + descriptor.cancellation === undefined + ? `${String(expected)} argument(s)` + : `${String(expected)} business argument(s) plus an optional AbortSignal`; + throw new Error(`client api: ${endpoint} expected ${contract}, got ${String(values.length)}`); + } + const args = Object.create(null) as Record; + if (projection !== undefined) { + const adapter = + boundIdentity === undefined + ? this.ownerCtx.typert.contexts.getClient(projection.context) + : undefined; + if (boundIdentity === undefined && adapter === undefined) { + throw new Error( + `client api: ${endpoint} has no Client Context adapter for ${JSON.stringify(projection.context)}`, + ); + } + const identity = + boundIdentity === undefined ? adapter?.identity(callerCtx) : boundIdentity.value; + if (identity === undefined) { + throw new Error( + `client api: ${endpoint} requires a ${JSON.stringify(projection.context)} Context`, + ); + } + args[projection.wire] = identity; + } + let valueIndex = 0; + descriptor.parameters.forEach((parameter, parameterIndex) => { + if (parameterIndex === projection?.parameterIndex) return; + const value = values[valueIndex]; + if (value !== undefined) args[parameter.wire] = value; + valueIndex += 1; + }); + const callerSignal = hasCallerSignal + ? (values[expected] as AbortSignal | undefined) + : undefined; + const signal = + callerSignal === undefined + ? token.abort.signal + : AbortSignal.any([token.abort.signal, callerSignal]); + return { endpoint, args, signal }; + } +} + +/** + * The handle a generated stream method returns: one generation of one logical + * stream. The downlink is iterated once; `send`/`end` feed the uplink queue the + * carrier pump drains; `dispose` aborts the generation and returns the carrier + * iterator, which sends `cancel` unless a terminal frame arrived, drops what was + * buffered, and ends the iteration quietly. + */ +class ClientStreamHandle implements RemoteStreamHandle { + private readonly downlink: AsyncIterator; + private primed: Promise> | undefined; + private consumed = false; + private disposed = false; + + constructor( + private readonly endpoint: string, + downlink: AsyncIterable, + private readonly uplink: ClientUplinkQueue, + private readonly generation: AbortController, + private readonly token: MountToken, + ) { + this.downlink = downlink[Symbol.asyncIterator](); + // The carrier opens the logical stream on the first pull; pulling now puts + // the `open` frame on the wire before any `send()`. The first read is kept + // for the consumer, and a failure waits for it instead of surfacing here; + // it also terminates the stream, so the queue closes and `send()` throws. + this.primed = this.downlink.next(); + void this.primed.catch(() => { + this.uplink.close(); + }); + } + + send(item: unknown): void { + if (!isRemoteUplinkItem(item)) + throw new Error(`client api: ${this.endpoint} uplink item is not a lossless JSON value`); + this.uplink.push(item); + } + + end(): void { + this.uplink.end(); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.uplink.close(); + this.generation.abort(new Error(`client api: ${this.endpoint} stream disposed`)); + // Returning the carrier iterator reaches its `finally` even while nobody + // reads the downlink, so `cancel` goes out now and the pump stops. + void Promise.resolve(this.downlink.return?.()).catch(() => undefined); + } + + [Symbol.asyncIterator](): AsyncIterator { + if (this.consumed) throw new Error(`client api: ${this.endpoint} stream has one consumer`); + this.consumed = true; + const iteration = this.iterate(); + return { + next: () => iteration.next(), + // A return before the first read ends a generator that never started, so its `finally` would not dispose. + return: (value?: unknown) => { + this.dispose(); + return iteration.return(value); + }, + }; + } + + private async *iterate(): AsyncGenerator { + try { + while (true) { + const next = await (this.primed ?? this.downlink.next()); + this.primed = undefined; + if (this.disposed || next.done === true) return; + if (!mountActive(this.token)) throw new Error(withdrawn(this.endpoint).error.message); + yield next.value; + } + } catch (error) { + if (this.disposed) return; + throw error; + } finally { + // Ending early is a dispose; a terminated stream accepts no more uplink. + this.dispose(); + await this.downlink.return?.(); + } + } +} + +type InvokeRemote = ( + direct: DirectMethod | undefined, + scoped: ScopedMethod | undefined, + callerCtx: Context, + args: readonly unknown[], +) => Promise> | RemoteStreamHandle; + +class RemoteNamespaceService extends Service { + private readonly methods = new Map(); + private readonly namespace: string; + + static assertMethodAvailable(namespace: string, method: string): void { + if (REMOTE_NAMESPACE_FIELDS.has(method) || method in RemoteNamespaceService.prototype) { + throw new Error( + `client api: method ${JSON.stringify(`${namespace}/${method}`)} conflicts with its namespace service`, + ); + } + } + + constructor( + ctx: Context, + name: string, + private readonly invokeRemote: InvokeRemote, + ) { + super(ctx, remoteServiceKey(name)); + this.namespace = name; + } + + assertMethodAvailable(method: string): void { + RemoteNamespaceService.assertMethodAvailable(this.namespace, method); + if (method in this && !this.methods.has(method)) { + throw new Error( + `client api: method ${JSON.stringify(`${this.namespace}/${method}`)} conflicts with its namespace service`, + ); + } + } + + get empty(): boolean { + return this.methods.size === 0; + } + + has(kind: "direct" | "scoped", method: string): boolean { + return this.methods.get(method)?.[kind] !== undefined; + } + + installDirect(descriptor: InvocationDescriptor, token: MountToken): void { + this.install(descriptor.method, "direct", { descriptor, token }); + } + + installScoped( + descriptor: InvocationDescriptor, + projection: ScopedProjection, + token: MountToken, + ): void { + this.install(descriptor.method, "scoped", { descriptor, projection, token }); + } + + private install(method: string, kind: "direct", value: DirectMethod): void; + private install(method: string, kind: "scoped", value: ScopedMethod): void; + private install( + method: string, + kind: "direct" | "scoped", + value: DirectMethod | ScopedMethod, + ): void { + this.assertMethodAvailable(method); + let record = this.methods.get(method); + const fresh = record === undefined; + record ??= {}; + if (fresh) { + Object.defineProperty(this, method, { + configurable: true, + enumerable: true, + get: function (this: RemoteNamespaceService): (...args: unknown[]) => unknown { + const callerCtx = this.ctx; + const current = this.methods.get(method); + const direct = current?.direct; + const scoped = current?.scoped; + return (...args: unknown[]) => { + return this.invokeRemote(direct, scoped, callerCtx, args); + }; + }, + }); + this.methods.set(method, record); + } + if (kind === "direct") record.direct = value; + else record.scoped = value as ScopedMethod; + } + + remove(kind: "direct" | "scoped", method: string, token: MountToken): void { + const record = this.methods.get(method); + const current = record?.[kind]; + /* v8 ignore next -- duplicate live variants are rejected before installation, so no newer token can replace this one. */ + if (record === undefined || current?.token !== token) return; + if (kind === "direct") delete record.direct; + else delete record.scoped; + if (record.direct !== undefined || record.scoped !== undefined) return; + this.methods.delete(method); + Reflect.deleteProperty(this, method); + } +} + +/** + * Install one descriptor group on a namespace service, unwinding the partial + * group when a descriptor is refused. + * @param service - Namespace service taking the methods. + * @param descriptors - Descriptor group of one contribution. + * @returns per-descriptor records for the group disposer. + */ +function installMethods( + service: RemoteNamespaceService, + descriptors: readonly InvocationDescriptor[], +): InstalledMethod[] { + const installed: InstalledMethod[] = []; + try { + for (const descriptor of descriptors) { + const method: InstalledMethod = { + descriptor, + token: { active: true, abort: new AbortController() }, + direct: false, + scoped: false, + }; + installed.push(method); + if (descriptor.invocation.kind === "direct") { + service.installDirect(descriptor, method.token); + method.direct = true; + } + const projection = scopedProjection(descriptor); + if (projection !== undefined) { + service.installScoped(descriptor, projection, method.token); + method.scoped = true; + } + } + } catch (error) { + for (const method of [...installed].reverse()) { + method.token.active = false; + method.token.abort.abort(); + if (method.scoped) service.remove("scoped", method.descriptor.method, method.token); + if (method.direct) service.remove("direct", method.descriptor.method, method.token); + } + throw error; + } + return installed; +} + +const REMOTE_NAMESPACE_FIELDS = new Set([ + "ctx", + "empty", + "invokeRemote", + "methods", + "name", + "namespace", +]); + +function remoteServiceKey(namespace: string): string { + return `remote.${namespace}`; +} + +function endpointOf(descriptor: Pick): string { + return `${descriptor.namespace}/${descriptor.method}`; +} + +function mountActive(token: MountToken): boolean { + return token.active; +} + +function scopedProjection(descriptor: InvocationDescriptor): ScopedProjection | undefined { + if (descriptor.invocation.kind === "context") { + return { + context: descriptor.invocation.context, + wire: descriptor.invocation.wire, + }; + } + if (descriptor.scope === undefined) return undefined; + const lookupParameters = descriptor.parameters + .map((parameter, index) => ({ parameter, index })) + .filter((candidate) => candidate.parameter.source === "lookup"); + const selected = lookupParameters.length === 1 ? lookupParameters[0] : undefined; + if ( + selected === undefined || + selected.parameter.wire !== descriptor.scope.wire || + selected.parameter.lookup !== descriptor.scope.context + ) { + throw new Error( + `client api: generated Remote ${endpointOf(descriptor)} scope must select its only lookup parameter`, + ); + } + return { + context: descriptor.scope.context, + wire: descriptor.scope.wire, + parameterIndex: selected.index, + }; +} + +function requireStrictInputs(descriptor: InvocationDescriptor): void { + const endpoint = endpointOf(descriptor); + for (const parameter of descriptor.parameters) { + requireStrictCodec(parameter.codec, endpoint, parameter.wire); + } + if (descriptor.uplink !== undefined) + requireStrictCodec(descriptor.uplink.codec, endpoint, "uplink"); + if (descriptor.invocation.kind === "context") { + requireStrictCodec(descriptor.invocation.codec, endpoint, descriptor.invocation.wire); + } +} + +function requireStrictCodec(codec: TypertCodec, endpoint: string, field: string): void { + if (codec.mode !== "strict") { + throw new Error( + `client api: generated Remote ${endpoint} field ${JSON.stringify(field)} has no strict codec`, + ); + } +} + +/** The namespace retired before or during the call, so no request outcome exists. */ +function withdrawn(endpoint: string): Extract, { readonly ok: false }> { + return internalFailure(`client api: Remote method ${endpoint} is no longer mounted`); +} + +/** + * The error branch a carrier throw (offline, transport fault) folds into: `gateway/internal` naming the endpoint and + * the thrown message. Exported so a stand-in for this face folds identically. + * @param endpoint - `/` that was called. + * @param error - what the carrier threw. + * @returns the failed result. + */ +export function carrierFailure( + endpoint: string, + error: unknown, +): Extract, { readonly ok: false }> { + return internalFailure( + `client api: ${endpoint} failed: ${error instanceof Error ? error.message : String(error)}`, + ); +} + +/** + * The error branch a call aborted by its caller folds into: `gateway/cancelled` with the carrier's throw as `cause`. + * @param endpoint - `/` that was called. + * @param cause - what the carrier threw when the signal aborted. + * @returns the failed result. + */ +export function cancelledFailure( + endpoint: string, + cause: unknown, +): Extract, { readonly ok: false }> { + return { + ok: false, + error: new RemoteError( + "gateway/cancelled", + `client api: Remote invocation "${endpoint}" was aborted`, + {}, + { cause }, + ), + }; +} + +function internalFailure(message: string): Extract, { readonly ok: false }> { + return { ok: false, error: new RemoteError("gateway/internal", message, {}) }; +} + +/** + * Whether a caught value is a Remote failure this face delivered or threw. + * The one consumer-facing discrimination point: marked instances carry their + * Host code; anything else is a local fault the caller should let crash. + * @param error - a caught value. + * @returns true when the value narrows to RemoteFailure. + */ +export function isRemoteFailure(error: unknown): error is RemoteFailure { + return remoteErrorOf(error) !== undefined; +} + +/** + * Rebuild the wire failure as a local RemoteError instance so the error branch + * carries a real Error and `throw result.error` keeps throw semantics. The code + * is passed through verbatim without runtime validation: a code outside this + * Client's merged map still surfaces as-is, so a newer Host stays readable. + */ +function rebuiltFailure(error: { code: string; message: string; details: object }): RemoteFailure { + return new RemoteError(error.code as never, error.message, error.details as never); +} + +type MarkedConnectionStreamFailure = Error & { + readonly dshRemoteStreamFailure?: + | { readonly kind: "remote"; readonly code: string; readonly details: object } + | { readonly kind: "carrier" }; +}; + +/** Preserve Gateway error classes across a worker transport's separately bundled page half. */ +async function* normalizeConnectionStream(source: AsyncIterable): AsyncGenerator { + try { + yield* source; + } catch (error) { + if (!(error instanceof Error)) throw error; + const marker = (error as MarkedConnectionStreamFailure).dshRemoteStreamFailure; + if (marker?.kind === "remote") { + throw new RemoteError(marker.code as never, error.message, marker.details as never); + } + if (marker?.kind === "carrier") { + throw new RemoteStreamCarrierError(error.message, { cause: error }); + } + throw error; + } +} diff --git a/apps/web-ui/packages/api/gateway/src/client/journal-stream.ts b/apps/web-ui/packages/api/gateway/src/client/journal-stream.ts new file mode 100644 index 000000000..952dfa33b --- /dev/null +++ b/apps/web-ui/packages/api/gateway/src/client/journal-stream.ts @@ -0,0 +1,604 @@ +/** Cursor, page, and live-tail coordination over a reconnecting Remote stream. */ + +import { RemoteError } from "@deepseek-ai/dsh-typert-protocol"; +import { RemoteStreamCarrierError } from "./stream-client.ts"; +import type { RemoteStream, RemoteStreamItem, RemoteStreamOptions } from "./remote-stream.ts"; + +/** Host-side stream protocol violation, marked so consumers surface it as an error state. */ +function protocolViolation(message: string): RemoteError<"gateway/internal"> { + return new RemoteError("gateway/internal", message, {}); +} + +/** Transport-neutral opening snapshot, durable entry, or cursorless notification. */ +export type RemoteJournalFrame = + | { readonly type: "opened"; readonly cursor: Cursor; readonly page: Page } + | { readonly type: "entry"; readonly entry: Entry } + | ([Notification] extends [never] + ? never + : { readonly type: "notification"; readonly notification: Notification }); + +/** One journal-window update or cursorless domain notification. */ +export type RemoteJournalChange = + | { + readonly type: "replace"; + readonly page: Page; + readonly entries: readonly Entry[]; + readonly hasMore: boolean; + } + | { + readonly type: "prepend"; + readonly page: Page; + readonly entries: readonly Entry[]; + readonly hasMore: boolean; + } + | { readonly type: "append"; readonly entry: Entry } + | ([Notification] extends [never] + ? never + : { readonly type: "notification"; readonly notification: Notification }); + +type JournalStreamItem = RemoteStreamItem< + RemoteJournalFrame +>; + +/** Gateway capability used to create one reconnecting Remote stream. */ +export interface RemoteStreamFactory { + /** + * Create one independently cancellable logical stream. + * @param options - domain-owned opener and generation-end classification. + * @returns a reconnecting single-consumer stream. + */ + $stream(options: RemoteStreamOptions): RemoteStream; +} + +/** Domain publication and cursor operations for one addressed journal stream. */ +export interface RemoteJournalStreamOptions { + /** Diagnostic stream name used in protocol failures. */ + readonly name: string; + /** Cursor representing a journal with no entries. */ + readonly emptyCursor: Cursor; + /** Read the ordered entries carried by a page. */ + readonly entries: (page: Page) => readonly Entry[]; + /** Read whether an older page exists. */ + readonly hasMore: (page: Page) => boolean; + /** Read the inclusive first durable cursor covered by one entry. */ + readonly first: (entry: Entry) => Cursor; + /** Read the inclusive final cursor, which must not precede the first. */ + readonly last: (entry: Entry) => Cursor; + /** Compare two cursors. */ + readonly compare: (left: Cursor, right: Cursor) => number; + /** Test whether the right cursor immediately follows the left cursor. */ + readonly follows: (left: Cursor, right: Cursor) => boolean; + /** Apply one complete journal-window change or cursorless notification. */ + readonly publish: (change: RemoteJournalChange) => void; + /** Observe a retryable carrier loss before reconnection. */ + readonly carrierFailed?: (error: RemoteStreamCarrierError) => void; + /** Publish a terminal stream, page, or protocol failure after opening. */ + readonly failed: (error: unknown) => void; +} + +/** + * Owns snapshot-first opening, ordered live delivery, pagination, and repair. + * + * The domain retains its published window during reconnection. A replacement is + * published only after the opening page reaches the generation's cursor. + * Notifications never change a cursor and wait behind an in-flight gap repair. + */ +export abstract class RemoteJournalStream< + Page, + Entry, + Cursor, + PageRequest = void, + Notification = never, +> { + private readonly stream: RemoteStream>; + private initialRequest!: PageRequest; + private resumeCursor: Cursor | undefined; + private hasResumeCursor = false; + private generation = 0; + private firstCursor: Cursor | undefined; + private lastCursor: Cursor | undefined; + private started = false; + private opened = false; + private disposed = false; + private done: Promise | undefined; + private closing: Promise | undefined; + private pendingNext: + Promise>> | undefined; + + /** + * @param remote - Gateway factory for the reconnecting physical-generation stream. + * @param options - cursor algebra and domain publication sinks. + */ + protected constructor( + remote: RemoteStreamFactory, + private readonly options: RemoteJournalStreamOptions, + ) { + this.stream = remote.$stream>({ + name: options.name, + open: (signal) => this.follow(this.initialRequest, signal), + ended: (accepted) => + accepted + ? new RemoteStreamCarrierError(`${options.name} ended without a terminal result`) + : protocolViolation( + `${this.hasResumeCursor ? "resumed " : ""}${options.name} ended before its opening cursor`, + ), + ...(options.carrierFailed === undefined ? {} : { carrierFailed: options.carrierFailed }), + }); + } + + /** + * Open one physical journal generation with a complete current snapshot. + * @param request - opening-window request retained for later repair. + * @param signal - cancellation lifetime of the physical generation. + * @returns opening cursor followed by live entries. + */ + protected abstract follow( + request: PageRequest, + signal: AbortSignal, + ): AsyncIterable>; + + /** + * Read one journal page through the addressed domain source. + * @param request - domain page request. + * @param through - inclusive journal cursor that fixes the source read. + * @param signal - cancellation lifetime shared with the logical stream. + * @returns the requested page, whose tail equals `through` unless the domain request selects older entries. + */ + protected abstract readPage( + request: PageRequest, + through: Cursor, + signal: AbortSignal, + ): Promise; + + /** + * Derive an unbounded-tail request from the initial page request. + * @param initial - request used to open the journal window. + * @returns request suitable for reconnect and gap repair. + */ + protected abstract repairRequest(initial: PageRequest): PageRequest; + + /** Cancellation lifetime shared by follow and page calls. */ + get signal(): AbortSignal { + return this.stream.signal; + } + + /** + * Establish follow and publish the opening snapshot carried by its first frame. + * @param request - initial tail-page request. + * @returns after the first complete window is published. + */ + async open(request: PageRequest): Promise { + if (this.started) throw new Error(`${this.options.name} already opened`); + this.started = true; + this.initialRequest = request; + const iterator = this.stream[Symbol.asyncIterator](); + try { + const first = await this.takeNext(iterator); + if (first.done) + throw protocolViolation(`${this.options.name} ended before its opening cursor`); + this.replaceGeneration(first.value, false); + this.opened = true; + this.done = this.consume(iterator); + } catch (error) { + await this.stream.dispose(); + throw error; + } + } + + /** + * Read and prepend one older page after a successful open. + * @param request - domain page request bound to this stream's address. + * @returns after the page is applied or rejected as discontinuous. + */ + async prepend(request: PageRequest): Promise { + if (!this.opened || this.disposed) throw new Error(`${this.options.name} is not open`); + const page = await this.readPage(request, this.currentCursor(), this.stream.signal); + this.stream.signal.throwIfAborted(); + const entries = this.options.entries(page); + this.assertPage(entries); + const before = this.firstCursor; + const accepted = + before === undefined + ? [...entries] + : entries.filter((entry) => this.options.compare(this.options.first(entry), before) < 0); + const tail = accepted.at(-1); + if ( + tail !== undefined && + before !== undefined && + !this.options.follows(this.options.last(tail), before) + ) { + this.options.publish({ type: "prepend", page, entries: [], hasMore: false }); + throw protocolViolation(`${this.options.name} history page is discontinuous`); + } + const first = accepted[0]; + if (first !== undefined) this.firstCursor = this.options.first(first); + this.options.publish({ + type: "prepend", + page, + entries: accepted, + hasMore: this.options.hasMore(page), + }); + } + + /** Replace the active physical generation while retaining the published window. */ + restart(): void { + this.stream.restart(); + } + + /** + * Permanently stop follow, page requests, and the background consumer. + * @returns when no stream work or publication callback can still run. + */ + dispose(): Promise { + if (this.closing !== undefined) return this.closing; + this.disposed = true; + const done = this.done; + const closing = (async () => { + await this.stream.dispose(); + await done; + })(); + this.closing = closing; + return closing; + } + + private async consume( + iterator: AsyncIterator>, + ): Promise { + try { + while (true) { + const next = await this.takeNext(iterator); + if (next.done) return; + const item = next.value; + if (item.generation !== this.generation) { + this.replaceGeneration(item, true); + continue; + } + if (item.value.type === "opened") { + throw protocolViolation(`${this.options.name} emitted more than one opening cursor`); + } + if (item.value.type === "notification") { + this.publishNotification(item.value.notification); + continue; + } + await this.acceptEntry(item.value.entry, item, iterator); + } + } catch (error) { + if (!this.disposed) this.options.failed(error); + } + } + + private replaceGeneration( + initial: JournalStreamItem, + resumed: boolean, + ): void { + const opening = this.opening(initial, resumed); + this.replaceFromOpening(opening.page, opening.cursor); + } + + private opening( + item: RemoteStreamItem>, + resumed: boolean, + ): { readonly cursor: Cursor; readonly page: Page } { + if (item.value.type !== "opened") { + throw protocolViolation( + `${resumed ? "resumed " : ""}${this.options.name} emitted an entry before its opening cursor`, + ); + } + const cursor = item.value.cursor; + if ( + resumed && + this.lastCursor !== undefined && + this.options.compare(cursor, this.lastCursor) < 0 + ) { + throw protocolViolation( + `${this.options.name} resumed at a cursor behind the last applied entry`, + ); + } + this.generation = item.generation; + item.accept(); + return { cursor, page: item.value.page }; + } + + /** Publish a generation's opening page without issuing a second Remote call. */ + private replaceFromOpening(page: Page, cursor: Cursor): void { + this.assertPageThrough(page, cursor); + const entries = [...this.options.entries(page)]; + this.assertPage(entries); + const first = entries[0]; + this.firstCursor = first === undefined ? undefined : this.options.first(first); + this.lastCursor = cursor; + this.setResumeCursor(cursor); + this.options.publish({ + type: "replace", + page, + entries, + hasMore: this.options.hasMore(page), + }); + } + + private async acceptEntry( + entry: Entry, + item: JournalStreamItem, + iterator: AsyncIterator>, + ): Promise { + const { first, last: cursor } = this.entryRange(entry); + const last = this.lastCursor as Cursor; + if (this.options.compare(cursor, last) <= 0) return; + if (this.options.compare(first, last) <= 0) { + throw protocolViolation(`${this.options.name} emitted a partially overlapping entry`); + } + if (!this.options.follows(last, first)) { + const request = this.repairPageRequest(); + const superseded = await this.replaceThrough( + request, + cursor, + item.generation, + item.signal, + iterator, + [entry], + [], + ); + if (superseded !== undefined) { + this.replaceGeneration(superseded, true); + } + return; + } + if (this.firstCursor === undefined) this.firstCursor = first; + this.lastCursor = cursor; + this.setResumeCursor(cursor); + this.options.publish({ type: "append", entry }); + } + + private async replaceThrough( + request: PageRequest, + requiredCursor: Cursor, + generation: number, + signal: AbortSignal, + iterator: AsyncIterator>, + queued: Entry[], + notifications: Notification[], + ): Promise | undefined> { + let read = await this.readPageWhileFollowing( + request, + requiredCursor, + generation, + signal, + iterator, + queued, + notifications, + ); + if (read.type === "superseded") return read.item; + let page = read.page; + this.assertPageThrough(page, requiredCursor); + let entries = this.mergeReplacement(page, queued); + let target = this.maxCursor(requiredCursor, queued); + if (entries === undefined || this.options.compare(this.tailCursor(entries), target) < 0) { + read = await this.readPageWhileFollowing( + this.repairPageRequest(), + target, + generation, + signal, + iterator, + queued, + notifications, + ); + if (read.type === "superseded") return read.item; + page = read.page; + this.assertPageThrough(page, target); + entries = this.mergeReplacement(page, queued); + target = this.maxCursor(requiredCursor, queued); + } + if (entries === undefined || this.options.compare(this.tailCursor(entries), target) < 0) { + throw protocolViolation(`${this.options.name} page did not reach its opening cursor`); + } + const first = entries[0]; + /* v8 ignore next -- a successful positive-cursor replacement page cannot be empty. */ + this.firstCursor = first === undefined ? undefined : this.options.first(first); + this.lastCursor = this.tailCursor(entries); + this.setResumeCursor(this.lastCursor); + this.options.publish({ + type: "replace", + page, + entries, + hasMore: this.options.hasMore(page), + }); + for (const notification of notifications) { + this.publishNotification(notification); + } + return undefined; + } + + private async readPageWhileFollowing( + request: PageRequest, + through: Cursor, + generation: number, + signal: AbortSignal, + iterator: AsyncIterator>, + queued: Entry[], + notifications: Notification[], + ): Promise< + | { readonly type: "page"; readonly page: Page } + | { + readonly type: "superseded"; + readonly item: JournalStreamItem; + } + > { + const page = this.readPage(request, through, signal).then( + (value) => ({ type: "page" as const, value }), + (error: unknown) => ({ type: "page-error" as const, error }), + ); + while (true) { + const pending = this.nextResult(iterator); + const next = pending.then( + (value) => ({ type: "next" as const, value }), + (error: unknown) => ({ type: "next-error" as const, error }), + ); + const result = await Promise.race([page, next]); + if (result.type === "page") { + signal.throwIfAborted(); + return { type: "page", page: result.value }; + } + if (result.type === "page-error") { + if (!signal.aborted || this.stream.signal.aborted) throw result.error; + return this.awaitReplacementGeneration(generation, iterator, pending); + } + this.releaseNext(); + if (result.type === "next-error") throw result.error; + if (result.value.done) { + signal.throwIfAborted(); + throw protocolViolation(`${this.options.name} ended while reading its replacement page`); + } + const item = result.value.value; + if (item.generation !== generation) return { type: "superseded", item }; + if (item.value.type === "opened") { + throw protocolViolation(`${this.options.name} emitted more than one opening cursor`); + } + if (item.value.type === "notification") { + notifications.push(item.value.notification); + continue; + } + queued.push(item.value.entry); + } + } + + private async awaitReplacementGeneration( + generation: number, + iterator: AsyncIterator>, + initial: Promise>>, + ): Promise<{ + readonly type: "superseded"; + readonly item: JournalStreamItem; + }> { + let pending = initial; + while (true) { + let next: IteratorResult>; + try { + next = await pending; + } finally { + this.releaseNext(); + } + if (next.done) { + this.stream.signal.throwIfAborted(); + throw protocolViolation( + `${this.options.name} ended while replacing an aborted page generation`, + ); + } + const item = next.value; + if (item.generation !== generation) return { type: "superseded", item }; + if (item.value.type === "opened") { + throw protocolViolation(`${this.options.name} emitted more than one opening cursor`); + } + pending = this.nextResult(iterator); + } + } + + private mergeReplacement(page: Page, queued: readonly Entry[]): Entry[] | undefined { + const entries = [...this.options.entries(page)]; + this.assertPage(entries); + for (const entry of queued) this.entryRange(entry); + const sorted = [...queued].sort((left, right) => + this.options.compare(this.options.first(left), this.options.first(right)), + ); + let tail = this.tailCursor(entries); + for (const entry of sorted) { + const first = this.options.first(entry); + const last = this.options.last(entry); + if (this.options.compare(last, tail) <= 0) continue; + if (this.options.compare(first, tail) <= 0) { + throw protocolViolation( + `${this.options.name} replacement contains a partially overlapping entry`, + ); + } + if (!this.options.follows(tail, first)) return undefined; + entries.push(entry); + tail = last; + } + return entries; + } + + private maxCursor(cursor: Cursor, entries: readonly Entry[]): Cursor { + let result = cursor; + for (const entry of entries) { + const candidate = this.options.last(entry); + if (this.options.compare(candidate, result) > 0) result = candidate; + } + return result; + } + + private nextResult( + iterator: AsyncIterator>, + ): Promise>> { + this.pendingNext ??= iterator.next(); + return this.pendingNext; + } + + private async takeNext( + iterator: AsyncIterator>, + ): Promise>> { + const pending = this.nextResult(iterator); + try { + return await pending; + } finally { + this.releaseNext(); + } + } + + private releaseNext(): void { + this.pendingNext = undefined; + } + + private publishNotification(notification: Notification): void { + this.options.publish({ + type: "notification", + notification, + } as RemoteJournalChange); + } + + private repairPageRequest(): PageRequest { + return this.repairRequest(this.initialRequest); + } + + private setResumeCursor(cursor: Cursor): void { + this.resumeCursor = cursor; + this.hasResumeCursor = true; + } + + private currentCursor(): Cursor { + return this.resumeCursor as Cursor; + } + + private tailCursor(entries: readonly Entry[]): Cursor { + const tail = entries.at(-1); + return tail === undefined ? this.options.emptyCursor : this.options.last(tail); + } + + private assertPage(entries: readonly Entry[]): void { + const iterator = entries[Symbol.iterator](); + const first = iterator.next(); + if (first.done) return; + let previousRange = this.entryRange(first.value); + for (const entry of iterator) { + const range = this.entryRange(entry); + if (!this.options.follows(previousRange.last, range.first)) { + throw protocolViolation(`${this.options.name} page contains discontinuous entries`); + } + previousRange = range; + } + } + + private entryRange(entry: Entry): { readonly first: Cursor; readonly last: Cursor } { + const first = this.options.first(entry); + const last = this.options.last(entry); + if (this.options.compare(first, last) > 0) { + throw protocolViolation(`${this.options.name} entry has an inverted cursor range`); + } + return { first, last }; + } + + private assertPageThrough(page: Page, through: Cursor): void { + const tail = this.tailCursor(this.options.entries(page)); + if (this.options.compare(tail, through) !== 0) { + throw protocolViolation(`${this.options.name} page did not end at its requested cursor`); + } + } +} diff --git a/apps/web-ui/packages/api/gateway/src/client/remote-events.ts b/apps/web-ui/packages/api/gateway/src/client/remote-events.ts new file mode 100644 index 000000000..7d6cc1d52 --- /dev/null +++ b/apps/web-ui/packages/api/gateway/src/client/remote-events.ts @@ -0,0 +1,354 @@ +/** Client owner for forwarded Remote Event subscriptions and deliveries. */ + +import type { Context } from "@deepseek-ai/cordis"; +import type { + ConnectionGenerationSource, + ConnectionHostInfo, + ConnectionHandle, +} from "@deepseek-ai/dsh-client-connection/client"; +import type { + TypertClientEventListener, + TypertOwnedValue, + TypertRemoteEvent, +} from "@deepseek-ai/dsh-typert-protocol"; +import { isRemoteJsonValue, isTypertOwnedValue } from "@deepseek-ai/dsh-typert-protocol"; +import { randomUUID } from "@deepseek-ai/dsh-util-crypto"; +import { + REMOTE_EVENT_RESULT_ENDPOINT, + REMOTE_EVENT_STREAM_ENDPOINT, + REMOTE_EVENT_STREAM_PAYLOAD, + isRemoteEventAgentId, + isRemoteEventClientId, + isRemoteEventId, + projectRemoteEventRejection, + type RemoteEventClientId, + type RemoteEventDownlinkFrame, + type RemoteEventEmitFrame, + type RemoteEventInvocationFrame, + type RemoteEventResult, +} from "../stream-protocol.ts"; + +/** Open the Gateway-internal forwarded-event stream on the selected carrier. */ +export type RemoteEventStreamOpener = ( + endpoint: string, + payload: unknown, + signal: AbortSignal, +) => AsyncIterable; + +/** One subscribed listener after its event-specific signature is erased. */ +type RemoteEventListener = (this: Context, ...args: unknown[]) => unknown; + +/** Untyped access used only for instance-private Cordis event keys. */ +interface PrivateEventContext { + on(name: string, listener: RemoteEventListener): () => boolean; + parallel(name: string, ...args: unknown[]): Promise; + waterfall( + thisArg: Context, + name: string, + request: Readonly>, + next: () => Promise, + ): unknown; +} + +/** Transport outcome after one Client listener chain either claims or delegates. */ +type RemoteEventReplyOutcome = + | { readonly kind: "result"; readonly value: unknown } + | { readonly kind: "next" } + | { readonly kind: "rejected"; readonly error: ReturnType }; + +/** Private end-of-chain marker that cannot collide with a JSON listener result. */ +const REMOTE_EVENT_NEXT = Symbol("api-gateway.remote-event.next"); + +/** Own Cordis registrations, generation pumping, waterfall dispatch, and HTTP replies. */ +export class ClientRemoteEvents { + private readonly eventPrefix = `internal/api-gateway/remote-event/${randomUUID()}/`; + private readonly unregisterGeneration: () => void; + private activeGeneration: Promise | undefined; + + /** + * @param ownerCtx - Client Gateway root used for Agent Context resolution. + * @param connection - Connection carrier used for HTTP result calls. + * @param openStream - selected in-process or WebSocket stream opener. + */ + constructor( + private readonly ownerCtx: Context, + private readonly connection: ConnectionHandle, + private readonly openStream: RemoteEventStreamOpener, + ) { + this.unregisterGeneration = connection.registerGenerationSource(this.runGeneration); + } + + /** + * Register one typed Remote Event listener in its calling fiber. + * @param callerCtx - fiber Context owning the registration. + * @param event - selected forwarded event. + * @param listener - listener derived from that event's declaration. + * @returns disposer for this exact registration. + */ + subscribe( + callerCtx: Context, + event: Event, + listener: TypertClientEventListener, + ): () => void { + const dispose = privateEvents(callerCtx).on( + this.eventKey(event), + listener as RemoteEventListener, + ); + return () => { + dispose(); + }; + } + + /** Withdraw the generation source and wait for active listener work to quiesce. */ + async dispose(): Promise { + this.unregisterGeneration(); + await Promise.allSettled([this.activeGeneration]); + } + + /** Track the current generation so plugin disposal waits for listener work to stop. */ + private readonly runGeneration: ConnectionGenerationSource = (signal, ready) => { + const tracked = this.pumpEvents(signal, ready).finally(() => { + if (this.activeGeneration === tracked) this.activeGeneration = undefined; + }); + this.activeGeneration = tracked; + return tracked; + }; + + /** Deliver one notification through Cordis while containing listener failures. */ + private deliver(frame: RemoteEventEmitFrame): void { + void privateEvents(this.ownerCtx) + .parallel(this.eventKey(frame.event), ...frame.args) + .catch((error: unknown) => { + this.reportError(frame.event, error); + }); + } + + /** Run one Connection generation over the forwarded-event logical stream. */ + private async pumpEvents( + signal: AbortSignal, + ready: (host: ConnectionHostInfo) => void, + ): Promise { + let clientId: RemoteEventClientId | undefined; + const failed = new AbortController(); + const generationSignal = AbortSignal.any([signal, failed.signal]); + const active = new Map(); + const tasks = new Set>(); + const source = this.openStream( + REMOTE_EVENT_STREAM_ENDPOINT, + REMOTE_EVENT_STREAM_PAYLOAD, + generationSignal, + ); + let streamFailed = false; + let streamError: unknown; + try { + for await (const value of source) { + if (clientId === undefined) { + const opening = parseRemoteEventReady(value); + clientId = opening.clientId; + ready(opening.host); + continue; + } + const frame = parseRemoteEventFrame(value); + if (frame.type === "cancel") { + active + .get(frame.eventId) + ?.abort(new Error("client api: Remote event was cancelled by the Host")); + continue; + } + if (frame.type === "emit") { + this.deliver(frame); + continue; + } + const controller = new AbortController(); + active.set(frame.eventId, controller); + const deliverySignal = AbortSignal.any([generationSignal, controller.signal]); + const task = this.answer(frame, clientId, deliverySignal) + .catch((error: unknown) => { + if (!deliverySignal.aborted) failed.abort(error); + }) + .finally(() => { + active.delete(frame.eventId); + tasks.delete(task); + }); + tasks.add(task); + } + } catch (error) { + streamFailed = true; + streamError = error; + } finally { + for (const controller of active.values()) { + controller.abort(new Error("client api: Remote event generation ended")); + } + await Promise.allSettled(tasks); + } + if (failed.signal.aborted) { + throw toError(failed.signal.reason, "client api: Remote event result delivery failed"); + } + if (signal.aborted) return; + if (streamFailed) throw streamError; + throw new Error("client api: forwarded Remote event stream ended unexpectedly"); + } + + private async answer( + frame: RemoteEventInvocationFrame, + clientId: RemoteEventClientId, + signal: AbortSignal, + ): Promise { + const adapter = this.ownerCtx.typert.contexts.getClient("agent"); + let resolved: Context | TypertOwnedValue | undefined; + try { + resolved = adapter?.resolve(frame.agentId); + } catch (error) { + this.reportError(frame.event, error); + } + const owned = isTypertOwnedValue(resolved) ? resolved : undefined; + try { + const target = isTypertOwnedValue(resolved) ? resolved.value : resolved; + let outcome: RemoteEventReplyOutcome = { kind: "next" }; + if (target !== undefined) { + try { + outcome = await this.dispatchWaterfall(target, frame, signal); + } catch (error) { + if (signal.aborted) return; + outcome = { kind: "rejected", error: projectRemoteEventRejection(error) }; + } + } + if (signal.aborted) return; + const result: RemoteEventResult = { + clientId, + eventId: frame.eventId, + outcome: + outcome.kind === "result" && outcome.value === undefined ? { kind: "result" } : outcome, + }; + const response = await this.connection.rpc.call( + "/api", + REMOTE_EVENT_RESULT_ENDPOINT, + { args: result }, + signal, + ); + if (!response.ok) throw new Error(response.error.message); + } finally { + owned?.[Symbol.dispose](); + } + } + + private async dispatchWaterfall( + target: Context, + frame: RemoteEventInvocationFrame, + signal: AbortSignal, + ): Promise { + const request = { + ...frame.request, + agent: target, + signal, + }; + // Cancellation reaches the handler, whose Context remains owned until it settles. + const value = await privateEvents(target).waterfall( + target, + this.eventKey(frame.event), + request, + () => Promise.resolve(REMOTE_EVENT_NEXT), + ); + if (value !== REMOTE_EVENT_NEXT && value !== undefined && !isRemoteJsonValue(value)) { + throw new TypeError("Remote event listener result is not lossless JSON data"); + } + return value === REMOTE_EVENT_NEXT ? { kind: "next" } : { kind: "result", value }; + } + + private eventKey(event: string): string { + return `${this.eventPrefix}${event}`; + } + + private reportError(event: string, error: unknown): void { + console.error(`client api: Remote event ${JSON.stringify(event)} listener threw:`, error); + } +} + +/** Validate and return one generation's Client identity and Host facts. */ +function parseRemoteEventReady(value: unknown): { + readonly clientId: RemoteEventClientId; + readonly host: ConnectionHostInfo; +} { + if ( + !isRemoteEventRecord(value) || + !hasExactRemoteEventKeys(value, ["type", "clientId", "host"]) || + value.type !== "ready" || + !isRemoteEventClientId(value.clientId) || + !isRemoteEventRecord(value.host) || + !hasExactRemoteEventKeys(value.host, ["home"]) || + typeof value.host.home !== "string" + ) { + throw new TypeError("client api: forwarded Remote event stream did not begin with ready"); + } + return { clientId: value.clientId, host: { home: value.host.home } }; +} + +/** Validate one untrusted value from the Gateway-internal forwarded-event stream. */ +function parseRemoteEventFrame( + value: unknown, +): Exclude { + if (!isRemoteEventRecord(value)) invalidRemoteEventFrame(); + if ( + value.type === "cancel" && + hasExactRemoteEventKeys(value, ["type", "eventId"]) && + isRemoteEventId(value.eventId) + ) { + return { type: "cancel", eventId: value.eventId }; + } + if ( + value.type === "emit" && + hasExactRemoteEventKeys(value, ["type", "event", "args"]) && + validRemoteEventName(value.event) && + Array.isArray(value.args) && + isRemoteJsonValue(value.args) + ) { + return { type: "emit", event: value.event, args: value.args }; + } + if ( + value.type === "waterfall" && + hasExactRemoteEventKeys(value, ["type", "event", "eventId", "agentId", "request"]) && + validRemoteEventName(value.event) && + isRemoteEventId(value.eventId) && + isRemoteEventAgentId(value.agentId) && + isRemoteEventRecord(value.request) && + !Object.hasOwn(value.request, "agent") && + !Object.hasOwn(value.request, "signal") && + isRemoteJsonValue(value.request) + ) { + return { + type: "waterfall", + event: value.event, + eventId: value.eventId, + agentId: value.agentId, + request: value.request, + }; + } + invalidRemoteEventFrame(); +} + +function isRemoteEventRecord(value: unknown): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const prototype: unknown = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function hasExactRemoteEventKeys(value: Record, keys: readonly string[]): boolean { + const ownKeys = Reflect.ownKeys(value); + return ownKeys.length === keys.length && keys.every((key) => Object.hasOwn(value, key)); +} + +function validRemoteEventName(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +function invalidRemoteEventFrame(): never { + throw new TypeError("client api: invalid forwarded Remote event frame"); +} + +function privateEvents(ctx: Context): PrivateEventContext { + return ctx; +} + +function toError(reason: unknown, message: string): Error { + return reason instanceof Error ? reason : new Error(message, { cause: reason }); +} diff --git a/apps/web-ui/packages/api/gateway/src/client/remote-stream.ts b/apps/web-ui/packages/api/gateway/src/client/remote-stream.ts new file mode 100644 index 000000000..d1783f5ba --- /dev/null +++ b/apps/web-ui/packages/api/gateway/src/client/remote-stream.ts @@ -0,0 +1,247 @@ +/** Reconnecting lifecycle for one single-consumer Remote stream. */ + +import { RemoteError, remoteErrorOf } from "@deepseek-ai/dsh-typert-protocol"; +import type { ConnectionHandle } from "@deepseek-ai/dsh-client-connection/client"; +import { RemoteStreamCarrierError } from "./stream-client.ts"; + +/** One item annotated with the physical Remote-stream generation that delivered it. */ +export interface RemoteStreamItem { + /** Monotone physical generation number within this logical stream. */ + readonly generation: number; + /** Decoded item yielded by the generated Remote method. */ + readonly value: Item; + /** Cancellation lifetime of the generation that delivered this item. */ + readonly signal: AbortSignal; + /** Mark this generation's opening baseline or cursor as accepted. */ + accept(): void; +} + +/** Domain-owned operations used by {@link RemoteStream}. */ +export interface RemoteStreamOptions { + /** Diagnostic owner name used for cancellation failures. */ + readonly name: string; + /** Open one physical generation of the logical stream. */ + readonly open: (signal: AbortSignal) => AsyncIterable; + /** Classify a normal generation end after or before its opening item was accepted. */ + readonly ended: (accepted: boolean) => Error; + /** Observe a retryable carrier loss before the supervisor waits or reopens. */ + readonly carrierFailed?: (error: RemoteStreamCarrierError) => void; +} + +/** + * Reopens one logical Remote stream across carrier generations. + * + * Connection owns physical retry timing; Gateway performs each requested + * replacement. The domain consumer owns its opening item and every later + * item, and calls {@link RemoteStreamItem.accept} only after validating the + * opening baseline or cursor. + */ +export class RemoteStream implements AsyncIterable> { + private readonly lifetime = new AbortController(); + private generationAbort: AbortController | undefined; + private iterator: AsyncGenerator> | undefined; + private closing: Promise | undefined; + private revision = 0; + private taken = false; + + /** + * @param connection - observable Host generation source used to pace retries. + * @param options - domain stream opener, end classification, and diagnostics. + */ + constructor( + private readonly connection: Pick, + private readonly options: RemoteStreamOptions, + ) {} + + /** Cancellation lifetime shared by the stream and sibling page requests. */ + get signal(): AbortSignal { + return this.lifetime.signal; + } + + /** Interrupt the current generation and immediately request a replacement. */ + restart(): void { + if (this.lifetime.signal.aborted) return; + this.revision++; + this.generationAbort?.abort(new Error(`${this.options.name} generation restarted`)); + } + + /** + * Permanently stop this stream and wait for its iterator to close. + * @returns when the active generation and consumer iterator are quiescent. + */ + dispose(): Promise { + if (this.closing !== undefined) return this.closing; + if (!this.lifetime.signal.aborted) { + const reason = new Error(`${this.options.name} disposed`); + this.lifetime.abort(reason); + this.generationAbort?.abort(reason); + } + const iterator = this.iterator; + if (iterator === undefined) return Promise.resolve(); + const closing = closeRemoteStreamIterator(iterator); + this.closing = closing; + return closing; + } + + /** @inheritdoc */ + [Symbol.asyncIterator](): AsyncIterator> { + if (this.taken) throw new Error(`${this.options.name} already has a consumer`); + this.taken = true; + const iterator = this.read(); + this.iterator = iterator; + return iterator; + } + + private async *read(): AsyncGenerator> { + let attempt = 0; + let generation = 0; + let observedRevision = this.revision; + try { + while (!isAborted(this.lifetime.signal)) { + if (observedRevision !== this.revision) { + observedRevision = this.revision; + attempt = 0; + } + const revision = this.revision; + const generationAbort = new AbortController(); + this.generationAbort = generationAbort; + const signal = AbortSignal.any([this.lifetime.signal, generationAbort.signal]); + const generationId = ++generation; + let accepted = false; + try { + for await (const value of this.options.open(signal)) { + if (isAborted(this.lifetime.signal)) return; + if (revision !== this.revision) break; + yield { + generation: generationId, + value, + signal, + accept: () => { + if (this.generationAbort !== generationAbort || revision !== this.revision) return; + accepted = true; + attempt = 0; + }, + }; + } + if (isAborted(this.lifetime.signal)) return; + if (revision !== this.revision) continue; + throw this.options.ended(accepted); + } catch (error) { + if (isAborted(this.lifetime.signal)) return; + if (revision !== this.revision) continue; + if (!(error instanceof RemoteStreamCarrierError)) throw terminalStreamFailure(error); + this.options.carrierFailed?.(error); + if (revision !== this.revision) continue; + attempt++; + try { + await waitForRemoteStreamRetry(this.connection, attempt, signal); + } catch (retryError) { + if (isAborted(this.lifetime.signal)) return; + if (revision !== this.revision) continue; + throw terminalStreamFailure(retryError); + } + } finally { + this.generationAbort = undefined; + if (!generationAbort.signal.aborted) { + generationAbort.abort(new Error(`${this.options.name} generation ended`)); + } + } + } + } finally { + if (!this.lifetime.signal.aborted) { + this.lifetime.abort(new Error(`${this.options.name} consumer closed`)); + } + this.generationAbort?.abort(this.lifetime.signal.reason); + this.generationAbort = undefined; + } + } +} + +async function waitForRemoteStreamRetry( + connection: Pick, + attempt: number, + signal: AbortSignal, +): Promise { + signal.throwIfAborted(); + if (connection.generation.getSnapshot() !== undefined) { + // The mux can fail before the event stream withdraws the Host generation, + // or while that generation remains healthy. Neither makes a carrier error + // terminal. Back off instead of abandoning the only projection consumer. + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => { + signal.removeEventListener("abort", aborted); + resolve(); + }, + Math.min(100 * 2 ** Math.min(attempt - 1, 5), 3000), + ); + const aborted = (): void => { + clearTimeout(timer); + signal.removeEventListener("abort", aborted); + reject(signal.reason); + }; + signal.addEventListener("abort", aborted, { once: true }); + if (signal.aborted) aborted(); + }); + return; + } + await new Promise((resolve, reject) => { + const subscription: { + dispose?: () => void; + finished: boolean; + } = { finished: false }; + const finish = (failure?: Error): void => { + if (subscription.finished) return; + subscription.finished = true; + subscription.dispose?.(); + signal.removeEventListener("abort", aborted); + if (failure === undefined) resolve(); + else reject(failure); + }; + const inspect = (): void => { + if (connection.generation.getSnapshot() !== undefined) finish(); + }; + const aborted = (): void => { + finish(new Error("Remote stream retry aborted", { cause: signal.reason })); + }; + const dispose = connection.generation.subscribe(inspect); + subscription.dispose = dispose; + if (subscription.finished) dispose(); + signal.addEventListener("abort", aborted, { once: true }); + if (signal.aborted) aborted(); + else inspect(); + }); +} + +/** + * Mark a terminal escape before it crosses the stream boundary: consumers + * discriminate failures by code, so an unmarked throw reads as a local bug. + * Marked failures pass through verbatim. The carrier class never escapes as a + * terminal outcome — it stays the retry-internal signal fed to `carrierFailed` + * and the `ended(true)` retry trigger. + */ +function terminalStreamFailure(error: unknown): Error { + return ( + remoteErrorOf(error) ?? + new RemoteError( + "gateway/internal", + error instanceof Error ? error.message : String(error), + {}, + { cause: error }, + ) + ); +} + +function isAborted(signal: AbortSignal): boolean { + return signal.aborted; +} + +async function closeRemoteStreamIterator( + iterator: AsyncIterator>, +): Promise { + try { + await iterator.return?.(); + } catch { + // The disposed logical stream has no remaining consumer for cancellation failures. + } +} diff --git a/apps/web-ui/packages/api/gateway/src/client/snapshot-stream.ts b/apps/web-ui/packages/api/gateway/src/client/snapshot-stream.ts new file mode 100644 index 000000000..764a4eb79 --- /dev/null +++ b/apps/web-ui/packages/api/gateway/src/client/snapshot-stream.ts @@ -0,0 +1,96 @@ +/** Baseline-and-delta protocol layered over a reconnecting Remote stream. */ + +import { RemoteError } from "@deepseek-ai/dsh-typert-protocol"; +import type { RemoteStream } from "./remote-stream.ts"; + +/** Host-side stream protocol violation, marked so consumers surface it as an error state. */ +function protocolViolation(message: string): RemoteError<"gateway/internal"> { + return new RemoteError("gateway/internal", message, {}); +} + +/** Domain operations for one snapshot stream. */ +export interface RemoteSnapshotStreamOptions { + /** Diagnostic stream name used in protocol failures. */ + readonly name: string; + /** Distinguish the opening snapshot from later deltas. */ + readonly isSnapshot: (value: Snapshot | Delta) => value is Snapshot; + /** Atomically replace the domain model from a complete snapshot. */ + readonly replace: (snapshot: Snapshot) => void; + /** Apply one incremental update after the generation snapshot. */ + readonly update: (delta: Delta) => void; + /** Publish a terminal business or protocol failure. */ + readonly failed: (error: unknown) => void; +} + +/** + * Consumes generations that each contain exactly one opening snapshot followed by deltas. + * + * The previous domain snapshot remains published while the underlying stream retries. A + * replacement becomes accepted only after the domain owner applies it successfully. + */ +export class RemoteSnapshotStream { + private started = false; + private disposed = false; + private done: Promise | undefined; + + /** + * @param stream - reconnecting physical-generation stream. + * @param options - frame discriminator and domain state destinations. + */ + constructor( + private readonly stream: RemoteStream, + private readonly options: RemoteSnapshotStreamOptions, + ) {} + + /** Start the single consumer; repeated calls are inert. */ + start(): void { + if (this.started) return; + this.started = true; + this.done = this.consume(); + } + + /** Replace the active physical generation without discarding the published snapshot. */ + restart(): void { + this.stream.restart(); + } + + /** + * Permanently stop the stream and wait for its consumer to become quiescent. + * @returns when no generation or callback can still run. + */ + async dispose(): Promise { + this.disposed = true; + await this.stream.dispose(); + await this.done; + } + + private async consume(): Promise { + let generation = 0; + let snapshotSeen = false; + try { + for await (const item of this.stream) { + if (item.generation !== generation) { + generation = item.generation; + snapshotSeen = false; + } + if (this.options.isSnapshot(item.value)) { + if (snapshotSeen) { + throw protocolViolation(`${this.options.name} emitted more than one opening snapshot`); + } + this.options.replace(item.value); + snapshotSeen = true; + item.accept(); + continue; + } + if (!snapshotSeen) { + throw protocolViolation( + `${this.options.name} emitted an update before its opening snapshot`, + ); + } + this.options.update(item.value); + } + } catch (error) { + if (!this.disposed) this.options.failed(error); + } + } +} diff --git a/apps/web-ui/packages/api/gateway/src/client/stream-client.ts b/apps/web-ui/packages/api/gateway/src/client/stream-client.ts new file mode 100644 index 000000000..50ee9eca4 --- /dev/null +++ b/apps/web-ui/packages/api/gateway/src/client/stream-client.ts @@ -0,0 +1,515 @@ +import { RemoteError } from "@deepseek-ai/dsh-typert-protocol"; +/** Browser owner for the Gateway multiplexed Remote stream socket. */ + +import { + parseRemoteStreamServerMessage, + REMOTE_STREAM_MUX_PATH, + type RemoteStreamClientMessage, + type RemoteStreamServerMessage, +} from "../stream-protocol.ts"; +import { Deque } from "@deepseek-ai/dsh-deque"; +import { randomUUID } from "@deepseek-ai/dsh-util-crypto"; + +/** Physical Remote stream socket failure that may be retried by a domain transport. */ +export class RemoteStreamCarrierError extends Error { + /** + * @param message - physical carrier failure description. + * @param options - optional causal error. + */ + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "RemoteStreamCarrierError"; + } +} + +interface SocketWaiter { + readonly revision: number; + resolve(socket: WebSocket): void; + reject(error: unknown): void; +} + +interface UplinkPump { + /** Settles once the pump has stopped sending. */ + readonly done: Promise; + /** Interrupt the pump, including a read blocked on the caller's iterator, and release the iterator. */ + stop(): void; +} + +/** One open logical stream: its downlink frames and, once the `open` frame is out, its uplink pump. */ +interface LogicalStream { + readonly inbox: StreamInbox; + pump: UplinkPump | undefined; +} + +const UPLINK_DONE: IteratorReturnResult = { value: undefined, done: true }; + +/** + * Keep one physical WebSocket and share it among independently cancellable + * Remote streams. A carrier that supplies an in-process stream opener never + * starts one. + */ +export class RemoteStreamMuxClient { + private socket: WebSocket | undefined; + private cancelCandidate: ((error: Error) => void) | undefined; + private keepAlive: Promise | undefined; + private revision = 0; + private readonly streams = new Map(); + private readonly waiters = new Set(); + private running = false; + private disposed = false; + + /** Ensure a physical attempt exists, following the current attempt once if needed. */ + start(): void { + if (this.disposed) return; + this.running = true; + if (this.socket?.readyState === WebSocket.OPEN) return; + const pending = this.keepAlive; + if (pending === undefined) this.maintain(); + else + void pending.then(() => { + this.maintain(); + }); + } + + /** Cancel the current socket or retry wait and start a fresh attempt immediately. */ + reconnect(): void { + if (!this.running || this.disposed) return; + const failure = new RemoteStreamCarrierError("api gateway: Remote stream reconnect requested"); + const pending = this.keepAlive; + this.revision++; + this.cancelCandidate?.(failure); + const socket = this.socket; + if (socket !== undefined) { + this.socket = undefined; + this.failAll(failure); + socket.close(4000, "reconnect requested"); + } + if (pending === undefined) this.maintain(); + else + void pending.then(() => { + this.maintain(); + }); + } + + /** + * Open one logical stream on the persistent physical connection. + * If no physical attempt is active, opening waits for Connection to request + * one or for the signal to abort. + * @param endpoint - Typert Remote stream endpoint. + * @param payload - endpoint request encoded on the wire. + * @param signal - cancellation for this logical stream. + * @param uplink - the Client's items: each is sent as an `item` frame, its end as `end`; its `return()` + * runs when the stream finishes, and its failure cancels the stream and fails the downlink. + * @returns Host items until completion, cancellation, or failure. + */ + async *open( + endpoint: string, + payload: unknown, + signal: AbortSignal, + uplink?: AsyncIterable, + ): AsyncGenerator { + signal.throwIfAborted(); + const streamId = randomUUID(); + const inbox = new StreamInbox(); + const stream: LogicalStream = { inbox, pump: undefined }; + let carrier: WebSocket | undefined; + let opened = false; + let terminal = false; + const abort = (): void => { + inbox.fail(signal.reason); + }; + signal.addEventListener("abort", abort, { once: true }); + try { + const socket = await this.waitForSocket(signal); + signal.throwIfAborted(); + carrier = socket; + this.streams.set(streamId, stream); + this.send(socket, { type: "open", streamId, endpoint, payload }); + opened = true; + if (uplink !== undefined) + stream.pump = this.pumpUplink(socket, streamId, uplink, signal, inbox); + while (true) { + const frame = await inbox.next(); + signal.throwIfAborted(); + if (frame.type === "item") { + yield frame.value; + continue; + } + terminal = true; + if (frame.type === "error") { + throw new RemoteError( + frame.error.code as never, + frame.error.message, + frame.error.details as never, + ); + } + return; + } + } finally { + signal.removeEventListener("abort", abort); + this.streams.delete(streamId); + stream.pump?.stop(); + if (opened && !terminal && carrier?.readyState === WebSocket.OPEN) { + this.send(carrier, { type: "cancel", streamId }); + } + // The old generation's pump has stopped before a supervisor reopens the next one. + if (stream.pump !== undefined) await stream.pump.done; + } + } + + /** + * Send the caller's uplink items on this generation's socket. `stop()` + * interrupts a pump blocked on `uplink.next()` and releases the iterator: a + * handle's queue closes at once, so `send()` throws from then on, and any + * other iterator's `return()` is invoked without being awaited because a + * generator blocked in `next()` only completes it once it yields. + */ + private pumpUplink( + socket: WebSocket, + streamId: string, + uplink: AsyncIterable, + signal: AbortSignal, + inbox: StreamInbox, + ): UplinkPump { + let stopped: PromiseWithResolvers> | undefined; + const interruption: IteratorReturnResult = { value: undefined, done: true }; + const uplinkIterator = uplink[Symbol.asyncIterator](); + const state = { stopping: false, exhausted: false, released: false }; + const release = (): void => { + if (state.released || state.exhausted) return; + state.released = true; + if (uplink instanceof ClientUplinkQueue) uplink.close(); + void Promise.resolve() + .then(() => uplinkIterator.return?.()) + .catch(() => undefined); + }; + const done = (async (): Promise => { + try { + while (true) { + const stoppingBeforeRead = state.stopping; + if (stoppingBeforeRead) return; + // A stop promise belongs to one read, not the whole uplink history. + stopped = Promise.withResolvers>(); + const next = await Promise.race([uplinkIterator.next(), stopped.promise]); + stopped = undefined; + if (state.stopping || next === interruption || signal.aborted || this.socket !== socket) + return; + if (next.done === true) { + state.exhausted = true; + break; + } + this.send(socket, { type: "item", streamId, value: next.value }); + } + this.send(socket, { type: "end", streamId }); + } catch (error) { + inbox.fail(error); + } finally { + stopped = undefined; + release(); + } + })(); + return { + done, + stop: () => { + if (state.stopping) return; + state.stopping = true; + stopped?.resolve(interruption); + release(); + }, + }; + } + + /** + * Permanently stop the carrier, close the physical socket, and fail every + * active logical stream. + * @returns once the active connection attempt has stopped. + */ + async close(): Promise { + if (!this.disposed) { + this.disposed = true; + this.running = false; + const error = new Error("api gateway: Remote stream client disposed"); + this.failAll(error); + for (const waiter of [...this.waiters]) waiter.reject(error); + this.cancelCandidate?.(error); + const socket = this.socket; + this.socket = undefined; + socket?.close(1000, "disposed"); + } + await this.keepAlive; + } + + private connect(): Promise { + const socket = new WebSocket(remoteStreamUrl()); + const connecting = new Promise((resolve, reject) => { + let settled = false; + const rejectCandidate = (error: Error): void => { + settled = true; + socket.removeEventListener("open", opened); + socket.removeEventListener("error", failed); + socket.removeEventListener("message", received); + socket.removeEventListener("close", closed); + this.cancelCandidate = undefined; + socket.close(); + reject(error); + }; + const opened = (): void => { + settled = true; + this.cancelCandidate = undefined; + this.socket = socket; + for (const waiter of [...this.waiters]) waiter.resolve(socket); + resolve(socket); + }; + const failed = (): void => { + if (!settled) { + rejectCandidate( + new RemoteStreamCarrierError("api gateway: Remote stream WebSocket failed to open"), + ); + return; + } + const error = new RemoteStreamCarrierError("api gateway: Remote stream WebSocket failed"); + this.lost(socket, error); + socket.close(); + }; + const closed = (): void => { + if (!settled) { + rejectCandidate( + new RemoteStreamCarrierError( + "api gateway: Remote stream WebSocket closed before opening", + ), + ); + return; + } + this.lost(socket); + }; + const received = (event: MessageEvent): void => { + this.receive(socket, event.data); + }; + this.cancelCandidate = rejectCandidate; + socket.addEventListener("open", opened, { once: true }); + socket.addEventListener("error", failed, { once: true }); + socket.addEventListener("message", received); + socket.addEventListener("close", closed, { once: true }); + }); + return connecting; + } + + private waitForSocket(signal: AbortSignal): Promise { + signal.throwIfAborted(); + if (this.socket?.readyState === WebSocket.OPEN) return Promise.resolve(this.socket); + if (this.disposed) + return Promise.reject(new Error("api gateway: Remote stream client disposed")); + if (!this.running) + return Promise.reject(new Error("api gateway: Remote stream client not started")); + return new Promise((resolve, reject) => { + const aborted = (): void => { + waiter.reject(signal.reason); + }; + const cleanup = (): void => { + this.waiters.delete(waiter); + signal.removeEventListener("abort", aborted); + }; + const waiter: SocketWaiter = { + revision: this.revision, + resolve: (socket) => { + cleanup(); + resolve(socket); + }, + reject: (error) => { + cleanup(); + // AbortSignal.reason belongs to the caller and may intentionally be a non-Error sentinel. + // oxlint-disable-next-line typescript/prefer-promise-reject-errors + reject(error); + }, + }; + this.waiters.add(waiter); + signal.addEventListener("abort", aborted, { once: true }); + }); + } + + private receive(socket: WebSocket, data: unknown): void { + if (socket !== this.socket) return; + try { + if (typeof data !== "string") + throw new Error("api gateway: Remote stream WebSocket requires text messages"); + const frame = parseRemoteStreamServerMessage(data); + const stream = this.streams.get(frame.streamId); + if (stream === undefined) return; + stream.inbox.push(frame); + // A terminal frame ends the uplink now, not on the consumer's next read. + if (frame.type !== "item") stream.pump?.stop(); + } catch (error) { + const failure = new RemoteStreamCarrierError("api gateway: invalid Remote stream frame", { + cause: error, + }); + this.failAll(failure); + this.lost(socket, failure); + socket.close(4002, "invalid Remote stream frame"); + } + } + + private lost( + socket: WebSocket, + error: RemoteStreamCarrierError = new RemoteStreamCarrierError( + "api gateway: Remote stream WebSocket closed", + ), + ): void { + if (this.socket !== socket) return; + this.socket = undefined; + this.failAll(error); + } + + private maintain(): void { + if (!this.running || this.disposed) return; + if (this.socket?.readyState === WebSocket.OPEN || this.keepAlive !== undefined) return; + const revision = this.revision; + const task = this.connect().then( + () => undefined, + (error: unknown) => { + if (!this.running) return; + for (const waiter of [...this.waiters]) { + if (waiter.revision <= revision) waiter.reject(error); + } + }, + ); + this.keepAlive = task; + void task.then(() => { + this.keepAlive = undefined; + }); + } + + private failAll(error: unknown): void { + for (const stream of this.streams.values()) { + stream.inbox.fail(error); + stream.pump?.stop(); + } + } + + private send(socket: WebSocket, message: RemoteStreamClientMessage): void { + socket.send(JSON.stringify(message)); + } +} + +class StreamInbox { + private readonly frames = new Deque(); + private wake: (() => void) | undefined; + private failure: Error | undefined; + + push(frame: RemoteStreamServerMessage): void { + if (this.failure !== undefined) return; + this.frames.pushBack(frame); + this.wake?.(); + this.wake = undefined; + } + + fail(error: unknown): void { + if (this.failure !== undefined) return; + this.failure = error instanceof Error ? error : new Error(String(error), { cause: error }); + this.frames.clear(); + this.wake?.(); + this.wake = undefined; + } + + async next(): Promise { + while (this.frames.size === 0) { + if (this.failure !== undefined) throw this.failure; + await new Promise((resolve) => { + this.wake = resolve; + }); + } + return this.frames.popFront() as RemoteStreamServerMessage; + } +} + +/** + * Uplink items a stream handle queues for its carrier: the mux pump or the + * in-process Host decoder iterates it as the stream's uplink. `end()` is the + * Client half-close; `close()` marks the stream terminated, after which + * `push()` throws. One consumer reads it, one read at a time. + */ +export class ClientUplinkQueue implements AsyncIterable, AsyncIterator { + private readonly items = new Deque(); + private ended = false; + private closed = false; + private wake: (() => void) | undefined; + + /** @param endpoint - canonical Remote endpoint named by failures. */ + constructor(private readonly endpoint: string) {} + + /** + * Queue one item for the carrier. + * @param item - item the Host validates against the method's uplink codec. + * @throws {Error} after `end()` or once the stream has terminated. + */ + push(item: unknown): void { + if (this.closed) throw new Error(`client api: ${this.endpoint} stream has terminated`); + if (this.ended) throw new Error(`client api: ${this.endpoint} uplink was ended`); + this.items.pushBack(item); + this.signal(); + } + + /** Half-close: the carrier reads the queued items, then `end`. Idempotent; ignored after termination. */ + end(): void { + if (this.ended || this.closed) return; + this.ended = true; + this.signal(); + } + + /** The carrier stopped reading: the logical stream terminated or was disposed. Idempotent. */ + close(): void { + if (this.closed) return; + this.closed = true; + this.items.clear(); + this.signal(); + } + + [Symbol.asyncIterator](): AsyncIterator { + return this; + } + + /** + * Take the next queued item, waiting for one; ends after `end()` or `close()`. + * @returns the next item, or the end of the uplink. + * @throws {Error} when a read is already pending. + */ + async next(): Promise> { + while (true) { + if (this.closed) return UPLINK_DONE; + if (this.items.size > 0) return { value: this.items.popFront(), done: false }; + if (this.ended) return UPLINK_DONE; + if (this.wake !== undefined) + throw new Error(`client api: ${this.endpoint} uplink has one pending read`); + await new Promise((resolve) => { + this.wake = resolve; + }); + } + } + + /** + * The carrier is done with the uplink: close it. + * @returns the end of the uplink. + */ + return(): Promise> { + this.close(); + return Promise.resolve(UPLINK_DONE); + } + + private signal(): void { + const wake = this.wake; + this.wake = undefined; + wake?.(); + } +} + +function remoteStreamUrl(): string { + // The mux route is registered absolute; a page resolves its document-relative + // form against its own document base. A shell-owned Host on another origin + // supplies that base through the transport. + const globals = globalThis as { __DSH_TRANSPORT__?: { streamBaseUrl?: string } }; + const url = new URL( + REMOTE_STREAM_MUX_PATH.slice(1), + globals.__DSH_TRANSPORT__?.streamBaseUrl ?? document.baseURI, + ); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return url.href; +} diff --git a/apps/web-ui/packages/api/gateway/src/index.ts b/apps/web-ui/packages/api/gateway/src/index.ts new file mode 100644 index 000000000..5a84e9e1d --- /dev/null +++ b/apps/web-ui/packages/api/gateway/src/index.ts @@ -0,0 +1,1688 @@ +/** + * Live Typert Remote dispatch over Cordis Services and registered providers. + * Unary transport and response envelopes belong to Connection; live Remote + * streams use the Gateway-owned WebSocket mux. + * @module @deepseek-ai/dsh-api-gateway + */ + +import { randomUUID } from "node:crypto"; +import { Context, Service, symbols } from "@deepseek-ai/cordis"; +// codexhost: import the runtime Peer from its module rather than the Connection +// plugin root, whose browser-auth chain pulls the DSH credentials service that +// the embedded Web backend does not mount. +import { OperatorPeer } from "@deepseek-ai/dsh-client-connection/src/operator-peer.ts"; +import type { + ConnectionRpcAttachment, + ConnectionRpcHandler, +} from "@deepseek-ai/dsh-client-connection/src/rpc.ts"; +import { Deque } from "@deepseek-ai/dsh-deque"; +import type { WebUpgradeRoute } from "@deepseek-ai/dsh-host-webserver"; +import { MAX_TIMER_DELAY_MS } from "@deepseek-ai/dsh-timeout"; +import type {} from "@deepseek-ai/dsh-cmdline"; +import z from "@deepseek-ai/schemastery"; +export type { TypertGatewayFaultDetails } from "./remote-error-codes.ts"; +import { + RemoteError, + isRemoteJsonValue, + remoteErrorOf, + remoteMethods, + type InvocationDescriptor, + type InvocationParameterDescriptor, + type PeerScope, + type RemoteInvocation, + type TypertCodec, + type TypertGatewayBinding, +} from "@deepseek-ai/dsh-typert-protocol"; +import type { + InvokeRemoteRequest, + TypertGateway, + TypertGatewayErrorCode, + TypertGatewayWireStream, + TypertRemoteEventDispatch, + TypertRemoteEventFrame, + TypertRemoteEventInvocation, + TypertRemoteEventOutcome, + TypertRemoteEventSource, +} from "./types.ts"; +import { RemoteStreamMuxServer, rejectRemoteStreamUpgrade } from "./stream-server.ts"; +import { + REMOTE_EVENT_STREAM_ENDPOINT, + REMOTE_EVENT_STREAM_READY, + REMOTE_EVENT_RESULT_ENDPOINT, + REMOTE_STREAM_MUX_PATH, + isRemoteEventAgentId, + parseRemoteEventResult, + projectRemoteEventRequest, + restoreRemoteEventRejection, + type RemoteEventCancellationFrame, + type RemoteEventClientId, + type RemoteEventEmitFrame, + type RemoteEventHostInfo, + type RemoteEventId, + type RemoteEventInvocationFrame, + type RemoteEventReadyFrame, + type RemoteStreamFailure, +} from "./stream-protocol.ts"; + +export type { + InvokeRemoteRequest, + TypertGateway, + TypertGatewayErrorCode, + TypertGatewayWireStream, + TypertRemoteEventContext, + TypertRemoteEventDispatch, + TypertRemoteEventFrame, + TypertRemoteEventInvocation, + TypertRemoteEventOutcome, + TypertRemoteEventSource, +} from "./types.ts"; +export type { RemoteEventHostInfo } from "./stream-protocol.ts"; + +interface GatewayErrorOptions { + readonly cause?: unknown; + readonly field?: string; +} + +interface ResolvedBinding { + readonly binding: TypertGatewayBinding; + readonly original: object; +} + +interface PreparedInvocation { + readonly endpoint: string; + readonly descriptor: InvocationDescriptor; + /** Service view bound to a Context carrying `invocation`, so the method reads it as `this.ctx.invocation`. */ + readonly receiver: object; + readonly args: readonly unknown[]; + readonly method: (...args: never[]) => unknown; + readonly invocation: GatewayInvocation; +} + +/** Carrier inputs `GatewayInvocation.uplink()` decodes on first use. */ +interface UplinkSource { + /** Carrier items; an immediately ended iterable when the carrier has none. */ + readonly source: AsyncIterable; + /** Descriptor codec, or the JSON-safety codec when the descriptor declares no uplink. */ + readonly codec: TypertCodec; + readonly endpoint: string; + /** Fail the logical stream with a Remote failure as the reason. */ + readonly abort: (reason: unknown) => void; +} + +interface RegisteredRemoteEventSource { + readonly lifetime: AbortController; + readonly done: Promise; + readonly host: RemoteEventHostInfo; +} + +interface RemoteEventClient { + readonly id: RemoteEventClientId; + readonly queue: RemoteEventQueue; + readonly signal: AbortSignal; + readonly deliveries: Map; +} + +interface PendingRemoteEvent { + readonly id: RemoteEventId; + readonly source: TypertRemoteEventInvocation; + readonly frame: RemoteEventInvocationFrame; + readonly deliveries: Set; + releaseContext: () => void; + releaseSignal: () => void; +} + +type ConnectionRpcResult = Awaited>; +type ConnectionRpcError = Extract["error"]; +const NEVER_ABORTED_SIGNAL = new AbortController().signal; +const DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS = 2_000; +const DEFAULT_STREAM_INBOX_BYTES = 262_144; +const EMPTY_ASYNC_ITERABLE: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ next: () => Promise.resolve({ value: undefined, done: true }) }), +}; +const UPLINK_DONE: IteratorReturnResult = { value: undefined, done: true }; +const SRC_JSON_CODEC: TypertCodec = { mode: "src-json" }; + +/** Gateway transport configuration. */ +export interface Config { + /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 2000 */ + readonly websocketHeartbeatIntervalMs?: number; + /** Buffered uplink frame bytes one logical stream may hold before it fails with `gateway/uplink-overflow`. @default 262144 */ + readonly streamInboxBytes?: number; +} + +interface ResolvedConfig extends Config { + readonly websocketHeartbeatIntervalMs: number; + readonly streamInboxBytes: number; +} + +/** + * Dispatch failure produced outside the invoked business method. Rides the + * shared Remote failure vocabulary, so its code crosses the wire instead of + * folding to `internal`. + */ +export class TypertGatewayError extends RemoteError { + /** Canonical `/` endpoint. */ + readonly endpoint: string; + /** Affected wire field when the failure is field-specific. */ + readonly field: string | undefined; + + /** + * Construct a Gateway failure without embedding boundary values in its message. + * @param code - stable failure category. + * @param endpoint - canonical Remote endpoint. + * @param message - correction-oriented diagnostic without sensitive values. + * @param options - optional field and contained cause. + */ + constructor( + code: TypertGatewayErrorCode, + endpoint: string, + message: string, + options: GatewayErrorOptions = {}, + ) { + super( + code, + `typert gateway: ${endpoint}: ${message}`, + { endpoint, ...(options.field === undefined ? {} : { field: options.field }) }, + options.cause === undefined ? undefined : { cause: options.cause }, + ); + this.name = "TypertGatewayError"; + this.endpoint = endpoint; + this.field = options.field; + } +} + +/** + * Resolve strict generated definitions or conservative SRC markers against + * current Cordis Services and Typert providers. + * @typert service typertGateway + */ +export class TypertGatewayService extends Service implements TypertGateway { + static inject = ["typert"]; + static Config: z = z.object({ + websocketHeartbeatIntervalMs: z + .number() + .step(1) + .min(1) + .max(MAX_TIMER_DELAY_MS) + .default(DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS), + streamInboxBytes: z.number().step(1).min(1).default(DEFAULT_STREAM_INBOX_BYTES), + }); + + /** Carrier adapter shared by the WebSocket mux and local Host transports. */ + readonly wireStream: TypertGatewayWireStream = { + open: (endpoint, payload, uplink, peer, signal) => + this.openWireStream(endpoint, payload, uplink, peer, signal, new AbortController()), + failure: (error) => rpcError(error), + }; + + private srcClaims: ReadonlySet | undefined; + private inProcessOperator: PeerScope | undefined; + private remoteEvents: RegisteredRemoteEventSource | undefined; + private readonly remoteEventClients = new Map(); + private readonly pendingRemoteEvents = new Map(); + + /** + * Register the Gateway against the active Typert registry. + * WebSocket admission waits for launcher-owned application readiness when supplied; + * direct invocation and in-process streams remain available independently. + * @param ctx - owning Host Context with Typert registry access. + * @param config - validated Gateway transport configuration. + */ + constructor(ctx: Context, config: Config) { + super(ctx, "typertGateway"); + const resolved = config as ResolvedConfig; + ctx.on("internal/service", () => { + this.srcClaims = undefined; + }); + ctx.inject(["connection"], (connectionCtx) => { + connectionCtx.connection.rpc.intercept( + "/api", + (endpoint) => this.claimsEndpoint(endpoint), + (endpoint, payload, signal, peer) => this.dispatchRpc(endpoint, payload, signal, peer), + ); + }); + ctx.inject(["connection", "webServer"], (webCtx) => { + const listen = (): void => { + const mux = new RemoteStreamMuxServer( + (endpoint, payload, uplink, peer, control) => + this.openWireStream(endpoint, payload, uplink, peer, control.signal, control), + this.wireStream.failure, + resolved.websocketHeartbeatIntervalMs, + resolved.streamInboxBytes, + ); + webCtx.effect(function* () { + yield () => mux.close(); + const route: WebUpgradeRoute = { + path: REMOTE_STREAM_MUX_PATH, + handler: (req, socket, head) => { + const admission = webCtx.connection.admit(req); + if ("rejection" in admission) { + rejectRemoteStreamUpgrade(socket, admission.rejection); + return; + } + mux.handleUpgrade(req, socket, head, admission.peer); + }, + }; + yield webCtx.webServer.registerUpgrade(route); + }, `api-gateway: ${REMOTE_STREAM_MUX_PATH} WebSocket`); + }; + // Existing pages reconnect before the new Host prints its URL. No stream + // may enter until the launcher has activated and audited its controllers. + const ready = webCtx.get("appReady"); + if (ready === undefined) listen(); + else + webCtx.effect(() => { + let closed = false; + const cancel = ready.onReady(() => { + if (!closed) listen(); + }); + return () => { + closed = true; + cancel(); + }; + }, "api-gateway: application readiness"); + }); + } + + /** + * Check for an active Client event stream. + * @returns whether a stream is open and has not been cancelled. + */ + hasLiveClient(): boolean { + for (const client of this.remoteEventClients.values()) { + if (!client.signal.aborted) return true; + } + return false; + } + + /** + * Register the sole application-selected forwarded-event source. + * @param source - stream factory installed by the Remote assembly. + * @param host - stable Host facts included in each Client generation's opening frame. + * @returns disposer removing this source and cancelling its active streams. + */ + registerRemoteEvents( + source: TypertRemoteEventSource, + host: RemoteEventHostInfo, + ): () => Promise { + if (this.remoteEvents !== undefined) { + throw new Error("typert gateway: forwarded Remote event source is already registered"); + } + const lifetime = new AbortController(); + const stream = source(lifetime.signal); + const done = this.consumeRemoteEvents(stream, lifetime.signal).catch((error: unknown) => { + if (this.remoteEvents?.lifetime !== lifetime || lifetime.signal.aborted) return; + this.closeRemoteEvents(error); + this.remoteEvents = undefined; + lifetime.abort(error); + }); + const registration: RegisteredRemoteEventSource = { lifetime, done, host: { home: host.home } }; + this.remoteEvents = registration; + return async () => { + if (this.remoteEvents === registration) { + this.remoteEvents = undefined; + const error = new Error("typert gateway: forwarded Remote event source was removed"); + registration.lifetime.abort(error); + this.closeRemoteEvents(error); + } + await registration.done; + }; + } + + private claimsEndpoint(endpoint: string): boolean { + if (endpoint === REMOTE_EVENT_RESULT_ENDPOINT) return true; + const segments = endpoint.split("/"); + if (segments.length !== 2 || segments[0] === "" || segments[1] === "") return false; + if ( + this.ctx.typert.local.get(endpoint) !== undefined || + this.ctx.typert.local.hasSeen(endpoint) + ) + return true; + this.srcClaims ??= this.collectSrcClaims(); + return this.srcClaims.has(endpoint); + } + + private collectSrcClaims(): ReadonlySet { + const claims = new Set(); + for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) { + if (definition.type !== "service") continue; + const receiver: unknown = this.ctx.get(serviceKey); + if (!isObject(receiver)) continue; + const original = originalOf(receiver); + const binding: unknown = Reflect.get(original, "typertRemote"); + if (!isObject(binding) || typeof Reflect.get(binding, "namespace") !== "string") continue; + const namespace = Reflect.get(binding, "namespace") as string; + for (const candidate of remoteMethods(original)) { + claims.add(endpointOf(namespace, candidate.exportName ?? candidate.method)); + } + } + return claims; + } + + /** + * Invoke one live Remote method through strict generated reflection or SRC markers. + * @param request - decoded endpoint and exact named wire arguments. + * @returns the business result without output decoding. + * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. + */ + async invoke(request: InvokeRemoteRequest): Promise { + return this.invokePrepared(await this.prepareInvocation(request, new AbortController())); + } + + private async invokePrepared(prepared: PreparedInvocation): Promise { + if (prepared.descriptor.mode !== undefined) { + throw new TypertGatewayError( + "gateway/signature-invalid", + prepared.endpoint, + "stream Remote methods must be opened through the stream carrier", + ); + } + + try { + return (await Reflect.apply(prepared.method, prepared.receiver, prepared.args)) as unknown; + } catch (error) { + if (prepared.invocation.signal.aborted) throw remoteCancelled(prepared.endpoint, error); + throw error; + } finally { + // A unary call's uplink is readable only while the method runs. + await prepared.invocation.close(); + } + } + + /** + * Open one live stream Remote method without assuming a physical carrier. + * @param request - decoded endpoint, named wire arguments, and the Client uplink when the carrier has one. + * @returns a cancellation-aware iterable over the business results. + */ + async stream(request: InvokeRemoteRequest): Promise> { + return this.openStream(request, new AbortController()); + } + + /** + * `control` belongs to the logical stream: a rejected uplink item aborts it + * with the Remote failure as the reason so the carrier delivers that failure. + */ + private async openStream( + request: InvokeRemoteRequest, + control: AbortController, + ): Promise> { + const prepared = await this.prepareInvocation(request, control); + if (prepared.descriptor.mode === undefined) { + await prepared.invocation.close(); + throw new TypertGatewayError( + "gateway/signature-invalid", + prepared.endpoint, + "unary Remote methods cannot be opened through the stream carrier", + ); + } + let source: unknown; + try { + source = Reflect.apply(prepared.method, prepared.receiver, prepared.args) as unknown; + } catch (error) { + await prepared.invocation.close(); + if (prepared.invocation.signal.aborted) throw remoteCancelled(prepared.endpoint, error); + throw error; + } + if (!isIterable(source)) { + await prepared.invocation.close(); + throw new TypertGatewayError( + "gateway/result-invalid", + prepared.endpoint, + "stream Remote method did not return Iterable or AsyncIterable", + { field: "result" }, + ); + } + return cancellableStream(source, prepared.endpoint, prepared.invocation); + } + + private async dispatchRpc( + endpoint: string, + payload: unknown, + signal: AbortSignal, + peer: PeerScope, + ): Promise { + if (endpoint === REMOTE_EVENT_RESULT_ENDPOINT) { + try { + const result = parseRemoteEventResultPayload(payload); + const client = this.remoteEventClients.get(result.clientId); + if (client === undefined) { + throw new Error("typert gateway: Remote event result identifies no active event stream"); + } + this.receiveRemoteEventResult(client, result); + return { ok: true, value: undefined }; + } catch (error) { + return rpcFailure(error); + } + } + return this.invokeRpc(endpoint, payload, signal, peer); + } + + private async openWireStream( + endpoint: string, + payload: unknown, + uplink: AsyncIterable, + peer: PeerScope | undefined, + signal: AbortSignal, + control: AbortController, + ): Promise> { + if (endpoint === REMOTE_EVENT_STREAM_ENDPOINT) { + // A Gateway-owned stream reads no uplink: releasing it now keeps its items out of the bounded inbox. + releaseUplink(uplink); + return this.openRemoteEvents(payload, signal); + } + return this.openStream({ ...remoteRequest(endpoint, payload, signal, peer), uplink }, control); + } + + /** + * The Peer an in-process carrier speaks for when it names none: the + * operator's Peer when Connection is mounted, otherwise an operator scope the + * Gateway owns for its own lifetime. + * @returns the operator Peer. + */ + private operatorPeer(): PeerScope { + const connection = this.ctx.get("connection"); + if (connection !== undefined) return connection.operator; + this.inProcessOperator ??= new OperatorPeer(this.ctx); + return this.inProcessOperator; + } + + private async *openRemoteEvents( + payload: unknown, + signal: AbortSignal, + ): AsyncGenerator< + | RemoteEventEmitFrame + | RemoteEventInvocationFrame + | RemoteEventCancellationFrame + | RemoteEventReadyFrame + > { + if ( + !isObject(payload) || + !isPlainObject(payload) || + Reflect.ownKeys(payload).length !== 1 || + !Object.hasOwn(payload, "args") || + !isObject(payload.args) || + !isPlainObject(payload.args) || + Reflect.ownKeys(payload.args).length !== 0 + ) { + throw new TypertGatewayError( + "gateway/arguments-invalid", + REMOTE_EVENT_STREAM_ENDPOINT, + "forwarded Remote event stream requires an empty args object", + ); + } + const registration = this.remoteEvents; + if (registration === undefined) { + throw new TypertGatewayError( + "gateway/service-unavailable", + REMOTE_EVENT_STREAM_ENDPOINT, + "forwarded Remote event source is unavailable", + ); + } + const lifetime = AbortSignal.any([signal, registration.lifetime.signal]); + let clientId = randomUUID() as RemoteEventClientId; + while (this.remoteEventClients.has(clientId)) clientId = randomUUID() as RemoteEventClientId; + const client: RemoteEventClient = { + id: clientId, + queue: new RemoteEventQueue(), + signal: lifetime, + deliveries: new Map(), + }; + this.remoteEventClients.set(clientId, client); + for (const pending of this.pendingRemoteEvents.values()) + this.deliverRemoteEvent(pending, client); + try { + yield { ...REMOTE_EVENT_STREAM_READY, clientId, host: registration.host }; + yield* client.queue.iterate(lifetime); + } finally { + this.removeRemoteEventClient(client); + } + } + + private async consumeRemoteEvents( + source: AsyncIterable, + signal: AbortSignal, + ): Promise { + for await (const dispatch of source) { + if (signal.aborted) { + if ("context" in dispatch) dispatch.reject(signal.reason); + return; + } + if ("context" in dispatch) this.startRemoteEvent(dispatch); + else this.broadcastRemoteEvent(dispatch); + } + if (!signal.aborted) { + throw new Error("typert gateway: forwarded Remote event source ended unexpectedly"); + } + } + + private broadcastRemoteEvent(frame: TypertRemoteEventFrame): void { + assertRemoteEventFrame(frame); + const wire: RemoteEventEmitFrame = { + type: "emit", + event: frame.event, + args: frame.args, + }; + for (const client of this.remoteEventClients.values()) client.queue.push(wire); + } + + private startRemoteEvent(source: TypertRemoteEventInvocation): void { + try { + assertRemoteEventName(source); + if (!isRemoteEventAgentId(source.context.agentId)) { + throw new TypeError( + "typert gateway: scoped Remote events require a non-empty Agent identity", + ); + } + const projected = projectRemoteEventRequest(source.request, source.context.subject); + let id = randomUUID() as RemoteEventId; + while (this.pendingRemoteEvents.has(id)) id = randomUUID() as RemoteEventId; + let releaseContext: () => void; + try { + const dispose = source.context.value.effect( + () => () => { + this.cancelRemoteEvent( + pending, + new Error("typert gateway: Remote event Agent Context was released"), + ); + }, + `api-gateway: Remote event ${JSON.stringify(source.event)}`, + ); + releaseContext = () => { + void dispose(); + }; + } catch { + source.resolve({ kind: "next" }); + return; + } + const signals = new Set(projected.signal === undefined ? [] : [projected.signal]); + const abort = (): void => { + const reason: unknown = [...signals].find((signal) => signal.aborted)?.reason; + this.cancelRemoteEvent( + pending, + reason instanceof Error + ? reason + : new Error("typert gateway: Remote event was cancelled", { cause: reason }), + ); + }; + const pending: PendingRemoteEvent = { + id, + source, + frame: { + type: "waterfall", + event: source.event, + eventId: id, + agentId: source.context.agentId, + request: projected.request, + }, + deliveries: new Set(), + releaseContext, + releaseSignal: () => { + for (const signal of signals) signal.removeEventListener("abort", abort); + }, + }; + this.pendingRemoteEvents.set(id, pending); + for (const signal of signals) signal.addEventListener("abort", abort, { once: true }); + if ([...signals].some((signal) => signal.aborted)) abort(); + else + for (const client of this.remoteEventClients.values()) + this.deliverRemoteEvent(pending, client); + } catch (error) { + source.reject(error); + } + } + + private deliverRemoteEvent(pending: PendingRemoteEvent, client: RemoteEventClient): void { + pending.deliveries.add(client); + client.deliveries.set(pending.id, pending); + client.queue.push(pending.frame); + } + + private receiveRemoteEventResult( + client: RemoteEventClient, + result: ReturnType, + ): void { + const pending = this.pendingRemoteEvents.get(result.eventId); + // Settlement and Client replacement may race the result request. Results + // from a completed event or a superseded delivery are idempotent no-ops. + if (pending === undefined || !pending.deliveries.has(client)) return; + this.removeRemoteEventDelivery(pending, client); + if (result.outcome.kind === "result") { + this.settleRemoteEvent(pending, { + kind: "result", + value: result.outcome.value, + }); + } else if (result.outcome.kind === "rejected") { + this.cancelRemoteEvent(pending, restoreRemoteEventRejection(result.outcome.error)); + } else if (pending.deliveries.size === 0) { + this.settleRemoteEvent(pending, { kind: "next" }); + } + } + + private removeRemoteEventDelivery(pending: PendingRemoteEvent, client: RemoteEventClient): void { + pending.deliveries.delete(client); + client.deliveries.delete(pending.id); + } + + private removeRemoteEventClient(client: RemoteEventClient): void { + this.remoteEventClients.delete(client.id); + for (const pending of [...client.deliveries.values()]) + this.removeRemoteEventDelivery(pending, client); + client.queue.end(); + } + + private settleRemoteEvent(pending: PendingRemoteEvent, outcome: TypertRemoteEventOutcome): void { + this.finishRemoteEvent(pending); + pending.source.resolve(outcome); + } + + private cancelRemoteEvent(pending: PendingRemoteEvent, reason: unknown): void { + if (this.pendingRemoteEvents.get(pending.id) !== pending) return; + this.finishRemoteEvent(pending); + pending.source.reject(reason); + } + + private finishRemoteEvent(pending: PendingRemoteEvent): void { + this.pendingRemoteEvents.delete(pending.id); + pending.releaseSignal(); + pending.releaseContext(); + const clients = new Set(pending.deliveries); + for (const client of clients) this.removeRemoteEventDelivery(pending, client); + const cancellation: RemoteEventCancellationFrame = { + type: "cancel", + eventId: pending.id, + }; + for (const client of clients) client.queue.push(cancellation); + } + + private closeRemoteEvents(reason: unknown): void { + for (const pending of [...this.pendingRemoteEvents.values()]) { + this.cancelRemoteEvent(pending, reason); + } + for (const client of [...this.remoteEventClients.values()]) client.queue.end(); + } + + private async invokeRpc( + endpoint: string, + payload: unknown, + signal: AbortSignal, + peer: PeerScope, + ): Promise { + try { + const prepared = await this.prepareInvocation( + remoteRequest(endpoint, payload, signal, peer), + new AbortController(), + ); + const value = await this.invokePrepared(prepared); + // A void or explicitly absent business result carries no `value` field; + // JSON has no `undefined`, and the envelope's optional slot is the one + // representation of absence that both args and results already use. + return encodeRpcResult(value, prepared.descriptor.result); + } catch (error) { + return rpcFailure(error); + } + } + + /** `control` fails the logical stream when an uplink item is rejected; unary calls hand over an inert one. */ + private async prepareInvocation( + request: InvokeRemoteRequest, + control: AbortController, + ): Promise { + const endpoint = endpointOf(request.namespace, request.method); + const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint); + assertExactArguments(request.args, descriptor, endpoint); + const receiverContext = await this.resolveReceiverContext(descriptor, request.args, endpoint); + const receiver: unknown = receiverContext.get(descriptor.service); + if (!isObject(receiver)) { + throw new TypertGatewayError( + "gateway/service-unavailable", + endpoint, + `active Service ${JSON.stringify(descriptor.service)} is unavailable`, + ); + } + validateBinding(receiver, descriptor.service, descriptor.namespace, endpoint); + const args = await Promise.all( + descriptor.parameters.map((parameter) => + this.resolveParameter(parameter, request.args, endpoint), + ), + ); + const signal = methodSignal(request, control); + const invocation = new GatewayInvocation( + { namespace: request.namespace, method: request.method, args: request.args }, + descriptor.service, + request.peer ?? this.operatorPeer(), + signal, + { + source: request.uplink ?? EMPTY_ASYNC_ITERABLE, + codec: descriptor.uplink?.codec ?? SRC_JSON_CODEC, + endpoint, + abort: (reason) => { + control.abort(reason); + }, + }, + ); + if (descriptor.cancellation !== undefined) args.push(signal); + // The method runs on a Service view bound to a Context carrying this call: + // Cordis rebinds `this.ctx` to the accessing Context, so `this.ctx.invocation` + // is this call and nothing travels through the parameter list. The view + // resolves the Service the plain read above already found. + const callReceiver = receiverContext.extend({ invocation }).get(descriptor.service) as object; + const implementation = descriptor.implementation ?? descriptor.method; + const method: unknown = Reflect.get(callReceiver, implementation); + if (typeof method !== "function") { + throw new TypertGatewayError( + "gateway/method-unavailable", + endpoint, + `active Service ${JSON.stringify(descriptor.service)} has no callable method ${JSON.stringify(implementation)}`, + ); + } + return { + endpoint, + descriptor, + receiver: callReceiver, + args, + method: method as (...args: never[]) => unknown, + invocation, + }; + } + + private resolveDescriptor( + namespace: string, + method: string, + endpoint: string, + ): InvocationDescriptor { + const strict = this.ctx.typert.local.get(endpoint); + if (strict !== undefined) return strict; + if (this.ctx.typert.local.hasSeen(endpoint)) { + throw new TypertGatewayError( + "gateway/definition-unavailable", + endpoint, + "its strict definition was withdrawn and SRC fallback is forbidden", + ); + } + return this.resolveSrcDescriptor(namespace, method, endpoint); + } + + private resolveSrcDescriptor( + namespace: string, + method: string, + endpoint: string, + ): InvocationDescriptor { + const candidates: InvocationDescriptor[] = []; + for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) { + if (definition.type !== "service") continue; + const receiver: unknown = this.ctx.get(serviceKey); + if (!isObject(receiver)) continue; + const original = originalOf(receiver); + const value: unknown = Reflect.get(original, "typertRemote"); + if (value === undefined) continue; + const binding = readBinding(value, original, serviceKey, endpoint); + if (binding.namespace !== namespace) continue; + const marker = remoteMethods(original).find( + (candidate) => (candidate.exportName ?? candidate.method) === method, + ); + if (marker === undefined) continue; + candidates.push(this.srcDescriptor(binding, marker, method, endpoint)); + } + if (candidates.length === 0) { + throw new TypertGatewayError( + "gateway/invocation-unavailable", + endpoint, + "no active Remote method exports this endpoint", + ); + } + if (candidates.length > 1) { + throw new TypertGatewayError( + "gateway/ambiguous-endpoint", + endpoint, + `multiple active Services export this endpoint: ${candidates + .map((candidate) => candidate.service) + .sort() + .join(", ")}`, + ); + } + return candidates[0] as InvocationDescriptor; + } + + private srcDescriptor( + binding: TypertGatewayBinding, + marker: ReturnType[number], + method: string, + endpoint: string, + ): InvocationDescriptor { + const names = methodParameterNames(binding.service, marker.method, endpoint); + const signalIndex = names.indexOf("signal"); + if (signalIndex >= 0 && signalIndex !== names.length - 1) { + throw new TypertGatewayError( + "gateway/signature-invalid", + endpoint, + "SRC cancellation parameter signal must be the final parameter", + { field: "signal" }, + ); + } + const cancellation = signalIndex >= 0 ? { parameter: "signal" as const } : undefined; + const businessNames = cancellation === undefined ? names : names.slice(0, -1); + const parameters: InvocationParameterDescriptor[] = []; + const wires = new Set(); + for (const name of businessNames) { + const matches = this.ctx.typert.lookups + .definitions() + .filter((definition) => definition.parameter === name); + if (matches.length > 1) { + throw new TypertGatewayError( + "gateway/signature-invalid", + endpoint, + `parameter ${JSON.stringify(name)} matches multiple lookup providers`, + { field: name }, + ); + } + const match = matches[0]; + const parameter: InvocationParameterDescriptor = + match === undefined + ? { name, wire: name, source: "json", codec: { mode: "src-json" } } + : { + name, + wire: match.wire, + source: "lookup", + lookup: match.key, + codec: { mode: "src-json" }, + }; + if (wires.has(parameter.wire)) { + throw new TypertGatewayError( + "gateway/signature-invalid", + endpoint, + `multiple parameters use wire field ${JSON.stringify(parameter.wire)}`, + { field: parameter.wire }, + ); + } + wires.add(parameter.wire); + parameters.push(parameter); + } + + let receiver: InvocationDescriptor["invocation"] = { kind: "direct" }; + if (marker.invocation.kind === "context") { + const provider = this.ctx.typert.contexts.getHost(marker.invocation.context); + if (provider === undefined) { + throw new TypertGatewayError( + "gateway/context-unavailable", + endpoint, + `Context provider ${JSON.stringify(marker.invocation.context)} is unavailable`, + ); + } + if (wires.has(provider.wire)) { + throw new TypertGatewayError( + "gateway/signature-invalid", + endpoint, + `Context identity conflicts with wire field ${JSON.stringify(provider.wire)}`, + { field: provider.wire }, + ); + } + receiver = { + kind: "context", + context: marker.invocation.context, + wire: provider.wire, + codec: { mode: "src-json" }, + }; + } + + return { + id: `src:${binding.serviceKey}#${endpoint}`, + service: binding.serviceKey, + namespace: binding.namespace, + method, + ...(marker.method === method ? {} : { implementation: marker.method }), + ...(marker.mode === undefined ? {} : { mode: marker.mode }), + invocation: receiver, + parameters, + ...(cancellation === undefined ? {} : { cancellation }), + result: { mode: "src-json" }, + }; + } + + private async resolveReceiverContext( + descriptor: InvocationDescriptor, + args: Readonly>, + endpoint: string, + ): Promise { + if (descriptor.invocation.kind === "direct") return this.ctx; + const invocation = descriptor.invocation; + const provider = this.ctx.typert.contexts.getHost(invocation.context); + if (provider === undefined) { + throw new TypertGatewayError( + "gateway/context-unavailable", + endpoint, + `Context provider ${JSON.stringify(invocation.context)} is unavailable`, + ); + } + if ( + provider.wire !== invocation.wire || + (invocation.codec.mode === "strict" && + provider.wireTypeSymbol !== invocation.codec.typeSymbol) + ) { + throw new TypertGatewayError( + "gateway/provider-mismatch", + endpoint, + `Context provider ${JSON.stringify(invocation.context)} does not match its strict definition`, + { field: invocation.wire }, + ); + } + const identity = decode(invocation.codec, args[invocation.wire], endpoint, invocation.wire); + let context: Context | undefined; + try { + context = await provider.resolve(identity); + } catch (cause) { + if (remoteErrorOf(cause) !== undefined) throw cause; + throw new TypertGatewayError( + "gateway/context-failed", + endpoint, + `Context provider ${JSON.stringify(invocation.context)} failed`, + { cause, field: invocation.wire }, + ); + } + if (context === undefined) { + throw new TypertGatewayError( + "gateway/context-not-found", + endpoint, + `Context provider ${JSON.stringify(invocation.context)} did not resolve the requested identity`, + { field: invocation.wire }, + ); + } + return context; + } + + private async resolveParameter( + parameter: InvocationParameterDescriptor, + args: Readonly>, + endpoint: string, + ): Promise { + // An absent field reached assertExactArguments' allowance, so this parameter + // takes undefined; a present-but-undefined field is not JSON-safe input and + // still fails decode. Lookup ids are never omissible, so absence here only + // ever belongs to a json parameter. + if (!Object.hasOwn(args, parameter.wire)) return undefined; + const value = decode(parameter.codec, args[parameter.wire], endpoint, parameter.wire); + if (parameter.source === "json") return value; + const key = parameter.lookup; + /* v8 ignore next -- registry validation rejects strict descriptors without a key, and SRC derivation always supplies one. */ + if (key === undefined) { + throw new TypertGatewayError( + "gateway/lookup-unavailable", + endpoint, + `lookup parameter ${JSON.stringify(parameter.name)} has no provider key`, + { field: parameter.wire }, + ); + } + const provider = this.ctx.typert.lookups.get(key); + if (provider === undefined) { + throw new TypertGatewayError( + "gateway/lookup-unavailable", + endpoint, + `lookup provider ${JSON.stringify(key)} is unavailable`, + { field: parameter.wire }, + ); + } + if ( + provider.wire !== parameter.wire || + (parameter.codec.mode === "strict" && provider.wireTypeSymbol !== parameter.codec.typeSymbol) + ) { + throw new TypertGatewayError( + "gateway/provider-mismatch", + endpoint, + `lookup provider ${JSON.stringify(key)} does not match its strict definition`, + { field: parameter.wire }, + ); + } + let resolved: unknown; + try { + resolved = await provider.resolve(value); + } catch (cause) { + if (remoteErrorOf(cause) !== undefined) throw cause; + throw new TypertGatewayError( + "gateway/lookup-failed", + endpoint, + `lookup provider ${JSON.stringify(key)} failed`, + { cause, field: parameter.wire }, + ); + } + if (resolved === undefined) { + throw new TypertGatewayError( + "gateway/lookup-not-found", + endpoint, + `lookup provider ${JSON.stringify(key)} did not resolve the requested identity`, + { field: parameter.wire }, + ); + } + return resolved; + } +} + +function encodeRpcResult(value: unknown, codec: TypertCodec): ConnectionRpcResult { + const attachments: ConnectionRpcAttachment[] = []; + const writeBytes = (bytes: Uint8Array, path: readonly (string | number)[]): null => { + attachments.push({ path: [...path], bytes }); + return null; + }; + const encoded = + codec.mode === "strict" + ? (codec.encode?.(value, writeBytes) ?? value) + : encodeRuntimeResult(value, writeBytes); + return { ok: true, value: encoded, ...(attachments.length === 0 ? {} : { attachments }) }; +} + +function encodeRuntimeResult( + input: unknown, + writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null, +): unknown { + const path: (string | number)[] = []; + const ancestors = new Set(); + const extract = (input: unknown, key: string): unknown => { + // Capture accessors and toJSON once, before materializing the JSON metadata. + let value = input; + if (input !== null && typeof input === "object" && !(input instanceof Uint8Array)) { + const toJSON: unknown = Reflect.get(input, "toJSON"); + if (typeof toJSON === "function") value = Reflect.apply(toJSON, input, [key]); + } + if (value instanceof Uint8Array) return writeBytes(value, path); + if (typeof value !== "object" || value === null) return value; + if (value instanceof Number || value instanceof String || value instanceof Boolean) + return value.valueOf(); + if (ancestors.has(value)) throw new TypeError("gateway: circular RPC result"); + ancestors.add(value); + let copy: object; + if (Array.isArray(value)) { + const items: unknown[] = []; + // JSON arrays include every index, even holes and non-enumerable elements. + for (let index = 0, length = value.length; index < length; index++) + items.push(child(value[index], index)); + copy = items; + } else { + const fields: Record = {}; + for (const key of Object.keys(value)) { + const item: unknown = Reflect.get(value, key); + // A toJSON method on the projected object must not run a second time. + if (key === "toJSON" && typeof item === "function") continue; + const extracted = child(item, key); + if (key === "__proto__") + Object.defineProperty(fields, key, { value: extracted, enumerable: true }); + else fields[key] = extracted; + } + copy = fields; + } + ancestors.delete(value); + return copy; + }; + const child = (value: unknown, key: string | number): unknown => { + if (typeof value !== "object" || value === null) return value; + path.push(key); + const extracted = extract(value, String(key)); + path.pop(); + return extracted; + }; + return extract(input, "value"); +} + +type RemoteEventWireFrame = + RemoteEventEmitFrame | RemoteEventInvocationFrame | RemoteEventCancellationFrame; + +/** Pull-driven queue owned by one connected Client event generation. */ +class RemoteEventQueue { + private readonly frames = new Deque(); + private waiter: (() => void) | undefined; + private closed = false; + + push(frame: RemoteEventWireFrame): void { + if (this.closed) return; + this.frames.pushBack(frame); + this.waiter?.(); + } + + end(): void { + if (this.closed) return; + this.closed = true; + this.waiter?.(); + } + + async *iterate(signal: AbortSignal): AsyncGenerator { + const abort = (): void => { + this.end(); + }; + signal.addEventListener("abort", abort, { once: true }); + try { + while (true) { + while (this.frames.size > 0) yield this.frames.popFront() as RemoteEventWireFrame; + if (this.closed || signal.aborted) return; + await new Promise((resolve) => { + this.waiter = resolve; + }); + this.waiter = undefined; + } + } finally { + signal.removeEventListener("abort", abort); + } + } +} + +function assertRemoteEventFrame(frame: TypertRemoteEventFrame): void { + assertRemoteEventName(frame); + if (!Array.isArray(frame.args) || !isRemoteJsonValue(frame.args)) { + throw new TypeError( + `typert gateway: Remote event ${JSON.stringify(frame.event)} arguments are not lossless JSON data`, + ); + } +} + +function assertRemoteEventName(frame: { readonly event: unknown }): void { + if (typeof frame.event !== "string" || frame.event.length === 0) { + throw new TypeError("typert gateway: Remote event name must be a nonempty string"); + } +} + +function parseRemoteEventResultPayload( + payload: unknown, +): ReturnType { + if ( + !isObject(payload) || + !isPlainObject(payload) || + Reflect.ownKeys(payload).length !== 1 || + !Object.hasOwn(payload, "args") + ) { + throw new Error( + "typert gateway: Remote event result requires exactly one plain-object args field", + ); + } + return parseRemoteEventResult(payload.args); +} + +function remoteRequest( + endpoint: string, + payload: unknown, + signal: AbortSignal, + peer?: PeerScope, +): InvokeRemoteRequest { + const segments = endpoint.split("/"); + if (segments.length !== 2 || segments[0] === "" || segments[1] === "") { + throw new Error(`invalid Remote endpoint ${JSON.stringify(endpoint)}`); + } + const [namespace, method] = segments as [string, string]; + if ( + !isObject(payload) || + !isPlainObject(payload) || + Reflect.ownKeys(payload).length !== 1 || + !Object.hasOwn(payload, "args") || + !isObject(payload.args) || + !isPlainObject(payload.args) + ) { + throw new Error("Remote payload must contain exactly one plain-object args field"); + } + return { namespace, method, args: payload.args, signal, ...(peer === undefined ? {} : { peer }) }; +} + +/** + * The signal a method observes. A carrier that supplies an uplink fails the + * stream through `control` when an item is rejected, so that invocation joins + * `control` with the carrier signal; every other invocation keeps the carrier + * signal's identity. + */ +function methodSignal(request: InvokeRemoteRequest, control: AbortController): AbortSignal { + const carrier = request.signal; + if (request.uplink === undefined) return carrier ?? NEVER_ABORTED_SIGNAL; + if (carrier === undefined || carrier === control.signal) return control.signal; + return AbortSignal.any([carrier, control.signal]); +} + +function isIterable(value: unknown): value is Iterable | AsyncIterable { + return ( + isObject(value) && + (typeof Reflect.get(value, Symbol.iterator) === "function" || + typeof Reflect.get(value, Symbol.asyncIterator) === "function") + ); +} + +async function* cancellableStream( + source: Iterable | AsyncIterable, + endpoint: string, + invocation: GatewayInvocation, +): AsyncGenerator { + const { signal } = invocation; + const asyncFactory: unknown = Reflect.get(source, Symbol.asyncIterator); + const syncFactory: unknown = Reflect.get(source, Symbol.iterator); + const iterator = + typeof asyncFactory === "function" + ? (Reflect.apply(asyncFactory, source, []) as AsyncIterator) + : Reflect.apply(syncFactory as (...args: never[]) => Iterator, source, []); + let rejectAbort: ((error: unknown) => void) | undefined; + const onAbort = (): void => { + rejectAbort?.(streamAbortFailure(endpoint, signal.reason)); + }; + signal.addEventListener("abort", onAbort, { once: true }); + try { + while (true) { + if (signal.aborted) throw streamAbortFailure(endpoint, signal.reason); + // Reusing a pending cancellation promise retains every completed race. + const aborted = new Promise((_resolve, reject) => { + rejectAbort = reject; + }); + // next() can abort and throw synchronously before the race subscribes. + void aborted.catch(() => undefined); + const next = await Promise.race([Promise.resolve(iterator.next()), aborted]); + rejectAbort = undefined; + if (next.done === true) return; + yield next.value; + } + } finally { + rejectAbort = undefined; + signal.removeEventListener("abort", onAbort); + // The uplink closes first so a method blocked on `uplink.next()` unwinds + // before its iterator is asked to return. + await invocation.close(); + await iterator.return?.(); + } +} + +/** + * The failure a stream surfaces for its abort: a Remote failure used as the + * reason is the Gateway or carrier failing the stream itself (a rejected, + * overflowing, or misplaced uplink item); any other abort is a cancellation. + */ +function streamAbortFailure(endpoint: string, reason: unknown): unknown { + return remoteErrorOf(reason) === undefined ? remoteCancelled(endpoint, reason) : reason; +} + +/** Carrier-signal cancellation as the shared failure vocabulary expresses it. */ +function remoteCancelled(endpoint: string, cause: unknown): RemoteError<"gateway/cancelled"> { + return new RemoteError( + "gateway/cancelled", + `Remote invocation "${endpoint}" was aborted`, + {}, + { cause }, + ); +} + +/** + * The iterable `invocation.uplink()` returns. Each uplink item passes the + * descriptor codec, or the JSON-safety check when the descriptor declares no + * uplink, before delivery; a rejected item fails the whole logical stream. + * Iteration ends when the Client half-closes or the downlink finishes, and + * fails when the stream is cancelled, so a method blocked on the uplink always + * wakes. + */ +class UplinkDecoder implements AsyncIterable, AsyncIterator { + private readonly source: AsyncIterator; + private readonly interrupted = new Set>>(); + private readonly onAbort = (): void => { + const failure = streamAbortFailure(this.endpoint, this.signal.reason); + for (const read of this.interrupted) read.reject(failure); + }; + private closed = false; + + constructor( + uplink: AsyncIterable, + private readonly codec: TypertCodec, + private readonly endpoint: string, + private readonly signal: AbortSignal, + private readonly abort: (reason: unknown) => void, + ) { + this.source = uplink[Symbol.asyncIterator](); + signal.addEventListener("abort", this.onAbort, { once: true }); + } + + [Symbol.asyncIterator](): AsyncIterator { + return this; + } + + async next(): Promise> { + // A cancelled stream reports the cancellation on every read, closed or not. + if (this.signal.aborted) throw streamAbortFailure(this.endpoint, this.signal.reason); + if (this.closed) return UPLINK_DONE; + // Only pending reads belong to the decoder; finish() must wake all of them. + const interrupted = Promise.withResolvers>(); + this.interrupted.add(interrupted); + // The source can abort and throw synchronously before the race subscribes. + void interrupted.promise.catch(() => undefined); + let next: IteratorResult; + try { + next = await Promise.race([this.source.next(), interrupted.promise]); + } finally { + this.interrupted.delete(interrupted); + } + if (next.done === true) { + this.finish(); + return UPLINK_DONE; + } + let value: unknown; + try { + // A top-level `undefined` is the absent `value` of its frame: without a codec it is the item itself. + value = + next.value === undefined && this.codec.mode === "src-json" + ? undefined + : decode(this.codec, next.value, this.endpoint, "uplink"); + } catch (failure) { + // The codec failure (`gateway/input-invalid`, field `uplink`) fails the whole logical stream. + this.abort(failure); + throw failure; + } + return { value, done: false }; + } + + /** Downlink finished or the method stopped reading: unread uplink items are dropped. */ + return(): Promise> { + if (!this.closed) { + this.finish(); + // The carrier owns the source iterator; a generator blocked in next() + // completes this return once it yields, so it is not awaited here. + void Promise.resolve() + .then(() => this.source.return?.()) + .catch(() => undefined); + } + return Promise.resolve(UPLINK_DONE); + } + + private finish(): void { + this.closed = true; + this.signal.removeEventListener("abort", this.onAbort); + for (const read of this.interrupted) read.resolve(UPLINK_DONE); + } +} + +/** + * The context of one Remote call as the receiving method reads it through + * `this.ctx.invocation`. The uplink is decoded on first use and released when + * the call's downlink finishes. + */ +class GatewayInvocation implements RemoteInvocation { + private decoder: UplinkDecoder | undefined; + private taken = false; + + /** + * @param request - decoded endpoint and wire arguments. + * @param service - Cordis service key of the receiver. + * @param peer - Peer the call speaks for. + * @param signal - the signal the method observes. + * @param uplink - carrier items and the codec that decodes them. + */ + constructor( + readonly request: RemoteInvocation["request"], + readonly service: string, + readonly peer: PeerScope, + readonly signal: AbortSignal, + private readonly uplink_: UplinkSource, + ) {} + + uplink(): AsyncIterable { + if (this.taken) { + throw new Error( + `typert gateway: ${this.uplink_.endpoint}: invocation.uplink() is available once per call`, + ); + } + this.taken = true; + const { source, codec, endpoint, abort } = this.uplink_; + this.decoder = new UplinkDecoder(source, codec, endpoint, this.signal, abort); + // The descriptor codec decides what arrives; `In` is the caller's assertion. + return this.decoder as AsyncIterable; + } + + /** + * The downlink finished: release the uplink. Unread items are dropped, and a + * carrier iterable the method never took is returned so it stops producing; + * a later `uplink()` throws like a second one would. + * @returns settles once a taken uplink has closed. + */ + async close(): Promise { + if (this.decoder !== undefined) { + await this.decoder.return(); + return; + } + this.taken = true; + releaseUplink(this.uplink_.source); + } +} + +/** + * Return a carrier uplink nobody will read, so it drops later items instead of + * buffering them. The carrier owns the iterator and `return()` is not awaited: + * a generator blocked in `next()` completes it only once it yields. + * @param source - the carrier's uplink iterable. + */ +function releaseUplink(source: AsyncIterable): void { + void Promise.resolve() + .then(() => source[Symbol.asyncIterator]().return?.()) + .catch(() => undefined); +} + +function rpcFailure(error: unknown): ConnectionRpcResult { + const remote = remoteErrorOf(error); + if (remote !== undefined) { + return { + ok: false, + error: { code: remote.code, message: remote.message, details: remote.details }, + }; + } + return { + ok: false, + error: { + code: "gateway/internal", + message: error instanceof Error ? error.message : String(error), + details: {}, + }, + }; +} + +function rpcError(error: unknown): ConnectionRpcError & RemoteStreamFailure { + return (rpcFailure(error) as Extract).error; +} + +function endpointOf(namespace: string, method: string): string { + return `${namespace}/${method}`; +} + +function validateBinding( + receiver: object, + serviceKey: string, + namespace: string, + endpoint: string, +): ResolvedBinding { + const original = originalOf(receiver); + const value: unknown = Reflect.get(original, "typertRemote"); + if (value === undefined) { + throw new TypertGatewayError( + "gateway/binding-invalid", + endpoint, + `Service ${JSON.stringify(serviceKey)} has no visible typertRemote binding`, + ); + } + return { + binding: readBinding(value, original, serviceKey, endpoint, namespace), + original, + }; +} + +function readBinding( + value: unknown, + original: object, + serviceKey: string, + endpoint: string, + namespace?: string, +): TypertGatewayBinding { + if ( + !isObject(value) || + Reflect.get(value, "service") !== original || + Reflect.get(value, "serviceKey") !== serviceKey || + typeof Reflect.get(value, "namespace") !== "string" || + (namespace !== undefined && Reflect.get(value, "namespace") !== namespace) + ) { + throw new TypertGatewayError( + "gateway/binding-invalid", + endpoint, + `Service ${JSON.stringify(serviceKey)} has an inconsistent typertRemote binding`, + ); + } + return value as TypertGatewayBinding; +} + +function originalOf(receiver: object): object { + const original: unknown = Reflect.get(receiver, symbols.original); + return isObject(original) ? original : receiver; +} + +function methodParameterNames( + service: object, + method: string, + endpoint: string, +): readonly string[] { + let prototype: object | null = Object.getPrototypeOf(service) as object | null; + let implementation: ((this: object, ...args: never[]) => unknown) | undefined; + while (prototype !== null) { + const descriptor = Object.getOwnPropertyDescriptor(prototype, method); + if (descriptor !== undefined) { + if ("value" in descriptor && typeof descriptor.value === "function") { + implementation = descriptor.value as (this: object, ...args: never[]) => unknown; + } + break; + } + prototype = Object.getPrototypeOf(prototype) as object | null; + } + if (implementation === undefined) { + throw new TypertGatewayError( + "gateway/method-unavailable", + endpoint, + `Remote marker has no prototype method ${JSON.stringify(method)}`, + ); + } + const source = Function.prototype.toString.call(implementation); + const open = source.indexOf("("); + const close = source.indexOf(")", open + 1); + /* v8 ignore next -- standard public class-method syntax always contains a parenthesized parameter list. */ + if (open < 0 || close < 0) return invalidSignature(endpoint, method); + const body = source.slice(open + 1, close).trim(); + if (body.length === 0) return []; + const parts = body.split(",").map((part) => part.trim()); + const names = new Set(); + for (const part of parts) { + if (!/^[$A-Z_a-z][$\w]*$/u.test(part) || names.has(part)) + return invalidSignature(endpoint, method); + names.add(part); + } + return [...names]; +} + +function invalidSignature(endpoint: string, method: string): never { + throw new TypertGatewayError( + "gateway/signature-invalid", + endpoint, + `SRC method ${JSON.stringify(method)} must use unique identifier parameters without destructuring, defaults, or rest`, + ); +} + +function assertExactArguments( + args: Readonly>, + descriptor: InvocationDescriptor, + endpoint: string, +): void { + if (!isPlainObject(args)) { + throw new TypertGatewayError( + "gateway/arguments-invalid", + endpoint, + "args must be a plain object", + ); + } + const expected = new Set(descriptor.parameters.map((parameter) => parameter.wire)); + if (descriptor.invocation.kind === "context") expected.add(descriptor.invocation.wire); + const actual = Reflect.ownKeys(args); + const extra = actual.filter((key) => typeof key !== "string" || !expected.has(key)); + // A JSON field may be omitted when the strict descriptor declares absence, + // and always under SRC: a weak descriptor reads parameter names from the + // JavaScript signature and cannot see which are optional, so LIB is where an + // omitted required argument is caught. Lookup ids are never omissible. + const acceptsMissing = new Set( + descriptor.parameters + .filter( + (parameter) => + parameter.source === "json" && + (parameter.acceptsUndefined === true || parameter.codec.mode === "src-json"), + ) + .map((parameter) => parameter.wire), + ); + const missing = [...expected].filter( + (key) => !Object.hasOwn(args, key) && !acceptsMissing.has(key), + ); + if (extra.length === 0 && missing.length === 0) return; + const clauses: string[] = []; + if (missing.length > 0) + clauses.push(`missing ${missing.map((key) => JSON.stringify(key)).join(", ")}`); + if (extra.length > 0) + clauses.push(`unexpected ${extra.map((key) => JSON.stringify(String(key))).join(", ")}`); + throw new TypertGatewayError( + "gateway/arguments-invalid", + endpoint, + `args fields do not match the descriptor: ${clauses.join("; ")}`, + ); +} + +function decode(codec: TypertCodec, value: unknown, endpoint: string, field: string): unknown { + try { + if (codec.mode === "strict") { + value = codec.create().parse(value); + /* v8 ignore next -- generated optional-input codecs are the only strict codecs that return undefined. */ + if (value === undefined) return value; + } + assertJsonValue(value, new Set()); + return value; + } catch (cause) { + throw new TypertGatewayError( + "gateway/input-invalid", + endpoint, + `wire field ${JSON.stringify(field)} failed boundary validation`, + { cause, field }, + ); + } +} + +function assertJsonValue(value: unknown, ancestors: Set): void { + if (value === null || typeof value === "string" || typeof value === "boolean") return; + if (typeof value === "number") { + if (Number.isFinite(value)) return; + throw new TypeError("non-finite number is not JSON-safe"); + } + if (!isObject(value)) throw new TypeError(`${typeof value} is not JSON-safe`); + if (ancestors.has(value)) throw new TypeError("cyclic value is not JSON-safe"); + ancestors.add(value); + try { + if (Array.isArray(value)) { + if ( + Object.getOwnPropertySymbols(value).length > 0 || + Object.keys(value).length !== value.length + ) { + throw new TypeError("sparse or decorated array is not JSON-safe"); + } + for (let index = 0; index < value.length; index += 1) { + if (!Object.hasOwn(value, index)) throw new TypeError("sparse array is not JSON-safe"); + assertJsonValue(value[index], ancestors); + } + return; + } + if (!isPlainObject(value)) throw new TypeError("non-plain object is not JSON-safe"); + if (Object.getOwnPropertySymbols(value).length > 0) + throw new TypeError("symbol property is not JSON-safe"); + for (const key of Reflect.ownKeys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + /* v8 ignore next -- ownKeys() just returned this key; only a hostile same-process Proxy can delete it between operations. */ + if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) { + throw new TypeError("non-data property is not JSON-safe"); + } + assertJsonValue(descriptor.value, ancestors); + } + } finally { + ancestors.delete(value); + } +} + +function isPlainObject(value: object): value is Record { + if (Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value) as object | null; + return prototype === null || prototype === Object.prototype; +} + +function isObject(value: unknown): value is object { + return (typeof value === "object" && value !== null) || typeof value === "function"; +} + +export default TypertGatewayService; diff --git a/apps/web-ui/packages/api/gateway/src/remote-error-codes.ts b/apps/web-ui/packages/api/gateway/src/remote-error-codes.ts new file mode 100644 index 000000000..98ff0b79f --- /dev/null +++ b/apps/web-ui/packages/api/gateway/src/remote-error-codes.ts @@ -0,0 +1,37 @@ +/** + * Gateway infrastructure failure codes merged into the shared Remote failure + * vocabulary. Face-neutral: the Host face and the Client face each import this + * module so both programs see the same map entries. + */ + +/** Wire details every Gateway infrastructure failure carries. */ +export interface TypertGatewayFaultDetails { + /** Canonical `/` endpoint. */ + readonly endpoint: string; + /** Affected wire field when the failure is field-specific. */ + readonly field?: string; +} + +declare module "@deepseek-ai/dsh-typert-protocol" { + interface RemoteErrorDetailsMap { + "gateway/ambiguous-endpoint": TypertGatewayFaultDetails; + "gateway/arguments-invalid": TypertGatewayFaultDetails; + "gateway/binding-invalid": TypertGatewayFaultDetails; + "gateway/context-failed": TypertGatewayFaultDetails; + "gateway/context-not-found": TypertGatewayFaultDetails; + "gateway/context-unavailable": TypertGatewayFaultDetails; + "gateway/definition-unavailable": TypertGatewayFaultDetails; + "gateway/input-invalid": TypertGatewayFaultDetails; + "gateway/invocation-unavailable": TypertGatewayFaultDetails; + "gateway/lookup-failed": TypertGatewayFaultDetails; + "gateway/lookup-not-found": TypertGatewayFaultDetails; + "gateway/lookup-unavailable": TypertGatewayFaultDetails; + "gateway/method-unavailable": TypertGatewayFaultDetails; + "gateway/protocol": TypertGatewayFaultDetails; + "gateway/provider-mismatch": TypertGatewayFaultDetails; + "gateway/result-invalid": TypertGatewayFaultDetails; + "gateway/service-unavailable": TypertGatewayFaultDetails; + "gateway/signature-invalid": TypertGatewayFaultDetails; + "gateway/uplink-overflow": TypertGatewayFaultDetails; + } +} diff --git a/apps/web-ui/packages/api/gateway/src/stream-protocol.ts b/apps/web-ui/packages/api/gateway/src/stream-protocol.ts new file mode 100644 index 000000000..f2f912fdb --- /dev/null +++ b/apps/web-ui/packages/api/gateway/src/stream-protocol.ts @@ -0,0 +1,399 @@ +/** Wire messages for Gateway-owned Remote streams and event-result RPCs. */ + +import type { Branded } from "@deepseek-ai/dsh-brand"; +import { isRemoteJsonValue } from "@deepseek-ai/dsh-typert-protocol"; + +/** Exact WebSocket route carrying every Typert Remote stream. */ +export const REMOTE_STREAM_MUX_PATH = "/api/remote.mux"; + +/** Gateway-internal logical stream carrying application-selected Cordis events. */ +export const REMOTE_EVENT_STREAM_ENDPOINT = "$events"; + +/** Gateway-internal unary endpoint returning one Client Remote Event outcome. */ +export const REMOTE_EVENT_RESULT_ENDPOINT = "$events/result"; + +/** Empty standard Remote payload used to open the forwarded-event stream. */ +export const REMOTE_EVENT_STREAM_PAYLOAD = { args: {} } as const; + +/** Discriminator for the first item proving the Host event source is ready. */ +export const REMOTE_EVENT_STREAM_READY = { type: "ready" } as const; + +/** Opaque identity for one active Client Remote Event generation. */ +export type RemoteEventClientId = Branded<"RemoteEventClientId">; + +/** Opaque correlation id for one pending Host-to-Client Remote Event. */ +export type RemoteEventId = Branded<"RemoteEventId">; + +/** Stable Host facts published with every established Client event generation. */ +export interface RemoteEventHostInfo { + /** Host account home used only to abbreviate displayed filesystem paths. */ + readonly home: string; +} + +/** Opening item that binds later HTTP results to this active event stream. */ +export interface RemoteEventReadyFrame { + readonly type: "ready"; + readonly clientId: RemoteEventClientId; + /** Stable Host facts attached to this connection generation. */ + readonly host: RemoteEventHostInfo; +} + +/** Opaque Agent identity carried by one scoped Remote Event. */ +export type RemoteEventAgentId = Branded<"RemoteEventAgentId">; + +/** One Host notification delivered to a Client generation. */ +export interface RemoteEventEmitFrame { + readonly type: "emit"; + readonly event: string; + readonly args: readonly unknown[]; +} + +/** One pending Agent-scoped waterfall delivered to a Client generation. */ +export interface RemoteEventInvocationFrame { + readonly type: "waterfall"; + readonly event: string; + readonly eventId: RemoteEventId; + readonly agentId: RemoteEventAgentId; + readonly request: Readonly>; +} + +/** Cancellation of a pending waterfall previously delivered under the same id. */ +export interface RemoteEventCancellationFrame { + readonly type: "cancel"; + readonly eventId: RemoteEventId; +} + +/** Every item carried by the Gateway-internal forwarded-event stream. */ +export type RemoteEventDownlinkFrame = + | RemoteEventReadyFrame + | RemoteEventEmitFrame + | RemoteEventInvocationFrame + | RemoteEventCancellationFrame; + +/** JSON request fields plus the Host cancellation lifetime removed for transport. */ +export interface ProjectedRemoteEventRequest { + readonly request: Readonly>; + readonly signal?: AbortSignal; +} + +/** Error fields retained when a Client listener rejects a Host waterfall. */ +export interface RemoteEventRejection { + readonly name: string; + readonly message: string; + readonly code?: string; + readonly details?: unknown; +} + +/** Client response to one scoped Remote Event delivery. */ +export interface RemoteEventResult { + readonly clientId: RemoteEventClientId; + readonly eventId: RemoteEventId; + readonly outcome: + | { readonly kind: "next" } + | { readonly kind: "result"; readonly value?: unknown } + | { readonly kind: "rejected"; readonly error: RemoteEventRejection }; +} + +/** + * Parse one result sent through the Client's `$events/result` HTTP RPC. + * @param value - untrusted result payload. + * @returns validated event correlation and outcome fields. + */ +export function parseRemoteEventResult(value: unknown): RemoteEventResult { + if ( + !isRecord(value) || + !exactKeys(value, ["clientId", "eventId", "outcome"]) || + !isRemoteEventClientId(value.clientId) || + !isRemoteEventId(value.eventId) || + !isRecord(value.outcome) + ) { + throw new Error("api gateway: invalid Remote event result"); + } + const outcome = value.outcome; + if (outcome.kind === "next" && exactKeys(outcome, ["kind"])) { + return { + clientId: value.clientId, + eventId: value.eventId, + outcome: { kind: "next" }, + }; + } + if ( + outcome.kind === "result" && + (exactKeys(outcome, ["kind"]) || exactKeys(outcome, ["kind", "value"])) && + (!Object.hasOwn(outcome, "value") || isRemoteJsonValue(outcome.value)) + ) { + return { + clientId: value.clientId, + eventId: value.eventId, + outcome: Object.hasOwn(outcome, "value") + ? { kind: "result", value: outcome.value } + : { kind: "result" }, + }; + } + if (outcome.kind === "rejected" && exactKeys(outcome, ["kind", "error"])) { + return { + clientId: value.clientId, + eventId: value.eventId, + outcome: { kind: "rejected", error: parseRemoteEventRejection(outcome.error) }, + }; + } + throw new Error("api gateway: invalid Remote event result"); +} + +/** + * Remove the direct Agent and cancellation fields from one waterfall request. + * @param value - request object before the waterfall's `next` callback. + * @param subject - Agent used by the Cordis scope carrier. + * @returns JSON-safe request fields and the optional Host cancellation signal. + */ +export function projectRemoteEventRequest( + value: unknown, + subject: object, +): ProjectedRemoteEventRequest { + if (!isPlainRecord(value) || !Object.hasOwn(value, "agent") || value.agent !== subject) { + throw new TypeError("api gateway: Remote event request must carry its scoped Agent directly"); + } + const signal = value.signal; + if (signal !== undefined && !(signal instanceof AbortSignal)) { + throw new TypeError("api gateway: Remote event request signal must be an AbortSignal"); + } + const request: Record = Object.create(null) as Record; + for (const key of Reflect.ownKeys(value)) { + if (key === "agent" || key === "signal") continue; + const descriptor = + typeof key === "string" ? Object.getOwnPropertyDescriptor(value, key) : undefined; + if (typeof key !== "string" || descriptor?.enumerable !== true) { + throw new TypeError("api gateway: Remote event request has a non-JSON property"); + } + request[key] = Reflect.get(value, key); + } + if (!isRemoteJsonValue(request)) { + throw new TypeError("api gateway: Remote event request is not lossless JSON data"); + } + return { + request, + ...(signal === undefined ? {} : { signal }), + }; +} + +/** + * Project an arbitrary rejection to stable, JSON-safe error fields. + * @param reason - value thrown or rejected by a Client listener. + * @returns wire-safe rejection fields. + */ +export function projectRemoteEventRejection(reason: unknown): RemoteEventRejection { + const record = typeof reason === "object" && reason !== null ? reason : undefined; + const name = stringProperty(record, "name") ?? "Error"; + const message = stringProperty(record, "message") ?? String(reason); + const code = stringProperty(record, "code"); + const details = record === undefined ? undefined : (Reflect.get(record, "details") as unknown); + return { + name, + message, + ...(code === undefined ? {} : { code }), + ...(details === undefined || !isRemoteJsonValue(details) ? {} : { details }), + }; +} + +/** + * Recreate a Client rejection for the Host continuation. + * @param rejection - validated wire-safe error fields. + * @returns an Error preserving the remote name, code, and JSON-safe details. + */ +export function restoreRemoteEventRejection(rejection: RemoteEventRejection): Error { + const error = new Error(rejection.message) as Error & { code?: string; details?: unknown }; + error.name = rejection.name; + if (rejection.code !== undefined) error.code = rejection.code; + if (rejection.details !== undefined) error.details = rejection.details; + return error; +} + +/** + * Recognize a non-empty Remote Event correlation id at a wire boundary. + * @param value - untrusted wire value. + * @returns whether the value is a valid Remote Event id. + */ +export function isRemoteEventId(value: unknown): value is RemoteEventId { + return typeof value === "string" && value.length > 0; +} + +/** + * Recognize a non-empty Remote Event Client id at a wire boundary. + * @param value - untrusted wire value. + * @returns whether the value identifies one event-stream generation. + */ +export function isRemoteEventClientId(value: unknown): value is RemoteEventClientId { + return typeof value === "string" && value.length > 0; +} + +/** + * Recognize the direct Agent identity used by a scoped Remote Event. + * @param value - untrusted wire value. + * @returns whether the value is a non-empty Agent identity. + */ +export function isRemoteEventAgentId(value: unknown): value is RemoteEventAgentId { + return typeof value === "string" && value.length > 0; +} + +/** One logical stream request sent from the browser: open, one uplink item, uplink half-close, or cancel. */ +export type RemoteStreamClientMessage = + | { + readonly type: "open"; + readonly streamId: string; + readonly endpoint: string; + readonly payload: unknown; + } + | { readonly type: "item"; readonly streamId: string; readonly value?: unknown } + | { readonly type: "end"; readonly streamId: string } + | { readonly type: "cancel"; readonly streamId: string }; + +/** Carrier-safe failure delivered by the Host. */ +export interface RemoteStreamFailure { + readonly code: string; + readonly message: string; + readonly details: object; +} + +/** One logical stream frame sent from the Host. */ +export type RemoteStreamServerMessage = + | { readonly type: "item"; readonly streamId: string; readonly value?: unknown } + | { readonly type: "error"; readonly streamId: string; readonly error: RemoteStreamFailure } + | { readonly type: "end"; readonly streamId: string }; + +/** + * Parse and validate one browser-to-Host text message. + * @param text - complete WebSocket text message. + * @returns the validated logical-stream request. + */ +export function parseRemoteStreamClientMessage(text: string): RemoteStreamClientMessage { + return parseMessage(text, (value) => { + if ( + (value.type === "cancel" || value.type === "end") && + exactKeys(value, ["type", "streamId"]) && + validId(value.streamId) + ) { + return value as RemoteStreamClientMessage; + } + if ( + value.type === "item" && + (exactKeys(value, ["type", "streamId"]) || exactKeys(value, ["type", "streamId", "value"])) && + validId(value.streamId) && + (!Object.hasOwn(value, "value") || isRemoteJsonValue(value.value)) + ) { + return value as RemoteStreamClientMessage; + } + if ( + value.type === "open" && + exactKeys(value, ["type", "streamId", "endpoint", "payload"]) && + validId(value.streamId) && + typeof value.endpoint === "string" && + value.endpoint.length > 0 + ) { + return value as RemoteStreamClientMessage; + } + throw new Error("api gateway: invalid Remote stream client message"); + }); +} + +/** + * Parse and validate one Host-to-browser text message. + * @param text - complete WebSocket text message. + * @returns the validated logical-stream frame. + */ +export function parseRemoteStreamServerMessage(text: string): RemoteStreamServerMessage { + return parseMessage(text, (value) => { + if ( + value.type === "item" && + (exactKeys(value, ["type", "streamId"]) || exactKeys(value, ["type", "streamId", "value"])) && + validId(value.streamId) + ) { + return value as RemoteStreamServerMessage; + } + if (value.type === "end" && exactKeys(value, ["type", "streamId"]) && validId(value.streamId)) { + return value as RemoteStreamServerMessage; + } + if ( + value.type === "error" && + exactKeys(value, ["type", "streamId", "error"]) && + validId(value.streamId) && + isRecord(value.error) && + exactKeys(value.error, ["code", "message", "details"]) && + typeof value.error.code === "string" && + typeof value.error.message === "string" && + isRecord(value.error.details) + ) { + return value as RemoteStreamServerMessage; + } + throw new Error("api gateway: invalid Remote stream server message"); + }); +} + +function parseMessage(text: string, validate: (value: Record) => T): T { + let decoded: unknown; + try { + decoded = JSON.parse(text) as unknown; + } catch (cause) { + throw new Error("api gateway: Remote stream message is not JSON", { cause }); + } + if (!isRecord(decoded)) throw new Error("api gateway: Remote stream message must be an object"); + return validate(decoded); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isPlainRecord(value: unknown): value is Record { + if (!isRecord(value)) return false; + const prototype: unknown = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function exactKeys(value: Record, expected: readonly string[]): boolean { + const keys = Reflect.ownKeys(value); + return keys.length === expected.length && expected.every((key) => Object.hasOwn(value, key)); +} + +function validId(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +function parseRemoteEventRejection(value: unknown): RemoteEventRejection { + if ( + !isRecord(value) || + !hasOnlyKeys(value, ["name", "message"], ["code", "details"]) || + typeof value.name !== "string" || + value.name.length === 0 || + typeof value.message !== "string" || + (Object.hasOwn(value, "code") && typeof value.code !== "string") || + (Object.hasOwn(value, "details") && !isRemoteJsonValue(value.details)) + ) { + throw new Error("api gateway: invalid Remote event rejection"); + } + return { + name: value.name, + message: value.message, + ...(typeof value.code === "string" ? { code: value.code } : {}), + ...(Object.hasOwn(value, "details") ? { details: value.details } : {}), + }; +} + +function hasOnlyKeys( + value: Record, + required: readonly string[], + optional: readonly string[], +): boolean { + const keys = Reflect.ownKeys(value); + return ( + required.every((key) => Object.hasOwn(value, key)) && + keys.every( + (key) => typeof key === "string" && (required.includes(key) || optional.includes(key)), + ) + ); +} + +function stringProperty(value: object | undefined, key: string): string | undefined { + if (value === undefined) return undefined; + const candidate: unknown = Reflect.get(value, key); + return typeof candidate === "string" ? candidate : undefined; +} diff --git a/apps/web-ui/packages/api/gateway/src/stream-server.ts b/apps/web-ui/packages/api/gateway/src/stream-server.ts new file mode 100644 index 000000000..aa3d1bb46 --- /dev/null +++ b/apps/web-ui/packages/api/gateway/src/stream-server.ts @@ -0,0 +1,480 @@ +/** Host WebSocket owner for multiplexed Typert Remote streams. */ + +import type { IncomingMessage } from "node:http"; +import type { Duplex } from "node:stream"; +import { Deque } from "@deepseek-ai/dsh-deque"; +import { RemoteError, remoteErrorOf, type PeerScope } from "@deepseek-ai/dsh-typert-protocol"; +import WebSocket, { WebSocketServer, type RawData } from "ws"; +import { + parseRemoteStreamClientMessage, + type RemoteStreamClientMessage, + type RemoteStreamFailure, + type RemoteStreamServerMessage, +} from "./stream-protocol.ts"; + +/** + * Open one validated Remote stream for a decoded wire request on behalf of one + * Peer. `uplink` carries the Client's items; `control` cancels the logical + * stream, and aborting it with a Remote failure as the reason delivers that + * failure to the Client. + */ +export type RemoteStreamOpener = ( + endpoint: string, + payload: unknown, + uplink: AsyncIterable, + peer: PeerScope, + control: AbortController, +) => Promise>; + +/** The opener one socket uses: its Peer is fixed at upgrade time. */ +type BoundStreamOpener = ( + endpoint: string, + payload: unknown, + uplink: AsyncIterable, + control: AbortController, +) => Promise>; + +/** Convert an invocation or carrier failure to a stable wire value. */ +export type RemoteStreamFailureMapper = (error: unknown) => RemoteStreamFailure; + +const MAX_MISSED_HEARTBEATS = 2; + +/** Own the no-server WebSocket acceptor and every active logical stream. */ +export class RemoteStreamMuxServer { + private readonly server = new WebSocketServer({ noServer: true }); + private readonly connections = new Set>(); + private readonly missedHeartbeats = new WeakMap(); + private heartbeatTimer: NodeJS.Timeout | undefined; + + /** + * @param open - Gateway stream dispatcher. + * @param failure - Gateway error-to-wire mapper. + * @param heartbeatIntervalMs - interval between WebSocket Ping control frames. + * @param streamInboxBytes - buffered uplink frame bytes one logical stream may hold before it fails. + */ + constructor( + private readonly open: RemoteStreamOpener, + private readonly failure: RemoteStreamFailureMapper, + private readonly heartbeatIntervalMs: number, + private readonly streamInboxBytes: number, + ) {} + + /** + * Upgrade one admitted request and begin serving its logical streams. Every + * stream the socket opens speaks for the Peer admitted at upgrade, and the + * socket closes when that Peer's scope is disposed. + * @param req - authenticated HTTP upgrade request. + * @param socket - carrier socket transferred to the WebSocket server. + * @param head - bytes already read after the HTTP upgrade headers. + * @param peer - Peer the upgrade was admitted as. + */ + handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer, peer: PeerScope): void { + this.server.handleUpgrade(req, socket, head, (websocket) => { + const release = bindPeer(websocket, peer); + if (release === undefined) return; + this.missedHeartbeats.set(websocket, 0); + websocket.on("pong", () => { + this.missedHeartbeats.set(websocket, 0); + }); + this.startHeartbeat(); + const bound: BoundStreamOpener = (endpoint, payload, uplink, control) => + this.open(endpoint, payload, uplink, peer, control); + const connection = new RemoteStreamMuxConnection( + websocket, + bound, + this.failure, + this.streamInboxBytes, + ); + const done = connection.run(); + this.connections.add(done); + void done.then(() => { + this.connections.delete(done); + void release(); + }); + }); + } + + /** Terminate all sockets and wait until every iterator has returned. */ + async close(): Promise { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = undefined; + for (const socket of this.server.clients) socket.terminate(); + const closed = Promise.withResolvers(); + this.server.close((error) => { + if (error === undefined) closed.resolve(); + else closed.reject(error); + }); + await closed.promise; + await Promise.all(this.connections); + } + + /** Start one `unref()` timer after the first upgrade; it spans empty-client periods until close(). */ + private startHeartbeat(): void { + if (this.heartbeatTimer !== undefined) return; + this.heartbeatTimer = setInterval(() => { + for (const socket of this.server.clients) { + if (socket.readyState !== WebSocket.OPEN) continue; + const missed = this.missedHeartbeats.get(socket) as number; + if (missed >= MAX_MISSED_HEARTBEATS) { + setImmediate(() => { + if ((this.missedHeartbeats.get(socket) as number) >= MAX_MISSED_HEARTBEATS) { + socket.terminate(); + } + }); + continue; + } + this.missedHeartbeats.set(socket, missed + 1); + socket.ping(); + } + }, this.heartbeatIntervalMs); + this.heartbeatTimer.unref(); + } +} + +interface ActiveStream { + readonly abort: AbortController; + readonly inbox: UplinkInbox; + /** Cancel the logical stream and end any Host read still waiting on its uplink. */ + readonly stop: (reason: Error) => void; + done: Promise; +} + +class RemoteStreamMuxConnection { + private readonly streams = new Map(); + private writes = Promise.resolve(); + + constructor( + private readonly socket: WebSocket, + private readonly open: BoundStreamOpener, + private readonly failure: RemoteStreamFailureMapper, + private readonly streamInboxBytes: number, + ) {} + + async run(): Promise { + const closed = new Promise((resolve) => { + this.socket.once("close", resolve); + this.socket.once("error", () => { + this.socket.terminate(); + }); + this.socket.on("message", (data, isBinary) => { + if (isBinary) { + this.socket.close(1003, "text messages required"); + return; + } + try { + this.receive(rawText(data)); + } catch { + this.socket.close(1008, "invalid Remote stream request"); + } + }); + }); + await closed; + const active = [...this.streams.values()]; + for (const stream of active) stream.stop(new Error("Remote stream socket closed")); + await Promise.all(active.map((stream) => stream.done)); + } + + /** + * Dispatch one frame. `item`, `end`, and `cancel` for a stream this connection + * no longer owns are dropped: a finished stream leaves the table while the + * Client's in-flight frames are still arriving. A duplicate `open` is the one + * protocol violation that closes the socket. + */ + private receive(text: string): void { + const message = parseRemoteStreamClientMessage(text); + switch (message.type) { + case "open": { + this.openStream(message); + return; + } + case "item": { + this.streams + .get(message.streamId) + ?.inbox.push(message.value, Buffer.byteLength(text, "utf8")); + return; + } + case "end": { + this.streams.get(message.streamId)?.inbox.end(); + return; + } + case "cancel": { + this.streams.get(message.streamId)?.stop(new Error("Remote stream cancelled")); + return; + } + /* v8 ignore next 4 -- parseRemoteStreamClientMessage admits only the four frame types above. */ + default: { + const unknown: never = message; + throw new Error( + `api gateway: unknown Remote stream client message ${JSON.stringify(unknown)}`, + ); + } + } + } + + private openStream(message: Extract): void { + if (this.streams.has(message.streamId)) { + throw new Error( + `api gateway: duplicate Remote stream id ${JSON.stringify(message.streamId)}`, + ); + } + const abort = new AbortController(); + // Created before the opener resolves so items the Client sends right + // after `open` wait in the inbox instead of being lost. + const inbox = new UplinkInbox(this.streamInboxBytes, message.endpoint, (error) => { + abort.abort(error); + }); + const active: ActiveStream = { + abort, + inbox, + stop: (reason) => { + abort.abort(reason); + inbox.fail(reason); + }, + done: Promise.resolve(), + }; + this.streams.set(message.streamId, active); + const done = this.pump(message.streamId, message.endpoint, message.payload, active); + active.done = done; + const remove = (): void => { + this.streams.delete(message.streamId); + }; + void done.then(remove, remove); + } + + private async pump( + streamId: string, + endpoint: string, + payload: unknown, + active: ActiveStream, + ): Promise { + let outcome: { readonly failed: false } | { readonly failed: true; readonly error: unknown }; + try { + const source = await this.open(endpoint, payload, active.inbox, active.abort); + for await (const value of source) { + await this.send({ type: "item", streamId, value }); + } + outcome = { failed: false }; + } catch (error) { + outcome = { failed: true, error }; + } + // The downlink has settled; later uplink frames cannot change the outcome. + active.inbox.fail(new Error("Remote stream ended")); + if (active.abort.signal.aborted) { + // A Remote failure as the abort reason is the Gateway or this mux failing + // the stream (a rejected or overflowing uplink item, or an item after + // end); the Client is still waiting for that terminal frame. Any other + // abort is a cancellation. + const reason: unknown = active.abort.signal.reason; + if (remoteErrorOf(reason) !== undefined) await this.sendFailure(streamId, reason); + return; + } + if (outcome.failed) { + await this.sendFailure(streamId, outcome.error); + return; + } + try { + await this.send({ type: "end", streamId }); + } catch (error) { + await this.sendFailure(streamId, error); + } + } + + private async sendFailure(streamId: string, error: unknown): Promise { + if (this.socket.readyState !== WebSocket.OPEN) return; + try { + await this.send({ type: "error", streamId, error: this.failure(error) }); + } catch { + // A terminal frame that cannot be encoded or written leaves the + // logical stream ambiguous, so fail the physical generation. + this.socket.close(1011, "Remote stream failure could not be delivered"); + } + } + + private send(message: RemoteStreamServerMessage): Promise { + let text: string; + try { + text = JSON.stringify(message); + } catch (cause) { + return Promise.reject( + new Error("api gateway: Remote stream item is not JSON serializable", { cause }), + ); + } + const delivery = this.writes.then( + () => + new Promise((resolve, reject) => { + if (this.socket.readyState !== WebSocket.OPEN) { + reject(new Error("api gateway: Remote stream socket is closed")); + return; + } + this.socket.send(text, (error) => { + if (error) reject(error); + else resolve(); + }); + }), + ); + this.writes = delivery.catch(() => undefined); + return delivery; + } +} + +interface UplinkEntry { + readonly value: unknown; + readonly bytes: number; +} + +const UPLINK_DONE: IteratorReturnResult = { value: undefined, done: true }; + +/** + * Bounded single-consumer uplink queue of one logical stream, the source the + * Host method reads through `invocation.uplink()`. Buffered frame bytes are + * capped: overflow, and an item after the Client's `end`, fail the queue and + * report a Remote failure that the connection uses to fail the logical stream. + */ +class UplinkInbox implements AsyncIterable, AsyncIterator { + private readonly queue = new Deque(); + private bytes = 0; + private ended = false; + private closed = false; + private taken = false; + private failure: Error | undefined; + private wake: (() => void) | undefined; + + constructor( + private readonly maxBytes: number, + private readonly endpoint: string, + private readonly onViolation: ( + error: RemoteError<"gateway/protocol" | "gateway/uplink-overflow">, + ) => void, + ) {} + + push(value: unknown, frameBytes: number): void { + if (this.failure !== undefined || this.closed) return; + if (this.ended) { + this.violate( + new RemoteError("gateway/protocol", "api gateway: Remote stream uplink item after end", { + endpoint: this.endpoint, + }), + ); + return; + } + if (this.bytes + frameBytes > this.maxBytes) { + this.violate( + new RemoteError( + "gateway/uplink-overflow", + `api gateway: Remote stream uplink exceeded ${String(this.maxBytes)} buffered bytes`, + { endpoint: this.endpoint }, + ), + ); + return; + } + this.queue.pushBack({ value, bytes: frameBytes }); + this.bytes += frameBytes; + this.signal(); + } + + /** Client half-close; idempotent. */ + end(): void { + if (this.ended) return; + this.ended = true; + this.signal(); + } + + /** End the consumer's next read with `error`; idempotent, drops buffered items. */ + fail(error: Error): void { + if (this.failure !== undefined) return; + this.failure = error; + this.queue.clear(); + this.bytes = 0; + this.signal(); + } + + [Symbol.asyncIterator](): AsyncIterator { + if (this.taken) + throw new Error("api gateway: Remote stream uplink inbox already has a consumer"); + this.taken = true; + return this; + } + + async next(): Promise> { + while (true) { + if (this.closed) return UPLINK_DONE; + const entry = this.queue.popFront(); + if (entry !== undefined) { + this.bytes -= entry.bytes; + return { value: entry.value, done: false }; + } + if (this.failure !== undefined) throw this.failure; + if (this.ended) return UPLINK_DONE; + if (this.wake !== undefined) + throw new Error("api gateway: Remote stream uplink inbox has one pending read"); + await new Promise((resolve) => { + this.wake = resolve; + }); + } + } + + /** Consumer stopped reading: later items are dropped, a pending read ends. */ + return(): Promise> { + this.closed = true; + this.queue.clear(); + this.bytes = 0; + this.signal(); + return Promise.resolve(UPLINK_DONE); + } + + private violate(error: RemoteError<"gateway/protocol" | "gateway/uplink-overflow">): void { + this.fail(error); + this.onViolation(error); + } + + private signal(): void { + const wake = this.wake; + this.wake = undefined; + wake?.(); + } +} + +/** + * Close the socket when the Peer's scope is disposed. A scope that is already + * disposed leaves no Peer for the socket to speak for, so the socket closes now. + * @returns the registration's disposer, or `undefined` when the socket was closed. + */ +function bindPeer(websocket: WebSocket, peer: PeerScope): (() => unknown) | undefined { + try { + return peer.ctx.effect( + () => () => { + websocket.close(1001, "peer left"); + }, + "api-gateway: Remote stream socket bound to its Peer", + ); + } catch { + websocket.close(1001, "peer left"); + return undefined; + } +} + +function rawText(data: RawData): string { + if (Array.isArray(data)) return Buffer.concat(data).toString("utf8"); + if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8"); + return Buffer.from(data).toString("utf8"); +} + +/** + * Reject an upgrade without transferring socket ownership to ws. + * @param socket - carrier socket that receives the HTTP rejection. + * @param status - authentication or browser-trust rejection status. + */ +export function rejectRemoteStreamUpgrade(socket: Duplex, status: 401 | 403): void { + const reason = status === 401 ? "Unauthorized" : "Forbidden"; + const body = reason.toLowerCase(); + socket.end( + [ + `HTTP/1.1 ${String(status)} ${reason}`, + "Connection: close", + "Content-Type: text/plain; charset=utf-8", + `Content-Length: ${String(Buffer.byteLength(body))}`, + "", + body, + ].join("\r\n"), + ); +} diff --git a/apps/web-ui/packages/api/gateway/src/types.ts b/apps/web-ui/packages/api/gateway/src/types.ts new file mode 100644 index 000000000..7077b7a8d --- /dev/null +++ b/apps/web-ui/packages/api/gateway/src/types.ts @@ -0,0 +1,179 @@ +/** + * Carrier-independent Typert Gateway request, service, and error contracts. + * @module @deepseek-ai/dsh-api-gateway/types + */ + +import type { Context } from "@deepseek-ai/cordis"; +import type { PeerScope } from "@deepseek-ai/dsh-typert-protocol"; +import type { RemoteEventHostInfo } from "./stream-protocol.ts"; + +/** One Remote method request after a carrier has decoded its envelope. */ +export interface InvokeRemoteRequest { + /** Remote namespace selected by the generated descriptor. */ + readonly namespace: string; + /** Exported Service method name. */ + readonly method: string; + /** Named wire values; fields must exactly match the descriptor. */ + readonly args: Readonly>; + /** + * Client uplink items of this logical stream, delivered to the method through + * `invocation.uplink()`; absent means an immediately ended iterable. + */ + readonly uplink?: AsyncIterable; + /** Peer the call speaks for; absent means an in-process carrier, answered as the operator. */ + readonly peer?: PeerScope; + /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ + readonly signal?: AbortSignal; +} + +/** One Host Cordis notification forwarded unchanged to Client Remote subscribers. */ +export interface TypertRemoteEventFrame { + /** Original Host Cordis event name. */ + readonly event: string; + /** Original event argument list after the owner validates it for JSON transport. */ + readonly args: readonly unknown[]; +} + +/** Live Host values used to project one scoped Remote Event. */ +export interface TypertRemoteEventContext { + /** Live Agent Context that owns cancellation of the forwarded waterfall. */ + readonly value: Context; + /** Agent object carried directly by the waterfall request. */ + readonly subject: object; + /** Agent identity read directly from the scoped event subject. */ + readonly agentId: string; +} + +/** Result returned from a Client waterfall, or delegation back to the Host chain. */ +export type TypertRemoteEventOutcome = + { readonly kind: "result"; readonly value: unknown } | { readonly kind: "next" }; + +/** + * One scoped waterfall invocation yielded by the application event source. + * The Gateway alone assigns transport ids and resolves the continuation after + * a Client result or explicit delegation. + */ +export interface TypertRemoteEventInvocation { + /** Original Host Cordis event name. */ + readonly event: string; + /** Sole request argument before the waterfall's `next()` callback. */ + readonly request: object; + readonly context: TypertRemoteEventContext; + /** Resume the source's Cordis listener with a Client result or `next()`. */ + readonly resolve: (outcome: TypertRemoteEventOutcome) => void; + /** Reject the source's Cordis listener after cancellation, transport failure, or Client rejection. */ + readonly reject: (reason: unknown) => void; +} + +/** Notification or scoped waterfall accepted from the sole Remote Event source. */ +export type TypertRemoteEventDispatch = TypertRemoteEventFrame | TypertRemoteEventInvocation; + +/** + * Open the application-selected event stream for one Client carrier. The + * factory must attach all incremental Host listeners before it returns; the + * Gateway publishes its readiness item immediately afterward. + * @param signal - cancellation shared with the Client stream and registration. + * @returns the long-lived stream of notifications and scoped waterfall invocations. + */ +export type TypertRemoteEventSource = ( + signal: AbortSignal, +) => AsyncIterable; + +/** Carrier-facing access to decoded Remote streams and their stable failures. */ +export interface TypertGatewayWireStream { + /** + * Open one logical stream from its wire endpoint and payload. + * @param endpoint - canonical Remote endpoint or Gateway-owned stream name. + * @param payload - decoded carrier payload. + * @param uplink - Client-to-Host items of the logical stream; a Gateway-owned endpoint returns its iterator + * as soon as it opens, so the carrier drops those items instead of buffering them. + * @param peer - Peer the stream speaks for; `undefined` means the operator's in-process carrier. + * @param signal - logical-stream cancellation. + * @returns validated stream values. + */ + readonly open: ( + endpoint: string, + payload: unknown, + uplink: AsyncIterable, + peer: PeerScope | undefined, + signal: AbortSignal, + ) => Promise>; + + /** + * Convert a stream failure to the carrier-safe Remote failure fields. + * @param error - failure raised while opening or consuming a stream. + * @returns stable code, message, and details for the Client. + */ + readonly failure: (error: unknown) => { + readonly code: string; + readonly message: string; + readonly details: object; + }; +} + +/** Stable infrastructure and boundary failures emitted before or after business execution. */ +export type TypertGatewayErrorCode = + | "gateway/ambiguous-endpoint" + | "gateway/arguments-invalid" + | "gateway/binding-invalid" + | "gateway/context-failed" + | "gateway/context-not-found" + | "gateway/context-unavailable" + | "gateway/definition-unavailable" + | "gateway/input-invalid" + | "gateway/invocation-unavailable" + | "gateway/lookup-failed" + | "gateway/lookup-not-found" + | "gateway/lookup-unavailable" + | "gateway/method-unavailable" + | "gateway/protocol" + | "gateway/provider-mismatch" + | "gateway/result-invalid" + | "gateway/service-unavailable" + | "gateway/signature-invalid" + | "gateway/uplink-overflow"; + +/** Host dispatcher consumed by Connection adapters. */ +export interface TypertGateway { + /** Carrier adapter shared by WebSocket and in-process transports. */ + readonly wireStream: TypertGatewayWireStream; + + /** + * Check for an active Client event stream. + * @returns whether a stream is open and has not been cancelled. + */ + hasLiveClient(): boolean; + + /** + * Register the application-selected forwarded-event source. + * @param source - stream factory installed by the Remote assembly. + * @param host - stable Host facts included in each Client generation's opening frame. + * @returns disposer removing this exact source and cancelling its active streams. + */ + registerRemoteEvents( + source: TypertRemoteEventSource, + host: RemoteEventHostInfo, + ): () => Promise; + + /** + * Invoke one live Remote method without assuming a carrier or response envelope. + * @param request - decoded endpoint and named wire arguments. + * @returns the business result without output decoding. + * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. + */ + invoke(request: InvokeRemoteRequest): Promise; + + /** + * Open one live stream Remote method without assuming a physical carrier. + * @param request - decoded endpoint, named wire arguments, and the Client uplink when the carrier has one. + * @returns a cancellation-aware iterable over the business results. + */ + stream(request: InvokeRemoteRequest): Promise>; +} + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Host dispatcher for Typert Remote calls. */ + typertGateway: TypertGateway; + } +} diff --git a/apps/web-ui/packages/api/gateway/test/remote-stream-recovery.test.ts b/apps/web-ui/packages/api/gateway/test/remote-stream-recovery.test.ts new file mode 100644 index 000000000..200a3ee2b --- /dev/null +++ b/apps/web-ui/packages/api/gateway/test/remote-stream-recovery.test.ts @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { it } from "node:test"; +import { RemoteError } from "@deepseek-ai/dsh-typert-protocol"; +import { RemoteStream } from "../src/client/remote-stream.ts"; +import { RemoteStreamCarrierError } from "../src/client/stream-client.ts"; + +// The event connection still reports a healthy generation while the separate +// stream carrier fails. Reconnection must not require an event-channel reset. +const online = { + generation: { + getSnapshot: () => ({ id: 1 }), + subscribe: () => () => {}, + }, +} as unknown as ConstructorParameters[0]; + +it("keeps retrying carrier failures until an authoritative stream can reopen", async () => { + let attempts = 0; + const stream = new RemoteStream(online, { + name: "queue and usage", + open: async function* () { + if (++attempts <= 3) throw new RemoteStreamCarrierError("temporary loss"); + yield "latest baseline"; + yield "next update"; + }, + ended: () => new Error("unexpected end"), + }); + const reader = stream[Symbol.asyncIterator](); + const baseline = await reader.next(); + assert.equal(baseline.value.value, "latest baseline"); + baseline.value.accept(); + assert.equal((await reader.next()).value.value, "next update"); + assert.equal(attempts, 4); + await stream.dispose(); +}); + +it("cancels retry backoff without reopening a disposed consumer", async () => { + const failed = Promise.withResolvers(); + let attempts = 0; + const stream = new RemoteStream(online, { + name: "disposed control", + open: async function* () { + attempts++; + throw new RemoteStreamCarrierError("temporary loss"); + }, + ended: () => new Error("unexpected end"), + carrierFailed: () => failed.resolve(undefined), + }); + const next = stream[Symbol.asyncIterator]().next(); + await failed.promise; + await stream.dispose(); + assert.equal((await next).done, true); + assert.equal(attempts, 1); +}); + +it("does not retry a business failure as a network failure", async () => { + let attempts = 0; + const failure = new RemoteError("gateway/internal", "invalid baseline", {}); + const stream = new RemoteStream(online, { + name: "invalid control", + open: async function* () { + attempts++; + throw failure; + }, + ended: () => new Error("unexpected end"), + }); + await assert.rejects(stream[Symbol.asyncIterator]().next(), (error) => error === failure); + assert.equal(attempts, 1); + await stream.dispose(); +}); diff --git a/apps/web-ui/packages/api/gateway/tsdown.config.ts b/apps/web-ui/packages/api/gateway/tsdown.config.ts new file mode 100644 index 000000000..f5bf13e85 --- /dev/null +++ b/apps/web-ui/packages/api/gateway/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from "../../client/tsdown.client.ts"; + +export default clientBundle("@deepseek-ai/dsh-api-gateway", ["lib/types/index.js"]); diff --git a/apps/web-ui/packages/api/job-controller/generated/remote.d.ts b/apps/web-ui/packages/api/job-controller/generated/remote.d.ts new file mode 100644 index 000000000..54d1da8f2 --- /dev/null +++ b/apps/web-ui/packages/api/job-controller/generated/remote.d.ts @@ -0,0 +1,27 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import type { + RemoteResult, + RemoteStreamHandle, + TypertRemoteContribution, +} from '@deepseek-ai/dsh-typert-protocol' +import type { JobFollowFrame, JobFollowRequest, JobKillRequest, JobKillValue, JobListFrame, JobListRequest } from '@deepseek-ai/dsh-api-job-controller/types' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertRemoteNamespace$6a6f62 { + follow: (request: JobFollowRequest, signal?: AbortSignal) => RemoteStreamHandle + kill: (request: JobKillRequest) => Promise> + list: (request: JobListRequest, signal?: AbortSignal) => RemoteStreamHandle + } + interface TypertRemoteMap { + 'job/follow': (request: JobFollowRequest, signal?: AbortSignal) => RemoteStreamHandle + 'job/kill': (request: JobKillRequest) => Promise> + 'job/list': (request: JobListRequest, signal?: AbortSignal) => RemoteStreamHandle + } + interface TypertRemoteNamespaceMap { + 'job': TypertRemoteNamespace$6a6f62 + } +} + +export declare const TYPERT_REMOTE: TypertRemoteContribution +export default TYPERT_REMOTE +//# sourceMappingURL=typert.remote-client.d.ts.map diff --git a/apps/web-ui/packages/api/job-controller/generated/remote.js b/apps/web-ui/packages/api/job-controller/generated/remote.js new file mode 100644 index 000000000..9e9ad3067 --- /dev/null +++ b/apps/web-ui/packages/api/job-controller/generated/remote.js @@ -0,0 +1,181 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import { z } from 'zod' + +let _deepseek_ai_dsh_api_job_controller_job_follow_parameter_0$schema$value +const _deepseek_ai_dsh_api_job_controller_job_follow_parameter_0$schema = () => (_deepseek_ai_dsh_api_job_controller_job_follow_parameter_0$schema$value ??= z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly().optional(), + 'jobId': z.intersection(z.string(), z.unknown()).readonly(), + 'from': z.number().readonly().optional(), +})) +let _deepseek_ai_dsh_api_job_controller_job_follow_result$schema$value +const _deepseek_ai_dsh_api_job_controller_job_follow_result$schema = () => (_deepseek_ai_dsh_api_job_controller_job_follow_result$schema$value ??= z.union([z.object({ + 'type': z.literal("opened").readonly(), + 'job': z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'kind': z.string().readonly(), + 'label': z.string().readonly(), + 'owner': z.intersection(z.string(), z.unknown()).readonly().optional(), + 'outputLimitBytes': z.number().readonly().optional(), + 'status': z.union([z.literal("failed"), z.literal("running"), z.literal("stopping"), z.literal("completed"), z.literal("killed")]).readonly(), + 'progress': z.string().readonly().optional(), + 'detail': z.string().readonly().optional(), + 'startedAt': z.number().readonly(), + 'finishedAt': z.number().readonly().optional(), + 'output': z.object({ + 'total': z.number().readonly(), + 'earliest': z.number().readonly(), + 'spillPaths': z.array(z.string()).readonly().optional(), +}).readonly(), +}).readonly(), + 'from': z.number().readonly(), +}), z.object({ + 'type': z.literal("output").readonly(), + 'chunks': z.array(z.object({ + 'at': z.number().readonly(), + 'text': z.string().readonly(), + 'channel': z.union([z.literal("stdout"), z.literal("stderr"), z.literal("log")]).readonly().optional(), + 'gapBefore': z.literal(true).readonly().optional(), +})).readonly(), + 'next': z.number().readonly(), + 'lossy': z.literal(true).readonly().optional(), +}), z.object({ + 'type': z.literal("status").readonly(), + 'job': z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'kind': z.string().readonly(), + 'label': z.string().readonly(), + 'owner': z.intersection(z.string(), z.unknown()).readonly().optional(), + 'outputLimitBytes': z.number().readonly().optional(), + 'status': z.union([z.literal("failed"), z.literal("running"), z.literal("stopping"), z.literal("completed"), z.literal("killed")]).readonly(), + 'progress': z.string().readonly().optional(), + 'detail': z.string().readonly().optional(), + 'startedAt': z.number().readonly(), + 'finishedAt': z.number().readonly().optional(), + 'output': z.object({ + 'total': z.number().readonly(), + 'earliest': z.number().readonly(), + 'spillPaths': z.array(z.string()).readonly().optional(), +}).readonly(), +}).readonly(), +})])) +let _deepseek_ai_dsh_api_job_controller_job_kill_parameter_0$schema$value +const _deepseek_ai_dsh_api_job_controller_job_kill_parameter_0$schema = () => (_deepseek_ai_dsh_api_job_controller_job_kill_parameter_0$schema$value ??= z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), + 'jobId': z.intersection(z.string(), z.unknown()).readonly(), +})) +let _deepseek_ai_dsh_api_job_controller_job_kill_result$schema$value +const _deepseek_ai_dsh_api_job_controller_job_kill_result$schema = () => (_deepseek_ai_dsh_api_job_controller_job_kill_result$schema$value ??= z.object({ + 'outcome': z.union([z.literal("requested"), z.literal("already-finished")]).readonly(), +})) +let _deepseek_ai_dsh_api_job_controller_job_list_parameter_0$schema$value +const _deepseek_ai_dsh_api_job_controller_job_list_parameter_0$schema = () => (_deepseek_ai_dsh_api_job_controller_job_list_parameter_0$schema$value ??= z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), +})) +let _deepseek_ai_dsh_api_job_controller_job_list_result$schema$value +const _deepseek_ai_dsh_api_job_controller_job_list_result$schema = () => (_deepseek_ai_dsh_api_job_controller_job_list_result$schema$value ??= z.object({ + 'type': z.literal("rows").readonly(), + 'jobs': z.array(z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'kind': z.string().readonly(), + 'label': z.string().readonly(), + 'owner': z.intersection(z.string(), z.unknown()).readonly().optional(), + 'outputLimitBytes': z.number().readonly().optional(), + 'status': z.union([z.literal("failed"), z.literal("running"), z.literal("stopping"), z.literal("completed"), z.literal("killed")]).readonly(), + 'progress': z.string().readonly().optional(), + 'detail': z.string().readonly().optional(), + 'startedAt': z.number().readonly(), + 'finishedAt': z.number().readonly().optional(), + 'output': z.object({ + 'total': z.number().readonly(), + 'earliest': z.number().readonly(), + 'spillPaths': z.array(z.string()).readonly().optional(), +}).readonly(), +})).readonly(), +})) + +export const TYPERT_REMOTE = { + package: '@deepseek-ai/dsh-api-job-controller', + descriptors: [ + { + id: '@deepseek-ai/dsh-api-job-controller#job/follow', + service: 'jobController', + namespace: 'job', + method: 'follow', + mode: 'stream', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-job-controller/types#JobFollowRequest', + create: _deepseek_ai_dsh_api_job_controller_job_follow_parameter_0$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-job-controller/types#JobFollowFrame', + create: _deepseek_ai_dsh_api_job_controller_job_follow_result$schema, + }, + sourceLocation: {"file":"packages/api/job-controller/src/index.ts","line":91,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-job-controller#job/kill', + service: 'jobController', + namespace: 'job', + method: 'kill', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-job-controller/types#JobKillRequest', + create: _deepseek_ai_dsh_api_job_controller_job_kill_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-job-controller/types#JobKillValue', + create: _deepseek_ai_dsh_api_job_controller_job_kill_result$schema, + }, + sourceLocation: {"file":"packages/api/job-controller/src/index.ts","line":110,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-job-controller#job/list', + service: 'jobController', + namespace: 'job', + method: 'list', + mode: 'stream', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-job-controller/types#JobListRequest', + create: _deepseek_ai_dsh_api_job_controller_job_list_parameter_0$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-job-controller/types#JobListFrame', + create: _deepseek_ai_dsh_api_job_controller_job_list_result$schema, + }, + sourceLocation: {"file":"packages/api/job-controller/src/index.ts","line":76,"column":3}, + }, + ], +} + +export default TYPERT_REMOTE diff --git a/apps/web-ui/packages/api/job-controller/package.json b/apps/web-ui/packages/api/job-controller/package.json new file mode 100644 index 000000000..4ec9d5d9d --- /dev/null +++ b/apps/web-ui/packages/api/job-controller/package.json @@ -0,0 +1,46 @@ +{ + "name": "@deepseek-ai/dsh-api-job-controller", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./types": { + "types": "./src/types.ts", + "default": "./src/types.ts" + }, + "./client": { + "types": "./src/client/index.ts", + "default": "./lib/client.js" + }, + "./typert": { + "types": "./src/typert.host.ts", + "default": "./src/typert.host.ts" + }, + "./remote": { + "types": "./generated/remote.d.ts", + "default": "./generated/remote.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dependencies": { + "zod": "^4.4.3" + }, + "dsh": { + "client": { + "external": [ + "@deepseek-ai/dsh-api-gateway/client" + ], + "inject": [ + "@deepseek-ai/dsh-api-gateway", + "@deepseek-ai/dsh-client-connection" + ], + "platform": "web" + } + } +} diff --git a/apps/web-ui/packages/api/job-controller/src/client/index.ts b/apps/web-ui/packages/api/job-controller/src/client/index.ts new file mode 100644 index 000000000..3d790414c --- /dev/null +++ b/apps/web-ui/packages/api/job-controller/src/client/index.ts @@ -0,0 +1,47 @@ +/** + * Job Controller client half: installs `ctx.jobs` (rosters, observations, and + * the human kill) over the generated `job` Remote namespace. The plugin resolves both Remote faces it drives while its + * own context is current, because stream (re)opens run on caller stacks — a + * React event, a carrier retry — whose dynamic context has not declared + * `remote.job`. + * @module @deepseek-ai/dsh-api-job-controller/client + */ + +import type { Context } from "@deepseek-ai/cordis"; +import type {} from "@deepseek-ai/dsh-api-job-controller/remote"; +import { ClientJobsModel } from "./model.ts"; +import { ClientJobs } from "./service.ts"; + +export type { JobsSnapshot, ObservedJob } from "./model.ts"; +// The `ctx.jobs` contract. Its module also carries the Context augmentation +// that declares `ctx.jobs`, which reaches consumers only through this export: +// declaration emit drops the value import above. +export type { IJobs } from "./service.ts"; +export type { + JobChunk, + JobKillRequest, + JobKillValue, + JobFollowFrame, + JobFollowRequest, + JobListFrame, + JobListRequest, + JobView, +} from "../types.ts"; + +/** Required Client Remote services. */ +export const inject = ["remote", "remote.job"]; + +/** + * Install the client jobs service. + * @param ctx - Client root Context. + */ +export function apply(ctx: Context): void { + // Read the namespace now, not inside `open`: see the module JSDoc. + const { remote } = ctx; + const { job } = remote; + new ClientJobs( + ctx, + { $stream: (options) => remote.$stream(options), job }, + new ClientJobsModel(), + ); +} diff --git a/apps/web-ui/packages/api/job-controller/src/client/model.ts b/apps/web-ui/packages/api/job-controller/src/client/model.ts new file mode 100644 index 000000000..3a9f4f627 --- /dev/null +++ b/apps/web-ui/packages/api/job-controller/src/client/model.ts @@ -0,0 +1,223 @@ +/** + * React-free client job state: the roster each watched session can see, fed + * by `job.list` frames, and per-job accumulated output views fed by + * `job.follow` frames. Pure data plus subscriptions — transport wiring stays + * in the client service, UI stays in slot components. + * @module @deepseek-ai/dsh-api-job-controller/client/model + */ + +import { notifySubscribers } from "@deepseek-ai/dsh-client-store"; +import type { JobId } from "@deepseek-ai/dsh-jobs/brand"; +import type { JobView } from "@deepseek-ai/dsh-jobs/view"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { JobFollowFrame } from "../types.ts"; + +/** Bounded per-job render tail, in UTF-16 code units. */ +const RENDER_TAIL_LIMIT = 128 * 1024; + +/** One observed job's live view state. */ +export interface ObservedJob { + readonly jobId: JobId; + /** Accumulated output tail, bounded to the render limit. */ + readonly text: string; + /** True when bytes before {@link text} were dropped (eviction, resume gap, or the render bound). */ + readonly gapBefore: boolean; + /** True while the observation stream is open and the job has not settled. */ + readonly streaming: boolean; + /** Terminal observation failure, when the stream ended abnormally. */ + readonly error?: string; +} + +/** Immutable client job state. */ +export interface JobsSnapshot { + /** + * The jobs each watched session can see, keyed by session id. A session + * nobody watches, or one that sees no job, has no key, so consumers read + * absence rather than a sentinel. + */ + readonly rows: Readonly>; + /** Live observation state keyed by job id. */ + readonly observed: Readonly>; +} + +/** Bare observable source for the client job snapshot. */ +export interface JobsSource { + /** Read the identity-stable current snapshot. */ + getSnapshot(): JobsSnapshot; + /** + * Subscribe to snapshot changes. + * @param listener - invalidation callback. + * @returns unsubscribe function. + */ + subscribe(listener: () => void): () => void; +} + +/** Mutable observation bookkeeping behind one {@link ObservedJob} view. */ +interface ObservedState { + view: ObservedJob; + /** Resume offset for the next generation's `from`. */ + cursor: number | undefined; +} + +/** Owns the per-session rosters and per-job observation state. */ +export class ClientJobsModel implements JobsSource { + private readonly rowsBySession = new Map(); + private readonly observedStates = new Map(); + private readonly listeners = new Set<() => void>(); + private snapshotCache: JobsSnapshot = { rows: {}, observed: {} }; + private snapshotDirty = false; + + getSnapshot(): JobsSnapshot { + if (this.snapshotDirty) { + const rows: Record = {}; + for (const [id, jobs] of this.rowsBySession) rows[id] = jobs; + const observed: Record = {}; + for (const [id, state] of this.observedStates) observed[id] = state.view; + this.snapshotCache = { rows, observed }; + this.snapshotDirty = false; + } + return this.snapshotCache; + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** + * Replace one session's roster with a `rows` frame's whole set. An empty + * set is stored as an absent key. + * @param sessionId - the watched session. + * @param jobs - the complete visible set. + */ + rowsReplaced(sessionId: SessionId, jobs: readonly JobView[]): void { + const key = String(sessionId); + if (jobs.length === 0) { + if (!this.rowsBySession.delete(key)) return; + } else { + this.rowsBySession.set(key, jobs); + } + this.changed(); + } + + /** + * Drop one session's roster after its last watcher stops or its stream fails. + * @param sessionId - the no-longer-watched session. + */ + rowsDropped(sessionId: SessionId): void { + if (!this.rowsBySession.delete(String(sessionId))) return; + this.changed(); + } + + /** + * The resume offset for one job's next observation generation. + * @param id - observed job. + * @returns the last accepted `next`, or undefined for a fresh observation. + */ + cursorOf(id: JobId): number | undefined { + return this.observedStates.get(String(id))?.cursor; + } + + /** + * Install or reset observation state when a generation's anchor arrives. + * @param id - observed job. + * @param frame - the generation's `opened` anchor. + */ + observeOpened(id: JobId, frame: Extract): void { + const existing = this.observedStates.get(String(id)); + // A fresh view anchored past offset zero starts after an evicted head + // (fresh observations anchor at the registry's earliest retained byte), so + // it owes the same gap mark a live observer earned from lossy reads. A + // resume that already accumulated text keeps its recorded gap state. + const freshPastHead = (existing === undefined || existing.view.text === "") && frame.from > 0; + const view: ObservedJob = { + jobId: id, + text: existing?.view.text ?? "", + gapBefore: + (existing?.view.gapBefore ?? false) || + frame.from < frame.job.output.earliest || + freshPastHead, + streaming: true, + }; + this.observedStates.set(String(id), { view, cursor: frame.from }); + this.changed(); + } + + /** + * Append one output frame's chunks to the bounded render tail. + * @param id - observed job. + * @param frame - a coalesced `output` frame. + */ + observeOutput(id: JobId, frame: Extract): void { + const state = this.observedStates.get(String(id)); + /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */ + if (state === undefined) return; + let text = state.view.text + frame.chunks.map((chunk) => chunk.text).join(""); + let gapBefore = + state.view.gapBefore || + frame.lossy === true || + frame.chunks.some((chunk) => chunk.gapBefore === true); + if (text.length > RENDER_TAIL_LIMIT) { + let cut = text.length - RENDER_TAIL_LIMIT; + // Never split a surrogate pair at the render bound. + const unit = text.charCodeAt(cut); + if (unit >= 0xdc00 && unit <= 0xdfff) cut += 1; + text = text.slice(cut); + gapBefore = true; + } + state.view = { ...state.view, text, gapBefore }; + state.cursor = frame.next; + this.changed(); + } + + /** + * Close the live view once the terminal `status` frame arrived: the ring is + * drained and the roster row carries the settled projection. + * @param id - observed job. + */ + observeSettled(id: JobId): void { + const state = this.observedStates.get(String(id)); + /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */ + if (state === undefined) return; + state.view = { ...state.view, streaming: false }; + this.changed(); + } + + /** + * Record a terminal observation failure. + * @param id - observed job. + * @param error - the stream's terminal failure. + */ + observeFailed(id: JobId, error: unknown): void { + const state = this.observedStates.get(String(id)); + if (state === undefined) { + // A failure before the anchor — a rejected request, a job gone between + // the click and the open — still owes the panel its notice; a later + // successful anchor replaces this view and resumes from no cursor. + this.observedStates.set(String(id), { + view: { jobId: id, text: "", gapBefore: false, streaming: false, error: String(error) }, + cursor: undefined, + }); + this.changed(); + return; + } + state.view = { ...state.view, streaming: false, error: String(error) }; + this.changed(); + } + + /** + * Drop observation state after the last observer stops. + * @param id - the no-longer-observed job. + */ + observeStopped(id: JobId): void { + if (!this.observedStates.delete(String(id))) return; + this.changed(); + } + + private changed(): void { + this.snapshotDirty = true; + notifySubscribers(this.listeners, "jobs"); + } +} diff --git a/apps/web-ui/packages/api/job-controller/src/client/service.ts b/apps/web-ui/packages/api/job-controller/src/client/service.ts new file mode 100644 index 000000000..d4a6d9cb0 --- /dev/null +++ b/apps/web-ui/packages/api/job-controller/src/client/service.ts @@ -0,0 +1,298 @@ +/** + * The `ctx.jobs` client service: reference-counted streams over the `job` + * namespace — one `job.list` roster stream per watched session and one + * `job.follow` stream per observed job — so overlapping viewers share a + * stream, rosters resume whole after a reconnect, and observations resume + * from the model's cursor, plus the human kill passthrough over `job.kill`. + * @module @deepseek-ai/dsh-api-job-controller/client/service + */ + +import { Service, type Context } from "@deepseek-ai/cordis"; +import { RemoteStreamCarrierError, type ClientRemote } from "@deepseek-ai/dsh-api-gateway/client"; +import type { RemoteResult } from "@deepseek-ai/dsh-typert-protocol"; +import type { JobId } from "@deepseek-ai/dsh-jobs/brand"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { + JobKillRequest, + JobKillValue, + JobFollowFrame, + JobFollowRequest, + JobListFrame, + JobListRequest, +} from "../types.ts"; +import type { ClientJobsModel, JobsSource } from "./model.ts"; + +/** The generated `job` namespace face the stream runners drive. */ +export interface JobRemote { + /** + * Open one roster generation. + * @param request - the session whose visible set to mirror. + * @param signal - generation cancellation. + * @returns the whole-set frame sequence of one generation. + */ + list(request: JobListRequest, signal?: AbortSignal): AsyncIterable; + /** + * Open one observation generation. + * @param request - target job, owning session, and optional resume offset. + * @param signal - generation cancellation. + * @returns the frame sequence of one generation. + */ + follow(request: JobFollowRequest, signal?: AbortSignal): AsyncIterable; + /** + * Kill one job on the human's behalf. + * @param request - the session whose list carries the job, and the job id. + * @returns the registry's admission, or the business/transport failure. + */ + kill(request: JobKillRequest): Promise>; +} + +/** Remote faces the runners drive: the Gateway stream factory and the `job` namespace. */ +export interface JobsRemote { + readonly $stream: ClientRemote["$stream"]; + readonly job: JobRemote; +} + +/** The client jobs service face. */ +export interface IJobs { + /** Rosters and per-job observation state. */ + readonly state: JobsSource; + /** + * Keep one session's roster current; reference-counted, so two watchers of + * the same session share one stream and the rows leave with the last. + * @param sessionId - the session whose visible jobs to mirror. + * @returns stop function releasing this watcher's reference. + */ + watchRows(sessionId: SessionId): () => void; + /** + * Start observing one job's live output; reference-counted, so two viewers + * of the same job share one stream. + * @param sessionId - owning session used for the fenced read; undefined for an unowned job. + * @param id - job to observe. + * @returns stop function releasing this observer's reference. + */ + observe(sessionId: SessionId | undefined, id: JobId): () => void; + /** + * Kill one background job from a session's job list. Pure RPC passthrough: + * row state converges through the roster stream, and the caller (the + * job-list control) owns error presentation. + * @param sessionId - session whose job list carries the job. + * @param id - the job row's registry id. + * @returns the registry's admission, or the business/transport failure. + */ + kill(sessionId: SessionId, id: JobId): Promise>; +} + +/** One reference-counted stream. */ +interface StreamEntry { + refs: number; + stopped: boolean; + dispose: () => Promise; +} + +declare module "@deepseek-ai/cordis" { + interface Context { + /** React-free client job rosters and observation control. */ + jobs: IJobs; + } +} + +/** Owns the bare jobs snapshot and the per-session and per-job streams. */ +export class ClientJobs extends Service implements IJobs { + readonly state: JobsSource; + private readonly rowsEntries = new Map(); + private readonly observations = new Map(); + + /** + * @param ctx - client root Context. + * @param remote - the Gateway stream factory plus the generated `job` namespace, both resolved by the caller. + * @param model - shared client jobs model. + */ + constructor( + ctx: Context, + private readonly remote: JobsRemote, + private readonly model: ClientJobsModel, + ) { + super(ctx, "jobs"); + this.state = model; + ctx.effect( + () => async () => { + const open = [...this.rowsEntries.values(), ...this.observations.values()]; + this.rowsEntries.clear(); + this.observations.clear(); + for (const entry of open) entry.stopped = true; + // Cordis awaits an async disposer, so the fiber stays unloading until + // every carrier iterator has closed and a successor plugin instance + // cannot overlap one. A carrier whose teardown fails is stopped all the + // same; its failure has no consumer here. + await Promise.allSettled(open.map((entry) => entry.dispose())); + }, + "job-controller.client.streams", + ); + } + + kill(sessionId: SessionId, id: JobId): Promise> { + return this.remote.job.kill({ sessionId, jobId: id }); + } + + watchRows(sessionId: SessionId): () => void { + return this.acquire( + this.rowsEntries, + String(sessionId), + () => this.startRows(sessionId), + () => { + this.model.rowsDropped(sessionId); + }, + ); + } + + observe(sessionId: SessionId | undefined, id: JobId): () => void { + return this.acquire( + this.observations, + String(id), + () => this.startObservation(sessionId, id), + () => { + this.model.observeStopped(id); + }, + ); + } + + /** Share the live entry under `key` or start one, and hand back its release. */ + private acquire( + entries: Map, + key: string, + start: () => StreamEntry, + cleared: () => void, + ): () => void { + const existing = entries.get(key); + if (existing !== undefined && !existing.stopped) { + existing.refs += 1; + return this.releaser(entries, key, existing, cleared); + } + const entry = start(); + entries.set(key, entry); + return this.releaser(entries, key, entry, cleared); + } + + /** + * Release closures bind the exact entry they were minted for, never the + * map's current occupant: a later acquire on the same key may have replaced + * a stopped entry, and decrementing or disposing through the key alone + * would tear down that newer stream's references. + */ + private releaser( + entries: Map, + key: string, + entry: StreamEntry, + cleared: () => void, + ): () => void { + let released = false; + return () => { + if (released) return; + released = true; + entry.refs -= 1; + if (entry.refs > 0) return; + if (entries.get(key) === entry) entries.delete(key); + entry.stopped = true; + void entry.dispose().then(() => { + // Clear the state only while no successor holds the key: a re-acquire + // inside the dispose round-trip already refilled the model, and a + // stale clear would blank it for good. + if (entries.has(key)) return; + cleared(); + }); + }; + } + + private startRows(sessionId: SessionId): StreamEntry { + const name = `job rows ${String(sessionId)}`; + const stream = this.remote.$stream({ + name, + open: (signal) => this.remote.job.list({ sessionId }, signal), + // The roster has no natural end while it is watched: an end after the + // first frame is a carrier interruption (a Host reload closes the + // generation) and the next generation's whole set loses nothing. An end + // before the first frame is terminal. + ended: (accepted) => + accepted + ? new RemoteStreamCarrierError(`${name} ended before release`) + : new Error(`${name} ended before its first frame`), + }); + const entry: StreamEntry = { + refs: 1, + stopped: false, + dispose: () => stream.dispose(), + }; + void (async () => { + try { + for await (const item of stream) { + this.model.rowsReplaced(sessionId, item.value.jobs); + item.accept(); + } + } catch { + // A terminal stream failure leaves nothing current to show; the model + // drops the roster rather than keeping a stale set on screen. + if (!entry.stopped) this.model.rowsDropped(sessionId); + } finally { + entry.stopped = true; + void entry.dispose(); + } + })(); + return entry; + } + + private startObservation(sessionId: SessionId | undefined, id: JobId): StreamEntry { + const name = `job observation ${String(id)}`; + const stream = this.remote.$stream({ + name, + open: (signal) => { + const from = this.model.cursorOf(id); + return this.remote.job.follow( + { + jobId: id, + ...(sessionId !== undefined ? { sessionId } : {}), + ...(from !== undefined ? { from } : {}), + }, + signal, + ); + }, + // A premature end after the anchor is retryable (a Host reload closes the + // generation); resuming from the cursor loses nothing. An end before the + // anchor is terminal. + ended: (accepted) => + accepted + ? new RemoteStreamCarrierError(`${name} ended before settlement`) + : new Error(`${name} ended before its anchor`), + }); + const entry: StreamEntry = { + refs: 1, + stopped: false, + dispose: () => stream.dispose(), + }; + void (async () => { + try { + for await (const item of stream) { + const frame = item.value; + if (frame.type === "opened") { + this.model.observeOpened(id, frame); + item.accept(); + continue; + } + if (frame.type === "output") { + this.model.observeOutput(id, frame); + continue; + } + // Terminal status: leave the loop before the generation end is + // classified, then close the stream for good. + this.model.observeSettled(id); + break; + } + } catch (error) { + if (!entry.stopped) this.model.observeFailed(id, error); + } finally { + entry.stopped = true; + void entry.dispose(); + } + })(); + return entry; + } +} diff --git a/apps/web-ui/packages/api/job-controller/src/index.ts b/apps/web-ui/packages/api/job-controller/src/index.ts new file mode 100644 index 000000000..268485995 --- /dev/null +++ b/apps/web-ui/packages/api/job-controller/src/index.ts @@ -0,0 +1,142 @@ +/** + * Host job Remote owner: streams the background-job roster one session can + * see and one job's retained output to browsers over the generated `job` + * namespace, and stops a job on a human's behalf. The streams are + * projections of `ctx.jobs`; the model's consuming cursor and notice state + * never observe them, and a human kill is not the model's own, so the + * completion notice still reaches the owning agent. + * @module @deepseek-ai/dsh-api-job-controller + */ + +import { Context } from "@deepseek-ai/cordis"; +import z from "@deepseek-ai/schemastery"; +import type {} from "@deepseek-ai/dsh-jobs"; +import { Remote, RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol"; +import { observeJobOutput } from "./observe.ts"; +import { streamJobRows } from "./rows.ts"; +import type { + JobKillRequest, + JobKillValue, + JobFollowFrame, + JobFollowRequest, + JobListFrame, + JobListRequest, +} from "./types.ts"; + +export type * from "./types.ts"; + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Host job Remote namespace owner. */ + jobController: JobController; + } +} + +/** Default coalescing window between reads, in milliseconds. */ +const DEFAULT_OBSERVE_FLUSH_MS = 100; + +/** Default soft byte budget per output frame. */ +const DEFAULT_OBSERVE_MAX_FRAME_BYTES = 64 * 1024; + +/** Job Controller deployment policy. */ +export interface Config { + /** Coalescing window after a registry commit before the next rows or output read, in milliseconds (default 100). */ + readonly observeFlushMs?: number; + /** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */ + readonly observeMaxFrameBytes?: number; +} + +/** Host service backing the generated `ctx.remote.job` namespace. */ +export class JobController extends TypertRemoteService { + static inject = ["jobs", "typert"]; + + static Config: z = z.object({ + observeFlushMs: z.natural().min(1).default(DEFAULT_OBSERVE_FLUSH_MS), + observeMaxFrameBytes: z.natural().min(1).default(DEFAULT_OBSERVE_MAX_FRAME_BYTES), + }); + + private readonly observeFlushMs: number; + private readonly observeMaxFrameBytes: number; + + /** + * @param ctx - Host context carrying the live Agent registry and the job registry. + * @param config - observation cadence and framing policy. + */ + constructor(ctx: Context, config: Config) { + super(ctx, "jobController", { namespace: "job" }); + // schemastery (the exported Config schema) has already filled the defaulted + // fields; the assertion records that resolution, not a hidden fallback. + const resolved = config as Required; + this.observeFlushMs = resolved.observeFlushMs; + this.observeMaxFrameBytes = resolved.observeMaxFrameBytes; + } + + /** + * Stream the jobs one session can see — its own plus every unowned job — + * as whole-set frames: one on open, then one after each coalesced burst of + * lifecycle commits. The stream has no natural end; the carrier closes it. + * @param request - the session whose visible set to mirror. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns the roster frames. + */ + @Remote({ mode: "stream" }) + list(request: JobListRequest, signal: AbortSignal): AsyncIterable { + return streamJobRows(this.ctx.jobs, request, { flushMs: this.observeFlushMs }, signal); + } + + /** + * Stream one job's retained output from an absolute byte offset, then its + * terminal projection once settled and drained. Non-consuming: the + * model-facing cursor and notice state never observe these reads. The + * request's session is the fenced read's caller; the registry rejects a + * job the session cannot see and an unknown job. + * @param request - target job, owning session, and optional resume offset. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns anchor, coalesced output frames, and the terminal status. + */ + @Remote({ mode: "stream" }) + follow(request: JobFollowRequest, signal: AbortSignal): AsyncIterable { + return observeJobOutput( + this.ctx.jobs, + request, + { + flushMs: this.observeFlushMs, + maxFrameBytes: this.observeMaxFrameBytes, + }, + signal, + ); + } + + /** + * Kill one background job on a human's behalf. The request's session is + * the fenced read's caller, so the job must be one that session can see: + * the registry's owner fence is the only access rule, and a child session's + * own jobs are killable from its list like any other. The kill records + * `cancelled by the user` as its reason; it is not one the model requested, + * so the owning agent still receives the completion notice, and a shell + * tool waiting on that job reads the reason in its own result. + * @param request - Session whose job list carries the job, and the job id. + * @returns the registry's admission of the kill request. + */ + @Remote("kill") + kill(request: JobKillRequest): JobKillValue { + const jobs = this.ctx.jobs; + try { + jobs.get(request.jobId, request.sessionId); + } catch (error) { + // `unknown job` and `belongs to another session` both mean this session's + // list no longer carries a killable row; the client renders one story. + throw new RemoteError("job/not-found", String(error), { + sessionId: request.sessionId, + jobId: request.jobId, + }); + } + // Same synchronous span as the lookup, so nothing can remove the job in + // between — and a producer-cancel throw propagates per the registry + // contract (job state unchanged) instead of masquerading as job-not-found. + const outcome = jobs.kill(request.jobId, request.sessionId, "cancelled by the user"); + return { outcome }; + } +} + +export default JobController; diff --git a/apps/web-ui/packages/api/job-controller/src/observe.ts b/apps/web-ui/packages/api/job-controller/src/observe.ts new file mode 100644 index 000000000..08ad43cba --- /dev/null +++ b/apps/web-ui/packages/api/job-controller/src/observe.ts @@ -0,0 +1,134 @@ +/** Per-job observation generations: anchor, coalesced output, terminal status. */ + +import type { JobId } from "@deepseek-ai/dsh-jobs/brand"; +import type { JobChunk, JobRegistry, JobStatus, JobView } from "@deepseek-ai/dsh-jobs"; +import type { JobFollowFrame, JobFollowRequest } from "./types.ts"; +import { OutputWaiter, sleep } from "./wake.ts"; + +/** Cadence and framing bounds for one observation generation. */ +export interface ObserveJobOptions { + /** Coalescing window after a wake before reading, in milliseconds. */ + readonly flushMs: number; + /** + * Soft byte budget per `output` frame. Accumulated chunks flush once the + * budget is met; one chunk larger than the budget ships whole (the + * registry's retention cap is the hard bound on any single chunk). + */ + readonly maxFrameBytes: number; +} + +function isTerminal(status: JobStatus): boolean { + return status !== "running" && status !== "stopping"; +} + +/** + * Stream one job's retained output from an absolute offset: one `opened` + * anchor, coalesced `output` frames as the ring advances, then one terminal + * `status` after the settled job is drained, after which the generation + * closes normally. A removal announced mid-generation (the owner's teardown) + * closes it with the removed job's terminal projection instead of a failed + * read. Reads are non-consuming — the model-facing cursor and + * notice state never observe them; reconnecting callers resume by passing + * the last frame's `next` as `from`. The request's session is the fenced + * read's caller: the registry rejects a job the session cannot see and an + * unknown job. + * @param registry - the live job registry. + * @param request - target job, owning session, and optional resume offset. + * @param options - cadence and framing bounds. + * @param signal - generation cancellation owned by the Remote stream carrier. + * @returns the observation frame sequence for one generation. + */ +export async function* observeJobOutput( + registry: JobRegistry, + request: JobFollowRequest, + options: ObserveJobOptions, + signal: AbortSignal, +): AsyncIterable { + if (request.from !== undefined && (!Number.isSafeInteger(request.from) || request.from < 0)) { + throw new Error( + `invalid observe offset: expected a non-negative safe integer, got ${JSON.stringify(request.from)}`, + ); + } + signal.throwIfAborted(); + // The brand is nominal typing only; the wire boundary stamps it here rather + // than value-importing the registry package's constructor. + const id = String(request.jobId) as JobId; + const waiter = new OutputWaiter(); + // A removal announced during the generation: the owner's teardown settled + // and dropped the record, so the projection it announced is the last word + // and the read after the flush window would only find an unknown id. + let removed: JobView | undefined; + // Subscribe before the first read so an append between the anchor read and + // the wait cannot be missed. + const unsubscribe = registry.events.subscribe({ owners: "all" }, (event) => { + const changed = event.type === "output" ? event.id : event.job.id; + if (changed !== id) return; + if (event.type === "removed") removed = event.job; + waiter.wake(); + }); + try { + let job = registry.get(id, request.sessionId); + let cursor = request.from ?? job.output.earliest; + yield { type: "opened", job, from: cursor }; + while (!signal.aborted) { + if (removed !== undefined) { + yield { type: "status", job: removed }; + return; + } + const read = registry.readAt(id, cursor, request.sessionId); + if (read.chunks.length > 0 || read.lossy) { + yield* outputFrames(read.chunks, read.next, read.lossy, options.maxFrameBytes); + } + cursor = read.next; + job = registry.get(id, request.sessionId); + if (isTerminal(job.status) && cursor >= job.output.total) { + yield { type: "status", job }; + return; + } + await waiter.wait(signal); + // Let a burst accumulate so producer chatter becomes bounded frames. + await sleep(options.flushMs, signal); + } + } finally { + unsubscribe(); + } +} + +/** Split one read into frames along the soft per-frame byte budget. */ +function* outputFrames( + chunks: readonly JobChunk[], + next: number, + lossy: boolean, + maxFrameBytes: number, +): Iterable { + let batch: JobChunk[] = []; + let batchBytes = 0; + let flaggedLossy = lossy; + for (const chunk of chunks) { + batch.push(chunk); + batchBytes += Buffer.byteLength(chunk.text, "utf8"); + if (batchBytes >= maxFrameBytes) { + const last = batch[batch.length - 1]; + /* v8 ignore start -- a non-empty batch always has a last chunk; the arm only discharges noUncheckedIndexedAccess. */ + const end = last === undefined ? next : last.at + Buffer.byteLength(last.text, "utf8"); + /* v8 ignore stop */ + yield { + type: "output", + chunks: batch, + next: end, + ...(flaggedLossy ? { lossy: true as const } : {}), + }; + flaggedLossy = false; + batch = []; + batchBytes = 0; + } + } + if (batch.length > 0 || flaggedLossy) { + yield { + type: "output", + chunks: batch, + next, + ...(flaggedLossy ? { lossy: true as const } : {}), + }; + } +} diff --git a/apps/web-ui/packages/api/job-controller/src/rows.ts b/apps/web-ui/packages/api/job-controller/src/rows.ts new file mode 100644 index 000000000..f42bdd7c1 --- /dev/null +++ b/apps/web-ui/packages/api/job-controller/src/rows.ts @@ -0,0 +1,53 @@ +/** Per-session roster generations: the caller-visible job set, replaced whole after every lifecycle change. */ + +import type { JobRegistry } from "@deepseek-ai/dsh-jobs"; +import type { JobListFrame, JobListRequest } from "./types.ts"; +import { OutputWaiter, sleep } from "./wake.ts"; + +/** Cadence bound for one roster generation. */ +export interface RowsOptions { + /** Coalescing window after a lifecycle event before the next rows read, in milliseconds. */ + readonly flushMs: number; +} + +/** + * Stream the jobs one session can see: one frame on open, then one after + * every lifecycle commit that touches a visible job (registration, progress, + * stopping, settlement, removal), coalesced over `flushMs`. Output appends + * never refresh the roster — a settled projection already carries the final + * byte count — so the stream is quiet while a job merely writes. Reads are + * projections; the model's cursor and notice state never observe them. + * @param registry - the live job registry. + * @param request - the session whose visible set to mirror. + * @param options - the coalescing window. + * @param signal - generation cancellation owned by the Remote stream carrier. + * @returns the roster frame sequence for one generation. + */ +export async function* streamJobRows( + registry: JobRegistry, + request: JobListRequest, + options: RowsOptions, + signal: AbortSignal, +): AsyncIterable { + signal.throwIfAborted(); + const waiter = new OutputWaiter(); + // Subscribe before the first read so a commit between the read and the + // wait cannot be missed. + const unsubscribe = registry.events.subscribe({ owners: "all" }, (event) => { + if (event.type === "output") return; + const owner = event.job.owner; + if (owner === undefined || owner === request.sessionId) waiter.wake(); + }); + try { + yield { type: "rows", jobs: registry.list(request.sessionId) }; + while (true) { + await waiter.wait(signal); + // Let a burst of commits settle into one frame. + await sleep(options.flushMs, signal); + if (signal.aborted) return; + yield { type: "rows", jobs: registry.list(request.sessionId) }; + } + } finally { + unsubscribe(); + } +} diff --git a/apps/web-ui/packages/api/job-controller/src/types.ts b/apps/web-ui/packages/api/job-controller/src/types.ts new file mode 100644 index 000000000..dd06b3e60 --- /dev/null +++ b/apps/web-ui/packages/api/job-controller/src/types.ts @@ -0,0 +1,90 @@ +/** + * Wire types of the generated `job` Remote namespace: the per-session roster + * stream, the per-job observation stream, and the human kill. Client-safe: no + * Host imports. + * @module @deepseek-ai/dsh-api-job-controller/types + */ + +import type { JobId } from "@deepseek-ai/dsh-jobs/brand"; +import type { JobChunk, JobView } from "@deepseek-ai/dsh-jobs/view"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; + +export type { JobChunk, JobView } from "@deepseek-ai/dsh-jobs/view"; + +/** Target of one `job.list` stream: the session whose visible jobs the stream mirrors. */ +export interface JobListRequest { + readonly sessionId: SessionId; +} + +/** + * One `job.list` frame: the complete set the session can see — its own jobs + * plus every unowned job — after a lifecycle change. Whole-set replacement, + * so a reconnect's first frame is already the truth. + */ +export interface JobListFrame { + readonly type: "rows"; + readonly jobs: readonly JobView[]; +} + +/** Human-initiated cancellation of one background job visible to a session. */ +export interface JobKillRequest { + /** Session whose job list carries the job; the fenced lookup reads as it. */ + readonly sessionId: SessionId; + readonly jobId: JobId; +} + +/** Receipt after the registry accepted the human kill request. */ +export interface JobKillValue { + readonly outcome: "requested" | "already-finished"; +} + +declare module "@deepseek-ai/dsh-typert-protocol" { + interface RemoteErrorDetailsMap { + /** The session's job list no longer carries a killable row under that id. */ + "job/not-found": { readonly sessionId: SessionId; readonly jobId: JobId }; + } +} + +/** Target of one `job.follow` stream: the job, its owning session, and an optional resume offset. */ +export interface JobFollowRequest { + /** + * Owning session for the fenced read. Omitted for an unowned job, which any + * caller may observe. + */ + readonly sessionId?: SessionId; + readonly jobId: JobId; + /** + * Absolute byte offset to resume from (a prior frame's `next`). Omitted, the + * stream starts at the oldest retained byte. + */ + readonly from?: number; +} + +/** + * Job observation stream frames: one `opened` anchor carrying the job's + * projection (its `output.earliest` and `output.total` at open time) and the + * offset the first `output` frame continues from, then coalesced `output` + * batches, then — once the job has settled and its ring is drained — one + * terminal `status` carrying the settled projection, after which the stream + * closes normally. Status rides the same stream as output so settlement can + * never race a still-open output channel. + */ +export type JobFollowFrame = + | { + readonly type: "opened"; + readonly job: JobView; + /** Offset the first `output` frame continues from. */ + readonly from: number; + } + | { + readonly type: "output"; + readonly chunks: readonly JobChunk[]; + /** Offset to resume from after this frame. */ + readonly next: number; + /** Bytes between the requested offset and `chunks` were already evicted. */ + readonly lossy?: true; + } + | { + readonly type: "status"; + readonly job: JobView; + }; diff --git a/apps/web-ui/packages/api/job-controller/src/wake.ts b/apps/web-ui/packages/api/job-controller/src/wake.ts new file mode 100644 index 000000000..16edbdce0 --- /dev/null +++ b/apps/web-ui/packages/api/job-controller/src/wake.ts @@ -0,0 +1,60 @@ +/** + * Wake bookkeeping shared by the two observation generators: a wake-flag + * waiter that never loses a wake between waits, and an abortable sleep that + * coalesces bursts into bounded frames. + * @module @deepseek-ai/dsh-api-job-controller/wake + */ + +/** Wake-flag waiter: a wake between waits is never lost. */ +export class OutputWaiter { + private dirty = false; + private resolve: (() => void) | undefined; + + /** Record one wake; releases a pending wait or arms the next one. */ + wake(): void { + this.dirty = true; + this.resolve?.(); + } + + /** + * Resolve on the next wake, immediately when one already arrived, or on abort. + * @param signal - generation cancellation. + * @returns settles when woken or aborted. + */ + wait(signal: AbortSignal): Promise { + if (this.dirty || signal.aborted) { + this.dirty = false; + return Promise.resolve(); + } + return new Promise((resolve) => { + const finish = (): void => { + signal.removeEventListener("abort", finish); + /* v8 ignore next -- one wait owns the sole installed resolver. */ + if (this.resolve === finish) this.resolve = undefined; + this.dirty = false; + resolve(); + }; + this.resolve = finish; + signal.addEventListener("abort", finish, { once: true }); + }); + } +} + +/** + * Sleep for the coalescing window, or return at once when aborted. + * @param ms - window in milliseconds. + * @param signal - generation cancellation. + * @returns settles after the window or on abort. + */ +export function sleep(ms: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve(); + return new Promise((resolve) => { + const timer = setTimeout(done, ms); + function done(): void { + clearTimeout(timer); + signal.removeEventListener("abort", done); + resolve(); + } + signal.addEventListener("abort", done, { once: true }); + }); +} diff --git a/apps/web-ui/packages/api/job-controller/tsdown.config.ts b/apps/web-ui/packages/api/job-controller/tsdown.config.ts new file mode 100644 index 000000000..e9e63c85e --- /dev/null +++ b/apps/web-ui/packages/api/job-controller/tsdown.config.ts @@ -0,0 +1,5 @@ +import { clientBundle } from "../../client/tsdown.client.ts"; + +export default clientBundle("@deepseek-ai/dsh-api-job-controller", ["lib/types/index.js"], { + hostPhase: true, +}); diff --git a/apps/web-ui/packages/api/remotes/package.json b/apps/web-ui/packages/api/remotes/package.json new file mode 100644 index 000000000..bc63ed7fb --- /dev/null +++ b/apps/web-ui/packages/api/remotes/package.json @@ -0,0 +1,59 @@ +{ + "name": "@deepseek-ai/dsh-api-remotes", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./client": { + "types": "./src/client/index.ts", + "default": "./lib/client.js" + }, + "./types": { + "types": "./src/types.ts", + "default": "./src/types.ts" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dependencies": { + "@deepseek-ai/dsh-client-product-analytics": "*", + "@deepseek-ai/dsh-agent-preset-registry": "*", + "@deepseek-ai/dsh-user-questions": "*", + "@deepseek-ai/dsh-commands": "*", + "@deepseek-ai/dsh-api-account-controller": "*", + "@deepseek-ai/dsh-api-settings-controller": "*", + "@deepseek-ai/dsh-office-to-pdf": "*", + "@deepseek-ai/dsh-goal": "*", + "@deepseek-ai/dsh-schedule": "*", + "@deepseek-ai/dsh-llm": "*", + "@deepseek-ai/dsh-cordis-host-runner": "*", + "@deepseek-ai/dsh-plugin-manager": "*", + "@deepseek-ai/dsh-client-ui-plugin-manager": "*", + "@deepseek-ai/dsh-host-plugin-inventory": "*", + "@deepseek-ai/dsh-message-feedback": "*", + "@deepseek-ai/dsh-permission-presets": "*", + "@deepseek-ai/dsh-command-feedback": "*", + "@deepseek-ai/dsh-client-file-upload": "*", + "@deepseek-ai/dsh-session-reference": "*", + "@deepseek-ai/dsh-subagent": "*", + "@deepseek-ai/dsh-api-session-controller": "*", + "@deepseek-ai/dsh-api-job-controller": "*", + "@deepseek-ai/dsh-api-workspace-controller": "*", + "@deepseek-ai/dsh-api-terminal-controller": "*", + "@deepseek-ai/dsh-api-workspace-files": "*" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-gateway" + ], + "platform": "web", + "immediately": true + } + } +} diff --git a/apps/web-ui/packages/api/remotes/src/client/index.ts b/apps/web-ui/packages/api/remotes/src/client/index.ts new file mode 100644 index 000000000..43c408bed --- /dev/null +++ b/apps/web-ui/packages/api/remotes/src/client/index.ts @@ -0,0 +1,251 @@ +/** Platform-neutral assembly of generated Host Remote contributions. */ + +import type { Context } from "@deepseek-ai/cordis"; +import productAnalyticsRemote from "@deepseek-ai/dsh-client-product-analytics/remote"; +export type {} from "@deepseek-ai/dsh-client-product-analytics/remote"; +import agentPresetsRemote from "@deepseek-ai/dsh-agent-preset-registry/remote"; +import userQuestionsRemote from "@deepseek-ai/dsh-user-questions/remote"; +import commandsRemote from "@deepseek-ai/dsh-commands/remote"; +import accountRemote from "@deepseek-ai/dsh-api-account-controller/remote"; +import settingsControllerRemote from "@deepseek-ai/dsh-api-settings-controller/remote"; +import officeToPdfRemote from "@deepseek-ai/dsh-office-to-pdf/remote"; +import goalsRemote from "@deepseek-ai/dsh-goal/remote"; +import scheduleRemote from "@deepseek-ai/dsh-schedule/remote"; +import llmRemote from "@deepseek-ai/dsh-llm/remote"; +import dynamicRemote from "@deepseek-ai/dsh-cordis-host-runner/remote"; +import pluginManagerRemote from "@deepseek-ai/dsh-plugin-manager/remote"; +import pluginRegistryProbeRemote from "@deepseek-ai/dsh-client-ui-plugin-manager/remote"; +import pluginInventoryRemote from "@deepseek-ai/dsh-host-plugin-inventory/remote"; +import messageFeedbackRemote from "@deepseek-ai/dsh-message-feedback/remote"; +import permissionPresetsRemote from "@deepseek-ai/dsh-permission-presets/remote"; +import sessionFeedbackRemote from "@deepseek-ai/dsh-command-feedback/remote"; +import fileUploadsRemote from "@deepseek-ai/dsh-client-file-upload/remote"; +import sessionReferencesRemote from "@deepseek-ai/dsh-session-reference/remote"; +import subagentsRemote from "@deepseek-ai/dsh-subagent/remote"; +import sessionRemote from "@deepseek-ai/dsh-api-session-controller/remote"; +import jobRemote from "@deepseek-ai/dsh-api-job-controller/remote"; +import workspaceRemote from "@deepseek-ai/dsh-api-workspace-controller/remote"; +import terminalRemote from "@deepseek-ai/dsh-api-terminal-controller/remote"; +import workspaceFilesRemote from "@deepseek-ai/dsh-api-workspace-files/remote"; +import type { ClientRemote } from "@deepseek-ai/dsh-api-gateway/client"; + +export type { ClientRemote } from "@deepseek-ai/dsh-api-gateway/client"; +export type { + BundleInfo, + BundleRowInfo, + ChangeResult, + IncompatiblePlugin, + InspectOptions, + InstallBundleOptions, + InstallSpecKind, + ManagementError, + PackageResult, + PluginChange, + PluginEntryId, + PluginInfo, + PluginInspectProblem, + PluginInstallCancellation, + PluginInstallFailureKind, + PluginInstallLogChunk, + PluginInstallProgress, + PluginInstallRequestId, + PluginRegistries, + PluginSpecInspection, + ReadOnlyReason, + Registry, +} from "@deepseek-ai/dsh-plugin-manager/types"; +export type {} from "@deepseek-ai/dsh-plugin-manager/remote"; +export type {} from "@deepseek-ai/dsh-client-ui-plugin-manager/remote"; +export type { PluginInventorySnapshot } from "@deepseek-ai/dsh-host-plugin-inventory/types"; +export type {} from "@deepseek-ai/dsh-agent-preset-registry/remote"; +export type {} from "@deepseek-ai/dsh-user-questions/remote"; +export type {} from "@deepseek-ai/dsh-commands/remote"; +export type {} from "@deepseek-ai/dsh-api-settings-controller/remote"; +export type {} from "@deepseek-ai/dsh-api-account-controller/remote"; +export type {} from "@deepseek-ai/dsh-goal/remote"; +export type {} from "@deepseek-ai/dsh-schedule/remote"; +export type {} from "@deepseek-ai/dsh-office-to-pdf/remote"; +export type {} from "@deepseek-ai/dsh-llm/remote"; +export type {} from "@deepseek-ai/dsh-host-plugin-inventory/remote"; +export type {} from "@deepseek-ai/dsh-message-feedback/remote"; +export type {} from "@deepseek-ai/dsh-permission-presets/remote"; +export type {} from "@deepseek-ai/dsh-command-feedback/remote"; +export type {} from "@deepseek-ai/dsh-client-file-upload/remote"; +export type {} from "@deepseek-ai/dsh-session-reference/remote"; +export type {} from "@deepseek-ai/dsh-subagent/remote"; +export type * from "@deepseek-ai/dsh-subagent/client"; +export type {} from "@deepseek-ai/dsh-api-session-controller/remote"; +export type * from "@deepseek-ai/dsh-api-session-controller/types"; +export type {} from "@deepseek-ai/dsh-api-job-controller/remote"; +export type * from "@deepseek-ai/dsh-api-job-controller/types"; +export type {} from "@deepseek-ai/dsh-api-workspace-controller/remote"; +export type * from "@deepseek-ai/dsh-api-workspace-controller/types"; +export type {} from "@deepseek-ai/dsh-api-workspace-files/remote"; +export type * from "@deepseek-ai/dsh-api-workspace-files/types"; +export type {} from "@deepseek-ai/dsh-api-terminal-controller/remote"; +export type * from "@deepseek-ai/dsh-api-terminal-controller/types"; +// The forwarded-event allowlist's selection seat: without it in the consumer's +// compilation face `TypertRemoteEvent` is `never` and every `$on` call fails. +export type { ApiRemoteForwardedEvent } from "../types.ts"; +// The owner packages' client-safe `./types` exports supply the `Events` +// signatures `$on` hands to a listener, so a consumer reads the very +// declaration the Host emits rather than a flattened restatement of it. +export type {} from "@deepseek-ai/dsh-commands/types"; +export type {} from "@deepseek-ai/dsh-cordis-host-runner/types"; +export type {} from "@deepseek-ai/dsh-credentials/types"; +export type {} from "@deepseek-ai/dsh-llm/types"; +export type {} from "@deepseek-ai/dsh-agent-preset-registry/types"; +export type {} from "@deepseek-ai/dsh-permission-presets/types"; +export type {} from "@deepseek-ai/dsh-settings/types"; +export type {} from "@deepseek-ai/dsh-user-approval/types"; +export type {} from "@deepseek-ai/dsh-user-questions/types"; +export type {} from "@deepseek-ai/dsh-api-session-controller/types"; + +/** + * The carrier's Client-facing types, re-exported so a business package names one + * assembly package instead of both this facade and the Connection plugin. Type-only: + * the carrier's runtime values stay behind their own module edge. + */ +export type { + ConnectionHandle, + ConnectionSinks, + ContentBlock, + MessageId, + RpcId, + RpcRequest, + RpcResponse, + RpcResult, + SessionId, + StreamChunk, +} from "@deepseek-ai/dsh-client-connection/client"; +export type {} from "@deepseek-ai/dsh-api-gateway/client"; +export type {} from "@deepseek-ai/dsh-cordis-host-runner/remote"; + +// The payload vocabulary of the selected namespaces, re-exported so a Client +// contribution can name what it sends and receives without importing a Host +// package: this assembly is the one place both planes legitimately meet. +export type { + ApprovalRequestId, + CordisHalfState, + CordisDynamicPackageId, + CordisDynamicPluginId, + CordisDynamicPluginRunId, + CordisDynamicRunMode, + CordisInspectMethodManifest, + CordisInspectPlatform, + CordisInspectProviderManifest, + CordisInspectProviderView, + CordisInspectQueryRequest, + CordisInspectQueryResolution, + CordisInspectQueryResolved, + CordisInspectRequestId, + CordisInspectResolveAck, + CordisRunDiagnostic, + CordisRunStatus, + DynamicCordisClientSource, + DynamicCordisHostHalfResult, + DynamicCordisInventoryRow, + DynamicCordisInvokeResult, + DynamicCordisPackage, + DynamicCordisRequestResolved, + DynamicCordisResolveAck, + DynamicCordisRetracted, + DynamicCordisRunRequest, + DynamicCordisRunResolution, + DynamicCordisRunAttempt, + DynamicCordisRunResponse, + DynamicCordisStopResponse, + DynamicCordisUndefineReceipt, + RequestRunOutcome, +} from "@deepseek-ai/dsh-cordis-host-runner/types"; +// Credential state vocabulary for the credentials namespace (values never ride it). +export type { CredentialInfo } from "@deepseek-ai/dsh-credentials/types"; +// Redacted namespace vocabulary for the settings namespace (secrets never ride +// it). It travels with its seam, whose `./types` the Client face already reads. +export type { + SettingsDescribeValue, + SettingsNamespaceView, + SettingsPathOpView, + SettingsSecretView, +} from "@deepseek-ai/dsh-settings/types"; +// Provider registry and discovery vocabulary for the llm namespace. +export type { + LlmConfigurableProvider, + LlmDiscoveredModel, + LlmModelDiscoveryRequest, + LlmProviderInfo, +} from "@deepseek-ai/dsh-llm/types"; +// Reference-discovery result vocabulary for the fileReferences and +// sessionReferenceResolver namespaces. +export type { FileReferenceCandidate } from "@deepseek-ai/dsh-file-reference/types"; +export type { SessionReferenceMentionCandidate } from "@deepseek-ai/dsh-session-reference/types"; + +// The Remote failure vocabulary, re-exported so business packages keep naming +// this assembly alone. Types only: a value export would make spec imports load +// this module's owner /remote artifacts; specs take RemoteError from +// dsh-client-test-runtime instead. +export type { + RemoteErrorCode, + RemoteErrorDetailsMap, + RemoteFailure, + RemoteResult, +} from "@deepseek-ai/dsh-typert-protocol"; +export type { RemoteHostFacts } from "@deepseek-ai/dsh-api-gateway/client"; + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Generated Remote namespaces selected by this Client assembly. */ + remote: ClientRemote; + } +} + +/** Required service: the typed Client Remote contribution mount. */ +export const inject = ["remote"]; + +/** + * Mount the Host capabilities explicitly selected for this Client assembly. + * @param ctx - Client Cordis root carrying the typed API service. + * @returns disposer after every selected Remote namespace is ready. + */ +export async function apply(ctx: Context): Promise<() => Promise> { + const disposers: Array<() => Promise> = []; + try { + for (const contribution of [ + productAnalyticsRemote, + agentPresetsRemote, + commandsRemote, + settingsControllerRemote, + accountRemote, + goalsRemote, + llmRemote, + dynamicRemote, + scheduleRemote, + pluginInventoryRemote, + pluginManagerRemote, + pluginRegistryProbeRemote, + messageFeedbackRemote, + sessionFeedbackRemote, + fileUploadsRemote, + sessionReferencesRemote, + permissionPresetsRemote, + subagentsRemote, + sessionRemote, + jobRemote, + workspaceRemote, + workspaceFilesRemote, + terminalRemote, + officeToPdfRemote, + userQuestionsRemote, + ]) { + disposers.push(await ctx.remote.$mount(contribution)); + } + } catch (error) { + for (const dispose of disposers.reverse()) await dispose(); + throw error; + } + // Unwound in reverse mount order, so a namespace never outlives one mounted + // after it. + return async () => { + for (const dispose of disposers.reverse()) await dispose(); + }; +} diff --git a/apps/web-ui/packages/api/remotes/src/index.ts b/apps/web-ui/packages/api/remotes/src/index.ts new file mode 100644 index 000000000..3782d9de5 --- /dev/null +++ b/apps/web-ui/packages/api/remotes/src/index.ts @@ -0,0 +1,182 @@ +/** Host BFF entry and Loader shell for the Remote contribution assembly. */ + +import { homedir } from "node:os"; +import type { Context } from "@deepseek-ai/cordis"; +import type { Agent } from "@deepseek-ai/dsh-agent"; +import type { + TypertRemoteEventDispatch, + TypertRemoteEventInvocation, + TypertRemoteEventOutcome, + TypertRemoteEventSource, +} from "@deepseek-ai/dsh-api-gateway"; +import { Deque } from "@deepseek-ai/dsh-deque"; +import { carrierKeyOf } from "@deepseek-ai/dsh-scope"; +import { isJsonValue, type JsonValue } from "@deepseek-ai/dsh-util-values"; +import { API_REMOTE_FORWARDED_EVENTS } from "./remote-events.ts"; + +// The owner packages' client-safe `./types` exports carry the cordis `Events` +// declarations for every allowlisted event. Pulling them into this face is what +// makes the shape assertion below judge real signatures rather than an empty +// event vocabulary. +import type {} from "@deepseek-ai/dsh-commands/types"; +import type {} from "@deepseek-ai/dsh-cordis-host-runner/types"; +import type {} from "@deepseek-ai/dsh-credentials/types"; +import type {} from "@deepseek-ai/dsh-goal/types"; +import type {} from "@deepseek-ai/dsh-llm/types"; +import type {} from "@deepseek-ai/dsh-agent-preset-registry/types"; +import type {} from "@deepseek-ai/dsh-permission-presets/types"; +import type {} from "@deepseek-ai/dsh-settings/types"; +import type {} from "@deepseek-ai/dsh-user-approval"; +import type {} from "@deepseek-ai/dsh-user-questions"; +export type {} from "@deepseek-ai/dsh-api-session-controller/types"; + +export { API_REMOTE_FORWARDED_EVENTS } from "./remote-events.ts"; +export type { ApiRemoteForwardedEvent } from "./types.ts"; + +/** Required Host service: the Gateway owns the physical Remote stream mux. */ +export const inject = ["typertGateway"]; + +/** Host plugin body registering this application's selected Cordis event source. */ +export function apply(ctx: Context): void { + ctx.effect( + () => ctx.typertGateway.registerRemoteEvents(remoteEventSource(ctx), { home: homedir() }), + "api-remotes: forwarded Cordis event source", + ); +} + +/** Create the sole queue and listener set consumed by the registered Gateway. */ +function remoteEventSource(ctx: Context): TypertRemoteEventSource { + return (signal) => { + const queue = new RemoteEventQueue(); + const disposers = API_REMOTE_FORWARDED_EVENTS.map(({ event, mode }) => { + if (mode === "emit") { + return ctx.on( + event as never, + ((...args: unknown[]) => { + queue.push({ event, args: assertJsonArgs(event, args) }); + }) as never, + ); + } + return ctx.on( + event as never, + function (this: unknown, request: object, next: () => unknown) { + const carrierAgent = carrierKeyOf(this); + if (carrierAgent === undefined) return next(); + const agent = (request as { readonly agent?: Agent }).agent; + if (agent === undefined || agent !== carrierAgent) { + throw new TypeError( + `forwarded scoped event ${JSON.stringify(event)} must carry its Agent directly`, + ); + } + return forwardWaterfall( + queue, + event, + request, + { value: agent.ctx, subject: agent, agentId: agent.id }, + next, + ); + } as never, + ); + }); + return queue.iterate(signal, () => { + for (const dispose of disposers) dispose(); + }); + }; +} + +/** One pull-driven queue bridging synchronous Cordis listeners to an AsyncIterable. */ +class RemoteEventQueue { + private readonly buffer = new Deque(); + private waiter: (() => void) | undefined; + private done = false; + + push(frame: TypertRemoteEventDispatch): boolean { + if (this.done) return false; + this.buffer.pushBack(frame); + this.waiter?.(); + return true; + } + + private end(reason: unknown): void { + if (this.done) return; + this.done = true; + while (this.buffer.size > 0) { + const dispatch = this.buffer.popFront() as TypertRemoteEventDispatch; + if ("context" in dispatch) dispatch.reject(reason); + } + this.waiter?.(); + } + + async *iterate( + signal: AbortSignal, + cleanup: () => void, + ): AsyncGenerator { + const abort = (): void => { + this.end(remoteEventSourceEndReason(signal)); + }; + signal.addEventListener("abort", abort, { once: true }); + try { + while (true) { + if (this.done || signal.aborted) return; + while (this.buffer.size > 0) yield this.buffer.popFront() as TypertRemoteEventDispatch; + await new Promise((resolve) => { + this.waiter = resolve; + }); + this.waiter = undefined; + } + } finally { + signal.removeEventListener("abort", abort); + this.end(remoteEventSourceEndReason(signal)); + cleanup(); + } + } +} + +/** + * Normalize an event-source shutdown for pending Host waterfalls. + * @param signal - source lifetime whose reason wins after cancellation. + * @returns the cancellation reason or an unexpected-end failure. + */ +function remoteEventSourceEndReason(signal: AbortSignal): unknown { + if (signal.aborted) return signal.reason; + return new Error("api-remotes: forwarded Remote event source ended"); +} + +/** Bridge one Cordis waterfall listener through the Gateway-owned pending event. */ +function forwardWaterfall( + queue: RemoteEventQueue, + event: string, + request: object, + context: TypertRemoteEventInvocation["context"], + next: () => unknown, +): Promise { + const settled = Promise.withResolvers(); + const dispatch: TypertRemoteEventInvocation = { + event, + request, + context, + resolve: (outcome: TypertRemoteEventOutcome) => { + if (outcome.kind === "result") { + settled.resolve(outcome.value); + return; + } + void Promise.resolve().then(next).then(settled.resolve, settled.reject); + }, + reject: settled.reject, + }; + if (!queue.push(dispatch)) + void Promise.resolve().then(next).then(settled.resolve, settled.reject); + return settled.promise; +} + +/** Reject an allowlisted event whose runtime arguments are not lossless JSON data. */ +function assertJsonArgs(event: string, args: readonly unknown[]): JsonValue[] { + for (const [index, arg] of args.entries()) { + if (!isJsonValue(arg)) { + throw new Error( + `forwarded host event "${event}" argument ${String(index)} is not lossless JSON data`, + ); + } + } + return args as JsonValue[]; +} diff --git a/apps/web-ui/packages/api/remotes/src/remote-events.ts b/apps/web-ui/packages/api/remotes/src/remote-events.ts new file mode 100644 index 000000000..d5e1c5c1c --- /dev/null +++ b/apps/web-ui/packages/api/remotes/src/remote-events.ts @@ -0,0 +1,62 @@ +/** + * The one home of this application's forwarded-Host-event allowlist. Both + * compiler faces list this file, so the Host forwarding loop and the consumer + * `ctx.remote.$on` key face read one declaration instead of two copies that + * could drift; `./types.ts` derives the type projection from it and stays + * type-only. + */ + +import type {} from "@deepseek-ai/dsh-api-session-controller/remote-events"; +import type {} from "@deepseek-ai/dsh-deepseek-account/types"; +import type {} from "@deepseek-ai/dsh-permission-presets/types"; +import type {} from "@deepseek-ai/dsh-plugin-manager/types"; +import type {} from "@deepseek-ai/dsh-schedule/client"; +import type { TypertForwardableEventEntry } from "@deepseek-ai/dsh-typert-protocol"; + +/** + * Host events this application forwards without renaming. The explicit mode is + * both the Host dispatch strategy and the legal key set of `ctx.remote.$on`. + */ +declare module "@deepseek-ai/cordis" { + interface Events { + /** Replace a browser-only draft with the canonical Thread returned by the existing CH Host. */ + "codexhost/session-bound"(draftId: string, threadId: string): void; + "codexhost/preferences-changed"(): void; + "codexhost/display-settings-changed"(): void; + "codexhost/preferences-error"(message: string): void; + } +} + +export const API_REMOTE_FORWARDED_EVENTS = [ + { event: "codexhost/session-bound", mode: "emit" }, + { event: "codexhost/preferences-changed", mode: "emit" }, + { event: "codexhost/display-settings-changed", mode: "emit" }, + { event: "codexhost/preferences-error", mode: "emit" }, + { event: "agent-preset/selected", mode: "emit" }, + { event: "approval/request", mode: "waterfall" }, + { event: "api-session/activity", mode: "emit" }, + { event: "api-session/added", mode: "emit" }, + { event: "api-session/error", mode: "emit" }, + { event: "api-session/removed", mode: "emit" }, + { event: "api-session/status", mode: "emit" }, + { event: "commands/change", mode: "emit" }, + { event: "deepseek-account/session-expired", mode: "emit" }, + { event: "deepseek-account/model-sign-in-required", 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" }, + { event: "cordis/request-run-resolved", mode: "emit" }, + { event: "cordis/dynamic-package", mode: "emit" }, + { event: "cordis/dynamic-retract", mode: "emit" }, + { event: "cordis/inspect-query", mode: "emit" }, + { event: "cordis/inspect-query-resolved", mode: "emit" }, + { event: "llm/adapters-updated", mode: "emit" }, + { event: "permission-presets/catalog-changed", mode: "emit" }, + { event: "plugin-manager/changed", mode: "emit" }, + { event: "plugin-manager/install-log", mode: "emit" }, + { event: "plugin-manager/install-state", mode: "emit" }, + { event: "settings/document-updated", mode: "emit" }, + { event: "schedule/changed", mode: "emit" }, + { event: "user-questions/request", mode: "waterfall" }, +] as const satisfies readonly TypertForwardableEventEntry[]; diff --git a/apps/web-ui/packages/api/remotes/src/types.ts b/apps/web-ui/packages/api/remotes/src/types.ts new file mode 100644 index 000000000..109dd5808 --- /dev/null +++ b/apps/web-ui/packages/api/remotes/src/types.ts @@ -0,0 +1,19 @@ +/** + * Type face of the forwarded-Host-event allowlist: the consumer key projection + * and the selection seat it fills. The allowlist VALUE lives in + * `./remote-events.ts`, keeping this module type-only per the package + * convention; both compiler faces list both files, so the Host forwarding loop + * and the consumer `ctx.remote.$on` key face read one declaration instead of + * two copies that could drift. + * + * @module @deepseek-ai/dsh-api-remotes/types + */ + +import type { API_REMOTE_FORWARDED_EVENTS } from "./remote-events.ts"; + +/** Type projection of the allowlist; the consumer and the Host read this one. */ +export type ApiRemoteForwardedEvent = (typeof API_REMOTE_FORWARDED_EVENTS)[number]["event"]; + +declare module "@deepseek-ai/dsh-typert-protocol" { + interface TypertRemoteEventSelection extends Record {} +} diff --git a/apps/web-ui/packages/api/remotes/tsdown.config.ts b/apps/web-ui/packages/api/remotes/tsdown.config.ts new file mode 100644 index 000000000..31dc98964 --- /dev/null +++ b/apps/web-ui/packages/api/remotes/tsdown.config.ts @@ -0,0 +1,5 @@ +import { clientBundle } from "../../client/tsdown.client.ts"; + +export default clientBundle("@deepseek-ai/dsh-api-remotes", ["lib/types/index.js"], { + hostPhase: true, +}); diff --git a/apps/web-ui/packages/api/session-controller/generated/remote.d.ts b/apps/web-ui/packages/api/session-controller/generated/remote.d.ts new file mode 100644 index 000000000..853e78868 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/generated/remote.d.ts @@ -0,0 +1,74 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import type { + RemoteResult, + RemoteStreamHandle, + TypertRemoteContribution, +} from '@deepseek-ai/dsh-typert-protocol' +import type { ModelCatalog, SessionAttachmentRequest, SessionAttachmentValue, SessionCancelRequest, SessionCancelValue, SessionControlFrame, SessionCreateRequest, SessionCreateValue, SessionFollowFrame, SessionFollowRequest, SessionForkRequest, SessionForkValue, SessionListRequest, SessionListValue, SessionOpenWorkspacePathRequest, SessionOpenWorkspacePathValue, SessionPage, SessionPageRequest, SessionProjectionsRequest, SessionProjectionsValue, SessionPromptRequest, SessionPromptValue, SessionRenameRequest, SessionRenameValue, SessionSearchRequest, SessionSearchValue, SessionSelectModelRequest, SessionSelectModelValue, SessionUpdateQueueRequest, SessionUpdateQueueValue, SessionWorkspacePathApplication, SkillListRequest, SkillListValue } from '@deepseek-ai/dsh-api-session-controller/types' +import type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types' +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertRemoteNamespace$66696c655265666572656e636573 { + list: (agentId: SessionId, query: string, signal?: AbortSignal) => Promise> + } + interface TypertRemoteNamespace$73657373696f6e { + attachment: (request: SessionAttachmentRequest) => Promise> + cancel: (request: SessionCancelRequest) => Promise> + canOpenWorkspacePath: () => Promise> + control: (signal?: AbortSignal) => RemoteStreamHandle + create: (request: SessionCreateRequest) => Promise> + follow: (request: SessionFollowRequest, signal?: AbortSignal) => RemoteStreamHandle + fork: (request: SessionForkRequest) => Promise> + initializeDefaultModel: () => Promise> + list: (_request: SessionListRequest, signal?: AbortSignal) => Promise> + modelCatalog: () => Promise> + openWorkspacePath: (request: SessionOpenWorkspacePathRequest, signal?: AbortSignal) => Promise> + page: (request: SessionPageRequest, signal?: AbortSignal) => Promise> + projections: (request: SessionProjectionsRequest, signal?: AbortSignal) => Promise> + prompt: (request: SessionPromptRequest, signal?: AbortSignal) => Promise> + rename: (request: SessionRenameRequest) => Promise> + search: (request: SessionSearchRequest, signal?: AbortSignal) => Promise> + selectModel: (request: SessionSelectModelRequest) => Promise> + updateQueue: (request: SessionUpdateQueueRequest) => Promise> + workspacePathApplications: (request: { readonly path: string; }, signal?: AbortSignal) => Promise> + } + interface TypertRemoteNamespace$736b696c6c73 { + list: (request: SkillListRequest, signal?: AbortSignal) => Promise> + } + interface TypertRemoteMap { + 'fileReferences/list': (agentId: SessionId, query: string, signal?: AbortSignal) => Promise> + 'session/attachment': (request: SessionAttachmentRequest) => Promise> + 'session/cancel': (request: SessionCancelRequest) => Promise> + 'session/canOpenWorkspacePath': () => Promise> + 'session/control': (signal?: AbortSignal) => RemoteStreamHandle + 'session/create': (request: SessionCreateRequest) => Promise> + 'session/follow': (request: SessionFollowRequest, signal?: AbortSignal) => RemoteStreamHandle + 'session/fork': (request: SessionForkRequest) => Promise> + 'session/initializeDefaultModel': () => Promise> + 'session/list': (_request: SessionListRequest, signal?: AbortSignal) => Promise> + 'session/modelCatalog': () => Promise> + 'session/openWorkspacePath': (request: SessionOpenWorkspacePathRequest, signal?: AbortSignal) => Promise> + 'session/page': (request: SessionPageRequest, signal?: AbortSignal) => Promise> + 'session/projections': (request: SessionProjectionsRequest, signal?: AbortSignal) => Promise> + 'session/prompt': (request: SessionPromptRequest, signal?: AbortSignal) => Promise> + 'session/rename': (request: SessionRenameRequest) => Promise> + 'session/search': (request: SessionSearchRequest, signal?: AbortSignal) => Promise> + 'session/selectModel': (request: SessionSelectModelRequest) => Promise> + 'session/updateQueue': (request: SessionUpdateQueueRequest) => Promise> + 'session/workspacePathApplications': (request: { readonly path: string; }, signal?: AbortSignal) => Promise> + 'skills/list': (request: SkillListRequest, signal?: AbortSignal) => Promise> + } + interface TypertRemoteNamespaceMap { + 'fileReferences': TypertRemoteNamespace$66696c655265666572656e636573 + 'session': TypertRemoteNamespace$73657373696f6e + 'skills': TypertRemoteNamespace$736b696c6c73 + } + interface TypertRemoteScopeMap { + 'agent:fileReferences/list': (query: string, signal?: AbortSignal) => Promise> + } +} + +export declare const TYPERT_REMOTE: TypertRemoteContribution +export default TYPERT_REMOTE +//# sourceMappingURL=typert.remote-client.d.ts.map diff --git a/apps/web-ui/packages/api/session-controller/generated/remote.js b/apps/web-ui/packages/api/session-controller/generated/remote.js new file mode 100644 index 000000000..6dc248b1c --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/generated/remote.js @@ -0,0 +1,1472 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import { z } from 'zod' + +let JsonValueRemoteCodec$schema$value +const JsonValueRemoteCodec$schema = () => (JsonValueRemoteCodec$schema$value ??= z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema()))])) +let JsonValueRemoteCodec$schema2$value +const JsonValueRemoteCodec$schema2 = () => (JsonValueRemoteCodec$schema2$value ??= z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema2())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema2()))])) +let JsonValueRemoteCodec$schema3$value +const JsonValueRemoteCodec$schema3 = () => (JsonValueRemoteCodec$schema3$value ??= z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema3())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema3()))])) +let JsonValueRemoteCodec$schema4$value +const JsonValueRemoteCodec$schema4 = () => (JsonValueRemoteCodec$schema4$value ??= z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema4())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema4()))])) +let JsonValueRemoteCodec$schema5$value +const JsonValueRemoteCodec$schema5 = () => (JsonValueRemoteCodec$schema5$value ??= z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema5())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema5()))])) +let _deepseek_ai_dsh_api_session_controller_fileReferences_list_parameter_0$schema$value +const _deepseek_ai_dsh_api_session_controller_fileReferences_list_parameter_0$schema = () => (_deepseek_ai_dsh_api_session_controller_fileReferences_list_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_session_controller_fileReferences_list_parameter_1$schema$value +const _deepseek_ai_dsh_api_session_controller_fileReferences_list_parameter_1$schema = () => (_deepseek_ai_dsh_api_session_controller_fileReferences_list_parameter_1$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_session_controller_fileReferences_list_result$schema$value +const _deepseek_ai_dsh_api_session_controller_fileReferences_list_result$schema = () => (_deepseek_ai_dsh_api_session_controller_fileReferences_list_result$schema$value ??= z.array(z.object({ + 'path': z.string(), + 'kind': z.union([z.literal("file"), z.literal("directory")]), +}))) +let _deepseek_ai_dsh_api_session_controller_session_attachment_parameter_0$schema$value +const _deepseek_ai_dsh_api_session_controller_session_attachment_parameter_0$schema = () => (_deepseek_ai_dsh_api_session_controller_session_attachment_parameter_0$schema$value ??= z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), + 'attachmentId': z.intersection(z.string(), z.unknown()).readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_attachment_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_attachment_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_attachment_result$schema$value ??= z.object({ + 'attachment': z.object({ + 'attachmentId': z.intersection(z.string(), z.unknown()), + 'mediaType': z.union([z.literal("image/png"), z.literal("image/jpeg"), z.literal("image/webp"), z.literal("image/gif")]), + 'bytes': z.number(), + 'width': z.number(), + 'height': z.number(), + 'name': z.string().optional(), + 'originalDimensions': z.object({ + 'width': z.number(), + 'height': z.number(), +}).optional(), +}).readonly(), + 'data': z.string().readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_cancel_parameter_0$schema$value +const _deepseek_ai_dsh_api_session_controller_session_cancel_parameter_0$schema = () => (_deepseek_ai_dsh_api_session_controller_session_cancel_parameter_0$schema$value ??= z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_cancel_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_cancel_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_cancel_result$schema$value ??= z.object({ + 'accepted': z.literal(true).readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_canOpenWorkspacePath_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_canOpenWorkspacePath_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_canOpenWorkspacePath_result$schema$value ??= z.boolean()) +let _deepseek_ai_dsh_api_session_controller_session_control_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_control_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_control_result$schema$value ??= z.union([z.object({ + 'type': z.literal("baseline").readonly(), + 'value': z.object({ + 'projections': z.record(z.intersection(z.string(), z.unknown()), z.object({ + 'asOfSeq': z.number().readonly(), + 'values': z.intersection(z.object({ + 'inbox': z.object({ + 'next-turn': z.array(z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema5())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema5()))])).readonly(), + 'next-step': z.array(z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema5())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema5()))])).readonly(), +}).optional(), + 'agentPreset': z.union([z.literal(null), z.string()]).optional(), + 'title': z.union([z.literal(null), z.string()]).optional(), + 'todos': z.union([z.literal(null), z.array(z.object({ + 'content': z.string(), + 'status': z.union([z.literal("completed"), z.literal("pending"), z.literal("in_progress")]), +}))]).optional(), + 'sessionListMetadata': z.object({ + 'blank': z.boolean().readonly(), + 'lastPromptAt': z.union([z.literal(null), z.number()]).readonly(), +}).optional(), + 'imageLimits': z.object({ + 'maxImageBytes': z.number(), + 'maxImagesPerMessage': z.number(), + 'maxMessageImageBytes': z.number(), + 'maxImagePixels': z.number(), + 'maxImageDimension': z.number(), + 'mediaTypes': z.array(z.union([z.literal("image/png"), z.literal("image/jpeg"), z.literal("image/webp"), z.literal("image/gif")])), +}).optional(), + 'modelSelection': z.object({ + 'lastUsed': z.union([z.literal(null), z.object({ + 'provider': z.string().readonly(), + 'model': z.string().readonly(), + 'reasoningEffort': z.string().readonly().optional(), +})]).readonly(), + 'next': z.union([z.literal(null), z.object({ + 'provider': z.string().readonly(), + 'model': z.string().readonly(), + 'reasoningEffort': z.string().readonly().optional(), +})]).readonly(), +}).optional(), + 'permissions': z.object({ + 'currentValue': z.string(), +}).optional(), + 'subagentCatalog': z.array(z.union([z.intersection(z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'createdAt': z.number().readonly(), +}), z.object({ + 'mode': z.literal("one-shot").readonly(), + 'label': z.string().readonly().optional(), +})), z.intersection(z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'createdAt': z.number().readonly(), +}), z.object({ + 'mode': z.literal("continuable").readonly(), + 'label': z.string().readonly(), +})), z.intersection(z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'createdAt': z.number().readonly(), +}), z.object({ + 'mode': z.literal("unknown").readonly(), + 'label': z.string().readonly().optional(), +}))])).optional(), + 'subagentTiming': z.object({ + 'settledMs': z.number(), + 'active': z.object({ + 'since': z.number(), + 'through': z.number(), +}).optional(), + 'lastTurnCompleted': z.boolean().optional(), +}).optional(), + 'subagent': z.union([z.literal(null), z.object({ + 'mode': z.literal("one-shot"), + 'label': z.string().optional(), + 'seq': z.intersection(z.number(), z.unknown()), +}), z.object({ + 'mode': z.literal("continuable"), + 'label': z.string(), + 'seq': z.intersection(z.number(), z.unknown()), +})]).optional(), + 'tokenUsage': z.object({ + 'uncachedInputTokens': z.number(), + 'outputTokens': z.number(), + 'cacheReadTokens': z.number(), + 'cacheWriteTokens': z.number(), +}).optional(), + 'contextPressure': z.object({ + 'pressureTokens': z.number().optional(), + 'projectedTokens': z.number().optional(), + 'contextWindow': z.number().optional(), +}).optional(), + 'contextBreakdown': z.object({ + 'systemTokens': z.number(), + 'toolsTokens': z.number(), + 'messageTokens': z.number(), +}).optional(), + 'userQuestions': z.object({ + 'active': z.array(z.object({ + 'callId': z.intersection(z.string(), z.unknown()).readonly(), + 'questions': z.array(z.object({ + 'id': z.string(), + 'question': z.string(), + 'detail': z.string().optional(), + 'header': z.string().optional(), + 'options': z.array(z.object({ + 'label': z.string(), + 'description': z.string().optional(), +})).optional(), + 'multiSelect': z.boolean().optional(), + 'intent': z.object({ + 'kind': z.literal("plan-review"), + 'approve': z.string(), + 'callId': z.intersection(z.string(), z.unknown()).optional(), +}).optional(), +})).readonly(), + 'state': z.union([z.literal("open"), z.literal("continued")]).readonly(), +})).readonly(), + 'settled': z.array(z.object({ + 'callId': z.intersection(z.string(), z.unknown()).readonly(), + 'answers': z.array(z.object({ + 'id': z.string(), + 'selected': z.array(z.string()), + 'custom': z.string().optional(), +})).readonly(), +})).readonly(), +}).optional(), + 'goal': z.union([z.literal(null), z.object({ + 'goal': z.object({ + 'objective': z.string().readonly(), + 'phase': z.union([z.literal("active"), z.literal("paused"), z.literal("blocked"), z.literal("complete")]).readonly(), + 'blockedReason': z.object({ + 'code': z.string().readonly(), + 'message': z.string().readonly(), +}).readonly().optional(), + 'maxGoalRounds': z.number().readonly(), + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'revision': z.number().readonly(), +}).readonly(), + 'roundsStarted': z.number().readonly(), + 'createdAt': z.number().readonly(), + 'updatedAt': z.number().readonly(), +})]).optional(), +}), z.record(z.string(), z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema5())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema5()))])).readonly()).readonly(), +})).readonly().readonly(), +}).readonly(), +}), z.intersection(z.object({ + 'type': z.literal("projection").readonly(), +}), z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), + 'key': z.string().readonly(), + 'value': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema5())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema5()))]).readonly(), + 'seq': z.number().readonly(), +}))])) +let _deepseek_ai_dsh_api_session_controller_session_create_parameter_0$schema$value +const _deepseek_ai_dsh_api_session_controller_session_create_parameter_0$schema = () => (_deepseek_ai_dsh_api_session_controller_session_create_parameter_0$schema$value ??= z.object({ + 'workspaceId': z.intersection(z.string(), z.unknown()).readonly().optional(), + 'cwd': z.string().readonly().optional(), + 'sessionId': z.intersection(z.string(), z.unknown()).readonly().optional(), + 'agentPreset': z.string().readonly().optional(), +})) +let _deepseek_ai_dsh_api_session_controller_session_create_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_create_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_create_result$schema$value ??= z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), + 'agentPreset': z.string().readonly().optional(), +})) +let _deepseek_ai_dsh_api_session_controller_session_follow_parameter_0$schema$value +const _deepseek_ai_dsh_api_session_controller_session_follow_parameter_0$schema = () => (_deepseek_ai_dsh_api_session_controller_session_follow_parameter_0$schema$value ??= z.object({ + 'address': z.union([z.object({ + 'kind': z.literal("session").readonly(), + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), +}), z.object({ + 'kind': z.literal("subagent").readonly(), + 'parentSessionId': z.intersection(z.string(), z.unknown()).readonly(), + 'childSessionId': z.intersection(z.string(), z.unknown()).readonly(), + 'mode': z.union([z.literal("one-shot"), z.literal("continuable"), z.literal("unknown")]).readonly(), +})]).readonly(), + 'assistantStream': z.literal(true).readonly().optional(), + 'maxMessages': z.number().readonly().optional(), + 'turnWindow': z.object({ + 'minMessages': z.number().readonly(), + 'minTurns': z.number().readonly(), +}).readonly().optional(), +})) +let _deepseek_ai_dsh_api_session_controller_session_follow_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_follow_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_follow_result$schema$value ??= z.union([z.object({ + 'type': z.literal("event").readonly(), + 'event': z.object({ + 'type': z.string().readonly(), + 'seq': z.number().readonly(), + 'time': z.number().readonly(), + 'data': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema3())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema3()))]).readonly(), + 'ignorable': z.literal(true).readonly().optional(), + 'sourceEventSeqs': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema3())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema3()))]).readonly().optional(), + 'surfaceOp': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema3())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema3()))]).readonly().optional(), +}).readonly(), +}), z.object({ + 'type': z.literal("snapshot").readonly(), + 'header': z.object({ + 'version': z.number().readonly(), + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'createdAt': z.number().readonly(), + 'cwd': z.string().readonly().optional(), + 'parentSession': z.intersection(z.string(), z.unknown()).readonly().optional(), + 'isSeeded': z.boolean().readonly(), + 'origin': z.literal("subagent").readonly().optional(), + 'delegationDepth': z.number().readonly().optional(), + 'agentPreset': z.string().readonly().optional(), +}).readonly(), + 'cursor': z.number().readonly(), + 'records': z.array(z.object({ + 'type': z.literal("event").readonly(), + 'event': z.object({ + 'type': z.string().readonly(), + 'seq': z.number().readonly(), + 'time': z.number().readonly(), + 'data': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema3())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema3()))]).readonly(), + 'ignorable': z.literal(true).readonly().optional(), + 'sourceEventSeqs': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema3())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema3()))]).readonly().optional(), + 'surfaceOp': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema3())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema3()))]).readonly().optional(), +}).readonly(), +})).readonly(), + 'hasMore': z.boolean().readonly(), + 'projections': z.object({ + 'asOfSeq': z.number().readonly(), + 'values': z.intersection(z.object({ + 'inbox': z.object({ + 'next-turn': z.array(z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema3())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema3()))])).readonly(), + 'next-step': z.array(z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema3())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema3()))])).readonly(), +}).optional(), + 'agentPreset': z.union([z.literal(null), z.string()]).optional(), + 'title': z.union([z.literal(null), z.string()]).optional(), + 'todos': z.union([z.literal(null), z.array(z.object({ + 'content': z.string(), + 'status': z.union([z.literal("completed"), z.literal("pending"), z.literal("in_progress")]), +}))]).optional(), + 'sessionListMetadata': z.object({ + 'blank': z.boolean().readonly(), + 'lastPromptAt': z.union([z.literal(null), z.number()]).readonly(), +}).optional(), + 'imageLimits': z.object({ + 'maxImageBytes': z.number(), + 'maxImagesPerMessage': z.number(), + 'maxMessageImageBytes': z.number(), + 'maxImagePixels': z.number(), + 'maxImageDimension': z.number(), + 'mediaTypes': z.array(z.union([z.literal("image/png"), z.literal("image/jpeg"), z.literal("image/webp"), z.literal("image/gif")])), +}).optional(), + 'modelSelection': z.object({ + 'lastUsed': z.union([z.literal(null), z.object({ + 'provider': z.string().readonly(), + 'model': z.string().readonly(), + 'reasoningEffort': z.string().readonly().optional(), +})]).readonly(), + 'next': z.union([z.literal(null), z.object({ + 'provider': z.string().readonly(), + 'model': z.string().readonly(), + 'reasoningEffort': z.string().readonly().optional(), +})]).readonly(), +}).optional(), + 'permissions': z.object({ + 'currentValue': z.string(), +}).optional(), + 'subagentCatalog': z.array(z.union([z.intersection(z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'createdAt': z.number().readonly(), +}), z.object({ + 'mode': z.literal("one-shot").readonly(), + 'label': z.string().readonly().optional(), +})), z.intersection(z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'createdAt': z.number().readonly(), +}), z.object({ + 'mode': z.literal("continuable").readonly(), + 'label': z.string().readonly(), +})), z.intersection(z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'createdAt': z.number().readonly(), +}), z.object({ + 'mode': z.literal("unknown").readonly(), + 'label': z.string().readonly().optional(), +}))])).optional(), + 'subagentTiming': z.object({ + 'settledMs': z.number(), + 'active': z.object({ + 'since': z.number(), + 'through': z.number(), +}).optional(), + 'lastTurnCompleted': z.boolean().optional(), +}).optional(), + 'subagent': z.union([z.literal(null), z.object({ + 'mode': z.literal("one-shot"), + 'label': z.string().optional(), + 'seq': z.intersection(z.number(), z.unknown()), +}), z.object({ + 'mode': z.literal("continuable"), + 'label': z.string(), + 'seq': z.intersection(z.number(), z.unknown()), +})]).optional(), + 'tokenUsage': z.object({ + 'uncachedInputTokens': z.number(), + 'outputTokens': z.number(), + 'cacheReadTokens': z.number(), + 'cacheWriteTokens': z.number(), +}).optional(), + 'contextPressure': z.object({ + 'pressureTokens': z.number().optional(), + 'projectedTokens': z.number().optional(), + 'contextWindow': z.number().optional(), +}).optional(), + 'contextBreakdown': z.object({ + 'systemTokens': z.number(), + 'toolsTokens': z.number(), + 'messageTokens': z.number(), +}).optional(), + 'userQuestions': z.object({ + 'active': z.array(z.object({ + 'callId': z.intersection(z.string(), z.unknown()).readonly(), + 'questions': z.array(z.object({ + 'id': z.string(), + 'question': z.string(), + 'detail': z.string().optional(), + 'header': z.string().optional(), + 'options': z.array(z.object({ + 'label': z.string(), + 'description': z.string().optional(), +})).optional(), + 'multiSelect': z.boolean().optional(), + 'intent': z.object({ + 'kind': z.literal("plan-review"), + 'approve': z.string(), + 'callId': z.intersection(z.string(), z.unknown()).optional(), +}).optional(), +})).readonly(), + 'state': z.union([z.literal("open"), z.literal("continued")]).readonly(), +})).readonly(), + 'settled': z.array(z.object({ + 'callId': z.intersection(z.string(), z.unknown()).readonly(), + 'answers': z.array(z.object({ + 'id': z.string(), + 'selected': z.array(z.string()), + 'custom': z.string().optional(), +})).readonly(), +})).readonly(), +}).optional(), + 'goal': z.union([z.literal(null), z.object({ + 'goal': z.object({ + 'objective': z.string().readonly(), + 'phase': z.union([z.literal("active"), z.literal("paused"), z.literal("blocked"), z.literal("complete")]).readonly(), + 'blockedReason': z.object({ + 'code': z.string().readonly(), + 'message': z.string().readonly(), +}).readonly().optional(), + 'maxGoalRounds': z.number().readonly(), + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'revision': z.number().readonly(), +}).readonly(), + 'roundsStarted': z.number().readonly(), + 'createdAt': z.number().readonly(), + 'updatedAt': z.number().readonly(), +})]).optional(), +}), z.record(z.string(), z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema3())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema3()))])).readonly()).readonly(), +}).readonly(), + 'assistantStream': z.object({ + 'revision': z.number().readonly(), + 'activeAttempt': z.object({ + 'attemptId': z.intersection(z.string(), z.unknown()).readonly(), + 'startedAfterSeq': z.union([z.intersection(z.number(), z.unknown()), z.literal(-1)]).readonly(), + 'turn': z.number().readonly(), + 'step': z.number().readonly(), + 'nextIndex': z.number().readonly(), + 'stream': z.array(z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema3())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema3()))])).readonly(), +}).readonly().optional(), +}).readonly().optional(), +}), z.object({ + 'type': z.literal("assistant-stream").readonly(), + 'frame': z.union([z.object({ + 'type': z.literal("start").readonly(), + 'attemptId': z.intersection(z.string(), z.unknown()).readonly(), + 'revision': z.number().readonly(), + 'startedAfterSeq': z.union([z.intersection(z.number(), z.unknown()), z.literal(-1)]).readonly(), + 'turn': z.number().readonly(), + 'step': z.number().readonly(), +}), z.object({ + 'type': z.literal("chunk").readonly(), + 'attemptId': z.intersection(z.string(), z.unknown()).readonly(), + 'revision': z.number().readonly(), + 'index': z.number().readonly(), + 'time': z.number().readonly(), + 'chunk': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema3())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema3()))]).readonly(), +}), z.object({ + 'type': z.literal("end").readonly(), + 'attemptId': z.intersection(z.string(), z.unknown()).readonly(), + 'revision': z.number().readonly(), + 'index': z.number().readonly(), + 'outcome': z.union([z.object({ + 'kind': z.literal("committed").readonly(), + 'eventType': z.union([z.literal("assistant/message"), z.literal("assistant/attempt")]).readonly(), + 'seq': z.number().readonly(), +}), z.object({ + 'kind': z.literal("abandoned").readonly(), +})]).readonly(), +})]).readonly(), +})])) +let _deepseek_ai_dsh_api_session_controller_session_fork_parameter_0$schema$value +const _deepseek_ai_dsh_api_session_controller_session_fork_parameter_0$schema = () => (_deepseek_ai_dsh_api_session_controller_session_fork_parameter_0$schema$value ??= z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), + 'atSeq': z.number().readonly().optional(), +})) +let _deepseek_ai_dsh_api_session_controller_session_fork_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_fork_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_fork_result$schema$value ??= z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_initializeDefaultModel_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_initializeDefaultModel_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_initializeDefaultModel_result$schema$value ??= z.void()) +let _deepseek_ai_dsh_api_session_controller_session_list_parameter_0$schema$value +const _deepseek_ai_dsh_api_session_controller_session_list_parameter_0$schema = () => (_deepseek_ai_dsh_api_session_controller_session_list_parameter_0$schema$value ??= z.object({ + 'cursor': z.string().readonly().optional(), +})) +let _deepseek_ai_dsh_api_session_controller_session_list_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_list_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_list_result$schema$value ??= z.object({ + 'items': z.array(z.object({ + 'agentAvailable': z.boolean().readonly(), + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), + 'updatedAt': z.number().readonly(), + 'running': z.boolean().readonly(), + 'blank': z.boolean().readonly(), + 'parentSessionId': z.intersection(z.string(), z.unknown()).readonly().optional(), + 'origin': z.literal("subagent").readonly().optional(), + 'cwd': z.string().readonly().optional(), + 'projections': z.object({ + 'kind': z.union([z.literal("cached"), z.literal("sequenced")]).readonly(), + 'asOfSeq': z.number().readonly(), + 'values': z.intersection(z.object({ + 'inbox': z.object({ + 'next-turn': z.array(z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema()))])).readonly(), + 'next-step': z.array(z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema()))])).readonly(), +}).optional(), + 'agentPreset': z.union([z.literal(null), z.string()]).optional(), + 'title': z.union([z.literal(null), z.string()]).optional(), + 'todos': z.union([z.literal(null), z.array(z.object({ + 'content': z.string(), + 'status': z.union([z.literal("completed"), z.literal("pending"), z.literal("in_progress")]), +}))]).optional(), + 'sessionListMetadata': z.object({ + 'blank': z.boolean().readonly(), + 'lastPromptAt': z.union([z.literal(null), z.number()]).readonly(), +}).optional(), + 'imageLimits': z.object({ + 'maxImageBytes': z.number(), + 'maxImagesPerMessage': z.number(), + 'maxMessageImageBytes': z.number(), + 'maxImagePixels': z.number(), + 'maxImageDimension': z.number(), + 'mediaTypes': z.array(z.union([z.literal("image/png"), z.literal("image/jpeg"), z.literal("image/webp"), z.literal("image/gif")])), +}).optional(), + 'modelSelection': z.object({ + 'lastUsed': z.union([z.literal(null), z.object({ + 'provider': z.string().readonly(), + 'model': z.string().readonly(), + 'reasoningEffort': z.string().readonly().optional(), +})]).readonly(), + 'next': z.union([z.literal(null), z.object({ + 'provider': z.string().readonly(), + 'model': z.string().readonly(), + 'reasoningEffort': z.string().readonly().optional(), +})]).readonly(), +}).optional(), + 'permissions': z.object({ + 'currentValue': z.string(), +}).optional(), + 'subagentCatalog': z.array(z.union([z.intersection(z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'createdAt': z.number().readonly(), +}), z.object({ + 'mode': z.literal("one-shot").readonly(), + 'label': z.string().readonly().optional(), +})), z.intersection(z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'createdAt': z.number().readonly(), +}), z.object({ + 'mode': z.literal("continuable").readonly(), + 'label': z.string().readonly(), +})), z.intersection(z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'createdAt': z.number().readonly(), +}), z.object({ + 'mode': z.literal("unknown").readonly(), + 'label': z.string().readonly().optional(), +}))])).optional(), + 'subagentTiming': z.object({ + 'settledMs': z.number(), + 'active': z.object({ + 'since': z.number(), + 'through': z.number(), +}).optional(), + 'lastTurnCompleted': z.boolean().optional(), +}).optional(), + 'subagent': z.union([z.literal(null), z.object({ + 'mode': z.literal("one-shot"), + 'label': z.string().optional(), + 'seq': z.intersection(z.number(), z.unknown()), +}), z.object({ + 'mode': z.literal("continuable"), + 'label': z.string(), + 'seq': z.intersection(z.number(), z.unknown()), +})]).optional(), + 'tokenUsage': z.object({ + 'uncachedInputTokens': z.number(), + 'outputTokens': z.number(), + 'cacheReadTokens': z.number(), + 'cacheWriteTokens': z.number(), +}).optional(), + 'contextPressure': z.object({ + 'pressureTokens': z.number().optional(), + 'projectedTokens': z.number().optional(), + 'contextWindow': z.number().optional(), +}).optional(), + 'contextBreakdown': z.object({ + 'systemTokens': z.number(), + 'toolsTokens': z.number(), + 'messageTokens': z.number(), +}).optional(), + 'userQuestions': z.object({ + 'active': z.array(z.object({ + 'callId': z.intersection(z.string(), z.unknown()).readonly(), + 'questions': z.array(z.object({ + 'id': z.string(), + 'question': z.string(), + 'detail': z.string().optional(), + 'header': z.string().optional(), + 'options': z.array(z.object({ + 'label': z.string(), + 'description': z.string().optional(), +})).optional(), + 'multiSelect': z.boolean().optional(), + 'intent': z.object({ + 'kind': z.literal("plan-review"), + 'approve': z.string(), + 'callId': z.intersection(z.string(), z.unknown()).optional(), +}).optional(), +})).readonly(), + 'state': z.union([z.literal("open"), z.literal("continued")]).readonly(), +})).readonly(), + 'settled': z.array(z.object({ + 'callId': z.intersection(z.string(), z.unknown()).readonly(), + 'answers': z.array(z.object({ + 'id': z.string(), + 'selected': z.array(z.string()), + 'custom': z.string().optional(), +})).readonly(), +})).readonly(), +}).optional(), + 'goal': z.union([z.literal(null), z.object({ + 'goal': z.object({ + 'objective': z.string().readonly(), + 'phase': z.union([z.literal("active"), z.literal("paused"), z.literal("blocked"), z.literal("complete")]).readonly(), + 'blockedReason': z.object({ + 'code': z.string().readonly(), + 'message': z.string().readonly(), +}).readonly().optional(), + 'maxGoalRounds': z.number().readonly(), + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'revision': z.number().readonly(), +}).readonly(), + 'roundsStarted': z.number().readonly(), + 'createdAt': z.number().readonly(), + 'updatedAt': z.number().readonly(), +})]).optional(), +}), z.record(z.string(), z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema()))])).readonly()).readonly(), +}).readonly().optional(), +})).readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_modelCatalog_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_modelCatalog_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_modelCatalog_result$schema$value ??= z.object({ + 'default': z.object({ + 'provider': z.string().readonly(), + 'model': z.string().readonly(), + 'reasoningEffort': z.string().readonly().optional(), +}).readonly(), + 'routableProviders': z.array(z.string()).readonly(), + 'groups': z.array(z.object({ + 'id': z.string().readonly(), + 'name': z.string().readonly(), + 'models': z.array(z.object({ + 'id': z.string().readonly(), + 'name': z.string().readonly(), + 'description': z.string().readonly().optional(), + 'reasoning': z.object({ + 'efforts': z.array(z.object({ + 'id': z.string().readonly(), + 'name': z.string().readonly(), + 'description': z.string().readonly().optional(), +})).readonly(), + 'defaultEffort': z.string().readonly().optional(), +}).readonly().optional(), +})).readonly(), +})).readonly(), + 'failures': z.array(z.object({ + 'id': z.string().readonly(), + 'name': z.string().readonly(), + 'message': z.string().readonly(), +})).readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_openWorkspacePath_parameter_0$schema$value +const _deepseek_ai_dsh_api_session_controller_session_openWorkspacePath_parameter_0$schema = () => (_deepseek_ai_dsh_api_session_controller_session_openWorkspacePath_parameter_0$schema$value ??= z.object({ + 'action': z.literal("reveal").readonly().optional(), + 'application': z.string().readonly().optional(), + 'path': z.string().readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_openWorkspacePath_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_openWorkspacePath_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_openWorkspacePath_result$schema$value ??= z.object({ + 'opened': z.literal(true).readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_page_parameter_0$schema$value +const _deepseek_ai_dsh_api_session_controller_session_page_parameter_0$schema = () => (_deepseek_ai_dsh_api_session_controller_session_page_parameter_0$schema$value ??= z.object({ + 'address': z.union([z.object({ + 'kind': z.literal("session").readonly(), + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), +}), z.object({ + 'kind': z.literal("subagent").readonly(), + 'parentSessionId': z.intersection(z.string(), z.unknown()).readonly(), + 'childSessionId': z.intersection(z.string(), z.unknown()).readonly(), + 'mode': z.union([z.literal("one-shot"), z.literal("continuable"), z.literal("unknown")]).readonly(), +})]).readonly(), + 'throughSeq': z.number().readonly(), + 'beforeSeq': z.number().readonly().optional(), + 'maxMessages': z.number().readonly().optional(), + 'turnWindow': z.object({ + 'minMessages': z.number().readonly(), + 'minTurns': z.number().readonly(), +}).readonly().optional(), +})) +let _deepseek_ai_dsh_api_session_controller_session_page_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_page_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_page_result$schema$value ??= z.object({ + 'records': z.array(z.object({ + 'type': z.literal("event").readonly(), + 'event': z.object({ + 'type': z.string().readonly(), + 'seq': z.number().readonly(), + 'time': z.number().readonly(), + 'data': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema2())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema2()))]).readonly(), + 'ignorable': z.literal(true).readonly().optional(), + 'sourceEventSeqs': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema2())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema2()))]).readonly().optional(), + 'surfaceOp': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema2())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema2()))]).readonly().optional(), +}).readonly(), +})).readonly(), + 'hasMore': z.boolean().readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_projections_parameter_0$schema$value +const _deepseek_ai_dsh_api_session_controller_session_projections_parameter_0$schema = () => (_deepseek_ai_dsh_api_session_controller_session_projections_parameter_0$schema$value ??= z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_projections_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_projections_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_projections_result$schema$value ??= z.union([z.literal(null), z.object({ + 'asOfSeq': z.number().readonly(), + 'values': z.intersection(z.object({ + 'inbox': z.object({ + 'next-turn': z.array(z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema4())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema4()))])).readonly(), + 'next-step': z.array(z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema4())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema4()))])).readonly(), +}).optional(), + 'agentPreset': z.union([z.literal(null), z.string()]).optional(), + 'title': z.union([z.literal(null), z.string()]).optional(), + 'todos': z.union([z.literal(null), z.array(z.object({ + 'content': z.string(), + 'status': z.union([z.literal("completed"), z.literal("pending"), z.literal("in_progress")]), +}))]).optional(), + 'sessionListMetadata': z.object({ + 'blank': z.boolean().readonly(), + 'lastPromptAt': z.union([z.literal(null), z.number()]).readonly(), +}).optional(), + 'imageLimits': z.object({ + 'maxImageBytes': z.number(), + 'maxImagesPerMessage': z.number(), + 'maxMessageImageBytes': z.number(), + 'maxImagePixels': z.number(), + 'maxImageDimension': z.number(), + 'mediaTypes': z.array(z.union([z.literal("image/png"), z.literal("image/jpeg"), z.literal("image/webp"), z.literal("image/gif")])), +}).optional(), + 'modelSelection': z.object({ + 'lastUsed': z.union([z.literal(null), z.object({ + 'provider': z.string().readonly(), + 'model': z.string().readonly(), + 'reasoningEffort': z.string().readonly().optional(), +})]).readonly(), + 'next': z.union([z.literal(null), z.object({ + 'provider': z.string().readonly(), + 'model': z.string().readonly(), + 'reasoningEffort': z.string().readonly().optional(), +})]).readonly(), +}).optional(), + 'permissions': z.object({ + 'currentValue': z.string(), +}).optional(), + 'subagentCatalog': z.array(z.union([z.intersection(z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'createdAt': z.number().readonly(), +}), z.object({ + 'mode': z.literal("one-shot").readonly(), + 'label': z.string().readonly().optional(), +})), z.intersection(z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'createdAt': z.number().readonly(), +}), z.object({ + 'mode': z.literal("continuable").readonly(), + 'label': z.string().readonly(), +})), z.intersection(z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'createdAt': z.number().readonly(), +}), z.object({ + 'mode': z.literal("unknown").readonly(), + 'label': z.string().readonly().optional(), +}))])).optional(), + 'subagentTiming': z.object({ + 'settledMs': z.number(), + 'active': z.object({ + 'since': z.number(), + 'through': z.number(), +}).optional(), + 'lastTurnCompleted': z.boolean().optional(), +}).optional(), + 'subagent': z.union([z.literal(null), z.object({ + 'mode': z.literal("one-shot"), + 'label': z.string().optional(), + 'seq': z.intersection(z.number(), z.unknown()), +}), z.object({ + 'mode': z.literal("continuable"), + 'label': z.string(), + 'seq': z.intersection(z.number(), z.unknown()), +})]).optional(), + 'tokenUsage': z.object({ + 'uncachedInputTokens': z.number(), + 'outputTokens': z.number(), + 'cacheReadTokens': z.number(), + 'cacheWriteTokens': z.number(), +}).optional(), + 'contextPressure': z.object({ + 'pressureTokens': z.number().optional(), + 'projectedTokens': z.number().optional(), + 'contextWindow': z.number().optional(), +}).optional(), + 'contextBreakdown': z.object({ + 'systemTokens': z.number(), + 'toolsTokens': z.number(), + 'messageTokens': z.number(), +}).optional(), + 'userQuestions': z.object({ + 'active': z.array(z.object({ + 'callId': z.intersection(z.string(), z.unknown()).readonly(), + 'questions': z.array(z.object({ + 'id': z.string(), + 'question': z.string(), + 'detail': z.string().optional(), + 'header': z.string().optional(), + 'options': z.array(z.object({ + 'label': z.string(), + 'description': z.string().optional(), +})).optional(), + 'multiSelect': z.boolean().optional(), + 'intent': z.object({ + 'kind': z.literal("plan-review"), + 'approve': z.string(), + 'callId': z.intersection(z.string(), z.unknown()).optional(), +}).optional(), +})).readonly(), + 'state': z.union([z.literal("open"), z.literal("continued")]).readonly(), +})).readonly(), + 'settled': z.array(z.object({ + 'callId': z.intersection(z.string(), z.unknown()).readonly(), + 'answers': z.array(z.object({ + 'id': z.string(), + 'selected': z.array(z.string()), + 'custom': z.string().optional(), +})).readonly(), +})).readonly(), +}).optional(), + 'goal': z.union([z.literal(null), z.object({ + 'goal': z.object({ + 'objective': z.string().readonly(), + 'phase': z.union([z.literal("active"), z.literal("paused"), z.literal("blocked"), z.literal("complete")]).readonly(), + 'blockedReason': z.object({ + 'code': z.string().readonly(), + 'message': z.string().readonly(), +}).readonly().optional(), + 'maxGoalRounds': z.number().readonly(), + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'revision': z.number().readonly(), +}).readonly(), + 'roundsStarted': z.number().readonly(), + 'createdAt': z.number().readonly(), + 'updatedAt': z.number().readonly(), +})]).optional(), +}), z.record(z.string(), z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema4())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema4()))])).readonly()).readonly(), +})])) +let _deepseek_ai_dsh_api_session_controller_session_prompt_parameter_0$schema$value +const _deepseek_ai_dsh_api_session_controller_session_prompt_parameter_0$schema = () => (_deepseek_ai_dsh_api_session_controller_session_prompt_parameter_0$schema$value ??= z.object({ + 'requestId': z.intersection(z.string(), z.unknown()).readonly(), + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), + 'mode': z.union([z.literal("queue"), z.literal("steer")]).readonly(), + 'content': z.array(z.union([z.object({ + 'type': z.literal("text").readonly(), + 'text': z.string().readonly(), +}), z.object({ + 'type': z.literal("image").readonly(), + 'mediaType': z.union([z.literal("image/png"), z.literal("image/jpeg"), z.literal("image/webp"), z.literal("image/gif")]).readonly(), + 'data': z.string().readonly(), + 'name': z.string().readonly().optional(), +}), z.object({ + 'type': z.literal("file").readonly(), + 'receiptId': z.intersection(z.string(), z.unknown()).readonly(), +})])).readonly(), + 'clientTimeZone': z.string().readonly().optional(), +})) +let _deepseek_ai_dsh_api_session_controller_session_prompt_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_prompt_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_prompt_result$schema$value ??= z.object({ + 'accepted': z.literal(true).readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_rename_parameter_0$schema$value +const _deepseek_ai_dsh_api_session_controller_session_rename_parameter_0$schema = () => (_deepseek_ai_dsh_api_session_controller_session_rename_parameter_0$schema$value ??= z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), + 'title': z.string().readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_rename_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_rename_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_rename_result$schema$value ??= z.object({ + 'title': z.string().readonly(), + 'seq': z.number().readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_search_parameter_0$schema$value +const _deepseek_ai_dsh_api_session_controller_session_search_parameter_0$schema = () => (_deepseek_ai_dsh_api_session_controller_session_search_parameter_0$schema$value ??= z.object({ + 'query': z.string().readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_search_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_search_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_search_result$schema$value ??= z.object({ + 'items': z.array(z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), + 'snippet': z.string().readonly(), +})).readonly(), + 'hasMore': z.boolean().readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_selectModel_parameter_0$schema$value +const _deepseek_ai_dsh_api_session_controller_session_selectModel_parameter_0$schema = () => (_deepseek_ai_dsh_api_session_controller_session_selectModel_parameter_0$schema$value ??= z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), + 'provider': z.string().readonly(), + 'model': z.string().readonly(), + 'reasoningEffort': z.string().readonly().optional(), +})) +let _deepseek_ai_dsh_api_session_controller_session_selectModel_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_selectModel_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_selectModel_result$schema$value ??= z.object({ + 'selected': z.object({ + 'provider': z.string().readonly(), + 'model': z.string().readonly(), + 'reasoningEffort': z.string().readonly().optional(), +}).readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_updateQueue_parameter_0$schema$value +const _deepseek_ai_dsh_api_session_controller_session_updateQueue_parameter_0$schema = () => (_deepseek_ai_dsh_api_session_controller_session_updateQueue_parameter_0$schema$value ??= z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), + 'itemId': z.intersection(z.string(), z.unknown()).readonly(), + 'action': z.union([z.object({ + 'kind': z.literal("edit").readonly(), + 'content': z.array(z.object({ + 'type': z.literal("text"), + 'text': z.string(), +})).readonly(), +}), z.object({ + 'kind': z.literal("remove").readonly(), +}), z.object({ + 'kind': z.literal("steer").readonly(), +})]).readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_updateQueue_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_updateQueue_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_updateQueue_result$schema$value ??= z.object({ + 'accepted': z.literal(true).readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_workspacePathApplications_parameter_0$schema$value +const _deepseek_ai_dsh_api_session_controller_session_workspacePathApplications_parameter_0$schema = () => (_deepseek_ai_dsh_api_session_controller_session_workspacePathApplications_parameter_0$schema$value ??= z.object({ + 'path': z.string().readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_workspacePathApplications_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_workspacePathApplications_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_workspacePathApplications_result$schema$value ??= z.array(z.object({ + 'id': z.string().readonly(), + 'name': z.string().readonly(), + 'default': z.boolean().readonly(), + 'icon': z.union([z.literal(null), z.string()]).readonly(), +}))) +let _deepseek_ai_dsh_api_session_controller_skills_list_parameter_0$schema$value +const _deepseek_ai_dsh_api_session_controller_skills_list_parameter_0$schema = () => (_deepseek_ai_dsh_api_session_controller_skills_list_parameter_0$schema$value ??= z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_skills_list_result$schema$value +const _deepseek_ai_dsh_api_session_controller_skills_list_result$schema = () => (_deepseek_ai_dsh_api_session_controller_skills_list_result$schema$value ??= z.object({ + 'skills': z.array(z.object({ + 'path': z.string().readonly().optional(), + 'name': z.string().readonly(), + 'description': z.string().readonly(), + 'whenToUse': z.string().readonly().optional(), + 'modelInvocable': z.boolean().readonly(), +})).readonly(), +})) + +export const TYPERT_REMOTE = { + package: '@deepseek-ai/dsh-api-session-controller', + descriptors: [ + { + id: '@deepseek-ai/dsh-api-session-controller#fileReferences/list', + service: 'sessionFileReferences', + namespace: 'fileReferences', + method: 'list', + invocation: { kind: 'direct' }, + scope: { + context: 'agent', + wire: 'agentId', + }, + parameters: [ + { + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'agent', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + create: _deepseek_ai_dsh_api_session_controller_fileReferences_list_parameter_0$schema, + }, + }, + { + name: 'query', + wire: 'query', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller#fileReferences/list:query', + create: _deepseek_ai_dsh_api_session_controller_fileReferences_list_parameter_1$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller#fileReferences/list:result', + create: _deepseek_ai_dsh_api_session_controller_fileReferences_list_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/file-references.ts","line":33,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/attachment', + service: 'sessionController', + namespace: 'session', + method: 'attachment', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionAttachmentRequest', + create: _deepseek_ai_dsh_api_session_controller_session_attachment_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionAttachmentValue', + create: _deepseek_ai_dsh_api_session_controller_session_attachment_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":444,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/cancel', + service: 'sessionController', + namespace: 'session', + method: 'cancel', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionCancelRequest', + create: _deepseek_ai_dsh_api_session_controller_session_cancel_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionCancelValue', + create: _deepseek_ai_dsh_api_session_controller_session_cancel_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":464,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/canOpenWorkspacePath', + service: 'sessionController', + namespace: 'session', + method: 'canOpenWorkspacePath', + invocation: { kind: 'direct' }, + parameters: [ + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller#session/canOpenWorkspacePath:result', + create: _deepseek_ai_dsh_api_session_controller_session_canOpenWorkspacePath_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":326,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/control', + service: 'sessionController', + namespace: 'session', + method: 'control', + mode: 'stream', + invocation: { kind: 'direct' }, + parameters: [ + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionControlFrame', + create: _deepseek_ai_dsh_api_session_controller_session_control_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":527,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/create', + service: 'sessionController', + namespace: 'session', + method: 'create', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionCreateRequest', + create: _deepseek_ai_dsh_api_session_controller_session_create_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionCreateValue', + create: _deepseek_ai_dsh_api_session_controller_session_create_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":280,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/follow', + service: 'sessionController', + namespace: 'session', + method: 'follow', + mode: 'stream', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionFollowRequest', + create: _deepseek_ai_dsh_api_session_controller_session_follow_parameter_0$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionFollowFrame', + create: _deepseek_ai_dsh_api_session_controller_session_follow_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":487,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/fork', + service: 'sessionController', + namespace: 'session', + method: 'fork', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionForkRequest', + create: _deepseek_ai_dsh_api_session_controller_session_fork_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionForkValue', + create: _deepseek_ai_dsh_api_session_controller_session_fork_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":422,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/initializeDefaultModel', + service: 'sessionController', + namespace: 'session', + method: 'initializeDefaultModel', + invocation: { kind: 'direct' }, + parameters: [ + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller#session/initializeDefaultModel:result', + create: _deepseek_ai_dsh_api_session_controller_session_initializeDefaultModel_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":299,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/list', + service: 'sessionController', + namespace: 'session', + method: 'list', + invocation: { kind: 'direct' }, + parameters: [ + { + name: '_request', + wire: '_request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionListRequest', + create: _deepseek_ai_dsh_api_session_controller_session_list_parameter_0$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionListValue', + create: _deepseek_ai_dsh_api_session_controller_session_list_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":259,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/modelCatalog', + service: 'sessionController', + namespace: 'session', + method: 'modelCatalog', + invocation: { kind: 'direct' }, + parameters: [ + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#ModelCatalog', + create: _deepseek_ai_dsh_api_session_controller_session_modelCatalog_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":317,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/openWorkspacePath', + service: 'sessionController', + namespace: 'session', + method: 'openWorkspacePath', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionOpenWorkspacePathRequest', + create: _deepseek_ai_dsh_api_session_controller_session_openWorkspacePath_parameter_0$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionOpenWorkspacePathValue', + create: _deepseek_ai_dsh_api_session_controller_session_openWorkspacePath_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":347,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/page', + service: 'sessionController', + namespace: 'session', + method: 'page', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionPageRequest', + create: _deepseek_ai_dsh_api_session_controller_session_page_parameter_0$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionPage', + create: _deepseek_ai_dsh_api_session_controller_session_page_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":475,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/projections', + service: 'sessionController', + namespace: 'session', + method: 'projections', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionProjectionsRequest', + create: _deepseek_ai_dsh_api_session_controller_session_projections_parameter_0$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionProjectionsValue', + create: _deepseek_ai_dsh_api_session_controller_session_projections_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":498,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/prompt', + service: 'sessionController', + namespace: 'session', + method: 'prompt', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionPromptRequest', + create: _deepseek_ai_dsh_api_session_controller_session_prompt_parameter_0$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionPromptValue', + create: _deepseek_ai_dsh_api_session_controller_session_prompt_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":433,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/rename', + service: 'sessionController', + namespace: 'session', + method: 'rename', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionRenameRequest', + create: _deepseek_ai_dsh_api_session_controller_session_rename_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionRenameValue', + create: _deepseek_ai_dsh_api_session_controller_session_rename_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":410,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/search', + service: 'sessionController', + namespace: 'session', + method: 'search', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionSearchRequest', + create: _deepseek_ai_dsh_api_session_controller_session_search_parameter_0$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionSearchValue', + create: _deepseek_ai_dsh_api_session_controller_session_search_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":270,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/selectModel', + service: 'sessionController', + namespace: 'session', + method: 'selectModel', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionSelectModelRequest', + create: _deepseek_ai_dsh_api_session_controller_session_selectModel_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionSelectModelValue', + create: _deepseek_ai_dsh_api_session_controller_session_selectModel_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":290,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/updateQueue', + service: 'sessionController', + namespace: 'session', + method: 'updateQueue', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionUpdateQueueRequest', + create: _deepseek_ai_dsh_api_session_controller_session_updateQueue_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionUpdateQueueValue', + create: _deepseek_ai_dsh_api_session_controller_session_updateQueue_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":454,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/workspacePathApplications', + service: 'sessionController', + namespace: 'session', + method: 'workspacePathApplications', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller#session/workspacePathApplications:request', + create: _deepseek_ai_dsh_api_session_controller_session_workspacePathApplications_parameter_0$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller#session/workspacePathApplications:result', + create: _deepseek_ai_dsh_api_session_controller_session_workspacePathApplications_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":377,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#skills/list', + service: 'sessionSkillCatalog', + namespace: 'skills', + method: 'list', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SkillListRequest', + create: _deepseek_ai_dsh_api_session_controller_skills_list_parameter_0$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SkillListValue', + create: _deepseek_ai_dsh_api_session_controller_skills_list_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/skill-catalog.ts","line":35,"column":9}, + }, + ], +} + +export default TYPERT_REMOTE diff --git a/apps/web-ui/packages/api/session-controller/package.json b/apps/web-ui/packages/api/session-controller/package.json new file mode 100644 index 000000000..eb38e466e --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/package.json @@ -0,0 +1,59 @@ +{ + "name": "@deepseek-ai/dsh-api-session-controller", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./types": { + "types": "./src/types.ts", + "default": "./src/types.ts" + }, + "./remote-events": { + "types": "./src/remote-events.ts", + "default": "./src/remote-events.ts" + }, + "./client": { + "types": "./src/client/index.ts", + "default": "./lib/client.js" + }, + "./typert": { + "types": "./src/typert.host.ts", + "default": "./src/typert.host.ts" + }, + "./remote": { + "types": "./generated/remote.d.ts", + "default": "./generated/remote.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dependencies": { + "@deepseek-ai/dsh-typert-protocol": "*", + "@deepseek-ai/dsh-client-store": "*", + "@deepseek-ai/dsh-session": "*", + "@deepseek-ai/dsh-util-workspace-path": "*", + "@deepseek-ai/cordis": "*", + "@deepseek-ai/dsh-api-gateway": "*", + "@deepseek-ai/dsh-util-values": "*", + "@deepseek-ai/dsh-util-crypto": "*", + "@deepseek-ai/dsh-llm": "*", + "zod": "^4.4.3" + }, + "dsh": { + "client": { + "external": [ + "@deepseek-ai/dsh-api-gateway/client" + ], + "inject": [ + "@deepseek-ai/dsh-api-gateway", + "@deepseek-ai/dsh-client-file-upload" + ], + "platform": "web" + } + } +} diff --git a/apps/web-ui/packages/api/session-controller/src/agent.ts b/apps/web-ui/packages/api/session-controller/src/agent.ts new file mode 100644 index 000000000..94bffb7fc --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/agent.ts @@ -0,0 +1,571 @@ +/** Agent activation, composition, and model-selection policy owned by API Session. */ + +import { mkdir } from "node:fs/promises"; +import type { Context } from "@deepseek-ai/cordis"; +import { installModelSelection } from "@deepseek-ai/dsh-agent"; +import type { + Agent, + AgentOptions, + AgentSetup, + ModelSelection as AgentModelSelection, + ModelSelectionRef, +} from "@deepseek-ai/dsh-agent"; +import type {} from "@deepseek-ai/dsh-agent-default-model"; +import type {} from "@deepseek-ai/dsh-agent-preset-registry"; +import { ReasoningEffortId } from "@deepseek-ai/dsh-llm"; +import type { Session, SessionId } from "@deepseek-ai/dsh-session"; +import type { SessionInspection } from "@deepseek-ai/dsh-session-persistence"; +import { SessionQueryError, type SessionObservation } from "@deepseek-ai/dsh-session-query"; +import { RemoteError } from "@deepseek-ai/dsh-typert-protocol"; +import type {} from "@deepseek-ai/dsh-typert-registry"; +import type { ModelSelection } from "./types.ts"; + +/** Cold Session identity absent from persistence. */ +export class ApiSessionNotFound extends Error {} + +/** Session identity whose lifecycle belongs to subagent routing. */ +export class ApiSessionSubagentOwnership extends Error { + /** @param sessionId - identity reserved to subagent routing. */ + constructor(readonly sessionId: SessionId) { + super(`session "${sessionId}" is a subagent session; use subagent delivery`); + } +} + +/** Explicit-id creation attempted to adopt a Session under another cwd. */ +export class ApiSessionCwdConflict extends Error { + constructor( + readonly sessionId: SessionId, + readonly requestedCwd: string, + readonly existingCwd: string | undefined, + ) { + super( + existingCwd === undefined + ? `session "${sessionId}" records no cwd and cannot be adopted for "${requestedCwd}"` + : `session "${sessionId}" belongs to "${existingCwd}", not "${requestedCwd}"`, + ); + } +} + +/** Explicit-id creation attempted to adopt a Session under another preset. */ +export class ApiSessionPresetConflict extends Error { + constructor( + readonly sessionId: SessionId, + readonly requestedPreset: string, + readonly existingPreset: string | undefined, + ) { + super( + existingPreset === undefined + ? `session "${sessionId}" records no agent preset and cannot be adopted under "${requestedPreset}"` + : `session "${sessionId}" runs agent preset "${existingPreset}", not "${requestedPreset}"`, + ); + } +} + +/** Failures produced while resolving one ordinary Session identity to its live Agent. */ +export type ApiSessionAgentError = RemoteError< + "session/not-found" | "session/agent-busy" | "session/writer-held" | "gateway/internal" +>; + +/** Result of resolving one ordinary Session identity to its live Agent. */ +export type ApiSessionAgentResult = + { readonly agent: Agent } | { readonly error: ApiSessionAgentError }; + +type InstalledSelection = ModelSelectionRef & { + current: AgentModelSelection; + consume(provider: string, model: string, reasoningEffort: string | undefined): boolean; +}; + +/** + * Test whether generic Session routing must leave an identity to subagent routing. + * @param ctx - Host context carrying the Agent ownership registry. + * @param session - attached or live Session whose ownership is tested. + * @param agent - live Agent when one exists for the Session. + * @returns whether subagent routing owns the Session identity. + */ +export function hasApiSessionSubagentOwner( + ctx: Context, + session: Pick, + agent: Agent | undefined, +): boolean { + if (session.header.origin === "subagent") return true; + const parentId = session.header.parentSession; + if (parentId === undefined || agent === undefined) return false; + const parent = ctx.agents.get(parentId); + return parent !== undefined && ctx.agents.isOwnedBy(agent.id, parent); +} + +/** + * Build the stable caller-facing subagent ownership rejection. + * @param sessionId - Session identity owned by subagent routing. + * @returns a stable Session-domain failure. + */ +export function apiSessionSubagentOwnershipError(sessionId: SessionId): ApiSessionAgentError { + return new RemoteError( + "session/agent-busy", + `session "${sessionId}" is owned by subagent routing`, + { reason: "use subagent delivery for this child session" }, + ); +} + +/** + * Inspect one cold Session without repairing, resuming, or publishing it. + * @param ctx - Host context carrying Session persistence. + * @param sessionId - durable Session identity. + * @param signal - optional cancellation for persistence reads. + * @returns the persisted header and complete event prefix. + */ +export async function inspectApiSession( + ctx: Context, + sessionId: SessionId, + signal?: AbortSignal, +): Promise { + try { + using observation = await ctx.sessionQuery.observeSession(sessionId, { + ...(signal === undefined ? {} : { signal }), + projectionMode: "none", + }); + if (observation.header.cwd === undefined) { + throw new ApiSessionNotFound(`session "${sessionId}" not found`); + } + return { + meta: observation.header, + inheritedEventCount: observation.inheritedEventCount, + events: [...observation.events], + }; + } catch (error: unknown) { + if (error instanceof SessionQueryError && error.code === "SESSION_QUERY_SESSION_NOT_FOUND") { + throw new ApiSessionNotFound(`session "${sessionId}" not found`); + } + throw error; + } +} + +/** Owns every operation that may create, resume, or configure a Web Agent. */ +export class ApiSessionAgentController { + private readonly resumes = new Map>(); + private readonly creations = new Map>(); + private readonly selections = new WeakMap(); + private readonly imageAdmissionChains = new WeakMap>(); + + /** @param ctx - Host context carrying Agent, model, persistence, and Typert services. */ + constructor(private readonly ctx: Context) { + ctx.typert.lookups.configure("agent", async (sessionId: SessionId) => { + const found = await this.resolveAgent(sessionId); + if ("error" in found) throw found.error; + return found.agent; + }); + ctx.typert.lookups.configure("session", async (sessionId: SessionId) => { + const found = await this.resolveAgent(sessionId); + if ("error" in found) throw found.error; + return found.agent.session; + }); + ctx.typert.contexts.configureHost("agent", async (sessionId: SessionId) => { + const found = await this.resolveAgent(sessionId); + if ("error" in found) throw found.error; + return found.agent.ctx; + }); + } + + /** + * Resolve or resume one ordinary Session, deduplicating concurrent resumes. + * @param sessionId - ordinary Session identity. + * @returns the live Agent or a stable Session-domain failure. + */ + async resolveAgent(sessionId: SessionId): Promise { + return this.resolve(sessionId); + } + + /** + * Resolve one ordinary Session from an already-retained exact observation. + * @param observation - Host-owned observation whose preparation stays pinned through setup. + * @returns the live Agent or a stable Session-domain failure. + */ + async resolveObservedAgent(observation: SessionObservation): Promise { + return this.resolve(observation.header.id, observation); + } + + private async resolve( + sessionId: SessionId, + observation?: SessionObservation, + ): Promise { + const live = this.liveAgent(sessionId); + if (live !== undefined) return live; + const attached = this.ctx.sessions.get(sessionId); + if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, undefined)) { + return { error: apiSessionSubagentOwnershipError(sessionId) }; + } + + let resume = this.resumes.get(sessionId); + if (resume === undefined) { + resume = this.resume(sessionId, observation).finally(() => { + this.resumes.delete(sessionId); + }); + this.resumes.set(sessionId, resume); + } + try { + const agent = await resume; + // A shared resume can publish an identity that subagent routing adopts + // before every waiter observes it; apply the live ownership policy again. + const published = this.liveAgent(sessionId); + return published ?? { agent }; + } catch (error: unknown) { + if (error instanceof ApiSessionNotFound) { + return { error: new RemoteError("session/not-found", error.message, { sessionId }) }; + } + if (error instanceof ApiSessionSubagentOwnership) { + return { error: apiSessionSubagentOwnershipError(error.sessionId) }; + } + const raced = this.liveAgent(sessionId); + if (raced !== undefined) return raced; + const racedSession = this.ctx.sessions.get(sessionId); + if ( + racedSession !== undefined && + hasApiSessionSubagentOwner(this.ctx, racedSession, undefined) + ) { + return { error: apiSessionSubagentOwnershipError(sessionId) }; + } + if (error instanceof Error && error.name === "SessionAlreadyOwnedError") { + return { error: new RemoteError("session/writer-held", error.message, { sessionId }) }; + } + return { + error: new RemoteError( + "gateway/internal", + `resume failed for session "${sessionId}": ${String(error)}`, + {}, + ), + }; + } + } + + /** + * Resolve one requested identity, creating or resuming it once. + * @param sessionId - requested Session identity. + * @param cwd - directory the Session must own. + * @param checkPersistedIdentity - whether to inspect a cold identity before creation. + * @param presetId - optional Agent preset the Session must own. + * @returns the matching live ordinary Agent. + */ + async ensureSession( + sessionId: SessionId, + cwd: string, + checkPersistedIdentity: boolean, + presetId?: string, + ): Promise { + let creation = this.creations.get(sessionId); + if (creation === undefined) { + creation = this.createOrAdopt(sessionId, cwd, checkPersistedIdentity, presetId) + .catch((error: unknown) => { + const live = this.ctx.agents.get(sessionId); + if (live !== undefined) { + if (hasApiSessionSubagentOwner(this.ctx, live.session, live)) { + throw new ApiSessionSubagentOwnership(sessionId); + } + return live; + } + const attached = this.ctx.sessions.get(sessionId); + if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, undefined)) { + throw new ApiSessionSubagentOwnership(sessionId); + } + throw error; + }) + .finally(() => { + this.creations.delete(sessionId); + }); + this.creations.set(sessionId, creation); + } + const agent = await creation; + if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) { + throw new ApiSessionSubagentOwnership(sessionId); + } + if (presetId !== undefined) { + this.assertPresetUnchanged(sessionId, presetId, this.presetForSession(agent.session)); + } + if (agent.session.header.cwd !== cwd) { + throw new ApiSessionCwdConflict(sessionId, cwd, agent.session.header.cwd); + } + return agent; + } + + /** + * Install or return the Session-local model selection used by prompt assembly. + * @param agent - live Agent that owns the selection. + * @returns the installed mutable selection reference. + */ + selectionFor(agent: Agent): InstalledSelection { + const installed = this.selections.get(agent); + if (installed !== undefined) return installed; + const projectionState = this.ctx.sessionProjections.stateOf(agent.session, "modelSelection"); + if (projectionState === undefined) { + throw new Error("api-session: required modelSelection projection is not registered"); + } + let picked = + projectionState.pending === null ? undefined : agentModelSelection(projectionState.pending); + const defaultModel = this.ctx.agentDefaultModel; + const selection: InstalledSelection = { + get current(): AgentModelSelection { + if (picked !== undefined) return picked; + const loggedHeader = agent.session.requestHeader(); + if (loggedHeader === undefined) return defaultModel.currentSelection(); + const logged = loggedHeader.config; + return { + provider: logged.provider, + model: logged.model, + // An effort the adapter defaulted is not a conversation choice: restoring + // it as one would make an unchanged default read as a request change. + ...(logged.reasoningEffort === undefined || + loggedHeader.adapterDefaults?.reasoningEffort === true + ? {} + : { reasoningEffort: logged.reasoningEffort }), + }; + }, + set current(next: AgentModelSelection) { + picked = next; + }, + consume(provider: string, model: string, reasoningEffort: string | undefined): boolean { + if ( + picked?.provider !== provider || + picked.model !== model || + picked.reasoningEffort !== reasoningEffort + ) + return false; + picked = undefined; + return true; + }, + assembled: undefined, + }; + installModelSelection(agent.ctx, selection); + this.selections.set(agent, selection); + return selection; + } + + /** + * Commit and cache one validated selection for the next prompt assembly. + * @param agent - live Agent that owns the selection. + * @param selection - validated selection to record and apply. + */ + selectForNextRequest(agent: Agent, selection: AgentModelSelection): void { + agent.session.append("model/selection", selection); + this.selectionFor(agent).current = selection; + } + + /** + * Let a matching durable request header retire the execution cache. + * @param agent - live Agent whose request was recorded. + * @param provider - provider route used by the request. + * @param model - provider-owned model used by the request. + * @param reasoningEffort - adapter-owned effort used by the request. + * @returns whether the pending selection was consumed. + */ + consumeSelection( + agent: Agent, + provider: string, + model: string, + reasoningEffort: string | undefined, + ): boolean { + return this.selections.get(agent)?.consume(provider, model, reasoningEffort) ?? false; + } + + /** + * Read the current Agent preset from the Session projection. + * @param session - live Session whose projection state is available. + * @returns the current preset, or undefined when the capability is absent. + */ + presetForSession(session: Session): string | undefined { + return this.ctx.sessionProjections.stateOf(session, "agentPreset") ?? undefined; + } + + /** + * Serialize image admission and model selection for one Agent. + * @param agent - live Agent that owns the serialization chain. + * @param operation - asynchronous operation admitted after prior work settles. + * @returns the operation result or rejection. + */ + serializeImageAdmission(agent: Agent, operation: () => Promise): Promise { + const result = (this.imageAdmissionChains.get(agent) ?? Promise.resolve()).then(operation); + this.imageAdmissionChains.set( + agent, + result.then( + () => undefined, + () => undefined, + ), + ); + return result; + } + + /** + * Resolve the preset id and pre-publication Agent setup for a create or resume. + * @param presetId - requested preset or the configured default when omitted. + * @returns the resolved preset identity and Agent setup callback. + */ + async composeAgent(presetId: string | undefined): Promise<{ + readonly agentPreset?: string; + readonly setup: AgentSetup; + }> { + const presets = this.ctx.get("agentPresets"); + if (presets === undefined) { + return { + setup: (_agentCtx, agent) => { + this.installSelection(agent); + }, + }; + } + const resolvedId = (await presets.resolve(presetId)).id; + return { + agentPreset: resolvedId, + setup: async (agentCtx, agent) => { + this.installSelection(agent); + await presets.mount(agentCtx, resolvedId); + }, + }; + } + + private liveAgent(sessionId: SessionId): ApiSessionAgentResult | undefined { + const agent = this.ctx.agents.get(sessionId); + if (agent === undefined) return undefined; + return hasApiSessionSubagentOwner(this.ctx, agent.session, agent) + ? { error: apiSessionSubagentOwnershipError(sessionId) } + : { agent }; + } + + private async resume(sessionId: SessionId, supplied?: SessionObservation): Promise { + if (supplied !== undefined) return this.resumeObserved(sessionId, supplied); + try { + using observation = await this.ctx.sessionQuery.observeSession(sessionId); + return await this.resumeObserved(sessionId, observation); + } catch (error: unknown) { + if (error instanceof SessionQueryError && error.code === "SESSION_QUERY_SESSION_NOT_FOUND") { + throw new ApiSessionNotFound(`session "${sessionId}" not found`); + } + throw error; + } + } + + private async resumeObserved( + sessionId: SessionId, + observation: SessionObservation, + ): Promise { + if (observation.header.id !== sessionId || observation.header.cwd === undefined) { + throw new ApiSessionNotFound(`session "${sessionId}" not found`); + } + if (hasApiSessionSubagentOwner(this.ctx, { header: observation.header }, undefined)) { + throw new ApiSessionSubagentOwnership(sessionId); + } + const composition = await this.composeAgent(this.presetForObservation(observation)); + const published = this.ctx.sessions.get(sessionId); + const live = this.ctx.agents.get(sessionId); + if (published !== undefined && hasApiSessionSubagentOwner(this.ctx, published, live)) { + throw new ApiSessionSubagentOwnership(sessionId); + } + return ( + await this.ctx.agents.resume({ + resumeSessionId: sessionId, + agentOptions: this.agentOptions(), + setup: composition.setup, + }) + ).agent; + } + + private async createOrAdopt( + sessionId: SessionId, + cwd: string, + checkPersistedIdentity: boolean, + presetId: string | undefined, + ): Promise { + const attached = this.ctx.sessions.get(sessionId); + const live = this.ctx.agents.get(sessionId); + if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, live)) { + throw new ApiSessionSubagentOwnership(sessionId); + } + if (live !== undefined) return live; + + if (checkPersistedIdentity) { + try { + using observation = await this.ctx.sessionQuery.observeSession(sessionId); + if (hasApiSessionSubagentOwner(this.ctx, { header: observation.header }, undefined)) { + throw new ApiSessionSubagentOwnership(sessionId); + } + if (observation.header.cwd !== cwd) { + throw new ApiSessionCwdConflict(sessionId, cwd, observation.header.cwd); + } + const storedPreset = this.presetForObservation(observation); + this.assertPresetUnchanged(sessionId, presetId, storedPreset); + const composition = await this.composeAgent(storedPreset); + return ( + await this.ctx.agents.resume({ + resumeSessionId: sessionId, + agentOptions: this.agentOptions(), + setup: composition.setup, + }) + ).agent; + } catch (error: unknown) { + if ( + !(error instanceof SessionQueryError) || + error.code !== "SESSION_QUERY_SESSION_NOT_FOUND" + ) + throw error; + } + } + + try { + await mkdir(cwd, { recursive: true }); + } catch (error: unknown) { + throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { + cause: error, + }); + } + const composition = await this.composeAgent(presetId); + return ( + await this.ctx.agents.create({ + sessionId, + agentOptions: this.agentOptions(), + meta: { + cwd, + ...(composition.agentPreset === undefined + ? {} + : { agentPreset: composition.agentPreset }), + }, + setup: composition.setup, + }) + ).agent; + } + + private agentOptions(): AgentOptions { + const { provider, model } = this.ctx.agentDefaultModel.currentSelection(); + return { provider, model }; + } + + private installSelection(agent: Agent): void { + this.selectionFor(agent); + } + + /** + * Read the current Agent preset from an all-projections observation. + * @param observation - exact Session observation carrying its projection snapshot. + * @returns the current preset, or undefined when the capability is absent. + */ + presetForObservation(observation: SessionObservation): string | undefined { + if (observation.projections === undefined) { + throw new Error("api-session: Agent activation requires a projected Session observation"); + } + return observation.projections.values.agentPreset ?? undefined; + } + + private assertPresetUnchanged( + sessionId: SessionId, + requested: string | undefined, + existing: string | undefined, + ): void { + if (requested === undefined || requested === existing) return; + throw new ApiSessionPresetConflict(sessionId, requested, existing); + } +} + +function agentModelSelection(selection: ModelSelection): AgentModelSelection { + return { + provider: selection.provider, + model: selection.model, + ...(selection.reasoningEffort === undefined + ? {} + : { reasoningEffort: ReasoningEffortId(selection.reasoningEffort) }), + }; +} diff --git a/apps/web-ui/packages/api/session-controller/src/archived-session-gate.ts b/apps/web-ui/packages/api/session-controller/src/archived-session-gate.ts new file mode 100644 index 000000000..a76799770 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/archived-session-gate.ts @@ -0,0 +1,57 @@ +/** + * The controller's admission gate for archived Sessions: an archived + * Session, or a subagent descendant of one, must not run a model step until + * it is restored. The work a Session still runs is reported and stopped by + * its owners — the Agent registry (`turn`), the job registry seam (`job`), + * the Subagent runtime (`subagent`), and the Schedule plugin (`schedule`) — + * through the Workspace registry's archive-admission events. + */ + +import type { Context, Plugin } from "@deepseek-ai/cordis"; +import type { Agent } from "@deepseek-ai/dsh-agent"; +import type { SessionId } from "@deepseek-ai/dsh-session"; +import type {} from "@deepseek-ai/dsh-workspace"; + +/** + * The gate as a plugin for `ctx.plugin(...)`: it loads once the Agent + * registry, Session store, and Workspace registry are available and unwinds + * with the plugin's fiber. A late waking delivery to an archived Session — a + * subagent settlement, a queued follow-up — proposes a step the gate rejects, + * which the loop ends as `blocked` without a request; unarchiving lifts the + * gate for the whole lineage. + */ +export const ArchivedSessionGate: Plugin.Object = { + name: "archived-session-gate", + inject: ["agents", "sessions", "workspaceRegistry"], + apply(ctx: Context): void { + ctx.on("agent/pre-step", (payload, next) => + underArchivedSession(ctx, payload.agent) + ? Promise.resolve({ kind: "reject" as const }) + : next(), + ); + }, +}; + +/** + * Whether the Agent's Session, or a Session above it in its subagent lineage, + * is archived. Lineage follows the durable header fields through + * subagent-origin Sessions only: a fork of an archived Session is an + * independent conversation. + * @param ctx - Host context. + * @param agent - the Agent proposing a step. + * @returns whether an archived Session owns the step. + */ +export function underArchivedSession(ctx: Context, agent: Agent): boolean { + const archived = ctx.workspaceRegistry.archivedSessionIds; + let header = agent.session.header; + const visited = new Set(); + while (!visited.has(header.id)) { + if (archived.includes(header.id)) return true; + visited.add(header.id); + if (header.origin !== "subagent" || header.parentSession === undefined) return false; + const parent = ctx.sessions.get(header.parentSession); + if (parent === undefined) return archived.includes(header.parentSession); + header = parent.header; + } + return false; +} diff --git a/apps/web-ui/packages/api/session-controller/src/assistant-stream.ts b/apps/web-ui/packages/api/session-controller/src/assistant-stream.ts new file mode 100644 index 000000000..35d0a6bcd --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/assistant-stream.ts @@ -0,0 +1,103 @@ +/** Process-local assistant state retained for reconnecting Web followers. */ + +import type { AssistantStreamFrame } from "@deepseek-ai/dsh-agent"; +import { AssistantStreamAccumulator } from "@deepseek-ai/dsh-llm"; +import type { SessionSeqCursor } from "@deepseek-ai/dsh-session"; +import type { JsonValue } from "@deepseek-ai/dsh-util-values"; +import type { SessionAssistantStreamAttempt, SessionAssistantStreamBaseline } from "./types.ts"; + +interface MutableAttempt { + readonly attemptId: SessionAssistantStreamAttempt["attemptId"]; + readonly startedAfterSeq: SessionSeqCursor; + readonly turn: number; + readonly step: number; + readonly stream: AssistantStreamAccumulator; + nextIndex: number; +} + +const EMPTY_BASELINE: SessionAssistantStreamBaseline = { revision: 0 }; + +/** + * Folds dense Agent frames and materializes one shared immutable reconnect + * baseline per accepted revision. + */ +export class SessionAssistantStreamAccumulator { + private activeAttempt: MutableAttempt | undefined; + private revision = 0; + private snapshotValue: SessionAssistantStreamBaseline = EMPTY_BASELINE; + private dirty = false; + + /** + * Fold one trusted frame from the current attached Agent lifecycle. + * @param frame - next dense process-local Assistant frame. + * @param durableCursor - last committed Session seq when this frame was observed. + */ + accept(frame: AssistantStreamFrame, durableCursor: SessionSeqCursor): void { + if (frame.type === "start" && frame.revision === 1 && this.revision !== 0) { + this.activeAttempt = undefined; + this.revision = 0; + } + if (frame.revision !== this.revision + 1) { + this.activeAttempt = undefined; + this.revision = frame.revision; + this.dirty = true; + return; + } + this.revision = frame.revision; + switch (frame.type) { + case "start": + this.activeAttempt = { + attemptId: frame.attemptId, + startedAfterSeq: durableCursor, + turn: frame.turn, + step: frame.step, + stream: new AssistantStreamAccumulator(), + nextIndex: 0, + }; + break; + case "chunk": { + const attempt = this.activeAttempt; + if ( + attempt === undefined || + attempt.attemptId !== frame.attemptId || + frame.index !== attempt.nextIndex + ) { + this.activeAttempt = undefined; + break; + } + attempt.stream.push({ time: frame.time, chunk: frame.chunk }); + attempt.nextIndex += 1; + break; + } + case "end": + this.activeAttempt = undefined; + break; + } + this.dirty = true; + } + + /** + * Read the cached reconnect baseline, materializing it after a state change. + * @returns the identity-stable baseline for the latest accepted revision. + */ + snapshot(): SessionAssistantStreamBaseline { + if (!this.dirty) return this.snapshotValue; + this.snapshotValue = { + revision: this.revision, + ...(this.activeAttempt === undefined + ? {} + : { + activeAttempt: { + attemptId: this.activeAttempt.attemptId, + startedAfterSeq: this.activeAttempt.startedAfterSeq, + turn: this.activeAttempt.turn, + step: this.activeAttempt.step, + nextIndex: this.activeAttempt.nextIndex, + stream: this.activeAttempt.stream.snapshot() as unknown as readonly JsonValue[], + }, + }), + }; + this.dirty = false; + return this.snapshotValue; + } +} diff --git a/apps/web-ui/packages/api/session-controller/src/catalog.ts b/apps/web-ui/packages/api/session-controller/src/catalog.ts new file mode 100644 index 000000000..25c0ae538 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/catalog.ts @@ -0,0 +1,132 @@ +/** Shared projection of the live LLM registry into the browser model catalog. */ + +import { RemoteError } from "@deepseek-ai/dsh-typert-protocol"; +import { credentialRef } from "@deepseek-ai/dsh-credentials"; +import type {} from "@deepseek-ai/dsh-settings"; +import type { LlmModelInfo } from "@deepseek-ai/dsh-llm"; +import type { Context } from "@deepseek-ai/cordis"; +import type { ModelCatalog, ModelReasoning, ModelSelection } from "./types.ts"; + +/** + * Build the browser model catalog without requiring a Session. + * @param ctx - Host context carrying the live LLM registry. + * @param defaultSelection - deployment default used before a Session selects a model. + * @returns successful non-empty provider groups and isolated provider failures. + */ +export async function buildModelCatalog( + ctx: Context, + defaultSelection: ModelSelection = ctx.agentDefaultModel.currentSelection(), +): Promise { + const providers = ctx.llm.listProviders(); + const catalog = await Promise.all( + providers.map(async (provider) => { + try { + const models = await ctx.llm.listModels(provider.id); + const entries = await Promise.all( + models.map(async (model) => { + const resolved = await ctx.llm.resolveModelInfo(provider.id, model.id); + const reasoning: ModelReasoning | undefined = + resolved.reasoning === undefined + ? undefined + : { + efforts: resolved.reasoning.efforts.map((effort) => ({ + id: effort.id, + name: effort.name, + ...(effort.description === undefined + ? {} + : { description: effort.description }), + })), + ...(resolved.reasoning.defaultEffort === undefined + ? {} + : { defaultEffort: resolved.reasoning.defaultEffort }), + }; + return { + id: model.id, + name: model.name, + ...(model.description === undefined ? {} : { description: model.description }), + ...(reasoning === undefined ? {} : { reasoning }), + }; + }), + ); + return { + kind: "group" as const, + group: { id: provider.id, name: provider.name, models: entries }, + }; + } catch (error) { + return { + kind: "failure" as const, + failure: { + id: provider.id, + name: provider.name, + message: error instanceof Error ? error.message : String(error), + }, + }; + } + }), + ); + const groups = catalog + .flatMap((item) => (item.kind === "group" ? [item.group] : [])) + .filter((group) => group.models.length > 0); + return { + default: { ...defaultSelection }, + routableProviders: groups.map((group) => group.id), + groups, + 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; + 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); +} + +/** + * Check configured provider API-key references independently of model availability. + * @param ctx - Host registry, settings, and credential services. + * @returns whether any API-key provider has a configured credential. + */ +export async function hasProviderApiKey(ctx: Context): Promise { + const settings = ctx.get("settings"); + const credentials = ctx.get("credentials"); + if (settings === undefined || credentials === undefined) { + throw new RemoteError( + "session/provider-credentials-unavailable", + "provider credentials are unavailable", + {}, + ); + } + const namespaces = settings.describe({ redactSecrets: true }); + for (const provider of ctx.llm.listConfigurableProviders()) { + if (provider.provider === "deepseek-account") continue; + let profile = namespaces.find((namespace) => namespace.ns === provider.settingsNs)?.value; + for (const key of provider.settingsPath) { + profile = + typeof profile === "object" && profile !== null ? Reflect.get(profile, key) : undefined; + } + if (typeof profile !== "object" || profile === null) continue; + const ref: unknown = Reflect.get(profile, "apiKeyEnv"); + if ( + typeof ref === "string" && + ref.length > 0 && + (await credentials.describe(credentialRef(ref))).configured + ) + return true; + } + return false; +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/contract/events.ts b/apps/web-ui/packages/api/session-controller/src/client/contract/events.ts new file mode 100644 index 000000000..a7176a068 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/contract/events.ts @@ -0,0 +1,207 @@ +/** Observable contiguous Session event window consumed by domain assemblers. */ +import { notifySubscribers, type ObservableSnapshot } from "@deepseek-ai/dsh-client-store"; +import type { LlmAttemptId, StreamChunk } from "@deepseek-ai/dsh-llm"; +import type { SessionEvent } from "@deepseek-ai/dsh-session/types"; + +/** Client-only live chunk presentation; `seq` orders the transient row between durable Session seqs. */ +export interface AssistantLiveChunkEvent { + readonly type: "assistant/live-chunk"; + readonly seq: number; + readonly time: number; + readonly data: { + readonly attemptId: LlmAttemptId; + readonly turn: number; + readonly step: number; + readonly chunk: StreamChunk; + }; +} + +/** Current durable Session event or one client-only live chunk presentation. */ +export type SessionEventLike = SessionEvent | AssistantLiveChunkEvent; + +/** Client history entry retaining its coarse transport discriminator. */ +export type SessionEventLikeEntry = + | { readonly type: "event"; readonly event: SessionEvent } + | { readonly type: "transient"; readonly event: AssistantLiveChunkEvent }; + +/** Scalar live entry accepted by append-only Client paths. */ +export type SessionLiveEventEntry = Extract; +/** Durable Assistant event that atomically supersedes one attempt's transient rows. */ +export interface SessionAssistantSettlementEntry { + readonly type: "event"; + readonly event: SessionEvent<"assistant/message"> | SessionEvent<"assistant/attempt">; +} +/** Client-only Assistant frame admitted outside durable cursor algebra. */ +export type SessionTransientEventEntry = Extract< + SessionEventLikeEntry, + { readonly type: "transient" } +>; + +interface EventWindowLeaf { + readonly kind: "leaf"; + readonly entries: readonly SessionEventLikeEntry[]; + readonly length: number; +} + +interface EventWindowConcat { + readonly kind: "concat"; + readonly left: EventWindowNode; + readonly right: EventWindowNode; + readonly length: number; +} + +type EventWindowNode = EventWindowLeaf | EventWindowConcat; + +function leaf(entries: readonly SessionEventLikeEntry[]): EventWindowLeaf { + return { kind: "leaf", entries, length: entries.length }; +} + +function concat(left: EventWindowNode, right: EventWindowNode): EventWindowConcat { + return { kind: "concat", left, right, length: left.length + right.length }; +} + +function materialize(node: EventWindowNode): readonly SessionEventLikeEntry[] { + if (node.kind === "leaf") return node.entries; + const entries = new Array(node.length); + const pending: EventWindowNode[] = [node]; + let index = 0; + while (pending.length > 0) { + const current = pending.pop() as EventWindowNode; + if (current.kind === "concat") { + pending.push(current.right, current.left); + continue; + } + for (const entry of current.entries) { + entries[index] = entry; + index += 1; + } + } + return entries; +} + +function windowSnapshot( + node: EventWindowNode, + hasMore: boolean, + revision: number, + change: SessionEventChange, +): SessionEventWindow { + let entries: readonly SessionEventLikeEntry[] | undefined; + return { + get entries() { + entries ??= materialize(node); + return entries; + }, + hasMore, + revision, + change, + }; +} + +/** Exact delta that produced the latest event-window revision. */ +export type SessionEventChange = + | { readonly kind: "replace"; readonly entries: readonly SessionEventLikeEntry[] } + | { readonly kind: "prepend"; readonly entries: readonly SessionEventLikeEntry[] } + | { readonly kind: "append"; readonly entries: readonly SessionEventLikeEntry[] } + | { + readonly kind: "settle-assistant"; + readonly attemptId: LlmAttemptId; + readonly entry?: SessionAssistantSettlementEntry; + }; + +/** Current contiguous event window and its latest synchronous delta. */ +export interface SessionEventWindow { + readonly entries: readonly SessionEventLikeEntry[]; + readonly hasMore: boolean; + readonly revision: number; + readonly change: SessionEventChange; +} + +/** Conversation-facing event source exposed by one Session binding. */ +export type SessionEventSource = ObservableSnapshot; + +/** Session-owned event feed; every accepted window mutation publishes synchronously. */ +export class MutableSessionEventSource implements SessionEventSource { + private readonly listeners = new Set<() => void>(); + private window: EventWindowNode = leaf([]); + private snapshot: SessionEventWindow = windowSnapshot(this.window, false, 0, { + kind: "replace", + entries: [], + }); + + /** @returns the cached event-window snapshot. */ + getSnapshot(): SessionEventWindow { + return this.snapshot; + } + + /** + * Subscribe to synchronous window publication. + * @param listener - invalidation callback. + * @returns unsubscribe function. + */ + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** + * Replace the complete contiguous window. + * @param entries - complete window. + * @param hasMore - whether older history remains. + */ + replace(entries: readonly SessionEventLikeEntry[], hasMore: boolean): void { + this.window = leaf(entries); + this.publish(hasMore, { kind: "replace", entries }); + } + + /** + * Prepend one older contiguous page. + * @param entries - newly loaded older entries. + * @param hasMore - whether still older history remains. + */ + prepend(entries: readonly SessionEventLikeEntry[], hasMore: boolean): void { + this.window = concat(leaf(entries), this.window); + this.publish(hasMore, { kind: "prepend", entries }); + } + + /** + * Append one contiguous live entry. + * @param entry - live tail entry. + */ + append(entry: SessionEventLikeEntry): void { + const entries = [entry]; + this.window = concat(this.window, leaf(entries)); + this.publish(this.snapshot.hasMore, { + kind: "append", + entries, + }); + } + + /** + * Replace one attempt's transient rows with its committed durable settlement. + * @param attemptId - process-local attempt whose live rows are now redundant. + * @param entry - durable settlement committed for that attempt. + */ + settleAssistant(attemptId: LlmAttemptId, entry?: SessionAssistantSettlementEntry): void { + const entries = materialize(this.window).filter( + (candidate) => candidate.type !== "transient" || candidate.event.data.attemptId !== attemptId, + ); + if (entry !== undefined) { + const index = entries.findIndex((candidate) => candidate.event.seq > entry.event.seq); + if (index < 0) entries.push(entry); + else entries.splice(index, 0, entry); + } + this.window = leaf(entries); + this.publish(this.snapshot.hasMore, { + kind: "settle-assistant", + attemptId, + ...(entry === undefined ? {} : { entry }), + }); + } + + private publish(hasMore: boolean, change: SessionEventChange): void { + this.snapshot = windowSnapshot(this.window, hasMore, this.snapshot.revision + 1, change); + notifySubscribers(this.listeners, "[session-controller] event feed"); + } +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/contract/session.ts b/apps/web-ui/packages/api/session-controller/src/client/contract/session.ts new file mode 100644 index 000000000..1e620360f --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/contract/session.ts @@ -0,0 +1,159 @@ +/** + * The outward session face. Feature packages never see the concrete Session + * class: components read lifecycle state through `useSession` (the + * ObservableSnapshot half), and orchestration code calls the behavior verbs + * below — nothing else. Widening this interface is the explicit act of + * widening what features may do to a session (and what every test fixture + * must stub); implementation-internal entry points (history staging, wire-frame + * dispatch) stay on the class, invisible out here. + */ +import type { + AttachmentIdType, + FileAttachmentRef, + ImageAttachmentRef, +} from "@deepseek-ai/dsh-attachment"; +import type { MessageId } from "@deepseek-ai/dsh-llm/brand"; +import type { SessionId, SessionSeq } from "@deepseek-ai/dsh-session/types"; +import type { RemoteResult } from "@deepseek-ai/dsh-typert-protocol"; +import type { ObservableSnapshot } from "@deepseek-ai/dsh-client-store"; +import type { PromptContentPart, QueueAction, SessionRequestId } from "../../types.ts"; +import type { PendingSubmissionAttachment, SessionSnapshot } from "./snapshot.ts"; + +/** + * Why a local submission echo left the snapshot: `observed` when its durable + * `user/message` event or host queue occurrence arrived (with the admitted + * attachment references in prompt order), `failed` when the prompt was rejected, + * threw, or was aborted before acceptance. + */ +export type PendingSubmissionRetirement = + | { + readonly reason: "observed"; + readonly attachments: readonly (ImageAttachmentRef | FileAttachmentRef)[]; + } + | { readonly reason: "failed" }; + +/** Input registering one local submission echo ahead of its prompt call. */ +export interface BeginSubmissionInput { + /** Delivery mode used with the upcoming prompt. */ + readonly mode: "queue" | "steer"; + /** Prompt text exactly as the upcoming prompt will send it. */ + readonly text: string; + /** Ordered image previews and durable file metadata matching the upcoming prompt attachments. */ + readonly attachments: readonly PendingSubmissionAttachment[]; + /** Settlement callback fired exactly once when the echo retires. */ + readonly onRetire?: (retirement: PendingSubmissionRetirement) => void; +} + +/** One registered submission echo: the identity its prompt must carry, and the pre-prompt escape hatch. */ +export interface SubmissionHandle { + /** The prompt RPC identity; pass it to {@link ISession.prompt}. */ + readonly requestId: SessionRequestId; + /** Retire the echo as failed when the caller cannot reach prompt() (serialization failure); no-op after any other settlement. */ + abandon(): void; +} + +/** Key-addressed projection read face (the useProjection resolution path; see ProjectionValueStore). */ +export interface ProjectionsFace { + /** + * The identity-stable bare observable for one projection key (absence is + * an `undefined` snapshot, never a missing face). + * @param key - projection key. + * @returns the key's value face. + */ + faceOf(key: string): ObservableSnapshot; +} + +/** Identity plus the behavior verbs features may invoke on a session. */ +export interface ISession { + /** The session's host identity (agent id — same axis). */ + readonly sessionId: SessionId; + /** Host-computed projection values by key (the useProjection seat). */ + readonly projections: ProjectionsFace; + /** + * Register one local submission echo in `snapshot.pendingSubmissions`, + * synchronously, before the caller serializes and sends the prompt. The + * Chat echoes persist until durable admission; transcript identities also + * wait for the Inbox claim watermark so stale queue rows stay suppressed. + * Queued echoes retire on queue acceptance. Identified failures retire + * submissions that have not already reached durable admission. + * @param input - echo content and the optional settlement callback. + * @returns the minted identity for {@link prompt} plus the pre-prompt abandon path. + */ + beginSubmission(input: BeginSubmissionInput): SubmissionHandle; + /** + * Send a prompt into the session. + * @param content - text plus browser-owned temporary image uploads. + * @param mode - 'queue' appends a turn; 'steer' interrupts the running one. + * @param signal - optional caller cancellation for the complete admission round-trip. + * @param requestId - identity from {@link beginSubmission}; a failed identified prompt retires its echo. + * @returns acceptance, or the business error (also mirrored into snapshot.promptError). + */ + prompt( + content: PromptContentPart[], + mode: "queue" | "steer", + signal?: AbortSignal, + requestId?: SessionRequestId, + ): Promise>; + /** + * Resolve one durable image referenced by this session. + * @param attachmentId - opaque id found in the folded session log. + * @returns the authenticated reference and decoded bytes. + */ + readAttachment( + attachmentId: AttachmentIdType, + ): Promise>; + /** + * Apply one edit, remove, or Steer action to a still-pending queue occurrence. + * @param itemId - agent-owned inbox occurrence identity. + * @param action - requested queue operation. + * @returns acceptance, or a business/transport error. + */ + updateQueue(itemId: MessageId, action: QueueAction): Promise>; + /** + * Cancel the running turn. Pending queued work remains and resumes in FIFO + * order after the Host reaches cancellation quiescence. + * @returns acceptance, or the business error. + */ + cancel(): Promise>; + /** + * Rename this session (explicit user title; pins it against automatic + * regeneration). + * @param title - raw title text (the host normalizes acceptance). + * @returns the normalized accepted title and its event seq, or the business error. + */ + rename(title: string): Promise>; + /** + * Extend history by at least 50 messages and two Turn starts, including a + * partial Turn at the window's beginning. Stop at 500 messages or history + * exhaustion even when those minima cannot be met. Publish one prepend. + * @returns completion; failures land in snapshot.olderError without discarding the open history. + */ + loadOlder(): Promise; + /** + * Page history backwards until the window covers `seq` (inclusive) — the + * turn-jump loader. Repeated calls while a jump is paging lower its shared + * target and return the in-flight completion; `snapshot.loadingOlder` is + * the busy signal for the whole jump. + * Each page uses the same Turn alignment as loadOlder, with a 200-message + * minimum. The 500-message limit applies per page, not to the whole jump. + * Older pages publish together at completion, including successful pages + * before a later failure; live events remain independently visible. + * @param seq - durable event seq the window must reach (a turn's `turn/start` seq). + * @returns completion once covered, exhausted, superseded, or failed soft. + */ + loadThrough(seq: SessionSeq): Promise; + /** + * Execute one slash-command line against this session's agent — pure + * admission semantics (the host executor durably logs the lifecycle). + * @param line - the full command line, leading slash included. + * @returns the admission result, or the Remote face's error branch. + */ + command(line: string): Promise>; +} + +/** + * The full outward face: behavior verbs plus the Session lifecycle read side + * (the `useSession` hook source). This is the type carried by + * `SessionBinding.session` and the provide channel. + */ +export type SessionFace = ISession & ObservableSnapshot; diff --git a/apps/web-ui/packages/api/session-controller/src/client/contract/sessions.ts b/apps/web-ui/packages/api/session-controller/src/client/contract/sessions.ts new file mode 100644 index 000000000..9daf7030f --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/contract/sessions.ts @@ -0,0 +1,167 @@ +/** + * The outward sessions-service face — what `ctx.sessions` exposes to feature + * packages. Transport entry points and implementation internals stay on + * the concrete class. Widening this interface is the + * explicit act of widening what features may do to the sessions domain. + */ +import type { Context } from "@deepseek-ai/cordis"; +import type { SubagentAddress } from "@deepseek-ai/dsh-subagent/client"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { WorkspaceId } from "@deepseek-ai/dsh-workspace/types"; +import type { RemoteResult } from "@deepseek-ai/dsh-typert-protocol"; +import type { AgentContext } from "../scope.ts"; +import type { SessionSearchResultItem } from "../sessions/manager.ts"; +import type { SessionBinding, SessionListState } from "../sessions/service.ts"; +import type { SessionFace } from "./session.ts"; +import type { ObservableSnapshot } from "@deepseek-ai/dsh-client-store"; +import type { SessionReferenceSource } from "../index.ts"; + +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; + +/** One independent use of an exact Client generation, without Host Agent ownership. */ +export interface SessionReference extends Disposable { + readonly sessionId: SessionId; + /** Shared binding; access fails after reference release or generation disposal. */ + readonly binding: SessionBinding; + /** This reference's cancellable wait for the shared initial `Session.open()` attempt to settle. */ + readonly ready: Promise; + /** Release once; the final reference starts local scope and history teardown. */ + release(): void; +} + +/** Consumer identity and optional cancellation of one acquisition waiter. */ +export interface SessionRetainOptions { + readonly source: SessionReferenceSource; + readonly signal?: AbortSignal | undefined; +} + +/** Local ownership counts, independent of catalog membership and never persisted. */ +export interface SessionRetainInfo { + readonly referenceCount: number; + /** Positive source counts only; a source without references is absent. */ + readonly retainedBy: Readonly>>; +} + +/** The sessions-service face injected as `ctx.sessions`. */ +export interface ISessions { + /** Host catalog and local reference-source counts; navigation belongs to view owners. */ + readonly list: ObservableSnapshot; + /** + * Retain an exact Client generation and start its shared initial history opening. + * @param target - known identity or durable direct-parent address. + * @param options - required consumer source and optional independent waiter cancellation. + * @returns an owned reference immediately; await `reference.ready` when the initial open attempt must settle first. + */ + retain(target: SessionTarget, options: SessionRetainOptions): SessionReference; + /** + * Hold one reference through callback settlement, including synchronous and asynchronous failures. + * @param target - Session to acquire. + * @param options - source and acquisition cancellation. + * @param operation - callback using the reference only until its returned value or Promise settles. + * @returns the callback result after release; acquisition and callback failures propagate unchanged. + */ + using( + target: SessionTarget, + options: SessionRetainOptions, + operation: (reference: SessionReference) => T | Promise, + ): Promise; + /** + * Observe local reference counts without retaining, creating a scope, or opening history. + * The returned source keeps stable identity across same-id generations and remains allocated + * until the Client root is disposed, even after its final subscriber leaves. + * @param id - explicit Session identity; Host existence is not implied. + * @returns a stable read-only source across same-id generations, with zero counts when none is live. + */ + retainInfo(id: SessionId): ObservableSnapshot; + /** + * The `session.search` result bound the wire schema fixes, exposed to + * presentation as injected data. Not per-connection state: every transport + * (fixture included) reports the same number. + */ + readonly searchResultLimit: number; + /** + * Create or adopt a Session on the Host. + * @param opts - target workspace, directory, and optional preallocated identity. + * @returns the catalogued identity; retain it before borrowing its binding. + */ + create(opts?: { + workspaceId?: WorkspaceId; + cwd?: string; + sessionId?: SessionId; + }): Promise; + /** + * Resolve an already discovered direct-parent address without opening it. + * @param id - possible addressed child id. + * @returns a retained or loaded-catalog address, without retaining a new selection or scope. + */ + subagentAddress(id: SessionId): SubagentAddress | undefined; + + /** + * Load all Session projections once per connection; retry an unsuccessful initial read. + * @param sessionId - Session to inspect without opening its conversation. + * @returns completion of the current or newly started refresh. + */ + refreshProjections(sessionId: SessionId): Promise; + + /** + * Refresh the Host-authoritative Session list. + * @returns completion of the current or newly started Session-list refresh. + */ + refresh(): Promise; + /** + * Search the Host's visible message-content index. Results stay + * request-local; the list snapshot remains the metadata authority. + * @param query - non-blank literal phrase. + * @param signal - cancellation for a superseded search. + * @returns bounded results, or a business/transport error. + */ + search( + query: string, + signal: AbortSignal, + ): Promise>; + /** + * Fork a session from an exact inclusive prefix of the source; on + * resolution the child is catalogued and can be explicitly retained. + * @param opts - source session id, the optional exact inclusive boundary + * seq (a real event seq the caller already knows; a cut inside an open + * turn is balanced Host-side with synthetic closers, and omission selects + * the latest completed-turn prefix), and whether to increment an + * inherited durable title before resolving. `onCreated` observes the catalogued child before that optional rename. + * @returns the child session id. + * @throws when the fork fails, or when a requested child-title rename fails after creation. + */ + fork(opts: { + sessionId: SessionId; + atSeq?: number; + increaseTitle?: boolean; + onCreated?: (childId: SessionId) => void; + }): Promise; + /** + * Borrow an already-retained Agent-scoped Context without extending its lifetime. + * @param id - session id. + * @returns the live scoped Context, or undefined without a retained generation. + */ + scope(id: SessionId): AgentContext | undefined; + /** + * Read the Agent scope tag off a context (service-method boundary: fetch + * bundles must reach scope resolution through ctx.sessions). + * @param ctx - any client context. + * @returns the session id, or undefined on root contexts. + */ + scopeOf(ctx: Context): SessionId | undefined; + /** + * Resolve the session face behind an Agent-scoped context. + * @param ctx - an Agent-scoped context. + * @returns the matching live Session, or undefined for an untagged, foreign, or ended generation. + */ + sessionOf(ctx: Context): SessionFace | undefined; + /** + * Borrow an already-retained Session binding without extending its lifetime. + * @param id - session id. + * @returns the live binding, or undefined without a retained generation. + */ + binding(id: SessionId): SessionBinding | undefined; +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/contract/snapshot.ts b/apps/web-ui/packages/api/session-controller/src/client/contract/snapshot.ts new file mode 100644 index 000000000..e1910cf33 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/contract/snapshot.ts @@ -0,0 +1,92 @@ +/** Session-owned observable state excluding Conversation target data. */ +import type { FileAttachmentRef } from "@deepseek-ai/dsh-attachment"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { SubagentAddress } from "@deepseek-ai/dsh-subagent/client"; +import type { RemoteFailure } from "@deepseek-ai/dsh-typert-protocol"; +import type { SessionRequestId } from "../../types.ts"; + +/** One image displayed by a local submission echo before durable admission. */ +export interface PendingSubmissionImage { + /** Browser-owned preview URL; its lifecycle belongs to the submitter, never this snapshot. */ + readonly previewUrl: string; + /** Browser file name, when the file had one. */ + readonly name?: string; + /** Intrinsic pixel width, when the submitter has probed it. */ + readonly width?: number; + /** Intrinsic pixel height, when the submitter has probed it. */ + readonly height?: number; +} + +/** Image branch of a local submission echo attachment. */ +export interface PendingSubmissionImageAttachment { + readonly type: "image"; + readonly value: PendingSubmissionImage; +} + +/** File branch of a local submission echo attachment. */ +export interface PendingSubmissionFileAttachment { + readonly type: "file"; + readonly value: FileAttachmentRef; +} + +/** One attachment displayed by a local submission echo, in prompt order. */ +export type PendingSubmissionAttachment = + PendingSubmissionImageAttachment | PendingSubmissionFileAttachment; + +/** Client surface selected when a local submission begins. */ +export type PendingSubmissionPlacement = "transcript" | "queued" | "steering"; + +/** + * One local prompt-submission echo: inserted synchronously when a submission + * begins, so the conversation can show the message before serialization, + * transport, and durable admission complete. Client-memory only — reload and + * reconnect rebuild the conversation from durable events alone. + */ +export interface PendingSubmission { + /** The prompt RPC identity; the durable `user/message` source echoes it as `rpcId`. */ + readonly requestId: SessionRequestId; + /** Surface fixed at submission time; Inbox acceptance does not move a Chat echo into the dock. */ + readonly placement: PendingSubmissionPlacement; + /** Client wall-clock ms when the submission began. */ + readonly time: number; + /** Prompt text exactly as it will be sent (one text block). */ + readonly text: string; + /** Ordered image previews and durable file metadata matching the prompt attachments. */ + readonly attachments: readonly PendingSubmissionAttachment[]; +} + +/** History-open lifecycle of a Session event window. */ +export type OpenState = "cold" | "loading" | "open" | "error"; + +/** Send/stop failure surfaced by Session consumers. */ +export interface PromptError { + readonly op: "send" | "stop"; + readonly error: RemoteFailure; +} + +/** Immutable Session lifecycle and control snapshot. */ +export interface SessionSnapshot { + readonly sessionId: SessionId; + /** Local prompt-submission echoes not yet observed as durable events or queue occurrences. */ + readonly pendingSubmissions: readonly PendingSubmission[]; + readonly running: boolean; + readonly subagent: { + readonly address: SubagentAddress; + /** Absent until the direct-parent catalog resolves. */ + readonly parentAvailable?: boolean; + } | null; + readonly removed: boolean; + readonly openState: OpenState; + readonly openError: RemoteFailure | null; + readonly hasMore: boolean; + readonly loadingOlder: boolean; + /** An older-page failure leaves the current conversation usable and retryable. */ + readonly olderError: RemoteFailure | null; + readonly promptError: PromptError | null; + readonly blank: boolean; + readonly lastAgentError: string | null; + /** A prompt call has begun on this Client Session object. */ + readonly promptAttempted: boolean; + /** The first accepted prompt has not reached a durable `turn/start` event. */ + readonly awaitingFirstTurn: boolean; +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/index.ts b/apps/web-ui/packages/api/session-controller/src/client/index.ts new file mode 100644 index 000000000..6283ae6b0 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/index.ts @@ -0,0 +1,176 @@ +/** Client Session object layer, Agent scopes, and Remote lifecycle wiring. */ + +import type { Context } from "@deepseek-ai/cordis"; +import type {} from "@deepseek-ai/dsh-agent/types"; +import type { ConnectionHandle } from "@deepseek-ai/dsh-client-connection/client"; +import type {} from "@deepseek-ai/dsh-client-file-upload/client"; +import { typertOwnedValue } from "@deepseek-ai/dsh-typert-protocol"; +import { createSessionControlStream, type SessionControlStream } from "./transport.ts"; +import { ClientSessions } from "./sessions/service.ts"; +import type { SessionRemotes } from "./sessions/remotes.ts"; +import type {} from "../remote-events.ts"; + +export { + createSessionControlStream, + SessionEventStream, + SESSION_SEARCH_RESULT_LIMIT, + SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS, +} from "./transport.ts"; +export type { + ClientSessionPageRequest, + SessionControlStream, + SessionControlStreamOptions, + SessionEventStreamOptions, + SessionJournalChange, + SessionRemote, +} from "./transport.ts"; +export { createScope, scopeOf } from "./scope.ts"; +export type { AgentContext, AgentScopeHandle } from "./scope.ts"; +export { SessionCreateError, SessionForkError } from "./sessions/service.ts"; +export type { SessionBinding, SessionListState, SessionSummary } from "./sessions/service.ts"; +export type { + SessionListPhase, + SessionListSnapshot, + SessionSearchResultItem, + SessionProjectionSnapshot, +} from "./sessions/manager.ts"; +export type { Session } from "./sessions/session.ts"; +export type { + ProjectionsBaseline, + ProjectionValueStore, + SessionProjectionMap, + UseProjection, +} from "./sessions/projection-store.ts"; +export type { + BeginSubmissionInput, + ISession, + PendingSubmissionRetirement, + ProjectionsFace, + SessionFace, + SubmissionHandle, +} from "./contract/session.ts"; +export type { + ISessions, + SessionReference, + SessionRetainInfo, + SessionRetainOptions, + SessionTarget, +} from "./contract/sessions.ts"; +export { MutableSessionEventSource } from "./contract/events.ts"; +export type { + AssistantLiveChunkEvent, + SessionAssistantSettlementEntry, + SessionEventChange, + SessionEventLike, + SessionEventLikeEntry, + SessionEventSource, + SessionEventWindow, + SessionLiveEventEntry, + SessionTransientEventEntry, +} from "./contract/events.ts"; +export type { + OpenState, + PendingSubmission, + PendingSubmissionAttachment, + PendingSubmissionFileAttachment, + PendingSubmissionImage, + PendingSubmissionImageAttachment, + PendingSubmissionPlacement, + PromptError, + SessionSnapshot, +} from "./contract/snapshot.ts"; + +/** Consumer-owned reference labels; extend this map through the package's canonical /client entry. */ +export interface SessionReferenceSourceMap { + /** Temporary Client Controller work, including fork-title preparation. */ + controllerOperation: unknown; + /** A Client Gateway invocation's synchronous Context ownership. */ + gateway: unknown; +} + +/** Declaration-merge-extensible labels carried by independent Client references. */ +export type SessionReferenceSource = Extract; + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Client Session object layer and Agent scope owner. */ + sessions: import("./contract/sessions.ts").ISessions; + } +} + +/** Required Remote and Context projection services. */ +export const inject = [ + "connection", + "fileUpload", + "typert", + "remote", + "remote.commands", + "remote.session", + "remote.subagents", +]; + +/** + * Install Client Session state and its reconnecting control stream. + * @param ctx - Client Cordis context. + */ +export function apply(ctx: Context): void { + const remotes = ctx.remote as unknown as SessionRemotes; + const connection = ctx.get("connection") as ConnectionHandle; + const sessions = new ClientSessions(ctx, remotes); + ctx.remote.$on("api-session/added", (summary) => { + sessions.handleSessionAdded(summary); + }); + ctx.remote.$on("api-session/removed", (sessionId) => { + sessions.handleSessionRemoved(sessionId); + }); + ctx.remote.$on("api-session/status", (sessionId, running) => { + sessions.handleSessionStatus(sessionId, running); + }); + ctx.remote.$on("api-session/activity", (sessionId, updatedAt) => { + sessions.handleSessionActivity(sessionId, updatedAt); + }); + ctx.remote.$on("api-session/error", (sessionId, message) => { + sessions.handleSessionError(sessionId, message); + }); + + let control: SessionControlStream | undefined; + const connected = (): void => { + if (connection.generation.getSnapshot() === undefined) return; + // A carrier failure can terminate the old stream before the connection + // reports its next generation. restart() cannot revive a closed consumer. + const previous = control; + control = undefined; + void previous?.dispose(); + sessions.handleConnected(); + const next = createSessionControlStream(remotes, { + accept: (frame) => { + if (control === next) sessions.handleControlFrame(frame); + }, + failed: (error) => { + if (control === next) console.error("[session-controller] control stream failed:", error); + }, + }); + control = next; + next.start(); + }; + ctx.effect( + () => connection.generation.subscribe(connected), + "session-controller.client.generation", + ); + connected(); + ctx.typert.contexts.registerClient("agent", { + identity: (candidate) => sessions.sessionOf(candidate)?.sessionId, + resolve: (sessionId) => { + const reference = sessions.retainAgentScope(sessionId); + return typertOwnedValue(reference.binding.ctx, () => { + reference.release(); + }); + }, + }); + ctx.effect( + () => async () => { + await control?.dispose(); + }, + "session-controller.client.control", + ); +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/ordered-baseline.ts b/apps/web-ui/packages/api/session-controller/src/client/ordered-baseline.ts new file mode 100644 index 000000000..73ae03ae4 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/ordered-baseline.ts @@ -0,0 +1,43 @@ +/** + * Merge an authoritative baseline without moving identities already visible to + * the client. Baseline-only identities are inserted relative to the nearest + * following known identity; identities absent from the baseline are removed. + * + * @param current - the established client order. + * @param baseline - the latest authoritative rows. + * @param keyOf - stable identity selector. + * @returns baseline-valued rows with the established relative order retained. + */ +export function mergeOrderedBaseline( + current: readonly T[], + baseline: readonly T[], + keyOf: (value: T) => unknown, +): T[] { + const baselineByKey = new Map(); + for (const value of baseline) baselineByKey.set(keyOf(value), value); + + const merged = current + .map((value) => baselineByKey.get(keyOf(value))) + .filter((value): value is T => value !== undefined); + const mergedKeys = new Set(merged.map(keyOf)); + + for (let index = 0; index < baseline.length; index++) { + const value = baseline[index]; + /* v8 ignore next -- dense-array guard: index is bounded by baseline.length. */ + if (value === undefined || mergedKeys.has(keyOf(value))) continue; + let insertion = merged.length; + for (let following = index + 1; following < baseline.length; following++) { + const candidate = baseline[following]; + /* v8 ignore next -- dense-array guard: following is bounded by baseline.length. */ + if (candidate === undefined) continue; + const known = merged.findIndex((item) => keyOf(item) === keyOf(candidate)); + if (known !== -1) { + insertion = known; + break; + } + } + merged.splice(insertion, 0, value); + mergedKeys.add(keyOf(value)); + } + return merged; +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/scope.ts b/apps/web-ui/packages/api/session-controller/src/client/scope.ts new file mode 100644 index 000000000..0a1c161f6 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/scope.ts @@ -0,0 +1,76 @@ +/** Client scope generations route local events independently of Host Agent residency. */ +import { Context as CordisContext } from "@deepseek-ai/cordis"; +import type { Context, Fiber } from "@deepseek-ai/cordis"; +import type { ClientRemote } from "@deepseek-ai/dsh-api-gateway/client"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { TypertRemoteScopeApi } from "@deepseek-ai/dsh-typert-protocol"; + +/** Client Cordis Context carrying one Agent identity and its scoped Remote namespaces. */ +export type AgentContext = Omit & { + readonly remote: ClientRemote & TypertRemoteScopeApi<"agent">; +}; + +/** Context tag written by {@link createScope}. */ +const kScope = Symbol("dsh.client.scope"); + +interface ScopeIdentity { + readonly sessionId: SessionId; +} + +/** A minted Agent scope and its disposal boundary. */ +export interface AgentScopeHandle { + /** + * Tagged context: scope-owned registrations and scoped dispatch both go + * through it (passing it as the dispatch subject routes to this generation's + * tagged listeners plus every untagged one). + */ + ctx: AgentContext; + /** Backing fiber (dispose tears down every scope-owned registration). */ + fiber: Fiber; +} + +/** Shared no-op plugin backing each Agent scope fiber. */ +function agentScope(): void {} + +/** + * Mint an Agent scope under `ctx`: a no-op plugin fiber whose context + * carries the agent tag and the dispatch filter — untagged listeners are + * admitted globally, tagged listeners only for the same Client generation. + * Registrations through the returned ctx dispose with the fiber. + * @param ctx - client root context the scope fiber mounts under. + * @param key - durable Session identity carried by this generation. + * @returns the tagged context and its backing fiber. + */ +export function createScope(ctx: Context, key: SessionId): AgentScopeHandle { + const fiber = ctx.plugin(agentScope); + const identity: ScopeIdentity = { sessionId: key }; + const scoped = fiber.ctx.extend({ + [kScope]: identity, + [CordisContext.filter](listenerCtx: Context): boolean { + const tag = scopeIdentityOf(listenerCtx); + return tag === undefined || tag === identity; + }, + }) as AgentContext; + return { + fiber, + ctx: scoped, + }; +} + +/** + * Read the nearest agent tag inherited by a context. + * @param ctx - any client context. + * @returns its agent identity (the session id), or undefined for root contexts. + */ +export function scopeOf(ctx: Context): SessionId | undefined { + return scopeIdentityOf(ctx)?.sessionId; +} + +/** + * Read the exact generation identity inherited by a Client Context. + * @param ctx - scoped or root Client Context. + * @returns the generation identity, or undefined for an unscoped Context. + */ +export function scopeIdentityOf(ctx: Context): ScopeIdentity | undefined { + return (ctx as Context & { [kScope]?: ScopeIdentity })[kScope]; +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/session-wire-event.ts b/apps/web-ui/packages/api/session-controller/src/client/session-wire-event.ts new file mode 100644 index 000000000..1159a05f9 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/session-wire-event.ts @@ -0,0 +1,56 @@ +/** Event-local acceptance for raw Session journal responses; payloads remain owner-defined JSON. */ + +import { + validateSessionEventData, + validateSurfaceMetadata, +} from "@deepseek-ai/dsh-session/surface"; +import type { SessionEvent } from "@deepseek-ai/dsh-session/types"; +import type { SessionWireEvent } from "../types.ts"; + +/** + * Reject non-current event envelopes without stripping or normalizing wire fields. + * Range membership and source existence require the durable log and remain Host-owned. + * @param value - one event received in a follow frame or history page. + * @returns nothing after narrowing the accepted event envelope. + * @throws when the envelope or current event-local metadata is invalid. + */ +export function assertSessionWireEvent(value: unknown): asserts value is SessionWireEvent { + const subject = "session wire event"; + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${subject} must be an object`); + } + const event = value as Record; + for (const key of Object.keys(event)) { + switch (key) { + case "type": + case "seq": + case "time": + case "data": + case "ignorable": + case "surfaceOp": + case "sourceEventSeqs": + break; + default: + throw new Error(`${subject} has unexpected field ${key}`); + } + } + const seq = event["seq"]; + if ( + typeof event["type"] !== "string" || + typeof seq !== "number" || + !Number.isSafeInteger(seq) || + seq < 0 || + Object.is(seq, -0) || + typeof event["time"] !== "number" || + !Number.isSafeInteger(event["time"]) || + !Object.hasOwn(event, "data") || + event["data"] === undefined || + (Object.hasOwn(event, "ignorable") && event["ignorable"] !== true) + ) { + throw new Error(`${subject} has an invalid envelope`); + } + // Event names and payloads are merge-extensible; only event-local owner rules run here. + const current = event as SessionEvent; + validateSurfaceMetadata(current); + validateSessionEventData(current, subject); +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/sessions/assistant-stream.ts b/apps/web-ui/packages/api/session-controller/src/client/sessions/assistant-stream.ts new file mode 100644 index 000000000..f6f32753f --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/sessions/assistant-stream.ts @@ -0,0 +1,240 @@ +/** Web presentation fold joining transient Assistant frames to one durable v2 settlement. */ + +import type { SessionAssistantStreamBaseline, SessionAssistantStreamFrame } from "../../types.ts"; +import { expandAssistantStream } from "@deepseek-ai/dsh-llm/assistant-stream"; +import type { AssistantStreamRecord } from "@deepseek-ai/dsh-llm/assistant-stream"; +import type { LlmAttemptId } from "@deepseek-ai/dsh-llm/brand"; +import type { + SessionAssistantSettlementEntry, + SessionEventLikeEntry, + SessionLiveEventEntry, + SessionTransientEventEntry, +} from "../contract/events.ts"; + +interface ActiveAttempt { + readonly attemptId: LlmAttemptId; + readonly startedAfterSeq: number; + readonly turn: number; + readonly step: number; + nextIndex: number; +} + +/** One Web publication decision from the assistant stream fold. */ +export type ClientAssistantStreamResult = + | { + readonly type: "publish"; + readonly entry: SessionLiveEventEntry; + /** Retire the successful attempt's transient rows after publishing its Step end. */ + readonly retireAttemptId?: LlmAttemptId; + } + | { + readonly type: "settlement"; + readonly attemptId: LlmAttemptId; + readonly entry: SessionAssistantSettlementEntry; + } + | { readonly type: "abandonment"; readonly attemptId: LlmAttemptId } + | { readonly type: "transient"; readonly entry: SessionTransientEventEntry } + | { readonly type: "rebaseline" } + | undefined; + +/** Keeps transient Assistant presentation behind one settlement-aware interface. */ +export class ClientAssistantStream { + private activeAttempt: ActiveAttempt | undefined; + private retainedAttempt: Pick | undefined; + private readonly pending = new Map(); + private publishedSeqs = new Set(); + private durableCursor = -1; + private transientInGap = 0; + + /** + * Replace the durable Web window and adopt an optional reconnect baseline. + * @param entries - durable entries in the replacement window. + * @param baseline - compact prefix for an Assistant attempt that is still live. + * @returns immediately visible durable entries plus reconstructed transient chunks. + */ + replace( + entries: readonly SessionEventLikeEntry[], + baseline?: SessionAssistantStreamBaseline, + ): readonly SessionEventLikeEntry[] { + this.pending.clear(); + this.transientInGap = 0; + this.activeAttempt = undefined; + this.retainedAttempt = undefined; + const opening = baseline?.activeAttempt; + if (opening !== undefined) { + this.activeAttempt = { + attemptId: opening.attemptId, + startedAfterSeq: opening.startedAfterSeq, + turn: opening.turn, + step: opening.step, + nextIndex: opening.nextIndex, + }; + } + const visible: SessionEventLikeEntry[] = [...entries]; + this.publishedSeqs = new Set(visible.map((entry) => entry.event.seq)); + this.durableCursor = visible.reduce((cursor, entry) => Math.max(cursor, entry.event.seq), -1); + if (opening !== undefined) { + for (const [index, member] of expandAssistantStream( + opening.stream as unknown as readonly AssistantStreamRecord[], + ).entries()) { + this.transientInGap += 1; + visible.push({ + type: "transient", + event: { + type: "assistant/live-chunk", + seq: this.durableCursor + 1 - 1 / (this.transientInGap + 1), + time: member.time, + data: { + attemptId: opening.attemptId, + turn: opening.turn, + step: opening.step, + chunk: member.chunk, + }, + }, + }); + if (index + 1 >= opening.nextIndex) break; + } + } + return visible; + } + + /** + * Stage one durable v2 settlement while its matching live attempt is open. + * @param entry - newly followed durable entry. + * @returns a publication decision, or `undefined` when no entry becomes visible. + */ + acceptDurable(entry: SessionLiveEventEntry): ClientAssistantStreamResult { + const event = entry.event; + this.durableCursor = Math.max(this.durableCursor, event.seq); + this.transientInGap = 0; + const settlement = assistantSettlementEntry(entry); + if (settlement !== undefined && this.attemptForSettlement(settlement.event) !== undefined) { + if (this.pending.has(event.seq)) return { type: "rebaseline" }; + this.pending.set(event.seq, settlement); + return undefined; + } + return this.publish(entry); + } + + /** + * Fold one dense transient frame and release its named durable settlement. + * Successful messages retain their transient rows until the owning Step ends; + * interrupted messages, failed attempts, and abandonment retire them immediately. + * @param frame - next Assistant stream frame received by the follow connection. + * @returns a transient, publication, or rebaseline decision, or `undefined` when no entry becomes visible. + */ + acceptFrame(frame: SessionAssistantStreamFrame): ClientAssistantStreamResult { + switch (frame.type) { + case "start": + if ( + this.activeAttempt !== undefined || + this.retainedAttempt !== undefined || + this.pending.size > 0 + ) + return { type: "rebaseline" }; + this.pending.clear(); + this.activeAttempt = { + attemptId: frame.attemptId, + startedAfterSeq: frame.startedAfterSeq, + turn: frame.turn, + step: frame.step, + nextIndex: 0, + }; + return undefined; + case "chunk": { + const attempt = this.activeAttempt; + // A controller mounted after the Host saw this attempt has no start + // frame to reconstruct. Its durable settlement publishes directly; + // ignore the transient suffix until the next known start. + if (attempt === undefined || attempt.attemptId !== frame.attemptId) return undefined; + if (frame.index !== attempt.nextIndex) return { type: "rebaseline" }; + attempt.nextIndex += 1; + this.transientInGap += 1; + return { + type: "transient", + entry: { + type: "transient", + event: { + type: "assistant/live-chunk", + seq: this.durableCursor + 1 - 1 / (this.transientInGap + 1), + time: frame.time, + data: { + attemptId: frame.attemptId, + turn: attempt.turn, + step: attempt.step, + chunk: frame.chunk as never, + }, + }, + }, + }; + } + case "end": { + const attempt = this.activeAttempt; + if (attempt === undefined || attempt.attemptId !== frame.attemptId) { + return undefined; + } + this.activeAttempt = undefined; + if (frame.index !== attempt.nextIndex) return { type: "rebaseline" }; + if (frame.outcome.kind === "abandoned") { + return this.pending.size === 0 + ? { type: "abandonment", attemptId: attempt.attemptId } + : { type: "rebaseline" }; + } + if (this.publishedSeqs.has(frame.outcome.seq)) return undefined; + const entry = this.pending.get(frame.outcome.seq); + if (entry === undefined || entry.event.type !== frame.outcome.eventType) { + return { type: "rebaseline" }; + } + this.pending.delete(frame.outcome.seq); + if (entry.event.type === "assistant/message" && entry.event.data.interrupted !== true) { + this.retainedAttempt = { + attemptId: attempt.attemptId, + turn: attempt.turn, + step: attempt.step, + }; + return this.publish(entry); + } + this.publishedSeqs.add(entry.event.seq); + return { type: "settlement", attemptId: attempt.attemptId, entry }; + } + } + } + + private attemptForSettlement( + event: SessionAssistantSettlementEntry["event"], + ): ActiveAttempt | undefined { + const attempt = this.activeAttempt; + if ( + attempt === undefined || + (event.type === "assistant/message" && event.surfaceOp !== "append") || + event.seq <= attempt.startedAfterSeq || + attempt.turn !== event.data.turn || + attempt.step !== event.data.step + ) + return undefined; + return attempt; + } + + private publish(entry: SessionLiveEventEntry): ClientAssistantStreamResult { + this.publishedSeqs.add(entry.event.seq); + const retained = this.retainedAttempt; + if ( + retained !== undefined && + entry.event.type === "step/end" && + entry.event.data.turn === retained.turn && + entry.event.data.step === retained.step + ) { + this.retainedAttempt = undefined; + return { type: "publish", entry, retireAttemptId: retained.attemptId }; + } + return { type: "publish", entry }; + } +} + +function assistantSettlementEntry( + entry: SessionLiveEventEntry, +): SessionAssistantSettlementEntry | undefined { + return entry.event.type === "assistant/message" || entry.event.type === "assistant/attempt" + ? (entry as SessionAssistantSettlementEntry) + : undefined; +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/sessions/history-records.ts b/apps/web-ui/packages/api/session-controller/src/client/sessions/history-records.ts new file mode 100644 index 000000000..3bac83c4d --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/sessions/history-records.ts @@ -0,0 +1,33 @@ +/** Client range access and type narrowing for aligned Session history records. */ + +import type { SessionHistoryRecord } from "../../types.ts"; +import type { SessionEventLikeEntry } from "../contract/events.ts"; + +/** + * Narrow aligned wire records to their Client event types without allocation. + * @param records - validated history transport records. + * @returns the same record array with typed inner events. + */ +export function historyEntries( + records: readonly SessionHistoryRecord[], +): readonly SessionEventLikeEntry[] { + return records as unknown as readonly SessionEventLikeEntry[]; +} + +/** + * Read the first logical sequence represented by one wire record. + * @param record - validated Session event. + * @returns inclusive first Session sequence. + */ +export function historyRecordFirstSeq(record: SessionHistoryRecord): number { + return record.event.seq; +} + +/** + * Read the final logical sequence represented by one wire record. + * @param record - validated Session event. + * @returns inclusive final Session sequence. + */ +export function historyRecordLastSeq(record: SessionHistoryRecord): number { + return record.event.seq; +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/sessions/lineage.ts b/apps/web-ui/packages/api/session-controller/src/client/sessions/lineage.ts new file mode 100644 index 000000000..b77333a27 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/sessions/lineage.ts @@ -0,0 +1,83 @@ +// flattenLineage: summaries -> flat list with lineage indentation (pure function). +// The input order is authoritative; lineage only makes each child adjacent to its parent. +// Orphaned lineage degrades to root level; cycles fail soft and emit as roots. + +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { SessionProjectionMap } from "@deepseek-ai/dsh-session-projection/types"; +import type { SessionSummary } from "../../types.ts"; + +/** Host list summary enriched with the latest Session Controller title projection. */ +export interface TitledSessionSummary extends SessionSummary { + title?: string; + /** Current host-computed projection values for list consumers. */ + projectionValues?: Readonly>; +} + +/** One flattened session-list row with lineage depth. */ +export interface SessionListEntry { + sessionId: SessionId; + title?: string; + updatedAt: number; + running: boolean; + /** + * New Session presentation and reuse eligibility, reconciled with + * `sessionListMetadata`; lists hide blank sessions (filtering stays with the consumer). + */ + blank: boolean; + parentSessionId?: SessionId; + /** Coarse durable origin for navigation filtering; not a continuation capability. */ + origin?: "subagent"; + cwd?: string; + /** Current host-computed projection values for list consumers. */ + projectionValues?: Readonly>; + /** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */ + depth: number; +} + +/** + * Summaries -> flat list with lineage indentation. Root and sibling order + * follows the established input order; this projection never re-sorts a + * hydrated list from mutable timestamps. + * @param summaries - the host's session.list items. + * @returns display rows in render order. + */ +export function flattenLineage(summaries: readonly TitledSessionSummary[]): SessionListEntry[] { + const byId = new Map(); + for (const s of summaries) byId.set(s.sessionId, s); + + const children = new Map(); + const roots: TitledSessionSummary[] = []; + for (const s of summaries) { + if (s.parentSessionId !== undefined && byId.has(s.parentSessionId)) { + const list = children.get(s.parentSessionId) ?? []; + list.push(s); + children.set(s.parentSessionId, list); + } else { + roots.push(s); // root, or an orphan whose parent is absent from summaries (degrade to root, never drop) + } + } + + const out: SessionListEntry[] = []; + const visited = new Set(); + const walk = (s: TitledSessionSummary, depth: number): void => { + if (visited.has(s.sessionId)) { + console.warn(`[session-controller] lineage cycle at ${s.sessionId}; emitting as root`); + return; + } + visited.add(s.sessionId); + const { agentAvailable: _agentAvailable, ...row } = s; + out.push({ + ...row, + depth, + }); + const kids = children.get(s.sessionId); + if (kids === undefined) return; + for (const kid of kids) walk(kid, depth + 1); + }; + for (const root of roots) walk(root, 0); + // Cycle members (unreachable from any root): emit as roots so no entry is lost. + for (const s of summaries) { + if (!visited.has(s.sessionId)) walk(s, 0); + } + return out; +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/sessions/manager.ts b/apps/web-ui/packages/api/session-controller/src/client/sessions/manager.ts new file mode 100644 index 000000000..aab4d2329 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/sessions/manager.ts @@ -0,0 +1,944 @@ +/** Host catalog, durable projection caches, and explicitly retained Client instances. */ + +import type { SubagentAddress } from "@deepseek-ai/dsh-subagent/client"; +import { SessionSeq, type SessionId, type SessionSeqCursor } from "@deepseek-ai/dsh-session/types"; +import type { SessionProjectionMap } from "@deepseek-ai/dsh-session-projection/types"; +import type { WorkspaceId } from "@deepseek-ai/dsh-workspace/types"; +import type { + SessionControlBaseline, + SessionControlFrame, + SessionProjectionHints, + SessionRenameValue, + SessionSummary, +} from "../../types.ts"; +import { mergeOrderedBaseline } from "../ordered-baseline.ts"; +import { isRemoteFailure } from "@deepseek-ai/dsh-api-gateway/client"; +import { assertNever } from "@deepseek-ai/dsh-util-values"; +import type { RemoteFailure, RemoteResult } from "@deepseek-ai/dsh-typert-protocol"; +import type { SessionListEntry, TitledSessionSummary } from "./lineage.ts"; +import { flattenLineage } from "./lineage.ts"; +// Type-only merge edge: the title domain's client-namespace outlet declares +// the 'title' projection key this manager projects into list rows (and any +// useProjection('title') consumer reads). Zero value imports by construction. +import type {} from "@deepseek-ai/dsh-session-title/client"; +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"; + +function sessionSeqCursor(value: number): SessionSeqCursor { + return value === -1 ? -1 : SessionSeq(value); +} + +/** + * List arrival lifecycle, orthogonal to the pull-activity `state` axis: + * `pending` (no successful pull yet — an empty items array means "nothing + * arrived", not "nothing exists") → `ready` (at least one pull landed). + * Monotone: `ready` never steps back — later pull failures and reconnect + * re-pulls ride the `state`/`error` axis, which is where failure is modeled + * (no `error` phase here; that would duplicate `state`). + */ +export type SessionListPhase = "pending" | "ready"; + +/** Request-local content hit returned to sidebar search consumers. */ +export interface SessionSearchResultItem { + sessionId: SessionId; + snippet: string; +} + +/** Immutable session-list snapshot for useSessionList. */ +export interface SessionListSnapshot { + items: readonly SessionListEntry[]; + state: "idle" | "loading" | "error"; + /** Arrival lifecycle (see {@link SessionListPhase}); `state` stays the pull-activity axis. */ + phase: SessionListPhase; + error: RemoteFailure | null; + projectionsBySession: Readonly>; +} + +/** Shared projection values and the lifecycle of their explicit baseline read. */ +export interface SessionProjectionSnapshot { + readonly values: Readonly>; + readonly state: "idle" | "loading" | "ready" | "error"; + readonly error: RemoteFailure | null; +} + +interface ProjectionInflight { + readonly promise: Promise; + readonly controller: AbortController; +} + +type ProjectionLoad = Omit; + +type SessionListMutation = + | { kind: "upsert" | "placeholder"; summary: SessionSummary } + | { kind: "remove"; sessionId: SessionId } + | { kind: "status"; sessionId: SessionId; running: boolean; agentAvailable: boolean } + | { kind: "activity"; sessionId: SessionId; updatedAt: number } + /** Local first-send flip: the sender clears blank without waiting for a host frame. */ + | { kind: "engaged"; sessionId: SessionId }; + +/** Instance cluster + frame entry + the session list. */ +export class SessionManager { + private readonly sessions = new Map(); + /** In-flight Session disposals remain here after instances leave `sessions`, so manager disposal can await quiescence. */ + private readonly sessionDisposals = new Set>(); + /** + * Accepted/running presentation must survive a later empty-history list + * response. Host-asserted running is recorded even before a row, instance, or + * address holds the identity — the listing that would hold it may not have + * landed yet — while the client-local acceptance callback requires a current + * holder, because it can arrive from a replaced or already-dropped Session. + */ + private readonly engagedSessions = new Set(); + private disposed = false; + /** Per-session projection value stores, retained independently of instance arrival (the + * title-snapshot precedent, generalized): push frames land here whether or not the Session + * is instantiated (list rows read the 'title' key), and an instantiated Session adopts the + * same store so history-baseline seeding and frames converge on one row set. */ + private readonly projectionStores = new Map(); + private summaries: SessionSummary[] = []; + private listState: "idle" | "loading" | "error" = "idle"; + /** Arrival phase; the pending → ready edge fires on the first successful pull (see SessionListPhase). */ + private listPhase: SessionListPhase = "pending"; + private listError: RemoteFailure | null = null; + private listInflight: Promise | null = null; + /** Active list request's mutation log; its identity also fences completion after reconnect. */ + private listMutations: SessionListMutation[] | null = null; + private readonly addresses = new Map(); + private readonly projectionLoads = new Map(); + private readonly projectionInflight = new Map(); + + private listSnapshotCache: SessionListSnapshot; + /** Entry-identity cache (reference stability): list rebuilds reuse the previous entry + * object when every field matches — wire refreshes mint all-new summary objects, so identity + * must be recovered by value or every SessionListItem memo misses on every refresh. */ + private entryCache = new Map(); + private itemsCache: readonly SessionListEntry[] = []; + private readonly notifier = new Notifier(() => { + this.listSnapshotCache = this.buildListSnapshot(); + }); + + /** @param remote - generated Remote namespaces used by catalog and history readers. */ + constructor(private readonly remote: SessionRemotes) { + this.listSnapshotCache = this.buildListSnapshot(); + } + + /** + * Resolve an acquisition target without materializing a Session. + * @param target - known identity or durable direct-parent address. + * @returns the resolved identity with its explicit or catalog-derived history route installed. + */ + resolveTarget(target: SessionTarget): SessionId { + const id = typeof target === "string" ? target : target.childSessionId; + const address = typeof target === "string" ? this.subagentAddress(id) : target; + if ( + typeof target === "string" && + !this.sessions.has(id) && + !this.summaries.some((summary) => summary.sessionId === id) && + address === undefined + ) { + throw new Error(`sessions.retain: unknown session ${id}`); + } + if (address !== undefined) this.addresses.set(id, address); + this.sessions + .get(id) + ?.configureSubagent( + address, + address === undefined ? undefined : this.agentAvailable(address.parentSessionId), + ); + return id; + } + + /** + * Resolve an address for breadcrumb navigation without retaining transport authority. + * @param sessionId - possible child id in an already-loaded catalog. + * @returns A retained or catalog-derived direct-parent address. + */ + subagentAddress(sessionId: SessionId): SubagentAddress | undefined { + const retained = this.addresses.get(sessionId); + if (retained !== undefined) return retained; + for (const parentSessionId of this.projectionStores.keys()) { + const child = this.projectionStores + .get(parentSessionId) + ?.values() + .subagentCatalog?.find((entry) => entry.id === sessionId); + if (child !== undefined) { + return { + parentSessionId, + childSessionId: sessionId, + mode: child.mode, + }; + } + } + return undefined; + } + + // ---- Instance management ---- + + /** + * Withdraw an exact Client instance before running its teardown callbacks. + * @param sessionId - identity to withdraw. + * @param expected - instance being released; a replacement is left untouched. + * @returns completion of the detached instance's stream teardown. + */ + drop(sessionId: SessionId, expected: Session): Promise { + const session = this.sessions.get(sessionId); + if (session !== expected) return Promise.resolve(); + this.sessions.delete(sessionId); + this.addresses.delete(sessionId); + this.pruneEngagement(sessionId, this.retainedIds(this.summaries)); + return this.startSessionDisposal(session); + } + + /** + * Stop catalog requests and dispose every resident Session. + * @returns once catalog requests and every Session stream have stopped. + */ + async dispose(): Promise { + this.disposed = true; + this.listMutations = null; + this.listInflight = null; + this.engagedSessions.clear(); + const reads = [...this.projectionInflight.values()]; + for (const { controller } of reads) controller.abort(); + this.projectionInflight.clear(); + await Promise.all(reads.map((read) => read.promise)); + const sessions = [...this.sessions.values()]; + this.sessions.clear(); + this.addresses.clear(); + for (const session of sessions) void this.startSessionDisposal(session); + await this.drainSessionDisposals(); + } + + private startSessionDisposal(session: Session): Promise { + const disposal = session.dispose(); + this.sessionDisposals.add(disposal); + void disposal.then( + () => { + this.sessionDisposals.delete(disposal); + }, + () => { + this.sessionDisposals.delete(disposal); + }, + ); + return disposal; + } + + private async drainSessionDisposals(): Promise { + while (this.sessionDisposals.size > 0) { + await Promise.allSettled([...this.sessionDisposals]); + } + } + + /** + * Lazy build: return the existing instance or construct one (no auto-open — + * the reference allocator opens history after binding the scope). + * New instances reconcile retained metadata before returning. + * @param sessionId - the session to get. + * @returns the resident instance. + */ + get(sessionId: SessionId): Session { + let session = this.sessions.get(sessionId); + if (session === undefined) { + session = this.createSession(sessionId); + this.sessions.set(sessionId, session); + // Sync the running and blank bits from the list snapshot into the new + // instance (consistency when the list precedes open). + const summary = this.summaries.find((s) => s.sessionId === sessionId); + if (summary !== undefined) { + session.handleBlank(this.effectiveBlank(summary)); + session.handleRunning(summary.running); + } else { + const address = this.addresses.get(sessionId); + const child = + address === undefined + ? undefined + : this.projectionStores + .get(address.parentSessionId) + ?.values() + .subagentCatalog?.find((entry) => entry.id === sessionId); + if (child !== undefined) { + // A catalogued child exists only after its delegated session has + // durable history, even though child rows do not carry `blank`. + session.handleBlank(false); + session.handleRunning(false); + } else { + // Retained metadata may have notified before this Session existed. + session.handleBlank(true); + } + } + } + return session; + } + + private createSession(sessionId: SessionId): Session { + const address = this.addresses.get(sessionId); + const parentAvailable = + address === undefined ? undefined : this.agentAvailable(address.parentSessionId); + return new Session(sessionId, this.remote, { + ...(address === undefined + ? {} + : { + address, + ...(parentAvailable === undefined ? {} : { parentAvailable }), + }), + // The sender's local first-send flip mirrors into the list row so the + // session surfaces (lists filter on blank) before any host frame lands. + onEngaged: (engaged) => { + if (this.disposed || !this.retainedIds(this.summaries).has(engaged.sessionId)) return; + // An identity already on the ledger needs no second row mutation. + if (!this.engagedSessions.has(engaged.sessionId)) { + this.engagedSessions.add(engaged.sessionId); + this.recordMutation({ kind: "engaged", sessionId: engaged.sessionId }); + } + this.sessions.get(engaged.sessionId)?.handleBlank(false); + }, + projections: this.projectionStore(sessionId), + }); + } + + private effectiveBlank(summary: SessionSummary): boolean { + return summary.blank && !this.engagedSessions.has(summary.sessionId); + } + + /** + * Identities an engagement may still belong to: the given list rows, resident + * Session instances, and retained child addresses. + * @param summaries - list rows of the caller's snapshot. + * @returns the retained identity set. + */ + private retainedIds(summaries: readonly SessionSummary[]): Set { + const retained = new Set(summaries.map((summary) => summary.sessionId)); + for (const sessionId of this.sessions.keys()) retained.add(sessionId); + for (const sessionId of this.addresses.keys()) retained.add(sessionId); + return retained; + } + + /** + * Forget one engagement that no retained identity holds. + * @param sessionId - identity whose engagement may be dropped. + * @param retained - identities from {@link retainedIds} for the caller's snapshot. + */ + private pruneEngagement(sessionId: SessionId, retained: ReadonlySet): void { + if (!retained.has(sessionId)) this.engagedSessions.delete(sessionId); + } + + /** Resident per-session projection store (create-on-demand; outlives instantiation). */ + private projectionStore(sessionId: SessionId): ProjectionValueStore { + let store = this.projectionStores.get(sessionId); + if (store === undefined) { + store = new ProjectionValueStore(); + const projections = store; + store.subscribeAny(() => { + // Newer history or control metadata corrects a resident Session's stale list hint. + if (projections.values().sessionListMetadata?.blank === false) { + this.sessions.get(sessionId)?.handleBlank(false); + } + this.notifier.markDirty(); + }); + this.projectionStores.set(sessionId, store); + } + return store; + } + + /** + * 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. + */ + refreshProjections(sessionId: SessionId): Promise { + const existing = this.projectionInflight.get(sessionId); + 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(); + this.projectionLoads.set(sessionId, { state: "loading", error: null }); + this.notifier.markDirty(); + const operation = (async () => { + try { + const result = await this.remote.session.projections({ sessionId }, controller.signal); + if (controller.signal.aborted) return; + if (result.ok) { + if (result.value !== null) { + store.seed({ ...result.value, asOfSeq: sessionSeqCursor(result.value.asOfSeq) }); + } else if (store.values() === initialValues) { + // A later frame proves existence independently of an earlier missing read. + store.clear(); + } + this.projectionLoads.set(sessionId, { state: "ready", error: null }); + } else { + this.projectionLoads.set(sessionId, { state: "error", error: result.error }); + } + } catch (error: unknown) { + if (controller.signal.aborted) return; + if (!isRemoteFailure(error)) throw error; + this.projectionLoads.set(sessionId, { state: "error", error }); + } finally { + if (!controller.signal.aborted) { + this.projectionInflight.delete(sessionId); + this.notifier.markDirty(); + } + } + })(); + this.projectionInflight.set(sessionId, { promise: operation, controller }); + return operation; + } + + private agentAvailable(sessionId: SessionId): boolean | undefined { + return ( + this.summaries.find((summary) => summary.sessionId === sessionId)?.agentAvailable ?? + (this.listPhase === "ready" ? false : undefined) + ); + } + + private updateParentAvailability(): void { + for (const [childId, address] of this.addresses) { + const available = this.agentAvailable(address.parentSessionId); + if (available !== undefined) + this.sessions.get(childId)?.handleSubagentParentAvailable(available); + } + } + + // ---- List API ---- + + /** Full refresh via session.list (single-flight within one Host generation). */ + refreshList(): Promise { + if (this.listInflight !== null) return this.listInflight; + this.listState = "loading"; + this.listError = null; + const established = this.summaries; + const mutations: SessionListMutation[] = []; + this.listMutations = mutations; + this.notifier.markDirty(); + this.listInflight = (async () => { + try { + const result = await this.remote.session.list({}); + if (this.listMutations !== mutations) return; + if (result.ok) { + const baseline: SessionSummary[] = + this.listPhase === "pending" + ? [...result.value.items] + : mergeOrderedBaseline( + established, + result.value.items, + (summary) => summary.sessionId, + ); + // A removal supersedes running observed in the pull, including after re-addition. + const removedSincePull = new Set(); + for (const mutation of mutations) { + if (mutation.kind === "remove") removedSincePull.add(mutation.sessionId); + } + for (const s of baseline) { + if (s.running && !removedSincePull.has(s.sessionId)) + this.engagedSessions.add(s.sessionId); + } + const summaries = mutations.reduce(applyMutation, baseline); + this.summaries = summaries; + // A full list can remove identities without a removal frame. + const retained = this.retainedIds(summaries); + for (const sessionId of this.engagedSessions) this.pruneEngagement(sessionId, retained); + this.listState = "idle"; + this.listPhase = "ready"; + this.updateParentAvailability(); + // Resident Sessions and list rows share one display blank, reconciled with the metadata projection. + for (const s of this.summaries) { + const session = this.sessions.get(s.sessionId); + if (session === undefined) continue; + session.handleBlank(this.effectiveBlank(s)); + session.handleRunning(s.running); + } + // Land each row's projection block in the per-session value store + // (cold titles surface without opening the session). A list block is + // partial — only the keys its source holds — so an absent key never + // clears; how the present keys merge depends on the block's kind. + for (const s of result.value.items) { + if (s.projections !== undefined) this.applyListBlock(s.sessionId, s.projections); + } + } else { + this.listState = "error"; + this.listError = result.error; + } + } catch (error) { + if (!isRemoteFailure(error)) throw error; + if (this.listMutations !== mutations) return; + this.listState = "error"; + this.listError = error; + } finally { + if (this.listMutations === mutations) { + this.listMutations = null; + this.listInflight = null; + this.notifier.markDirty(); + } + } + })(); + return this.listInflight; + } + + /** + * Search visible session message content without adding transient query + * state to the list snapshot. + * @param query - non-blank literal phrase. + * @param signal - cancellation for superseded UI queries. + * @returns the Host result or a folded transport error. + */ + async search( + query: string, + signal: AbortSignal, + ): Promise> { + const result = await this.remote.session.search({ query }, signal); + if (!result.ok) return result; + return { + ok: true, + value: { + items: [...result.value.items], + hasMore: result.value.hasMore, + }, + }; + } + + /** + * Contract session.create; on success merge into summaries immediately (no + * wait for the next refresh). A created session is blank by definition + * (entity birth precedes the first message). + * @param opts - target workspace or working directory, plus an optional caller-owned id. + * @returns the create result. + */ + async create( + opts: { + workspaceId?: WorkspaceId; + cwd?: string; + sessionId?: SessionId; + } = {}, + ): Promise> { + const shared = opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }; + const payload = + opts.workspaceId !== undefined + ? { workspaceId: opts.workspaceId, ...shared } + : { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared }; + const result = await this.remote.session.create(payload); + if (result.ok) { + this.recordMutation({ + kind: "placeholder", + summary: { + agentAvailable: true, + sessionId: result.value.sessionId, + updatedAt: Date.now(), + running: false, + blank: true, + ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}), + }, + }); + } else { + const publishedSessionId = workspaceAttachSessionId(result.error); + // Publication precedes attachment. The error's id is a real Session, + // so expose it immediately as Ungrouped while the caller keeps the + // prompt buffer and decides whether to retry attachment. + if (publishedSessionId !== undefined) { + this.recordMutation({ + kind: "placeholder", + summary: { + agentAvailable: true, + sessionId: publishedSessionId, + updatedAt: Date.now(), + running: false, + blank: true, + }, + }); + } + } + return result; + } + + /** + * Contract session.fork; on success merge the child into summaries + * immediately (same synchronous-addressability guarantee as create). + * Blankness starts provisionally true so the authoritative Host summary can + * preserve it or lower it after an exact cut before the first `turn/start`; + * lineage rides parentSessionId. A child published before Workspace + * attachment fails is also reconciled into the list. + * @param opts - source session and the optional exact inclusive boundary seq. + * @returns the fork result (the child session id). + */ + async fork(opts: { + sessionId: SessionId; + atSeq?: SessionSeq; + }): Promise> { + const source = this.summaries.find((s) => s.sessionId === opts.sessionId); + const result = await this.remote.session.fork({ + sessionId: opts.sessionId, + ...(opts.atSeq === undefined ? {} : { atSeq: opts.atSeq }), + }); + const childId = result.ok ? result.value.sessionId : workspaceAttachSessionId(result.error); + if (childId !== undefined) { + this.recordMutation({ + kind: "placeholder", + summary: { + agentAvailable: true, + sessionId: childId, + updatedAt: Date.now(), + running: false, + blank: true, + parentSessionId: opts.sessionId, + ...(source?.cwd !== undefined ? { cwd: source.cwd } : {}), + }, + }); + } + return result; + } + + /** + * Rename a Session and update its title projection without opening its history. + * @param sessionId - Session to rename. + * @param title - raw title text for Host normalization. + * @returns the accepted title and event position, or the Remote failure. + */ + async rename(sessionId: SessionId, title: string): Promise> { + const result = await this.remote.session.rename({ sessionId, title }); + if (result.ok) { + this.projectionStore(sessionId).apply( + "title", + result.value.title, + SessionSeq(result.value.seq), + ); + } + return result; + } + + /** + * Merge a Host summary, replacing live state and filling missing metadata. + * Local create/fork placeholders only fill metadata on an existing row. + */ + private mergeSummary(summary: SessionSummary): void { + this.recordMutation({ kind: "upsert", summary }); + this.updateParentAvailability(); + } + + /** Apply immediately and retain for replay when a list response is in flight. */ + private recordMutation(mutation: SessionListMutation): void { + if (this.disposed) return; + this.listMutations?.push(mutation); + this.summaries = applyMutation(this.summaries, mutation); + this.notifier.markDirty(); + } + + // ---- Subscription API (for useSessionList) ---- + + /** + * uSES subscription entry for useSessionList. + * @param listener - change callback. + * @returns the unsubscribe function. + */ + subscribe(listener: () => void): () => void { + return this.notifier.subscribe(listener); + } + + /** + * Cached list snapshot (rebuilt lazily when dirty with no listeners). + * @returns the cached reference (stable until the next flush). + */ + getListSnapshot(): SessionListSnapshot { + this.notifier.ensureFresh(); + return this.listSnapshotCache; + } + + /** + * Read cached projection values for a Session that may exist only in a loaded subagent catalog. + * @param sessionId - Session whose control or history baseline supplied projections. + * @returns current values, or undefined before any projection store exists. + */ + projectionValues(sessionId: SessionId): Readonly> | undefined { + return this.projectionStores.get(sessionId)?.values(); + } + + // ---- Live control and Host-event sinks ---- + + /** + * Apply a complete control baseline or one later replacement frame. + * @param frame - baseline or live control replacement from Session Controller. + */ + handleControlFrame(frame: SessionControlFrame): void { + if (frame.type === "baseline") { + this.replaceControlBaseline(frame.value); + return; + } + this.projectionStore(frame.sessionId).applyLive(frame.key, frame.value, SessionSeq(frame.seq)); + this.notifier.markDirty(); + } + + private replaceControlBaseline(baseline: SessionControlBaseline): void { + for (const [sessionId, block] of Object.entries(baseline.projections)) { + const store = this.projectionStore(sessionId as SessionId); + const asOfSeq = sessionSeqCursor(block.asOfSeq); + store.seed({ ...block, asOfSeq }); + } + this.notifier.markDirty(); + } + + /** + * Apply one Session-list addition forwarded through `ctx.remote.$on`. + * @param summary - current Host summary for the added Session. + */ + handleSessionAdded(summary: SessionSummary): void { + this.mergeSummary(summary); + if (!this.disposed && summary.running) this.engagedSessions.add(summary.sessionId); + this.sessions.get(summary.sessionId)?.handleBlank(this.effectiveBlank(summary)); + if (summary.projections !== undefined) + this.applyListBlock(summary.sessionId, summary.projections); + } + + /** + * Merge one list-surface projection block by the sequence space it declares. + * A `sequenced` block came from the Host's live registry for an attached + * Session, so each key lands under higher-seq-wins against that Session's + * baselines and frames. A `cached` block was viewed from the persisted + * checkpoint by a header-only listing: its watermark is not comparable with + * this connection's seqs, so it only fills keys no sequenced row holds. + */ + private applyListBlock(sessionId: SessionId, block: SessionProjectionHints): void { + const store = this.projectionStore(sessionId); + switch (block.kind) { + case "sequenced": { + const seq = sessionSeqCursor(block.asOfSeq); + for (const [key, value] of Object.entries(block.values)) store.apply(key, value, seq); + return; + } + case "cached": + store.applyCached(block.values); + return; + default: + assertNever(block.kind, "session list projection block kind"); + } + } + + /** + * Apply one Session removal forwarded through `ctx.remote.$on`. + * @param sessionId - removed Session identity. + */ + handleSessionRemoved(sessionId: SessionId): void { + const durableSubagent = + this.subagentAddress(sessionId) !== undefined || + this.summaries.some( + (summary) => summary.sessionId === sessionId && summary.origin === "subagent", + ); + this.recordMutation( + durableSubagent + ? { kind: "status", sessionId, running: false, agentAvailable: false } + : { kind: "remove", sessionId }, + ); + if (durableSubagent) this.sessions.get(sessionId)?.handleRunning(false); + else this.sessions.get(sessionId)?.handleRemoved(); + const catalog = this.projectionStores.get(sessionId)?.values().subagentCatalog; + if (!durableSubagent && (catalog === undefined || catalog.length === 0)) { + this.projectionStores.delete(sessionId); + } + this.pruneEngagement(sessionId, this.retainedIds(this.summaries)); + this.projectionInflight.get(sessionId)?.controller.abort(); + this.projectionInflight.delete(sessionId); + this.projectionLoads.delete(sessionId); + for (const [childId, address] of this.addresses) { + if (address.parentSessionId === sessionId) { + this.sessions.get(childId)?.handleSubagentParentAvailable(false); + } + } + } + + /** + * Apply one live Agent running-state change. + * @param sessionId - Session whose Agent state changed. + * @param running - current Agent running state. + */ + handleSessionStatus(sessionId: SessionId, running: boolean): void { + if (!this.disposed && running) this.engagedSessions.add(sessionId); + this.recordMutation({ kind: "status", sessionId, running, agentAvailable: true }); + this.updateParentAvailability(); + this.sessions.get(sessionId)?.handleRunning(running); + } + + /** + * Advance Session-list activity from one user-authored durable message. + * @param sessionId - Session whose activity changed. + * @param updatedAt - durable message timestamp. + */ + handleSessionActivity(sessionId: SessionId, updatedAt: number): void { + this.recordMutation({ kind: "activity", sessionId, updatedAt }); + } + + /** + * Surface one live Agent failure on an already-materialized Session. + * @param sessionId - Session whose Agent failed. + * @param message - caller-visible failure description. + */ + handleSessionError(sessionId: SessionId, message: string): void { + this.sessions.get(sessionId)?.handleAgentError(message); + } + + /** + * Repair one re-established Host-event generation with queryable baselines. + * Discard old projection cuts before new queries, including cold Sessions + * absent from the process-local control baseline. + * Opened Session follow streams resume independently through API Gateway. + */ + handleConnected(): void { + for (const store of this.projectionStores.values()) store.clear(); + this.listMutations = null; + this.listInflight = null; + void this.refreshList(); + const parents = new Set(this.projectionLoads.keys()); + for (const id of this.sessions.keys()) { + const address = this.addresses.get(id); + if (address !== undefined) parents.add(address.parentSessionId); + } + for (const { controller } of this.projectionInflight.values()) controller.abort(); + this.projectionInflight.clear(); + this.projectionLoads.clear(); + for (const parentSessionId of parents) void this.refreshProjections(parentSessionId); + } + + private buildListSnapshot(): SessionListSnapshot { + const merged: TitledSessionSummary[] = this.summaries.map((summary) => { + // List rows read the generic 'title' projection key (host-computed unit + // value; there is no dedicated title frame). + const projectionStore = this.projectionStores.get(summary.sessionId); + const title = projectionStore?.get("title"); + const projectionValues = projectionStore?.values(); + const metadata = projectionValues?.sessionListMetadata; + return { + ...summary, + // Cached list hints can precede a history opening or control update. + blank: this.effectiveBlank(summary) && metadata?.blank !== false, + updatedAt: Math.max(summary.updatedAt, metadata?.lastPromptAt ?? 0), + ...(typeof title === "string" && title !== "" ? { title } : {}), + ...(projectionValues === undefined ? {} : { projectionValues }), + }; + }); + const fresh = flattenLineage(merged); + const items = fresh.map((entry) => { + const prev = this.entryCache.get(entry.sessionId); + if ( + prev !== undefined && + prev.updatedAt === entry.updatedAt && + prev.running === entry.running && + prev.blank === entry.blank && + prev.parentSessionId === entry.parentSessionId && + prev.cwd === entry.cwd && + prev.origin === entry.origin && + prev.title === entry.title && + prev.depth === entry.depth && + prev.projectionValues === entry.projectionValues + ) + return prev; + this.entryCache.set(entry.sessionId, entry); + return entry; + }); + const itemIds = new Set(items.map((entry) => entry.sessionId)); + for (const id of this.entryCache.keys()) { + if (!itemIds.has(id)) this.entryCache.delete(id); + } + const sameOrder = + items.length === this.itemsCache.length && items.every((e, i) => e === this.itemsCache[i]); + if (!sameOrder) this.itemsCache = items; + return { + items: this.itemsCache, + state: this.listState, + phase: this.listPhase, + error: this.listError, + projectionsBySession: Object.fromEntries( + [...this.projectionStores].map(([sessionId, store]) => [ + sessionId, + { + values: store.values(), + state: "idle", + error: null, + ...this.projectionLoads.get(sessionId), + }, + ]), + ), + }; + } +} + +/** Apply one list mutation without deriving display order. */ +function applyMutation( + summaries: readonly SessionSummary[], + mutation: SessionListMutation, +): SessionSummary[] { + switch (mutation.kind) { + case "upsert": + case "placeholder": { + const existing = summaries.find( + (summary) => summary.sessionId === mutation.summary.sessionId, + ); + if (existing === undefined) return [mutation.summary, ...summaries]; + const filled: SessionSummary = { + ...existing, + // Blank only lowers: a stale true (session-added racing the local + // first send) never re-hides an already-surfaced session. + blank: existing.blank && mutation.summary.blank, + ...(mutation.kind === "upsert" + ? { + agentAvailable: mutation.summary.agentAvailable, + running: mutation.summary.running, + } + : {}), + ...(existing.cwd === undefined && mutation.summary.cwd !== undefined + ? { cwd: mutation.summary.cwd } + : {}), + ...(existing.parentSessionId === undefined && mutation.summary.parentSessionId !== undefined + ? { parentSessionId: mutation.summary.parentSessionId } + : {}), + ...(existing.origin === undefined && mutation.summary.origin !== undefined + ? { origin: mutation.summary.origin } + : {}), + }; + if ( + filled.cwd === existing.cwd && + filled.parentSessionId === existing.parentSessionId && + filled.origin === existing.origin && + filled.blank === existing.blank && + filled.agentAvailable === existing.agentAvailable && + filled.running === existing.running + ) + return [...summaries]; + return summaries.map((summary) => + summary.sessionId === mutation.summary.sessionId ? filled : summary, + ); + } + case "remove": + return summaries.filter((summary) => summary.sessionId !== mutation.sessionId); + case "status": + // Running converts display state; it does not establish durable history. + return summaries.map((summary) => + summary.sessionId === mutation.sessionId && + (summary.running !== mutation.running || + summary.agentAvailable !== mutation.agentAvailable || + (mutation.running && summary.blank)) + ? { + ...summary, + running: mutation.running, + agentAvailable: mutation.agentAvailable, + blank: summary.blank && !mutation.running, + } + : summary, + ); + case "activity": + return summaries.map((summary) => + summary.sessionId === mutation.sessionId && mutation.updatedAt > summary.updatedAt + ? { ...summary, updatedAt: mutation.updatedAt } + : summary, + ); + case "engaged": + return summaries.map((summary) => + summary.sessionId === mutation.sessionId && summary.blank + ? { ...summary, blank: false } + : summary, + ); + } +} + +/** Temporary source-plane bridge while the Host contract and client project build independently. */ +function workspaceAttachSessionId(error: RemoteFailure): SessionId | undefined { + return error.code === "session/workspace-attach-failed" ? error.details.sessionId : undefined; +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/sessions/notifier.ts b/apps/web-ui/packages/api/session-controller/src/client/sessions/notifier.ts new file mode 100644 index 000000000..c4cab1013 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/sessions/notifier.ts @@ -0,0 +1,97 @@ +import { notifySubscribers } from "@deepseek-ai/dsh-client-store"; + +/** + * Batches structural updates in microtasks and stream updates by animation + * frame. Reads may rebuild a dirty snapshot without consuming the pending + * subscriber notification. + */ +export class Notifier { + private listeners = new Set<() => void>(); + private dirty = false; + private notifyPending = false; + private scheduled: "none" | "microtask" | "frame" = "none"; + private scheduleGeneration = 0; + + /** @param rebuild - snapshot rebuild function injected by the owner (writes the owner's snapshotCache). */ + constructor(private readonly rebuild: () => void) {} + + /** + * uSES subscription entry. + * @param listener - change callback. + * @returns the unsubscribe function. + */ + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** Mark the snapshot dirty and notify in a microtask. */ + markDirty(): void { + this.dirty = true; + this.notifyPending = true; + if (this.scheduled === "microtask") return; + this.schedule("microtask"); + } + + /** Mark the snapshot dirty and publish cumulative state at most once per frame. */ + markFrameDirty(): void { + this.dirty = true; + this.notifyPending = true; + if (this.scheduled !== "none") return; + this.schedule(typeof globalThis.requestAnimationFrame === "function" ? "frame" : "microtask"); + } + + /** + * Synchronous flush: controlled-input writes must notify in the same tick as + * onChange, or React rolls the DOM back to the stale value and the caret jumps to the end. + */ + notifyNow(): void { + this.dirty = true; + this.notifyPending = true; + this.invalidateSchedule(); + this.flush(); + } + + /** + * Pre-getSnapshot check: rebuild synchronously when dirty (read path + * before first subscribe / while unobserved). Notification stays pending. + */ + ensureFresh(): void { + if (!this.dirty) return; + this.dirty = false; + this.rebuild(); + } + + private schedule(kind: "microtask" | "frame"): void { + const generation = ++this.scheduleGeneration; + this.scheduled = kind; + const publish = () => { + if (generation !== this.scheduleGeneration) return; + this.scheduled = "none"; + this.flush(); + }; + if (kind === "frame") { + globalThis.requestAnimationFrame(publish); + } else { + queueMicrotask(publish); + } + } + + private invalidateSchedule(): void { + this.scheduleGeneration++; + this.scheduled = "none"; + } + + private flush(): void { + if (!this.notifyPending) return; + if (this.listeners.size === 0) return; // lazy: dirty (if still set) rebuilds on next getSnapshot + this.notifyPending = false; + if (this.dirty) { + this.dirty = false; + this.rebuild(); + } + notifySubscribers(this.listeners, "[session-controller]"); + } +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/sessions/projection-store.ts b/apps/web-ui/packages/api/session-controller/src/client/sessions/projection-store.ts new file mode 100644 index 000000000..f54073532 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/sessions/projection-store.ts @@ -0,0 +1,260 @@ +/** + * Generic per-session projection value store (push model; see the + * session-projection subsystem page, docs/subsystems/session-projection.md): + * the host is the only computation site; the client holds finished whole + * values per key in one of two row kinds. A `sequenced` row + * (`{ value, seq }`) comes from the connected Host — a follow opening + * baseline, a Session Controller `projection` frame, a list block the Host + * computed for an attached Session — and merges under **higher seq wins** + * against other sequenced rows. A `cached` row (`{ value }`) comes from the + * session list's view of the persisted checkpoint, carries no comparable + * seq, and yields to every sequenced write. No client-side domain folding + * exists: a domain ships projection support with zero client code. Per-key + * bare observable faces feed `useProjection` (ui-renderer binds them). + */ +import type { SessionProjectionMap } from "@deepseek-ai/dsh-session-projection/types"; +import type { SessionSeqCursor } from "@deepseek-ai/dsh-session/types"; +import type { ObservableSnapshot } from "@deepseek-ai/dsh-client-store"; +import { Notifier } from "./notifier.ts"; + +// The single projection type table, typed end to end (host unit, wire block, +// client store, React hook) — the Service Definition package's pure-type outlet +// (`/types`, zero imports), never the package root: the root's dsh-agent → +// dsh-session chain would drag the host `Context.sessions` merge into the +// client program (one program must not hold both sides). No second +// client-side "views" table (rejected in the Alternatives of +// .agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md). +export type { SessionProjectionMap } from "@deepseek-ai/dsh-session-projection/types"; + +/** + * The fifth framework hook seat (see the session-projection subsystem page, + * docs/subsystems/session-projection.md): key-addressed + * projection reader delivered through the standard kit. `undefined` uniformly + * means capability absent — host unit unmounted, or no baseline/frame has + * carried the key yet. The selector overload mirrors useSession (per-key uSES + * binding; reference stability holds because a key's value reference changes + * only when a frame or baseline lands). + */ +export type UseProjection = { + >( + key: K, + ): SessionProjectionMap[K] | undefined; + , S>( + key: K, + selector: (value: SessionProjectionMap[K] | undefined) => S, + eq?: (a: S, b: S) => boolean, + ): S; +}; + +/** + * Follow-opening projection baseline, restated here so the + * React-free store depends only on the type table, not the wire package's + * response vocabulary. + */ +export interface ProjectionsBaseline { + /** The consistent-cut seq (equals the window tail seq by construction). */ + asOfSeq: SessionSeqCursor; + /** Whole current values by key; a registered key absent here means the capability is absent. */ + values: Readonly>; +} + +/** + * One key's row. A `sequenced` row was computed by the Host for this Session + * within the current connection generation and carries the seq it is + * consistent with; seqs of sequenced rows are comparable with each other. A + * `cached` row was viewed from the persisted projection checkpoint by a + * header-only listing without a Session: its origin cannot vouch that its + * stored seq is comparable with the log the client later opens, so it carries + * none and yields to every sequenced write. + */ +type Row = + { kind: "cached"; value: unknown } | { kind: "sequenced"; value: unknown; seq: SessionSeqCursor }; + +/** Per-key notification channel: the bare face plus its batching notifier. */ +interface Channel { + face: ObservableSnapshot; + notifier: Notifier; +} + +/** + * One session's projection values. Framework semantics, uniform across every + * key. Sequenced writes (a baseline seeds rows at its cut, a push frame + * updates one row) compare seqs among themselves: a lower-or-equal seq within + * the Host generation loses, so a replayed frame cannot regress a value and a + * stale baseline cannot overwrite a newer frame. Cached writes (the session + * list's zero-I/O block) only fill keys no sequenced row holds, and a baseline + * discards every cached row before it seeds, regardless of seq: the connected + * Session is the truth and a cached value never outranks it. A key the store + * has never seen reads `undefined` (capability absent). Faces are identity-stable + * per key (create-on-demand, cached) so the React side binds each exactly + * once; the store-level channel (`subscribeAny`) serves coarse consumers (the + * manager's list projection reads the `title` key). + */ +export class ProjectionValueStore { + private readonly rows = new Map(); + private readonly channels = new Map(); + private valuesCache: Readonly> | undefined; + /** Coarse any-key channel (no snapshot cache to rebuild: reads hit rows directly). */ + private readonly anyNotifier = new Notifier(() => {}); + + /** + * Key-addressed bare observable face (the useProjection resolution path). + * Always defined — absence is an `undefined` snapshot, never a missing + * face, so a component may subscribe before the key ever carries a value. + * @param key - projection key. + * @returns the identity-stable face for this key. + */ + faceOf(key: string): ObservableSnapshot { + return this.channel(key).face; + } + + /** + * Current whole value for a key (erased framework read; typed reads go + * through `useProjection`'s map lookup). + * @param key - projection key. + * @returns the value, or undefined while the key is absent. + */ + get(key: string): unknown { + return this.rows.get(key)?.value; + } + + /** + * Read the accepted Host watermark without subscribing or copying a value. + * @param key - projection key. + * @returns the current sequence, or undefined for absent and cached values. + */ + seqOf(key: string): SessionSeqCursor | undefined { + const row = this.rows.get(key); + return row?.kind === "sequenced" ? row.seq : undefined; + } + + /** + * Read every current projection value as one reference-stable snapshot. + * @returns The same frozen value map until a row changes. + */ + values(): Readonly> { + if (this.valuesCache === undefined) { + this.valuesCache = Object.freeze( + Object.fromEntries([...this.rows].map(([key, row]) => [key, row.value])), + ); + } + return this.valuesCache; + } + + /** + * Subscribe to any-key changes (microtask-batched) — the manager's list + * rebuild channel. + * @param listener - change callback. + * @returns the unsubscribe function. + */ + subscribeAny(listener: () => void): () => void { + return this.anyNotifier.subscribe(listener); + } + + /** + * Apply one finished value from the Session control stream. + * @param key - projection key. + * @param value - whole value computed by the host unit. + * @param seq - the unit's watermark at emission. + */ + apply(key: string, value: unknown, seq: SessionSeqCursor): void { + const row = this.rows.get(key); + // higher seq wins among sequenced rows; replays and stale frames drop. A + // cached row has no comparable seq and always yields. + if (row?.kind === "sequenced" && seq <= row.seq) return; + this.rows.set(key, { kind: "sequenced", value, seq }); + this.changed(key); + } + + /** + * Apply one live control frame. Frames arrive in order on the control stream, so an equal seq + * is a newer value: CodexHost projections also change between journal events (token usage, + * running tool output) without advancing the durable seq. + * @param key - projection key. + * @param value - replacement value. + * @param seq - durable watermark carried by the frame. + */ + applyLive(key: string, value: unknown, seq: SessionSeqCursor): void { + const row = this.rows.get(key); + if (row?.kind === "sequenced" && seq < row.seq) return; + this.rows.set(key, { kind: "sequenced", value, seq }); + this.changed(key); + } + + /** + * Fill keys from a session-list block the Host labeled `cached`: a zero-I/O + * view of the persisted checkpoint. A cached value lands only where no + * sequenced row exists: a connected Session has already answered for such + * a key, and the list's view of the persisted checkpoint cannot be newer + * than it. + * @param values - whole values by key viewed from the persisted checkpoint. + */ + applyCached(values: Readonly>): void { + for (const key of Object.keys(values)) { + if (this.rows.get(key)?.kind === "sequenced") continue; + this.rows.set(key, { kind: "cached", value: values[key] }); + this.changed(key); + } + } + + /** + * Seed from a history tail page's projections block. Every cached row is + * discarded first, regardless of seq: the block comes from the connected + * Session, and a value viewed from the persisted checkpoint never outranks + * it. Then every carried key lands under the same seq rule as frames, and a + * key the block omits is capability-absent as of the cut — its row clears + * unless a newer frame already superseded the cut (a stale baseline can + * neither overwrite nor clear newer sequenced values). + * @param baseline - the response's projections block. + */ + seed(baseline: ProjectionsBaseline): void { + for (const [key, row] of this.rows) { + if (row.kind !== "cached") continue; + this.rows.delete(key); + this.changed(key); + } + // Erased walk: the framework crosses the open key space; per-key typing + // is re-established at the consumer (useProjection's map lookup). + const values = baseline.values as Record; + for (const key of Object.keys(values)) this.apply(key, values[key], baseline.asOfSeq); + for (const [key, row] of this.rows) { + if (Object.hasOwn(values, key)) continue; + // Every cached row was deleted above; the kind test only narrows the + // type so `row.seq` is readable. + if (row.kind === "sequenced" && row.seq > baseline.asOfSeq) continue; + this.rows.delete(key); + this.changed(key); + } + } + + /** Discard one Host generation's values and watermarks while preserving subscribed faces. */ + clear(): void { + for (const key of this.rows.keys()) { + this.rows.delete(key); + this.changed(key); + } + } + + private changed(key: string): void { + this.valuesCache = undefined; + this.channels.get(key)?.notifier.markDirty(); + this.anyNotifier.markDirty(); + } + + private channel(key: string): Channel { + let channel = this.channels.get(key); + if (channel === undefined) { + // The notifier only batches (no snapshot cache to rebuild: faces read rows directly). + const notifier = new Notifier(() => {}); + channel = { + notifier, + face: { + getSnapshot: () => this.rows.get(key)?.value, + subscribe: (listener) => notifier.subscribe(listener), + }, + }; + this.channels.set(key, channel); + } + return channel; + } +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/sessions/remotes.ts b/apps/web-ui/packages/api/session-controller/src/client/sessions/remotes.ts new file mode 100644 index 000000000..53af4904e --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/sessions/remotes.ts @@ -0,0 +1,48 @@ +/** + * Remote namespaces the Session cluster calls. One parameter for one concept: + * the generated surface a Session and its manager reach the Host through. + * + * @module @deepseek-ai/dsh-api-session-controller/client/sessions/remotes + */ + +import type { ClientRemote } from "@deepseek-ai/dsh-api-gateway/client"; +import type { CommandSubmitAttachment } from "@deepseek-ai/dsh-commands/types"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { + SubagentInterruptReceipt, + SubagentPromptReceipt, + SubagentPromptRequest, +} from "@deepseek-ai/dsh-subagent/client"; +import type { RemoteResult } from "@deepseek-ai/dsh-typert-protocol"; +import type { SessionRemote } from "../transport.ts"; + +/** Narrow Commands namespace consumed by a Client Session. */ +export interface SessionCommandsRemote { + execute( + agentId: SessionId, + line: string, + attachments: readonly CommandSubmitAttachment[], + signal?: AbortSignal, + ): Promise>; +} + +/** Narrow subagent namespace consumed by a Client Session and its manager. */ +export interface SessionSubagentsRemote { + prompt( + request: SubagentPromptRequest, + signal?: AbortSignal, + ): Promise>; + interruptByParent( + childSessionId: SessionId, + parentSessionId: SessionId, + mode: "continuable", + ): Promise>; +} + +/** Generated Remote namespaces consumed by the Client Session object layer. */ +export interface SessionRemotes { + readonly $stream: ClientRemote["$stream"]; + readonly commands: SessionCommandsRemote; + readonly session: SessionRemote; + readonly subagents: SessionSubagentsRemote; +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/sessions/service.ts b/apps/web-ui/packages/api/session-controller/src/client/sessions/service.ts new file mode 100644 index 000000000..11997cd72 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/sessions/service.ts @@ -0,0 +1,779 @@ +/** Client catalog and source-labelled ownership of exact Session generations. */ +import type { Context, Fiber } from "@deepseek-ai/cordis"; +import type { SubagentAddress } from "@deepseek-ai/dsh-subagent/client"; +import { SessionSeq, type SessionId } from "@deepseek-ai/dsh-session/types"; +import { workspaceTitleOf } from "@deepseek-ai/dsh-util-workspace-path"; +import type { WorkspaceId } from "@deepseek-ai/dsh-workspace/types"; +import { SESSION_SEARCH_RESULT_LIMIT } from "../../types.ts"; +import type { SessionProjectionMap } from "@deepseek-ai/dsh-session-projection/types"; +import { + createSnapshotStore, + notifySubscribers, + type ObservableSnapshot, + type SnapshotStore, +} from "@deepseek-ai/dsh-client-store"; +import type { RemoteFailure, RemoteResult } from "@deepseek-ai/dsh-typert-protocol"; +import type { SessionEventSource } from "../contract/events.ts"; +import type { SessionFace } from "../contract/session.ts"; +import type { + 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"; +import { SessionManager } from "./manager.ts"; +import type { SessionRemotes } from "./remotes.ts"; +import type { + SessionListPhase, + SessionSearchResultItem, + SessionProjectionSnapshot, +} from "./manager.ts"; +import type { Session } from "./session.ts"; + +/** Session list row projected from the host list RPC plus live stream increments. */ +export interface SessionSummary { + id: SessionId; + /** Latest durable log-backed title, absent until the host projects one. */ + title?: string; + /** Human-facing label: durable title, project basename, then session id. */ + displayTitle: string; + cwd?: string; + parentId?: SessionId; + /** Coarse durable origin for navigation filtering; not a continuation capability. */ + origin?: "subagent"; + /** Host running state for `ids` members; a display fallback for other rows. */ + running: boolean; + /** Local ownership counts; Host metadata refreshes cannot overwrite them. */ + readonly retainedBy: SessionRetainInfo["retainedBy"]; + /** + * New Session presentation and reuse eligibility, derived from the Host + * summary, `sessionListMetadata`, and client acceptance/running observations. + * New Session reuses a blank one targeting the same workspace. Filtering + * stays with the consumer: the store carries every row, while the Workspace + * browser shows only the selected blank entry. + */ + blank: boolean; + updatedAt: number; + /** Current host-computed projection values retained by the object layer. */ + projectionValues?: Readonly>; +} + +/** Catalog metadata and local source counts; catalog membership owns no Client generation. */ +export interface SessionListState { + /** Host list order; every id has a matching byId row in the same snapshot. */ + ids: SessionId[]; + /** Host/catalog rows plus retained subagent fallbacks; only `ids` expresses Host-list membership. */ + byId: Record; + /** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */ + phase: SessionListPhase; + /** Shared projection values and explicit-read state, including unopened Sessions. */ + projectionsBySession: Readonly>; +} + +/** Structured session-create failure. */ +export class SessionCreateError extends Error { + override readonly name = "SessionCreateError"; + + /** + * @param rpcError - Host business or folded transport error. + * @param requestedSessionId - caller-preallocated id used for later stream/list reconciliation. + */ + constructor( + readonly rpcError: RemoteFailure, + readonly requestedSessionId: SessionId | undefined, + ) { + super(`session create failed: ${rpcError.code}: ${rpcError.message}`); + } +} + +/** Structured session-fork failure. */ +export class SessionForkError extends Error { + override readonly name = "SessionForkError"; + + /** + * @param rpcError - Host business or folded transport error. + * @param sourceSessionId - the session the fork was cut from. + */ + constructor( + readonly rpcError: RemoteFailure, + readonly sourceSessionId: SessionId, + ) { + super(`session fork failed: ${rpcError.code}: ${rpcError.message}`); + } +} + +/** Identity-stable logical binding for one materialized Client Session. */ +export interface SessionBinding { + readonly sessionId: SessionId; + /** The outward session face only — feature code never sees the concrete class. */ + readonly session: SessionFace; + /** Contiguous event window reserved for Conversation assembly. */ + readonly eventSource: SessionEventSource; + readonly ctx: AgentContext; +} + +// Scope primitives live in ../scope.ts (the client mirror of host +// dsh-scope, keyed by Agent identity); re-exported here so existing +// consumers keep their import site. +export { scopeOf } from "../scope.ts"; + +/** + * Display title projection: durable title, project directory basename, then + * the raw id. + */ +function displayTitleOf(title: string | undefined, cwd: string | undefined, id: SessionId): string { + if (title !== undefined) return title; + if (cwd !== undefined && cwd !== "") { + const base = workspaceTitleOf(cwd); + if (base !== "") return base; + } + return id; +} + +/** + * Increment a trailing fork number while preserving its half-width or + * full-width parentheses; an unnumbered title starts with ` (1)`. + * @param title - source session's durable title. + * @returns the title assigned to the fork child. + */ +function increasedForkTitle(title: string): string { + const ascii = /^(.*?)\((\d+)\)$/u.exec(title); + if (ascii?.[1] !== undefined && ascii[2] !== undefined) { + return `${ascii[1]}(${BigInt(ascii[2]) + 1n})`; + } + const fullWidth = /^(.*?)((\d+))$/u.exec(title); + if (fullWidth?.[1] !== undefined && fullWidth[2] !== undefined) { + return `${fullWidth[1]}(${BigInt(fullWidth[2]) + 1n})`; + } + return `${title} (1)`; +} + +/** Source labels are dictionary keys, including names also present on Object.prototype. */ +function freezeRetainedBy( + counts: Partial>, +): SessionRetainInfo["retainedBy"] { + Object.setPrototypeOf(counts, null); + return Object.freeze(counts); +} + +const EMPTY_RETAIN_INFO: SessionRetainInfo = Object.freeze({ + referenceCount: 0, + retainedBy: freezeRetainedBy({}), +}); + +interface ScopeRecord { + fiber: Fiber; + ctx: AgentContext; + binding: SessionBinding; + session: Session; + retention: SessionRetainInfo; + live: boolean; +} + +interface RetentionObserver { + readonly source: ObservableSnapshot; + readonly listeners: Set<() => void>; + published: SessionRetainInfo; +} + +/** A cancelled waiter releases only its own reference, not the shared opening. */ +async function waitForOpen(opening: Promise, signal?: AbortSignal): Promise { + if (signal === undefined) return opening; + const aborted = Promise.withResolvers(); + const onAbort = (): void => { + aborted.reject(signal.reason); + }; + signal.addEventListener("abort", onAbort, { once: true }); + try { + if (signal.aborted) onAbort(); + await Promise.race([opening, aborted.promise]); + } finally { + signal.removeEventListener("abort", onAbort); + } +} + +class ClientSessionReference implements SessionReference { + private readonly released = new AbortController(); + private readonly readiness = Promise.withResolvers(); + readonly ready = this.readiness.promise; + + constructor( + readonly sessionId: SessionId, + private record: ScopeRecord | undefined, + private releaseReference: (() => void) | undefined, + ) { + void this.ready.catch(() => {}); + } + + get binding(): SessionBinding { + if (this.record === undefined || !this.record.live) + throw new Error(`Session reference "${this.sessionId}" is released`); + return this.record.binding; + } + + attachOpening(opening: Promise, signal?: AbortSignal): void { + const waitSignal = + signal === undefined ? this.released.signal : AbortSignal.any([this.released.signal, signal]); + void waitForOpen(opening, waitSignal).then( + () => { + try { + waitSignal.throwIfAborted(); + this.readiness.resolve(this.binding); + } catch (error: unknown) { + this.readiness.reject(error); + } + }, + (error: unknown) => { + this.readiness.reject(error); + }, + ); + } + + release(): void { + const reason = new Error(`Session reference "${this.sessionId}" is released`); + const release = this.releaseReference; + this.released.abort(reason); + this.readiness.reject(reason); + this.record = undefined; + this.releaseReference = undefined; + release?.(); + } + + [Symbol.dispose](): void { + this.release(); + } +} + +/** Host catalog and local reference allocator; view selection remains outside the Controller. */ +export class ClientSessions implements ISessions { + /** + * The wire schema's own result bound, re-exposed for presentation plugins as + * injected data. Not per-connection state: the `session.search` response + * schema caps `items` at this constant, so every transport (fixture included) + * reports the same number. + */ + readonly searchResultLimit = SESSION_SEARCH_RESULT_LIMIT; + /** Catalog metadata and local reference-source projection. */ + readonly list: SnapshotStore; + /** The object-layer instance cluster and frame dispatch entry. */ + private readonly manager: SessionManager; + private readonly scopes = new Map(); + /** Stable per-id sources retained for the Client root lifetime, including across generation replacement. */ + private readonly retainObservers = new Map(); + private readonly scopeDrops = new Set>(); + private closed = false; + + /** + * @param ctx - client root context (scope fibers mount under it). + * @param remote - generated Remote namespaces shared with every Session. + */ + constructor( + private readonly rootCtx: Context, + remote: SessionRemotes, + ) { + this.manager = new SessionManager(remote); + this.list = createSnapshotStore({ + ids: [], + byId: {}, + phase: "pending", + projectionsBySession: {}, + }); + const disposeManagerProjection = this.manager.subscribe(() => { + this.projectList(); + }); + rootCtx.effect( + () => async () => { + this.closed = true; + disposeManagerProjection(); + const scopes = [...this.scopes]; + this.scopes.clear(); + for (const [, record] of scopes) { + record.live = false; + record.session.unbindScope(); + } + const managerDisposal = this.manager.dispose(); + for (const [id, record] of scopes) { + this.startScopeDrop(id, record); + this.publishRetention(id); + } + await this.drainScopeDrops(); + await managerDisposal; + }, + "session-controller.client.sessions", + ); + rootCtx.reflect.provide("sessions", this, undefined); + } + + retain(target: SessionTarget, options: SessionRetainOptions): SessionReference { + const { source, signal } = options; + signal?.throwIfAborted(); + if (this.closed) throw new Error("Session Controller is disposed"); + const id = this.manager.resolveTarget(target); + const reference = this.retainScope(id, source); + try { + reference.attachOpening(this.manager.get(id).open(), signal); + return reference; + } catch (error) { + reference.release(); + throw error; + } + } + + async using( + target: SessionTarget, + options: SessionRetainOptions, + operation: (reference: SessionReference) => T | Promise, + ): Promise { + const reference = this.retain(target, options); + try { + await reference.ready; + return await operation(reference); + } finally { + reference.release(); + } + } + + retainInfo(id: SessionId): ObservableSnapshot { + let observer = this.retainObservers.get(id); + if (observer === undefined) { + const listeners = new Set<() => void>(); + observer = { + listeners, + published: this.retentionSnapshot(id), + source: { + getSnapshot: () => this.retentionSnapshot(id), + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }, + }; + this.retainObservers.set(id, observer); + } + return observer.source; + } + + /** + * Resolve an already discovered direct-parent address without opening it. + * Feature plugins use this to avoid Agent-bound RPCs in persisted child views. + * @param id - possible addressed child id. + * @returns A retained or loaded-catalog address, without retaining a new selection or scope. + */ + subagentAddress(id: SessionId): SubagentAddress | undefined { + return this.manager.subagentAddress(id); + } + + /** + * Load all Session projections once per connection; retry an unsuccessful initial read. + * @param sessionId - Session to inspect without opening its conversation. + */ + refreshProjections(sessionId: SessionId): Promise { + return this.manager.refreshProjections(sessionId); + } + + /** + * Refresh the real Session baseline, reusing an in-flight pull. + * @returns completion of the current or newly started baseline pull. + */ + refresh(): Promise { + return this.manager.refreshList(); + } + + /** + * Search the Host's visible message-content index. Results stay + * request-local; the list snapshot remains the metadata authority. + * @param query - non-blank literal phrase. + * @param signal - cancellation for a superseded search. + * @returns bounded results or a business/transport error. + */ + search( + query: string, + signal: AbortSignal, + ): Promise> { + return this.manager.search(query, signal); + } + + /** + * Apply one Session Controller live-control frame. + * @param frame - baseline or live control replacement. + */ + handleControlFrame(frame: Parameters[0]): void { + this.manager.handleControlFrame(frame); + } + + /** + * Apply one remotely forwarded Session-list addition. + * @param summary - current Host summary for the added Session. + */ + handleSessionAdded(summary: Parameters[0]): void { + this.manager.handleSessionAdded(summary); + } + + /** + * Apply one remotely forwarded Session removal. + * @param sessionId - removed Session identity. + */ + handleSessionRemoved(sessionId: Parameters[0]): void { + this.manager.handleSessionRemoved(sessionId); + } + + /** + * Apply one remotely forwarded running-state change. + * @param args - Session identity and current Agent running state. + */ + handleSessionStatus(...args: Parameters): void { + this.manager.handleSessionStatus(...args); + } + + /** + * Apply one remotely forwarded list-activity change. + * @param args - Session identity and durable activity timestamp. + */ + handleSessionActivity(...args: Parameters): void { + this.manager.handleSessionActivity(...args); + } + + /** + * Apply one remotely forwarded Agent failure. + * @param args - Session identity and caller-visible failure description. + */ + handleSessionError(...args: Parameters): void { + this.manager.handleSessionError(...args); + } + + /** Rebuild the Session baseline and every opened window after connection. */ + handleConnected(): void { + this.manager.handleConnected(); + } + + /** + * Create a Host Session and publish its catalog row before resolving. + * Callers retain the returned identity before borrowing its binding. + * @param opts - target workspace or directory and an optional preallocated id. + * @returns the new session id. + * @throws {SessionCreateError} with the requested id. + */ + async create( + opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}, + ): Promise { + const result = await this.manager.create(opts); + if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId); + this.projectList(); + return result.value.sessionId; + } + + /** + * Fork a session from an exact inclusive prefix of the source (same + * synchronous-addressability guarantee as {@link ClientSessions.create}: + * on resolution the child is catalogued and may be explicitly retained). + * @param opts - source session id, the optional exact inclusive boundary + * seq (a real event seq the caller already knows; a cut inside an open + * turn is balanced Host-side with synthetic closers, and omission selects + * the latest completed-turn prefix), and whether to increment an + * inherited durable title before resolving. + * @returns the child session id. + * @throws {SessionForkError} with the source id. + * @throws {Error} when a requested child-title rename fails after creation. + */ + async fork(opts: { + sessionId: SessionId; + atSeq?: number; + increaseTitle?: boolean; + onCreated?: (childId: SessionId) => void; + }): Promise { + const sourceTitle = opts.increaseTitle + ? this.list.getSnapshot().byId[opts.sessionId]?.title + : undefined; + const result = await this.manager.fork({ + sessionId: opts.sessionId, + ...(opts.atSeq === undefined ? {} : { atSeq: SessionSeq(opts.atSeq) }), + }); + if (!result.ok) throw new SessionForkError(result.error, opts.sessionId); + this.projectList(); + const childId = result.value.sessionId; + opts.onCreated?.(childId); + if (sourceTitle !== undefined) { + const renamed = await this.manager.rename(childId, increasedForkTitle(sourceTitle)); + if (!renamed.ok) + throw new Error( + `fork child rename failed: ${renamed.error.code}: ${renamed.error.message}`, + ); + } + return childId; + } + + /** + * Borrow an already-retained Agent-scoped Context. + * @param id - session id (the agent identity — 1:1 same axis). + * @returns the scoped Context, or undefined without a retained generation. + */ + scope(id: SessionId): AgentContext | undefined { + return this.scopes.get(id)?.ctx; + } + + /** + * Retain a validated Gateway identity synchronously, without history or catalog I/O. + * @param id - Host-projected Session identity, possibly not yet catalogued. + * @returns a Gateway-source reference owned by the invocation. + */ + retainAgentScope(id: SessionId): SessionReference { + if (this.closed) throw new Error("Session Controller is disposed"); + return this.retainScope(id, "gateway"); + } + + /** + * Read the Agent scope tag off a context. Service-method boundary: fetch + * bundles must reach scope resolution through ctx.sessions — a cross-bundle + * value import of the standalone helper would inline a second module + * instance whose private tag Symbol never matches. + * @param ctx - any client context. + * @returns the session id, or undefined on root contexts. + */ + scopeOf(ctx: Context): SessionId | undefined { + return scopeTagOf(ctx); + } + + /** + * Resolve the business Session behind an Agent-scoped context — the one + * hop every scoped consumer (event listeners, per-session controllers) + * takes from ctx-space into object-space (the client mirror of host + * `agent.session`). Same service-method boundary as + * {@link ClientSessions.scopeOf}. + * @param ctx - an Agent-scoped context. + * @returns the matching live Session, or undefined for an untagged or ended generation. + */ + sessionOf(ctx: Context): SessionFace | undefined { + const id = scopeTagOf(ctx); + if (id === undefined) return undefined; + const record = this.scopes.get(id); + return record !== undefined && scopeIdentityOf(record.ctx) === scopeIdentityOf(ctx) + ? record.binding.session + : undefined; + } + + /** + * Borrow an already-retained binding without extending its lifetime. + * @param id - Session identity. + * @returns the live binding, or undefined without a retained generation. + */ + binding(id: SessionId): SessionBinding | undefined { + return this.scopes.get(id)?.binding; + } + + private retainScope(id: SessionId, source: SessionReferenceSource): ClientSessionReference { + const record = this.scopes.get(id) ?? this.materializeScope(id); + const previous = record.retention; + record.retention = Object.freeze({ + referenceCount: previous.referenceCount + 1, + retainedBy: freezeRetainedBy({ + ...previous.retainedBy, + [source]: (previous.retainedBy[source] ?? 0) + 1, + }), + }); + const reference = new ClientSessionReference(id, record, () => { + if (!record.live) return; + const count = record.retention.referenceCount - 1; + const { [source]: sourceCount = 0, ...otherSources } = record.retention.retainedBy; + const retainedBy = + sourceCount > 1 ? { ...otherSources, [source]: sourceCount - 1 } : otherSources; + record.retention = + count === 0 + ? EMPTY_RETAIN_INFO + : Object.freeze({ referenceCount: count, retainedBy: freezeRetainedBy(retainedBy) }); + if (count === 0) this.retireScope(id, record); + else this.publishRetention(id); + }); + if ( + this.list.getSnapshot().byId[id] === undefined && + this.manager.subagentAddress(id) !== undefined + ) { + this.projectList(); + } + this.publishRetention(id); + return reference; + } + + private retentionSnapshot(id: SessionId): SessionRetainInfo { + return this.scopes.get(id)?.retention ?? EMPTY_RETAIN_INFO; + } + + private publishRetention(id: SessionId): void { + const state = this.list.getSnapshot(); + const row = state.byId[id]; + const retainedBy = this.retentionSnapshot(id).retainedBy; + if (row !== undefined && row.retainedBy !== retainedBy) { + this.list.set({ ...state, byId: { ...state.byId, [id]: { ...row, retainedBy } } }); + } + const observer = this.retainObservers.get(id); + const snapshot = this.retentionSnapshot(id); + if (observer === undefined || observer.published === snapshot) return; + observer.published = snapshot; + notifySubscribers(observer.listeners, "[session-controller] reference sources"); + } + + private retireScope(id: SessionId, record: ScopeRecord, disposeFiber = true): void { + if (!record.live) return; + record.live = false; + if (this.scopes.get(id) === record) this.scopes.delete(id); + record.session.unbindScope(); + const sessionDisposal = this.manager.drop(id, record.session); + this.projectList(); + this.publishRetention(id); + this.startScopeDrop(id, record, disposeFiber, sessionDisposal); + } + + /** Materialize one scope after its caller establishes that the id may be addressed. */ + private materializeScope(id: SessionId): ScopeRecord { + const { fiber, ctx } = createScope(this.rootCtx, id); + const session = this.manager.get(id); + // The Session owns its scoped dispatch point (host Agent.loopCtx mirror); + // mint and bind are one step so a live scope record implies a bound actx. + session.bindScope(ctx); + const binding: SessionBinding = { + sessionId: id, + session, + eventSource: session.eventSource, + ctx, + }; + const record: ScopeRecord = { + fiber, + ctx, + binding, + session, + retention: EMPTY_RETAIN_INFO, + live: true, + }; + this.scopes.set(id, record); + ctx.effect( + () => () => { + this.retireScope(id, record, false); + }, + "session-controller: exact generation", + ); + return record; + } + + /** Project the manager's list snapshot into the store (title derivation is display-only). */ + private projectList(): void { + const previousById = this.list.getSnapshot().byId; + const { items, phase, projectionsBySession } = this.manager.getListSnapshot(); + const ids: SessionId[] = []; + const byId: Record = {}; + for (const entry of items) { + ids.push(entry.sessionId); + byId[entry.sessionId] = { + id: entry.sessionId, + displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId), + running: entry.running, + retainedBy: this.retentionSnapshot(entry.sessionId).retainedBy, + blank: entry.blank, + updatedAt: entry.updatedAt, + ...(entry.projectionValues === undefined + ? {} + : { projectionValues: entry.projectionValues }), + ...(entry.title !== undefined ? { title: entry.title } : {}), + ...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}), + ...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}), + ...(entry.origin !== undefined ? { origin: entry.origin } : {}), + }; + } + for (const [parentId, projection] of Object.entries(projectionsBySession)) { + for (const child of projection.values.subagentCatalog ?? []) { + const childId = child.id; + const summary = byId[childId]; + const projectionValues = + summary?.projectionValues ?? this.manager.projectionValues(childId); + const projectedTitle = projectionValues?.title; + const title = + typeof projectedTitle === "string" && projectedTitle !== "" ? projectedTitle : undefined; + const displayTitle = title ?? child.label ?? childId; + if (summary === undefined) { + byId[childId] = { + id: childId, + displayTitle, + parentId: parentId as SessionId, + origin: "subagent", + running: this.scopes.get(childId)?.session.getSnapshot().running ?? false, + blank: false, + updatedAt: 0, + retainedBy: this.retentionSnapshot(childId).retainedBy, + ...(projectionValues === undefined ? {} : { projectionValues }), + ...(title === undefined ? {} : { title }), + }; + } else if ( + summary.displayTitle !== displayTitle || + summary.projectionValues !== projectionValues + ) { + byId[childId] = { + ...summary, + displayTitle, + ...(projectionValues === undefined ? {} : { projectionValues }), + }; + } + } + } + for (const [id, record] of this.scopes) { + if (byId[id] !== undefined) continue; + const address = this.manager.subagentAddress(id); + if (address === undefined) continue; + const previous = previousById[id]; + const snapshot = record.session.getSnapshot(); + const projectionValues = this.manager.projectionValues(id); + const projectedTitle = projectionValues?.title; + const title = + typeof projectedTitle === "string" && projectedTitle !== "" + ? projectedTitle + : previous?.title; + byId[id] = { + ...(previous ?? { id, displayTitle: id, updatedAt: 0 }), + running: snapshot.running, + retainedBy: record.retention.retainedBy, + blank: snapshot.blank, + parentId: address.parentSessionId, + origin: "subagent", + ...(projectionValues === undefined ? {} : { projectionValues }), + ...(title === undefined ? {} : { title, displayTitle: title }), + }; + } + this.list.set({ ids, byId, phase, projectionsBySession }); + } + + private startScopeDrop( + id: SessionId, + record: ScopeRecord, + disposeFiber = true, + sessionDisposal = this.manager.drop(id, record.session), + ): void { + const drop = this.dropScope(record, disposeFiber, sessionDisposal); + this.scopeDrops.add(drop); + void drop.then( + () => { + this.scopeDrops.delete(drop); + }, + () => { + this.scopeDrops.delete(drop); + }, + ); + } + + private async drainScopeDrops(): Promise { + while (this.scopeDrops.size > 0) { + await Promise.allSettled([...this.scopeDrops]); + } + } + + /** Await the already-withdrawn Session and scoped cleanup to quiescence. */ + private async dropScope( + record: ScopeRecord, + disposeFiber: boolean, + sessionDisposal: Promise, + ): Promise { + await Promise.allSettled([sessionDisposal, ...(disposeFiber ? [record.fiber.dispose()] : [])]); + } +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/sessions/session.ts b/apps/web-ui/packages/api/session-controller/src/client/sessions/session.ts new file mode 100644 index 000000000..98e6b4937 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/sessions/session.ts @@ -0,0 +1,1048 @@ +// Sessions remain resident after creation so their open Remote sources keep running off-screen. + +import type { Context } from "@deepseek-ai/cordis"; +import type { InboxState, InboxTarget } from "@deepseek-ai/dsh-agent/types"; +import { randomUUID } from "@deepseek-ai/dsh-util-crypto"; +import type { + AttachmentIdType, + FileAttachmentRef, + ImageAttachmentRef, +} from "@deepseek-ai/dsh-attachment"; +import type { SubagentAddress } from "@deepseek-ai/dsh-subagent/client"; +import type { MessageId } from "@deepseek-ai/dsh-llm/brand"; +import type { UserMessage } from "@deepseek-ai/dsh-llm/types"; +import { SessionLogOffset, SessionSeq, type SessionId } from "@deepseek-ai/dsh-session/types"; +import { SessionEventStream } from "../transport.ts"; +import type { SessionJournalChange } from "../transport.ts"; +import type { + PromptContentPart, + QueueAction, + SessionAddress, + SessionAssistantStreamBaseline, + SessionProjectionBaseline, + SessionRequestId, +} from "../../types.ts"; +import type { + BeginSubmissionInput, + PendingSubmissionRetirement, + SessionFace, + SubmissionHandle, +} from "../contract/session.ts"; +import type { + OpenState, + PendingSubmission, + PromptError, + SessionSnapshot, +} from "../contract/snapshot.ts"; +import { MutableSessionEventSource } from "../contract/events.ts"; +import type { + SessionEventLike, + SessionEventLikeEntry, + SessionLiveEventEntry, +} from "../contract/events.ts"; +import { Notifier } from "./notifier.ts"; +import { isRemoteFailure } from "@deepseek-ai/dsh-api-gateway/client"; +import { RemoteError } from "@deepseek-ai/dsh-typert-protocol"; +import type { RemoteFailure, RemoteResult } from "@deepseek-ai/dsh-typert-protocol"; +import type { SessionRemotes } from "./remotes.ts"; +import { ProjectionValueStore } from "./projection-store.ts"; +import type { ProjectionsBaseline } from "./projection-store.ts"; +import { resolvedClientTimeZone } from "../time-zone.ts"; +import { ClientAssistantStream, type ClientAssistantStreamResult } from "./assistant-stream.ts"; + +function projectionsBaseline(value: SessionProjectionBaseline): ProjectionsBaseline { + return { + ...value, + asOfSeq: value.asOfSeq === -1 ? -1 : SessionSeq(value.asOfSeq), + }; +} + +/** Minimum message count for ordinary history windows. */ +export const PAGE_MESSAGES = 50; + +const HISTORY_PAGE_OPTIONS = { + maxMessages: 500, + turnWindow: { minMessages: PAGE_MESSAGES, minTurns: 2 }, +}; + +/** Minimum messages per page while a turn jump loops backwards. */ +export const JUMP_PAGE_MESSAGES = 200; + +const JUMP_PAGE_OPTIONS = { + ...HISTORY_PAGE_OPTIONS, + turnWindow: { ...HISTORY_PAGE_OPTIONS.turnWindow, minMessages: JUMP_PAGE_MESSAGES }, +}; + +interface PendingHistory { + beforeSeq: SessionLogOffset; + hasMore: boolean; + readonly pages: (readonly SessionEventLikeEntry[])[]; +} + +/** Manager-owned observers of a Session object's local state edges. */ +export interface SessionOptions { + /** Catalog-discovered address selecting non-activating subagent transport. */ + address?: SubagentAddress; + /** Whether the exact direct parent Agent is available in Host summaries; absent until known. */ + parentAvailable?: boolean; + /** + * Publish each accepted prompt to the Manager, including after this Session + * object is replaced. Acceptance converts display state, but does not + * establish that a turn started or reached durable history. + */ + onEngaged?(session: Session): void; + /** + * Manager-owned projection value store to adopt (frames route through the + * manager and values outlive instantiation); omitted, the Session owns a + * private store (bare object-layer construction). + */ + projections?: ProjectionValueStore; +} + +/** + * Owns a session's event window, lifecycle state, and observable + * snapshot. React bindings remain outside this data layer. Features see only + * the {@link SessionFace} slice (ISession verbs + the snapshot source); the + * remaining public members are Session Controller internals. + */ +export class Session implements SessionFace { + // ---- Window and derived state (all private; the snapshot is the only read API) ---- + private baseSeq = SessionLogOffset(0); + private hasMore = false; + private openState: OpenState = "cold"; + private openError: RemoteFailure | null = null; + private openPromise: Promise | null = null; + /** Bumped by stream replacement to invalidate an in-flight doOpen. Stale + * passes drop all writes once the generation moves on. */ + private openGeneration = 0; + private loadingOlder = false; + private olderError: RemoteFailure | null = null; + /** Shared low-water target of the running jump loop; null when no jump is paging. */ + private jumpTargetSeq: SessionSeq | null = null; + /** The running jump loop's completion, shared by retargeting callers. */ + private jumpPromise: Promise | null = null; + private pendingHistory: PendingHistory | null = null; + private readonly stopObservingInbox: () => void; + private readonly assistantStream = new ClientAssistantStream(); + private running = false; + private address: SubagentAddress | undefined; + private parentAvailable: boolean | undefined; + /** + * Sticky send marker, private input of the composerPhase derivation: set + * synchronously before prompt()'s first await, never reset — the blank → + * engaging edge of the phase machine (see ComposerPhase). + */ + private promptAttempted = false; + /** A first accepted prompt stays in the engaging phase until its turn is observable. */ + private firstPromptPendingTurn = false; + /** New Session display state; unknown bare sessions begin conservatively blank. */ + private blankBit = true; + private removed = false; + private promptError: PromptError | null = null; + private lastAgentError: string | null = null; + /** Local submission echoes, insertion-ordered (see SessionSnapshot.pendingSubmissions). */ + private pendingSubmissions: readonly PendingSubmission[] = []; + /** Per-echo settlement state; `retiring` latches the first observation so a + * Inbox projection and its durable event cannot both retire one echo. */ + private readonly submissionSettlements = new Map< + SessionRequestId, + { + readonly placement: PendingSubmission["placement"]; + /** Latest received Inbox position; null means removed from that queue. */ + receipt?: { + readonly target: InboxTarget; + readonly seq: number; + readonly index: number | null; + readonly attachments: readonly (ImageAttachmentRef | FileAttachmentRef)[]; + }; + /** Durable admission may precede the Inbox projection acknowledging its claim. */ + admitted?: readonly (ImageAttachmentRef | FileAttachmentRef)[]; + readonly onRetire?: ((retirement: PendingSubmissionRetirement) => void) | undefined; + retiring: boolean; + } + >(); + /** Owns the addressed page/follow lifecycle while this Session is open. */ + private events: SessionEventStream | undefined; + + /** + * Per-session projection value store (push model; see the session-projection + * subsystem page, docs/subsystems/session-projection.md): finished whole + * values computed on the Host, seeded by the tail page's + * projections block and updated by Session Controller control frames; + * Host-sequenced writes merge under higher-seq-wins and cached list blocks + * yield to them (projection-store.ts). Keys are read via `projections.faceOf(key)` + * (the useProjection resolution face); the conversation snapshot never + * carries projection values, and no client-side domain folding exists. + * Manager-owned when constructed through SessionManager (frames route and + * the store outlives instantiation, the title-snapshot precedent); a bare + * construction gets a private store. + */ + readonly projections: ProjectionValueStore; + + /** Contiguous history and live tail consumed by Conversation assembly. */ + readonly eventSource = new MutableSessionEventSource(); + private snapshotCache: SessionSnapshot; + private readonly notifier: Notifier; + /** + * Agent-scoped cordis context, bound once by ClientSessions when it + * mints the scope (the client mirror of the host Agent's loopCtx). The + * Session dispatches its own scoped events through it; undefined means + * unbound (bare object-layer construction) or already pruned — both skip + * dispatch-dependent behavior rather than fail. + */ + private actx: Context | undefined; + + /** + * @param sessionId - Host session identity (client sessions are always Host-born). + * @param remote - generated Remote namespaces this session calls. + * @param options - optional manager-owned state observers. + */ + constructor( + readonly sessionId: SessionId, + private readonly remote: SessionRemotes, + private readonly options: SessionOptions = {}, + ) { + this.projections = options.projections ?? new ProjectionValueStore(); + this.address = options.address; + this.parentAvailable = options.parentAvailable; + this.notifier = new Notifier(() => { + this.snapshotCache = this.buildSnapshot(); + }); + this.snapshotCache = this.buildSnapshot(); + this.stopObservingInbox = this.projections.faceOf("inbox").subscribe(() => { + this.observeSubmissionInbox(); + }); + } + + /** + * Bind the Agent-scoped context minted by ClientSessions (single write; + * a second bind is a wiring error and throws). Direction stays one-way at + * this binding boundary: consumers still reach the Session via `sessions.sessionOf`, + * while the Session holds its own dispatch point (host Agent.loopCtx + * mirror). + * @param actx - the agent's scoped context. + */ + bindScope(actx: Context): void { + if (this.actx !== undefined) + throw new Error(`session ${this.sessionId} already has a bound scope`); + this.actx = actx; + } + + /** Release the bound scope at prune time (a later rebind accompanies a freshly minted scope). */ + unbindScope(): void { + this.actx = undefined; + } + + // ---- Operations ---- + + /** + * Register one local submission echo (see the ISession declaration). + * Synchronous through markDirty: the echo is in the very next snapshot, so + * the conversation can paint it before the caller starts serializing. + * @param input - echo content and the optional settlement callback. + * @returns the minted identity for {@link prompt} plus the pre-prompt abandon path. + */ + beginSubmission(input: BeginSubmissionInput): SubmissionHandle { + const requestId = randomUUID() as SessionRequestId; + const placement = this.running + ? input.mode === "steer" + ? "steering" + : "queued" + : "transcript"; + this.pendingSubmissions = [ + ...this.pendingSubmissions, + { + requestId, + placement, + time: Date.now(), + text: input.text, + attachments: input.attachments, + }, + ]; + this.submissionSettlements.set(requestId, { + placement, + onRetire: input.onRetire, + retiring: false, + }); + // The blank → engaging edge flips here, ahead of prompt(): the composer + // docks and the echo renders on the click's own frame. + this.promptAttempted = true; + this.notifier.markDirty(); + return { + requestId, + abandon: () => { + this.retireFailedSubmission(requestId); + }, + }; + } + + /** + * Send (queue/steer passed through 1:1); failures land in the snapshot's promptError. + * @param content - text, browser-owned temporary image uploads, and staged-file receipts. + * @param mode - queue appends after the current turn; steer interrupts it. + * @param signal - optional caller cancellation for the complete admission round-trip. + * @param requestId - identity from {@link beginSubmission}; a failed identified prompt retires its echo. + * @returns the prompt result (also mirrored into promptError on failure). + */ + async prompt( + content: PromptContentPart[], + mode: "queue" | "steer", + signal?: AbortSignal, + requestId?: SessionRequestId, + ): Promise> { + this.promptError = null; + this.lastAgentError = null; + // Synchronous, before the first await: the blank → engaging edge must be + // visible on the session area's very first frame when a caller sends + // ahead of navigation (first-send flow). + this.promptAttempted = true; + if (this.blankBit) this.firstPromptPendingTurn = true; + this.notifier.markDirty(); + let result: RemoteResult<{ accepted: true }>; + if (this.address === undefined) { + const clientTimeZone = resolvedClientTimeZone(); + result = await this.remote.session.prompt( + { + requestId: requestId ?? (randomUUID() as SessionRequestId), + sessionId: this.sessionId, + mode, + content, + clientTimeZone, + }, + signal, + ); + } else if (content.some((part) => part.type === "file")) { + result = { + ok: false, + error: new RemoteError( + "subagent/attachment-invalid", + "subagent continuation does not accept files", + { reason: "SUBAGENT_FILE_UNSUPPORTED" }, + ), + }; + } else { + // The preceding branch rejects file parts before the narrower subagent + // wire type is used; this array is not filtered or reordered. + const routedContent = content as Exclude[]; + const routed = await this.remote.subagents.prompt( + { + requestId: randomUUID() as SessionRequestId, + parentSessionId: this.address.parentSessionId, + childSessionId: this.address.childSessionId, + mode: "continuable", + delivery: mode, + content: routedContent, + clientTimeZone: resolvedClientTimeZone(), + }, + signal, + ); + result = routed.ok ? { ok: true, value: { accepted: true } } : routed; + } + if (!result.ok) { + if (requestId !== undefined) this.retireFailedSubmission(requestId); + this.promptError = { op: "send", error: result.error }; + this.notifier.markDirty(); + return result; + } + // Rejection must leave a first prompt blank and eligible for workspace reuse. + if (this.blankBit) { + this.blankBit = false; + this.notifier.markDirty(); + } + this.options.onEngaged?.(this); + return result; + } + + /** + * Resolve one image referenced by this session into browser-consumable bytes. + * @param attachmentId - opaque id found in the folded session log. + * @returns the authenticated reference and decoded bytes. + */ + async readAttachment( + attachmentId: AttachmentIdType, + ): Promise> { + const result = await this.remote.session.attachment({ + sessionId: this.sessionId, + attachmentId, + }); + if (!result.ok) return result; + const binary = atob(result.value.data); + const data = Uint8Array.from(binary, (char) => char.charCodeAt(0)); + return { ok: true, value: { attachment: result.value.attachment, data } }; + } + + /** Apply one operation to a still-pending queue occurrence. */ + async updateQueue( + itemId: MessageId, + action: QueueAction, + ): Promise> { + return this.remote.session.updateQueue({ sessionId: this.sessionId, itemId, action }); + } + + /** + * Stop the active turn while the Host preserves pending inbox work; failures + * land in promptError (same error-strip display slot). A subagent address + * routes through `subagents.interruptByParent`, whose durable parent-address + * authority works without a live parent Agent. + * @returns the cancel result. + */ + async cancel(): Promise> { + const address = this.address; + const result = + address !== undefined + ? await this.remote.subagents.interruptByParent( + address.childSessionId, + address.parentSessionId, + "continuable", + ) + : await this.remote.session.cancel({ sessionId: this.sessionId }); + if (!result.ok) { + this.promptError = { op: "stop", error: result.error }; + this.notifier.markDirty(); + } + return result; + } + + /** + * Rename: contract session.rename 1:1. On success settle the 'title' + * projection cell from the response's `{title, seq}` under the store's + * higher-seq-wins rule (the push frame arriving later is a no-op replay), + * so the list row and any useProjection('title') reader update without + * waiting for the control-stream projection update. + * @param title - raw title text (the host normalizes acceptance). + * @returns the rename result (normalized accepted title + title event seq). + */ + async rename(title: string): Promise> { + const result = await this.remote.session.rename({ sessionId: this.sessionId, title }); + if (!result.ok) return result; + const seq = SessionSeq(result.value.seq); + this.projections.apply("title", result.value.title, seq); + return { ok: true, value: { title: result.value.title, seq } }; + } + + /** + * Execute one slash-command line against this session's agent — pure + * admission semantics (the host executor durably logs the lifecycle; + * outcomes render as flow nodes, never as a response echo). + * @param line - the full command line, leading slash included. + * @returns the admission result. + */ + async command(line: string): Promise> { + const result = await this.remote.commands.execute(this.sessionId, line, []); + if (!result.ok) return result; + return { ok: true, value: { matched: result.value !== undefined } }; + } + + /** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */ + open(): Promise { + if (this.openState === "open") return Promise.resolve(); + if (this.openPromise !== null) return this.openPromise; + const promise = this.doOpen(this.openGeneration).finally(() => { + // Identity-guarded: a superseded open must not null out the promise resync just started. + if (this.openPromise === promise) this.openPromise = null; + }); + this.openPromise = promise; + return promise; + } + + /** Prepend one Turn-aligned page: at least 50 messages and two Turn starts, capped at 500 messages. */ + async loadOlder(): Promise { + if (this.openState !== "open" || !this.hasMore || this.loadingOlder) return; + const events = this.events; + if (events === undefined) return; + const generation = this.openGeneration; + this.loadingOlder = true; + this.olderError = null; + this.notifier.markDirty(); + try { + await events.prepend({ + beforeSeq: this.baseSeq, + ...HISTORY_PAGE_OPTIONS, + }); + } catch (error) { + if (generation === this.openGeneration) + this.olderError = isRemoteFailure(error) + ? error + : new RemoteError("gateway/internal", "Could not load earlier history.", {}); + } finally { + if (generation === this.openGeneration) { + this.loadingOlder = false; + this.notifier.markDirty(); + } + } + } + + /** Jump loader: page backwards until the window covers seq (see ISession.loadThrough). */ + loadThrough(seq: SessionSeq): Promise { + if (this.openState !== "open" || !this.hasMore || this.baseSeq <= seq) return Promise.resolve(); + if (this.jumpPromise !== null) { + // Retarget the running loop to the lowest requested seq. + this.jumpTargetSeq = SessionSeq(Math.min(this.jumpTargetSeq ?? seq, seq)); + return this.jumpPromise; + } + // A plain single-page pull owns the busy flag; the jump does not queue + // behind it (the caller retries once it settles) and must leave no + // target behind — only the loop's finally clears that field, and no + // loop starts here. + if (this.loadingOlder) return Promise.resolve(); + const events = this.events; + if (events === undefined) return Promise.resolve(); + const pending: PendingHistory = { + beforeSeq: this.baseSeq, + hasMore: this.hasMore, + pages: [], + }; + this.pendingHistory = pending; + this.jumpTargetSeq = seq; + this.olderError = null; + this.loadingOlder = true; + this.notifier.markDirty(); + // Stale-pass guard (the doOpen pattern): a resync mid-loop replaces the + // stream generation; this pass then stops instead of paging the new + // generation toward its old target. + const generation = this.openGeneration; + this.jumpPromise = (async () => { + try { + while ( + pending.hasMore && + this.jumpTargetSeq !== null && + pending.beforeSeq > this.jumpTargetSeq + ) { + if (generation !== this.openGeneration) return; + const before = pending.beforeSeq; + await events.prepend({ beforeSeq: before, ...JUMP_PAGE_OPTIONS }); + // No-progress guard: an empty or dropped page that still claims more + // history must end the loop, not spin it. + if (pending.beforeSeq >= before) return; + } + } catch (error) { + if (!isRemoteFailure(error)) { + console.error("[session-controller] loadThrough failed:", error); + } + } finally { + this.jumpTargetSeq = null; + this.jumpPromise = null; + this.pendingHistory = null; + this.loadingOlder = false; + if (generation === this.openGeneration && pending.pages.length > 0) { + this.prependWindow(pending.pages.reverse().flat(), pending.hasMore); + } + this.notifier.markDirty(); + } + })(); + return this.jumpPromise; + } + + /** Rebuild an opened history source after address replacement. + * Invalidates any in-flight open first; projection state belongs to the independently + * reconnecting control stream and remains untouched. */ + async resync(): Promise { + if (this.openState === "cold") return; // never opened: no window to rebuild (doOpen flips to 'loading' synchronously, so cold implies no in-flight open) + this.openGeneration++; + const events = this.events; + this.events = undefined; + await events?.dispose(); + this.openPromise = null; + this.openState = "cold"; + this.openError = null; + this.baseSeq = SessionLogOffset(0); + this.notifier.markDirty(); + await this.open(); + } + + // ---- Subscription API (useSyncExternalStore direct wiring) ---- + + /** + * uSES subscription entry. + * @param listener - change callback. + * @returns the unsubscribe function. + */ + subscribe(listener: () => void): () => void { + return this.notifier.subscribe(listener); + } + + /** + * Cached Session snapshot (rebuilt lazily when dirty with no listeners). + * @returns the cached reference (stable until the next flush). + */ + getSnapshot(): SessionSnapshot { + this.notifier.ensureFresh(); + return this.snapshotCache; + } + + // ---- Manager-only entry points (@internal; never called by the UI) ---- + + /** + * Running-bit relay from the host stream (list entry and snapshot stay consistent). + * @param running - the new running state. + */ + handleRunning(running: boolean): void { + // Running converts display state without establishing durable turn history. + if (running && this.blankBit) { + this.blankBit = false; + this.notifier.markDirty(); + } + if (running) this.firstPromptPendingTurn = false; + if (this.running === running) return; + this.running = running; + this.notifier.markDirty(); + } + + /** + * Install or clear the catalog-discovered transport address. A changed + * address rebuilds an already-open window through its new history route. + * @param address - direct parent/child address, or undefined for ordinary transport. + * @param parentAvailable - latest exact-parent availability hint, or undefined before a catalog read. + */ + configureSubagent(address: SubagentAddress | undefined, parentAvailable?: boolean): void { + const same = + this.address?.parentSessionId === address?.parentSessionId && + this.address?.childSessionId === address?.childSessionId && + this.address?.mode === address?.mode; + this.address = address; + this.parentAvailable = parentAvailable; + if (!same && this.openState !== "cold") void this.resync(); + else this.notifier.markDirty(); + } + + /** + * Update only the parent availability hint from a catalog refresh. + * @param available - whether the exact direct parent is live. + */ + handleSubagentParentAvailable(available: boolean): void { + if (this.parentAvailable === available) return; + this.parentAvailable = available; + this.notifier.markDirty(); + } + + /** + * Apply the Manager's effective display blank, further reconciled with the + * current `sessionListMetadata` projection. Local send attempts and current + * running state prevent re-blanking; an earlier false summary alone does not. + * The Manager retains acceptance and earlier running observations across + * Session-object replacement. + * @param blank - New Session display state after Manager reconciliation. + */ + handleBlank(blank: boolean): void { + blank = blank && this.projections.values().sessionListMetadata?.blank !== false; + if (blank === this.blankBit) return; + if (blank && (this.promptAttempted || this.running)) return; + this.blankBit = blank; + this.notifier.markDirty(); + } + + /** `api-session/removed` relay: flag the snapshot while retaining the resident instance. */ + handleRemoved(): void { + this.removed = true; + this.notifier.markDirty(); + } + + /** + * `api-session/error` relay: the outlet for live failures with no turn position. + * @param message - the stringified error. + */ + handleAgentError(message: string): void { + this.lastAgentError = message; + this.notifier.markDirty(); + } + + /** + * Stop the Session's live Remote source. + * @returns when the Remote iterator has completed teardown. + */ + async dispose(): Promise { + this.stopObservingInbox(); + // Unsettled echoes retire as failed so their owners can restore or + // release browser resources; admitted echoes keep their observed outcome. + for (const [requestId, settlement] of [...this.submissionSettlements]) { + if (settlement.admitted !== undefined) + this.scheduleObservedRetirement(requestId, settlement.admitted); + else this.retireFailedSubmission(requestId); + } + this.openGeneration++; + const events = this.events; + this.events = undefined; + await events?.dispose(); + } + + // ---- Private ---- + + /** @param generation - openGeneration at launch; stale passes cannot publish after replacement. */ + private async doOpen(generation: number): Promise { + this.openState = "loading"; + this.openError = null; + this.olderError = null; + this.loadingOlder = false; + this.notifier.markDirty(); + const events = new SessionEventStream(this.remote, this.sessionAddress(), { + publish: (change) => { + if (generation !== this.openGeneration || this.events !== events) return; + this.acceptEventChange(change); + }, + failed: (error) => { + this.failEventStream(events, generation, error); + }, + }); + this.events = events; + try { + await events.open(HISTORY_PAGE_OPTIONS); + if (generation !== this.openGeneration || this.events !== events) return; + this.openState = "open"; + } catch (error) { + if (generation !== this.openGeneration || this.events !== events) return; + if (!isRemoteFailure(error)) throw error; + this.events = undefined; + this.openState = "error"; + this.openError = error; + } finally { + if (generation === this.openGeneration) this.notifier.markDirty(); + } + } + + /** Apply one contiguous journal update already reconciled by the Remote stream. */ + private acceptEventChange(change: SessionJournalChange): void { + switch (change.type) { + case "replace": + this.installWindow( + change.entries, + change.hasMore, + change.page.projections === undefined + ? undefined + : projectionsBaseline(change.page.projections), + change.page.assistantStream, + ); + return; + case "prepend": + this.prependWindow(change.entries, change.hasMore); + return; + case "append": + this.publishAssistantEntry(this.assistantStream.acceptDurable(change.entry)); + return; + case "assistant-stream": + this.publishAssistantEntry(this.assistantStream.acceptFrame(change.frame)); + } + } + + /** Replace the complete contiguous window and apply page-owned projection metadata. */ + private installWindow( + entries: readonly SessionEventLikeEntry[], + hasMore: boolean, + projections?: ProjectionsBaseline, + assistantStream?: SessionAssistantStreamBaseline, + ): void { + // A durable gap-repair page has no assistant baseline. Clearing transient + // attempts makes a held notification reopen follow once for an atomic + // page/baseline pair instead of applying it to an unrelated repair cut. + const visible = this.assistantStream.replace(entries, assistantStream); + this.baseSeq = SessionLogOffset(entries[0]?.event.seq ?? 0); + this.hasMore = hasMore; + if (this.pendingHistory !== null) { + this.pendingHistory.beforeSeq = this.baseSeq; + this.pendingHistory.hasMore = hasMore; + this.pendingHistory.pages.length = 0; + } + if (visible.some((entry) => entry.event.type === "turn/start")) + this.firstPromptPendingTurn = false; + if (projections !== undefined) this.projections.seed(projections); + this.eventSource.replace(visible, hasMore); + // A new follow baseline replaces confirmed optimistic inputs with Host-owned rows. + // Receipt-backed inputs are accepted, not failed, even if their history is outside this window. + if (projections !== undefined) { + for (const [requestId, { receipt }] of this.submissionSettlements) { + if (receipt !== undefined && receipt.seq <= projections.asOfSeq) { + this.scheduleObservedRetirement(requestId, receipt.attachments); + } + } + } + for (const entry of visible) this.observeSubmissionEvent(entry.event); + if (projections !== undefined) { + const inbox = projections.values.inbox as InboxState | undefined; + for (const target of ["next-turn", "next-step"] as const) { + this.observeSubmissionInsertions(target, inbox?.[target] ?? [], 0, projections.asOfSeq); + } + } + this.notifier.markDirty(); + } + + private publishAssistantEntry(result: ClientAssistantStreamResult): void { + if (result?.type === "rebaseline") { + const events = this.events; + queueMicrotask(() => { + if (events !== undefined && this.events === events) events.restart(); + }); + return; + } + if (result?.type === "settlement") { + this.eventSource.settleAssistant(result.attemptId, result.entry); + this.observeSubmissionEvent(result.entry.event); + this.notifier.markDirty(); + return; + } + if (result?.type === "abandonment") { + this.eventSource.settleAssistant(result.attemptId); + this.notifier.markDirty(); + return; + } + if (result?.type === "publish") { + const changed = this.appendLive(result.entry); + if (result.retireAttemptId !== undefined) + this.eventSource.settleAssistant(result.retireAttemptId); + if (changed || result.retireAttemptId !== undefined) this.notifier.markDirty(); + } else if (result?.type === "transient") { + this.eventSource.append(result.entry); + this.notifier.markDirty(); + } + } + + /** Prepend one stream-validated history page. */ + private prependWindow(entries: readonly SessionEventLikeEntry[], hasMore: boolean): void { + if (this.pendingHistory !== null) { + const pending = this.pendingHistory; + pending.beforeSeq = + entries[0] === undefined ? pending.beforeSeq : SessionLogOffset(entries[0].event.seq); + pending.hasMore = hasMore; + pending.pages.push(entries); + return; + } + this.baseSeq = entries[0] === undefined ? this.baseSeq : SessionLogOffset(entries[0].event.seq); + this.hasMore = hasMore; + this.eventSource.prepend(entries, hasMore); + } + + /** Append one stream-validated live event. */ + private appendLive(entry: SessionLiveEventEntry): boolean { + const event = entry.event; + const awaitingFirstTurn = this.firstPromptPendingTurn; + if (event.type === "turn/start") this.firstPromptPendingTurn = false; + this.eventSource.append(entry); + // After the feed append: the conversation assembly's animation frame is + // registered by the feed subscribers above, so the echo-retirement frame + // scheduled here always runs after the durable node became renderable. + this.observeSubmissionEvent(event); + return awaitingFirstTurn !== this.firstPromptPendingTurn; + } + + /** Observe durable acceptance even when insertion and claim share one projection notification. */ + private observeSubmissionEvent(event: SessionEventLike): void { + if (this.submissionSettlements.size === 0) return; + if (event.type === "agent/inbox/spliced") { + const { target, start, removedCount = 0, inserted, outcome } = event.data; + for (const [requestId, settlement] of this.submissionSettlements) { + const receipt = settlement.receipt; + if (receipt?.target !== target || receipt.index === null || receipt.seq >= event.seq) + continue; + const removed = receipt.index >= start && receipt.index < start + removedCount; + if (removed && outcome === "canceled") this.retireFailedSubmission(requestId); + else + settlement.receipt = { + ...receipt, + seq: event.seq, + index: removed + ? null + : receipt.index < start + ? receipt.index + : receipt.index + inserted.length - removedCount, + }; + } + this.observeSubmissionInsertions(target, inserted, start, event.seq); + for (const message of inserted) this.observeSubmissionMessage(message, false); + return; + } + if (event.type === "request/context" || event.type === "turn/end") { + for (const [requestId, settlement] of this.submissionSettlements) { + if ( + settlement.admitted === undefined && + settlement.receipt?.index === null && + settlement.receipt.seq < event.seq + ) + this.retireFailedSubmission(requestId); + } + return; + } + if (event.type === "user/message") this.observeSubmissionMessage(event.data, true); + } + + private observeSubmissionInsertions( + target: InboxTarget, + messages: readonly UserMessage[], + start: number, + seq: number, + ): void { + for (const [index, message] of messages.entries()) { + const source = message.source; + if (source.kind !== "user" || !("rpcId" in source)) continue; + const settlement = this.submissionSettlements.get(source.rpcId); + if ( + settlement === undefined || + settlement.placement === "queued" || + settlement.retiring || + (settlement.receipt?.seq ?? -1) > seq + ) + continue; + settlement.receipt = { + target, + seq, + index: start + index, + attachments: attachmentRefsIn(message.content), + }; + } + } + + private observeSubmissionMessage(message: UserMessage, admitted: boolean): void { + const source = message.source; + if (source.kind !== "user" || !("rpcId" in source)) return; + const settlement = this.submissionSettlements.get(source.rpcId); + if (settlement === undefined || settlement.retiring) return; + if (!admitted) { + if (settlement.placement === "queued") + this.scheduleObservedRetirement(source.rpcId, attachmentRefsIn(message.content)); + return; + } + settlement.admitted = attachmentRefsIn(message.content); + this.retireAdmittedSubmission(source.rpcId); + } + + /** Retire admitted Chat identities only after stale Inbox rows can no longer reappear. */ + private retireAdmittedSubmission(requestId: SessionRequestId): void { + const settlement = this.submissionSettlements.get(requestId); + if (settlement?.admitted === undefined) return; + const receipt = settlement.receipt; + if (receipt?.index === null && (this.projections.seqOf("inbox") ?? -1) < receipt.seq) return; + this.scheduleObservedRetirement(requestId, settlement.admitted); + } + + /** Inbox acceptance retires queued echoes; its watermark completes admitted Chat handoffs. */ + private observeSubmissionInbox(): void { + if (this.submissionSettlements.size === 0) return; + const inbox = this.projections.get("inbox") as InboxState | undefined; + if (inbox === undefined) return; + const seq = this.projections.seqOf("inbox"); + for (const target of ["next-turn", "next-step"] as const) { + if (seq !== undefined) this.observeSubmissionInsertions(target, inbox[target], 0, seq); + for (const message of inbox[target]) this.observeSubmissionMessage(message, false); + } + for (const requestId of this.submissionSettlements.keys()) + this.retireAdmittedSubmission(requestId); + } + + /** + * Latch one observed settlement and remove the echo an animation frame + * later. The delay keeps the echo in the snapshot until the frame in which + * the durable node (whose assembly frame was registered first) is + * renderable; the render-time rpcId dedupe hides the one-frame overlap. + */ + private scheduleObservedRetirement( + requestId: SessionRequestId, + attachments: readonly (ImageAttachmentRef | FileAttachmentRef)[], + ): void { + const settlement = this.submissionSettlements.get(requestId); + if (settlement === undefined || settlement.retiring) return; + settlement.retiring = true; + scheduleFrame(() => { + this.finishSubmission(requestId, { reason: "observed", attachments }); + }); + } + + /** Remove one unsettled echo immediately (prompt rejection, abort, or disposal). */ + private retireFailedSubmission(requestId: SessionRequestId): void { + const settlement = this.submissionSettlements.get(requestId); + if (settlement === undefined || settlement.retiring || settlement.admitted !== undefined) + return; + settlement.retiring = true; + this.finishSubmission(requestId, { reason: "failed" }); + } + + /** Single removal point: drop the echo, publish, then notify the owner. */ + private finishSubmission( + requestId: SessionRequestId, + retirement: PendingSubmissionRetirement, + ): void { + const settlement = this.submissionSettlements.get(requestId); + /* v8 ignore next -- retiring latches before every schedule, so one settlement never finishes twice. */ + if (settlement === undefined) return; + this.submissionSettlements.delete(requestId); + this.pendingSubmissions = this.pendingSubmissions.filter( + (echo) => echo.requestId !== requestId, + ); + this.notifier.markDirty(); + settlement.onRetire?.(retirement); + } + + /** Publish a terminal background failure only while this stream still owns the Session. */ + private failEventStream(events: SessionEventStream, generation: number, error: unknown): void { + if (generation !== this.openGeneration || this.events !== events) return; + if (!isRemoteFailure(error)) throw error; + this.openGeneration++; + this.events = undefined; + this.openPromise = null; + this.openState = "error"; + this.openError = error; + void events.dispose(); + this.notifier.markDirty(); + } + + private buildSnapshot(): SessionSnapshot { + const identity = this.projections.values().subagent; + return { + sessionId: this.sessionId, + pendingSubmissions: this.pendingSubmissions, + running: this.running, + subagent: + this.address === undefined + ? null + : { + address: + this.address.mode === "unknown" && identity != null + ? { ...this.address, mode: identity.mode } + : this.address, + ...(this.parentAvailable === undefined + ? {} + : { parentAvailable: this.parentAvailable }), + }, + removed: this.removed, + openState: this.openState, + openError: this.openError, + hasMore: this.hasMore, + loadingOlder: this.loadingOlder, + olderError: this.olderError, + promptError: this.promptError, + blank: this.blankBit, + lastAgentError: this.lastAgentError, + promptAttempted: this.promptAttempted, + awaitingFirstTurn: this.firstPromptPendingTurn, + }; + } + + private sessionAddress(): SessionAddress { + return this.address === undefined + ? { kind: "session", sessionId: this.sessionId } + : { kind: "subagent", ...this.address }; + } +} + +/** Run one callback on the next animation frame, or a macrotask where no frame clock exists. */ +function scheduleFrame(fn: () => void): void { + if (typeof requestAnimationFrame === "function") + requestAnimationFrame(() => { + fn(); + }); + else setTimeout(fn, 0); +} + +/** Attachment references in one structurally-read content block list, in block order. */ +function attachmentRefsIn(content: unknown): readonly (ImageAttachmentRef | FileAttachmentRef)[] { + if (!Array.isArray(content)) return []; + const refs: Array = []; + for (const block of content) { + if (typeof block !== "object" || block === null) continue; + const candidate = block as { readonly type?: unknown; readonly attachment?: unknown }; + if ( + (candidate.type === "image" || candidate.type === "file") && + typeof candidate.attachment === "object" && + candidate.attachment !== null + ) { + refs.push(candidate.attachment as ImageAttachmentRef | FileAttachmentRef); + } + } + return refs; +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/time-zone.ts b/apps/web-ui/packages/api/session-controller/src/client/time-zone.ts new file mode 100644 index 000000000..53a5d0d68 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/time-zone.ts @@ -0,0 +1,14 @@ +/** Browser-owned time-zone sampling for one prompt RPC. */ + +/** + * Resolve the current browser IANA zone for one outbound operation. + * @returns The browser-provided canonical zone. + * @throws when the runtime cannot provide a non-empty zone. + */ +export function resolvedClientTimeZone(): string { + const timeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone; + if (typeof timeZone !== "string" || timeZone.length === 0) { + throw new Error("browser time zone is unavailable"); + } + return timeZone; +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/transport.ts b/apps/web-ui/packages/api/session-controller/src/client/transport.ts new file mode 100644 index 000000000..1ca6abcbc --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/transport.ts @@ -0,0 +1,252 @@ +/** Session-specific adapters for Gateway-owned Remote stream lifecycles. */ + +import type {} from "@deepseek-ai/dsh-api-session-controller/remote"; +import { RemoteError } from "@deepseek-ai/dsh-typert-protocol"; +import { + RemoteJournalStream, + RemoteSnapshotStream, + RemoteStreamCarrierError, + type ClientRemote, + type RemoteJournalChange, + type RemoteJournalFrame, +} from "@deepseek-ai/dsh-api-gateway/client"; +import type { + SessionAddress, + SessionAssistantStreamBaseline, + SessionAssistantStreamFrame, + SessionControlFrame, + SessionHistoryRecord, + SessionPage, + SessionPageRequest, + SessionProjectionBaseline, +} from "../types.ts"; +import { + historyEntries, + historyRecordFirstSeq, + historyRecordLastSeq, +} from "./sessions/history-records.ts"; +import type { SessionEventLikeEntry, SessionLiveEventEntry } from "./contract/events.ts"; +import type { SessionRemotes } from "./sessions/remotes.ts"; +import { assertSessionWireEvent } from "./session-wire-event.ts"; + +export { SESSION_SEARCH_RESULT_LIMIT, SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS } from "../types.ts"; + +/** Pagination fields bound to an already-addressed Session journal. */ +export type ClientSessionPageRequest = Omit; + +/** Complete generated `ctx.remote.session` namespace. */ +export type SessionRemote = ClientRemote["session"]; + +/** Opening metadata carried only by a follow snapshot, never by loadOlder pages. */ +interface SessionJournalPage extends SessionPage { + readonly projections?: SessionProjectionBaseline; + readonly assistantStream?: SessionAssistantStreamBaseline; +} + +/** One complete publication from the Session journal stream. */ +export type SessionJournalChange = + | { + readonly type: "replace" | "prepend"; + readonly page: SessionJournalPage; + readonly entries: readonly SessionEventLikeEntry[]; + readonly hasMore: boolean; + } + | { readonly type: "append"; readonly entry: SessionLiveEventEntry } + | { readonly type: "assistant-stream"; readonly frame: SessionAssistantStreamFrame }; + +function toSessionJournalChange( + change: RemoteJournalChange< + SessionJournalPage, + SessionHistoryRecord, + SessionAssistantStreamFrame + >, +): SessionJournalChange { + switch (change.type) { + case "replace": + case "prepend": + return { ...change, entries: historyEntries(change.entries) }; + case "append": { + return { + type: "append", + entry: change.entry as unknown as SessionLiveEventEntry, + }; + } + case "notification": + return { type: "assistant-stream", frame: change.notification }; + } +} + +type SessionControlBaselineFrame = Extract; +type SessionControlDeltaFrame = Exclude; + +/** Gateway-owned control snapshot stream configured for Session frames. */ +export type SessionControlStream = RemoteSnapshotStream< + SessionControlBaselineFrame, + SessionControlDeltaFrame +>; + +/** Domain sinks used by the Host-wide Session control stream. */ +export interface SessionControlStreamOptions { + /** Apply a complete baseline or one later update. */ + readonly accept: (frame: SessionControlFrame) => void; + /** Observe a retryable carrier loss before reconnection. */ + readonly carrierFailed?: (error: RemoteStreamCarrierError) => void; + /** Publish a terminal business or protocol failure. */ + readonly failed: (error: unknown) => void; +} + +/** Domain sinks used by one addressed Session event journal. */ +export interface SessionEventStreamOptions { + /** Apply one complete event-window change. */ + readonly publish: (change: SessionJournalChange) => void; + /** Observe a retryable carrier loss before reconnection. */ + readonly carrierFailed?: (error: RemoteStreamCarrierError) => void; + /** Publish a terminal stream, page, or protocol failure after opening. */ + readonly failed: (error: unknown) => void; +} + +/** + * Create the Host-wide Session control snapshot stream. + * @param remote - generated Session namespace and Gateway stream factory. + * @param options - Session state destinations. + * @returns an unstarted stream owned by the Client Session runtime. + */ +export function createSessionControlStream( + remote: SessionRemotes, + options: SessionControlStreamOptions, +): SessionControlStream { + const stream = remote.$stream({ + name: "session control stream", + open: (signal) => remote.session.control(signal), + ended: (accepted) => + accepted + ? new RemoteStreamCarrierError("session control stream ended without a terminal result") + : new Error("session control stream ended before its opening snapshot"), + ...(options.carrierFailed === undefined ? {} : { carrierFailed: options.carrierFailed }), + }); + return new RemoteSnapshotStream(stream, { + name: "session control stream", + isSnapshot: (frame): frame is SessionControlBaselineFrame => frame.type === "baseline", + replace: options.accept, + update: options.accept, + failed: options.failed, + }); +} + +/** Gateway-owned event journal bound to one ordinary or direct-subagent Session address. */ +export class SessionEventStream extends RemoteJournalStream< + SessionJournalPage, + SessionHistoryRecord, + number, + ClientSessionPageRequest, + SessionAssistantStreamFrame +> { + /** + * @param remote - generated Session namespace and Gateway stream factory. + * @param address - durable ordinary-Session or direct-subagent address. + * @param options - Session event-window destinations. + */ + constructor( + private readonly remote: SessionRemotes, + private readonly address: SessionAddress, + options: SessionEventStreamOptions, + ) { + super(remote, { + name: "session event stream", + emptyCursor: -1, + entries: (page) => page.records, + hasMore: (page) => page.hasMore, + first: historyRecordFirstSeq, + last: historyRecordLastSeq, + compare: (left, right) => left - right, + follows: (left, right) => right === left + 1, + publish: (change) => { + options.publish(toSessionJournalChange(change)); + }, + ...(options.carrierFailed === undefined ? {} : { carrierFailed: options.carrierFailed }), + failed: options.failed, + }); + } + + /** @inheritdoc */ + protected override async *follow( + request: ClientSessionPageRequest, + signal: AbortSignal, + ): AsyncIterable< + RemoteJournalFrame< + SessionHistoryRecord, + number, + SessionJournalPage, + SessionAssistantStreamFrame + > + > { + let assistantRevision: number | undefined; + for await (const frame of this.remote.session.follow( + { + address: this.address, + assistantStream: true, + ...this.repairRequest(request), + }, + signal, + )) { + if (frame.type === "snapshot") { + for (const record of frame.records) assertSessionWireEvent(record.event); + if (frame.assistantStream === undefined) { + throw new RemoteError( + "gateway/internal", + "session assistant stream omitted its opted-in opening baseline", + {}, + ); + } + assistantRevision = frame.assistantStream.revision; + yield { + type: "opened", + cursor: frame.cursor, + page: { + records: frame.records, + hasMore: frame.hasMore, + projections: frame.projections, + assistantStream: frame.assistantStream, + }, + }; + continue; + } + if (frame.type === "assistant-stream") { + const expected = (assistantRevision ?? 0) + 1; + if (frame.frame.revision !== expected) { + throw new RemoteStreamCarrierError( + `session assistant stream skipped revision ${String(expected)}`, + ); + } + assistantRevision = frame.frame.revision; + yield { type: "notification", notification: frame.frame }; + continue; + } + assertSessionWireEvent(frame.event); + yield { type: "entry", entry: frame }; + } + } + + /** @inheritdoc */ + protected override async readPage( + request: ClientSessionPageRequest, + throughSeq: number, + signal: AbortSignal, + ): Promise { + const result = await this.remote.session.page( + { address: this.address, throughSeq, ...request }, + signal, + ); + if (!result.ok) throw result.error; + for (const record of result.value.records) assertSessionWireEvent(record.event); + return result.value; + } + + /** @inheritdoc */ + protected override repairRequest(request: ClientSessionPageRequest): ClientSessionPageRequest { + return { + ...(request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }), + ...(request.turnWindow === undefined ? {} : { turnWindow: request.turnWindow }), + }; + } +} diff --git a/apps/web-ui/packages/api/session-controller/src/commands.ts b/apps/web-ui/packages/api/session-controller/src/commands.ts new file mode 100644 index 000000000..674a26e36 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/commands.ts @@ -0,0 +1,751 @@ +/** 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"; +import type { Agent, ModelSelection as AgentModelSelection } from "@deepseek-ai/dsh-agent"; +import { AttachmentError } from "@deepseek-ai/dsh-attachment"; +import type { + AttachmentAdmissionPart, + FileAttachmentRef, + ImageAttachmentRef, +} from "@deepseek-ai/dsh-attachment"; +import type { FileUploadReceiptId } from "@deepseek-ai/dsh-client-file-upload/types"; +import type {} from "@deepseek-ai/dsh-client-file-upload"; +import { + ReasoningEffortId, + assistantStreamChunks, + createUserMessage, + freezeMessage, +} from "@deepseek-ai/dsh-llm"; +import type { MessageSource } from "@deepseek-ai/dsh-llm"; +import { buildForkSeed } from "@deepseek-ai/dsh-session/fork"; +import { SessionLogOffset, SessionSeq } from "@deepseek-ai/dsh-session"; +import type { SessionEvent, SessionHeader, SessionId, UserMessage } from "@deepseek-ai/dsh-session"; +import { SessionQueryError, type SessionObservation } from "@deepseek-ai/dsh-session-query"; +import { SessionTitleInvalidError } from "@deepseek-ai/dsh-session-title"; +import { canonicalClientTimeZone } from "@deepseek-ai/dsh-util-time"; +import { assertNever } from "@deepseek-ai/dsh-util-values"; +import { RemoteError, remoteErrorOf } from "@deepseek-ai/dsh-typert-protocol"; +import type { Workspace } from "@deepseek-ai/dsh-workspace"; +import { + ApiSessionAgentController, + ApiSessionCwdConflict, + ApiSessionNotFound, + ApiSessionPresetConflict, + ApiSessionSubagentOwnership, + apiSessionSubagentOwnershipError, + hasApiSessionSubagentOwner, + inspectApiSession, +} from "./agent.ts"; +import type { + SessionAttachmentRequest, + SessionAttachmentValue, + SessionCancelRequest, + SessionCancelValue, + SessionCreateRequest, + SessionCreateValue, + SessionForkRequest, + SessionForkValue, + SessionPromptRequest, + SessionPromptValue, + SessionRenameRequest, + SessionRenameValue, + SessionSelectModelRequest, + SessionSelectModelValue, + SessionUpdateQueueRequest, + SessionUpdateQueueValue, + SessionRequestId, +} from "./types.ts"; + +interface SessionReadState { + readonly id: SessionId; + readonly header: SessionHeader; + readonly events: readonly SessionEvent[]; +} + +type PromptContentCandidate = + | SessionPromptRequest["content"][number] + | Extract["content"][number]; + +function hasPromptContent(content: readonly PromptContentCandidate[]): boolean { + return content.some((part) => part.type !== "text" || part.text.trim().length > 0); +} + +/** + * Resolve the omitted-`atSeq` default to the latest completed-turn prefix, + * including standalone events before the next turn begins. + */ +function latestCompletedPrefixBoundary(events: readonly SessionEvent[]): SessionSeq | undefined { + const lastTurnEnd = events.findLast((event) => event.type === "turn/end"); + if (lastTurnEnd === undefined) return undefined; + let boundary = lastTurnEnd.seq; + for (const next of events.slice(boundary + 1)) { + if ( + next.type === "turn/start" || + (next.type === "user/message" && next.surfaceOp === "append") || + next.type === "agent/inbox/spliced" + ) + break; + boundary = next.seq; + } + return boundary; +} + +/** Implements Session business commands delegated by the Session Controller Remote service. */ +export class SessionCommandController { + /** + * @param ctx - Host context carrying Agent, model, attachment, title, and Workspace services. + * @param agents - sole owner of create, resume, and Session-local model selection. + * @param defaultCwd - project directory used when create names neither a Workspace nor a cwd. + */ + constructor( + private readonly ctx: Context, + private readonly agents: ApiSessionAgentController, + private readonly defaultCwd: string, + ) {} + + /** + * Create or idempotently adopt one ordinary Session. + * @param request - requested identity, location, and Agent preset. + * @returns the Session identity and resolved preset when configured. + */ + async create(request: SessionCreateRequest): Promise { + if (request.workspaceId !== undefined && request.cwd !== undefined) { + throw new RemoteError( + "gateway/bad-request", + "session.create accepts workspaceId or cwd, not both", + {}, + ); + } + const sessionId = request.sessionId ?? brandString(`session-${randomUUID()}`); + let workspace: Workspace | undefined; + if (request.workspaceId !== undefined) { + workspace = this.ctx.workspaceRegistry.get(request.workspaceId); + if (workspace === undefined) { + throw new RemoteError( + "workspace/not-found", + `workspace "${request.workspaceId}" not found`, + { + workspaceId: request.workspaceId, + }, + ); + } + } + const cwd = workspace?.path ?? request.cwd ?? this.defaultCwd; + let adopted: Agent; + try { + adopted = await this.agents.ensureSession( + sessionId, + cwd, + request.sessionId !== undefined, + request.agentPreset, + ); + } catch (error) { + this.rejectCreation(sessionId, error); + } + if (workspace !== undefined) { + try { + await workspace.attachSession(sessionId); + } catch (error) { + throw new RemoteError( + "session/workspace-attach-failed", + `session "${sessionId}" was created but could not attach to workspace "${workspace.id}": ${String(error)}`, + { sessionId, workspaceId: workspace.id }, + ); + } + } + const agentPreset = this.agents.presetForSession(adopted.session); + return { sessionId, ...(agentPreset === undefined ? {} : { agentPreset }) }; + } + + /** + * Validate and install one Session-local model selection; save the default in the background. + * @param request - Session identity and requested model selection. + * @returns the normalized selection installed for the Session, without waiting for default persistence. + */ + async selectModel(request: SessionSelectModelRequest): Promise { + 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, + ...(request.reasoningEffort === undefined + ? {} + : { reasoningEffort: ReasoningEffortId(request.reasoningEffort) }), + }); + const selected: AgentModelSelection = { + provider: resolved.provider, + model: resolved.model, + ...(resolved.reasoningEffort === undefined + ? {} + : { reasoningEffort: resolved.reasoningEffort }), + }; + this.agents.selectForNextRequest(agent, selected); + void this.ctx.agentDefaultModel.saveSelection(selected).catch((error: unknown) => { + this.ctx.logger.warn( + `session-controller: model selection changed for the Session but the default was not saved: ${String(error)}`, + ); + }); + return { selected: { ...selected } }; + } catch (error) { + if (remoteErrorOf(error) !== undefined) throw error; + throw new RemoteError( + "session/model-unavailable", + error instanceof Error ? error.message : String(error), + { provider: request.provider, model: request.model }, + ); + } + }); + } + + /** + * Normalize and append a user-owned Session title. + * @param request - Session identity and proposed title. + * @returns the accepted title and durable event sequence. + */ + async rename(request: SessionRenameRequest): Promise { + const agent = await this.resolveAgent(request.sessionId); + const titles = this.ctx.get("sessionTitle"); + if (titles === undefined) { + throw new RemoteError( + "gateway/internal", + "renaming is unavailable: this deployment mounts no session-title service", + {}, + ); + } + try { + const accepted = titles.rename(agent.session, request.title); + return { title: accepted.title, seq: accepted.eventSeq }; + } catch (error) { + if (error instanceof SessionTitleInvalidError) { + throw new RemoteError("session/title-invalid", error.message, { + sessionId: request.sessionId, + }); + } + throw new RemoteError( + "gateway/internal", + `failed to rename session "${request.sessionId}": ${String(error)}`, + {}, + ); + } + } + + /** + * Create a new ordinary Session from an exact event prefix. An explicit + * `atSeq` is the inclusive cut; an omitted value selects the latest + * completed-turn prefix. An open cut receives synthetic fork closers. + * @param request - source Session and optional exact event boundary. + * @returns the new Session identity. + */ + async fork(request: SessionForkRequest): Promise { + let atSeq: ReturnType | undefined; + try { + atSeq = request.atSeq === undefined ? undefined : SessionSeq(request.atSeq); + } catch { + throw new RemoteError("gateway/bad-request", "atSeq must be a non-negative safe integer", {}); + } + let observed: SessionObservation; + try { + observed = await this.ctx.sessionQuery.observeSession(request.sessionId); + } catch (error) { + if (error instanceof SessionQueryError && error.code === "SESSION_QUERY_SESSION_NOT_FOUND") { + throw new RemoteError("session/not-found", `session "${request.sessionId}" not found`, { + sessionId: request.sessionId, + }); + } + throw new RemoteError( + "gateway/internal", + `fork source unavailable for session "${request.sessionId}": ${String(error)}`, + {}, + ); + } + using source = observed; + const boundary = atSeq ?? latestCompletedPrefixBoundary(source.events); + if (boundary === undefined || source.events[boundary]?.seq !== boundary) { + throw new RemoteError( + "session/fork-unavailable", + request.atSeq === undefined + ? `session "${request.sessionId}" has no completed turn to fork from` + : `event ${String(request.atSeq)} does not exist in session "${request.sessionId}" (last seq: ${String(source.events.at(-1)?.seq ?? "none")})`, + { sessionId: request.sessionId }, + ); + } + const seed = buildForkSeed(source.events, boundary); + let workspace: Workspace | undefined; + try { + workspace = await this.forkWorkspace(source.header); + } catch (error) { + throw new RemoteError( + "gateway/internal", + `failed to resolve fork workspace for session "${request.sessionId}": ${String(error)}`, + {}, + ); + } + const childId = brandString(`session-${randomUUID()}`); + const composition = await this.agents.composeAgent(this.agents.presetForObservation(source)); + try { + const { provider, model } = this.ctx.agentDefaultModel.currentSelection(); + await this.ctx.agents.create({ + sessionId: childId, + seed, + inheritedEventCount: SessionLogOffset(boundary + 1), + meta: { + ...(source.header.cwd === undefined ? {} : { cwd: source.header.cwd }), + parentSession: source.header.id, + isSeeded: true, + ...(composition.agentPreset === undefined + ? {} + : { agentPreset: composition.agentPreset }), + }, + agentOptions: { provider, model }, + setup: composition.setup, + }); + } catch (error) { + throw new RemoteError( + "gateway/internal", + `failed to fork session "${request.sessionId}": ${String(error)}`, + {}, + ); + } + if (workspace !== undefined) { + try { + await workspace.attachSession(childId); + } catch (error) { + throw new RemoteError( + "session/workspace-attach-failed", + `session "${childId}" was forked but could not attach to workspace "${workspace.id}": ${String(error)}`, + { sessionId: childId, workspaceId: workspace.id }, + ); + } + } + return { sessionId: childId }; + } + + /** + * Reject empty content, then admit one prompt after Agent and attachment validation. + * @param request - Session identity, prompt content, source metadata, and delivery mode. + * @returns acknowledgement that the Agent accepted the prompt. + */ + async prompt(request: SessionPromptRequest): Promise { + if (!hasPromptContent(request.content)) { + throw new RemoteError( + "gateway/bad-request", + "prompt content must include non-whitespace text or an attachment", + {}, + ); + } + const clientTimeZone = + request.clientTimeZone === undefined + ? undefined + : canonicalClientTimeZone(request.clientTimeZone); + if (request.clientTimeZone !== undefined && clientTimeZone === undefined) { + throw new RemoteError( + "session/invalid-time-zone", + "clientTimeZone must be UTC or a valid IANA Area/Location name", + { value: request.clientTimeZone }, + ); + } + const agent = await this.resolveAgent(request.sessionId); + if (hasPromptRequest(agent, request.requestId)) return { accepted: true }; + const source: MessageSource = { + kind: "user", + rpcId: request.requestId, + ...(clientTimeZone === undefined ? {} : { clientTimeZone }), + }; + const hasImage = request.content.some((part) => part.type === "image"); + const admit = async (): Promise => { + try { + if (hasImage) { + const current = this.agents.selectionFor(agent).current; + const model = await this.ctx.llm.resolveModelInfo(current.provider, current.model); + if (model.inputModalities !== undefined && !model.inputModalities.includes("image")) { + throw new RemoteError( + "session/attachment-invalid", + `Model "${current.model}" does not support image input.`, + { reason: "MODEL_DOES_NOT_SUPPORT_IMAGES" }, + ); + } + } + const admission = resolvePromptFileReceipts(request.content, (receiptId) => + this.ctx.fileUploads.resolve(agent, receiptId), + ); + const content = await this.ctx.attachments.admitPromptContent(admission.content); + const message: UserMessage = createUserMessage({ content, source }); + if (this.ctx.agents.get(agent.id) !== agent) { + throw new RemoteError( + "session/not-found", + `session "${agent.id}" was disposed during prompt admission`, + { sessionId: agent.id }, + ); + } + using binding = this.ctx.fileUploads.bindPrompt( + agent, + admission.receiptIds, + request.requestId, + ); + if (request.mode === "steer") agent.steer(message); + else agent.followup(message); + binding.commit(); + } catch (error) { + if (remoteErrorOf(error) !== undefined) throw error; + if (error instanceof AttachmentError) { + throw new RemoteError("session/attachment-invalid", error.message, { + reason: error.code, + }); + } + throw new RemoteError("session/agent-busy", "prompt rejected", { reason: String(error) }); + } + return { accepted: true }; + }; + 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. + * @returns the durable attachment reference and base64-encoded bytes. + */ + async attachment(request: SessionAttachmentRequest): Promise { + let source: SessionReadState; + try { + source = await this.readSessionState(request.sessionId); + } catch (error) { + if (error instanceof ApiSessionNotFound) { + throw new RemoteError("session/not-found", error.message, { sessionId: request.sessionId }); + } + throw new RemoteError( + "gateway/internal", + `attachment authorization unavailable for session "${request.sessionId}": ${String(error)}`, + {}, + ); + } + const ref = referencedImage(source.events, String(request.attachmentId)); + if (ref === undefined) { + throw new RemoteError( + "session/attachment-invalid", + "Image is not referenced by this session.", + { reason: "ATTACHMENT_NOT_REFERENCED" }, + ); + } + try { + const stored = await this.ctx.attachments.readImage(ref); + return { + attachment: stored.ref, + data: Buffer.from(stored.data).toString("base64"), + }; + } catch (error) { + if (error instanceof AttachmentError) { + throw new RemoteError("session/attachment-invalid", error.message, { reason: error.code }); + } + throw new RemoteError("gateway/internal", "Unable to read image attachment.", {}); + } + } + + /** + * Mutate one pending Inbox occurrence, restoring an ordinary cold Agent when needed. + * @param request - Session, queue item, and requested mutation. + * @returns acknowledgement that the queue mutation was applied. + */ + async updateQueue(request: SessionUpdateQueueRequest): Promise { + if (request.action.kind === "edit") { + // oxlint-disable-next-line typescript/no-unnecessary-condition -- Remote callers can submit untyped JSON. + if (request.action.content.some((block) => block.type !== "text")) { + throw new RemoteError( + "session/attachment-invalid", + "queue edits accept text content only", + { reason: "QUEUE_EDIT_NON_TEXT" }, + ); + } + if (!hasPromptContent(request.action.content)) { + throw new RemoteError( + "gateway/bad-request", + "queue edit content must include non-whitespace text", + {}, + ); + } + } + let agent = this.ctx.agents.get(request.sessionId); + if (agent === undefined) { + const found = await this.agents.resolveAgent(request.sessionId); + if ("error" in found) { + if (found.error.code !== "session/not-found") throw found.error; + throw new RemoteError("session/queue-item-not-found", "queued item is no longer pending", { + itemId: request.itemId, + }); + } + agent = found.agent; + } + if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) { + const identity = this.ctx.sessionProjections.snapshot(agent.session, ["subagent"]).values + .subagent; + if (identity?.mode !== "continuable" || !agent.session.isOwnSeq(identity.seq)) { + throw apiSessionSubagentOwnershipError(request.sessionId); + } + } + const nextTurn = agent.inbox.nextTurn.find((message) => message.id === request.itemId); + const nextStep = agent.inbox.nextStep.find((message) => message.id === request.itemId); + const located = + nextTurn === undefined + ? nextStep === undefined + ? undefined + : { target: "next-step" as const, message: nextStep } + : { target: "next-turn" as const, message: nextTurn }; + if (located === undefined) { + throw new RemoteError("session/queue-item-not-found", "queued item is no longer pending", { + itemId: request.itemId, + }); + } + const { target, message } = located; + if (request.action.kind === "steer" && (target !== "next-turn" || agent.status !== "running")) { + throw new RemoteError( + "session/steer-unavailable", + "current turn no longer accepts steering", + { itemId: request.itemId }, + ); + } + switch (request.action.kind) { + case "edit": + agent.inbox.replace( + request.itemId, + freezeMessage({ + ...message, + content: [...request.action.content], + }), + ); + break; + case "remove": { + agent.inbox.remove(request.itemId); + const source = message.source; + if (source.kind === "user" && "rpcId" in source) { + this.ctx.fileUploads.retirePrompt(agent, source.rpcId); + } + break; + } + case "steer": + agent.inbox.remove(request.itemId); + agent.steer(message); + break; + /* v8 ignore next 2 -- closed-union exhaustiveness guard */ + default: + assertNever(request.action, "queue action"); + } + return { accepted: true }; + } + + /** + * Cancel one live ordinary Agent while retaining pending inbox work. + * @param request - Session whose active Agent turn is cancelled. + * @returns acknowledgement that cancellation was requested. + */ + cancel(request: SessionCancelRequest): SessionCancelValue { + const agent = this.ctx.agents.get(request.sessionId); + if (agent === undefined) { + throw new RemoteError( + "session/not-found", + `session "${request.sessionId}" not found (not attached)`, + { sessionId: request.sessionId }, + ); + } + if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) { + throw apiSessionSubagentOwnershipError(request.sessionId); + } + agent.cancel({ kind: "user" }, { keepInbox: true }); + return { accepted: true }; + } + + private async resolveAgent(sessionId: SessionId): Promise { + const found = await this.agents.resolveAgent(sessionId); + if ("error" in found) throw found.error; + return found.agent; + } + + private rejectCreation(sessionId: SessionId, error: unknown): never { + if (remoteErrorOf(error) !== undefined) throw error; + if (error instanceof Error && error.name === "SessionAlreadyOwnedError") { + throw new RemoteError("session/writer-held", error.message, { sessionId }); + } + if (error instanceof ApiSessionPresetConflict) { + throw new RemoteError("agent-preset/conflict", error.message, { + sessionId: error.sessionId, + requestedPreset: error.requestedPreset, + ...(error.existingPreset === undefined ? {} : { existingPreset: error.existingPreset }), + }); + } + if (error instanceof ApiSessionCwdConflict) { + throw new RemoteError("session/conflict", error.message, { + sessionId: error.sessionId, + requestedCwd: error.requestedCwd, + ...(error.existingCwd === undefined ? {} : { existingCwd: error.existingCwd }), + }); + } + if (error instanceof ApiSessionSubagentOwnership) { + throw apiSessionSubagentOwnershipError(error.sessionId); + } + throw new RemoteError( + "gateway/internal", + `failed to create session "${sessionId}": ${String(error)}`, + {}, + ); + } + + private async readSessionState(sessionId: SessionId): Promise { + const attached = this.ctx.sessions.get(sessionId); + if (attached !== undefined) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. + return { id: attached.id, header: attached.header, events: attached.snapshotEvents() }; + } + const inspected = await inspectApiSession(this.ctx, sessionId); + return { id: inspected.meta.id, header: inspected.meta, events: inspected.events }; + } + + private async forkWorkspace(source: SessionHeader): Promise { + const workspaces = this.ctx.workspaceRegistry.list(); + const direct = workspaces.find((workspace) => workspace.sessionIds.includes(source.id)); + if (direct !== undefined || source.origin !== "subagent") return direct; + const lineage = await this.ctx.sessionQuery.traceSession(source.id); + for (const ancestor of lineage.ancestors) { + const workspace = workspaces.find((candidate) => + candidate.sessionIds.includes(ancestor.header.id), + ); + if (workspace !== undefined) return workspace; + } + return undefined; + } +} + +function resolvePromptFileReceipts( + content: SessionPromptRequest["content"], + stagedFile: (receiptId: FileUploadReceiptId) => FileAttachmentRef | undefined, +): { + readonly content: AttachmentAdmissionPart[]; + readonly receiptIds: readonly FileUploadReceiptId[]; +} { + const receiptIds = new Set(); + const resolved = content.map((part): AttachmentAdmissionPart => { + if (part.type !== "file") return part; + const attachment = stagedFile(part.receiptId); + if (attachment === undefined) { + throw new RemoteError( + "session/attachment-invalid", + "File was not uploaded for this session.", + { reason: "FILE_NOT_STAGED" }, + ); + } + receiptIds.add(part.receiptId); + return { type: "file", attachment }; + }); + return { content: resolved, receiptIds: [...receiptIds] }; +} + +function hasPromptRequest(agent: Agent, requestId: SessionRequestId): boolean { + const matches = (message: UserMessage): boolean => { + const source = message.source; + return source.kind === "user" && "rpcId" in source && source.rpcId === requestId; + }; + if (agent.inbox.nextTurn.some(matches) || agent.inbox.nextStep.some(matches)) return true; + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. + return agent.session.snapshotEvents().some((event) => { + if (event.type !== "user/message") return false; + const source = event.data.source; + return source.kind === "user" && "rpcId" in source && source.rpcId === requestId; + }); +} +function imageBlockIn( + content: unknown, + match: (ref: ImageAttachmentRef) => boolean, +): ImageAttachmentRef | undefined { + if (!Array.isArray(content)) return undefined; + for (const value of content) { + if (typeof value !== "object" || value === null || Array.isArray(value)) continue; + const block = value as { readonly type?: unknown; readonly attachment?: unknown }; + if ( + block.type === "image" && + typeof block.attachment === "object" && + block.attachment !== null + ) { + const ref = block.attachment as ImageAttachmentRef; + if (match(ref)) return ref; + } + } + return undefined; +} + +/** Read only first-party declared content fields; unknown event payloads stay opaque. */ +function imageInEvent( + event: SessionEvent, + match: (ref: ImageAttachmentRef) => boolean, +): ImageAttachmentRef | undefined { + const data = event.data as { + readonly content?: unknown; + readonly message?: { readonly content?: unknown }; + readonly inserted?: unknown; + readonly summary?: unknown; + readonly rawOutput?: unknown; + }; + // First-party event payloads can be present without their producer plugin mounted. + const type: string = event.type; + switch (type) { + case "user/message": + case "tool/ptc-dispatch": + return imageBlockIn(data.content, match); + case "system/message": + case "developer/message": + case "tool/result": + case "team/message/queued": + return imageBlockIn(data.message?.content, match); + case "agent/inbox/spliced": { + const messages = data.inserted; + if (!Array.isArray(messages)) return undefined; + for (const message of messages as readonly unknown[]) { + if (typeof message !== "object" || message === null || Array.isArray(message)) continue; + const found = imageBlockIn((message as { readonly content?: unknown }).content, match); + if (found !== undefined) return found; + } + return undefined; + } + case "compaction/summary": + return imageBlockIn(data.summary, match) ?? imageBlockIn(data.rawOutput, match); + case "assistant/message": { + const found = imageBlockIn(data.message?.content, match); + if (found !== undefined) return found; + break; + } + case "assistant/attempt": + break; + default: + return undefined; + } + const assistant = event as SessionEvent<"assistant/message" | "assistant/attempt">; + for (const chunk of assistantStreamChunks(assistant.data.stream, "block-end")) { + const found = imageBlockIn([chunk.block], match); + if (found !== undefined) return found; + } + return undefined; +} + +function referencedImage( + events: readonly SessionEvent[], + attachmentId: string, +): ImageAttachmentRef | undefined { + for (const event of events) { + const found = imageInEvent(event, (ref) => String(ref.attachmentId) === attachmentId); + if (found !== undefined) return found; + } + return undefined; +} diff --git a/apps/web-ui/packages/api/session-controller/src/control.ts b/apps/web-ui/packages/api/session-controller/src/control.ts new file mode 100644 index 000000000..e7de7e643 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/control.ts @@ -0,0 +1,127 @@ +/** Live Session projection state with reconnect baselines. */ + +import type { Context } from "@deepseek-ai/cordis"; +import { Deque } from "@deepseek-ai/dsh-deque"; +import type { Session, SessionId } from "@deepseek-ai/dsh-session"; +import type { JsonValue } from "@deepseek-ai/dsh-util-values"; +import type { + SessionControlBaseline, + SessionControlFrame, + SessionProjectionBaseline, + SessionProjectionValues, +} from "./types.ts"; + +/** Owns the Host-wide Session control stream. */ +export class SessionControlController { + private readonly streams = new Set(); + + /** @param ctx - Host context carrying live Agent and projection services. */ + constructor(private readonly ctx: Context) { + ctx.sessionProjections.onChanged((session, key, value, seq) => { + this.broadcast({ + type: "projection", + sessionId: session.id, + key, + value: value as JsonValue, + seq, + }); + }); + ctx.effect( + () => () => { + for (const stream of this.streams) stream.end(); + this.streams.clear(); + }, + "session-controller.control", + ); + } + + /** + * Open one generation of Host-wide live control state. + * @param signal - Remote stream cancellation. + * @returns one complete baseline followed by live replacement frames. + */ + async *control(signal: AbortSignal): AsyncIterable { + signal.throwIfAborted(); + const queue = new ControlQueue(); + this.streams.add(queue); + try { + yield { type: "baseline", value: this.baseline() }; + yield* queue.iterate(signal); + } finally { + this.streams.delete(queue); + queue.end(); + } + } + + private baseline(): SessionControlBaseline { + const sessions = this.ctx.sessions.list(); + return { + projections: this.projectionBaseline(sessions), + }; + } + + private projectionBaseline( + sessions: readonly Session[], + ): Readonly> { + const blocks = Object.create(null) as Record; + for (const session of sessions) { + const snapshot = this.ctx.sessionProjections.snapshot(session); + blocks[session.id] = { + asOfSeq: snapshot.asOfSeq, + // Every projection definition validates its value before snapshot publication. + values: snapshot.values as SessionProjectionValues, + }; + } + return blocks; + } + + private broadcast(frame: SessionControlFrame): void { + for (const stream of this.streams) stream.push(frame); + } +} + +class ControlQueue { + private readonly buffer = new Deque(); + private wake: (() => void) | undefined; + private done = false; + + push(frame: SessionControlFrame): void { + if (this.done) return; + this.buffer.pushBack(frame); + const wake = this.wake; + this.wake = undefined; + wake?.(); + } + + end(): void { + if (this.done) return; + this.done = true; + const wake = this.wake; + this.wake = undefined; + wake?.(); + } + + async *iterate(signal: AbortSignal): AsyncIterable { + const onAbort = (): void => { + this.end(); + }; + signal.addEventListener("abort", onAbort, { once: true }); + try { + while (!this.done && !signal.aborted) { + const frame = this.buffer.popFront(); + if (frame !== undefined) { + yield frame; + continue; + } + await new Promise((resolve) => { + this.wake = resolve; + }); + } + while (this.buffer.size > 0 && !signal.aborted) + yield this.buffer.popFront() as SessionControlFrame; + } finally { + signal.removeEventListener("abort", onAbort); + this.end(); + } + } +} diff --git a/apps/web-ui/packages/api/session-controller/src/file-references.ts b/apps/web-ui/packages/api/session-controller/src/file-references.ts new file mode 100644 index 000000000..2362d657b --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/file-references.ts @@ -0,0 +1,38 @@ +/** Session Controller adapter for Agent-scoped file-reference discovery. */ + +import type { Context } from "@deepseek-ai/cordis"; +import type { Agent } from "@deepseek-ai/dsh-agent"; +import type {} from "@deepseek-ai/dsh-file-reference"; +import type { FileReferenceCandidate } from "@deepseek-ai/dsh-file-reference/types"; +import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol"; + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Host owner of the `fileReferences` Remote namespace. */ + sessionFileReferences: SessionFileReferences; + } +} + +/** Host Remote adapter over the composed file-reference provider. */ +export class SessionFileReferences extends TypertRemoteService { + static inject = ["fileReferences", "typert"]; + + /** @param ctx - Host context carrying the selected file-reference provider. */ + constructor(ctx: Context) { + super(ctx, "sessionFileReferences", { namespace: "fileReferences" }); + } + + /** + * List file and directory candidates for one Agent's working directory. + * @param agent - target Agent resolved from the Session identity on the wire. + * @param query - path text following `@` or `@"`. + * @param signal - caller cancellation. + * @returns deterministic path-only candidates from the composed provider. + */ + @Remote + list(agent: Agent, query: string, signal: AbortSignal): Promise { + return this.ctx.fileReferences.list(agent, query, signal); + } +} + +export default SessionFileReferences; diff --git a/apps/web-ui/packages/api/session-controller/src/history.ts b/apps/web-ui/packages/api/session-controller/src/history.ts new file mode 100644 index 000000000..0cf3d1998 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/history.ts @@ -0,0 +1,528 @@ +/** Cold Session history pagination and live-event source. */ + +import type { Context } from "@deepseek-ai/cordis"; +import { Deque } from "@deepseek-ai/dsh-deque"; +import type { AssistantStreamFrame } from "@deepseek-ai/dsh-agent"; +import { isAppendSurfaceEvent, SessionLogOffset, SessionSeq } from "@deepseek-ai/dsh-session"; +import type { + SessionEvent, + SessionHeader, + SessionId, + SessionLogOffset as SessionLogOffsetType, + SessionSeqCursor, +} from "@deepseek-ai/dsh-session"; +import { SessionQueryError, type SessionObservation } from "@deepseek-ai/dsh-session-query"; +import type {} from "@deepseek-ai/dsh-subagent"; +import { RemoteError } from "@deepseek-ai/dsh-typert-protocol"; +import type { JsonValue } from "@deepseek-ai/dsh-util-values"; +import type { + SessionAddress, + SessionAssistantStreamFrame, + SessionEventEntry, + SessionFollowRequest, + SessionFollowFrame, + SessionHistoryRecord, + SessionPage, + SessionPageRequest, + SessionProjectionBaseline, + SessionProjectionValues, + SessionWireHeader, + SessionWireEvent, +} from "./types.ts"; +import { SessionAssistantStreamAccumulator } from "./assistant-stream.ts"; + +const DEFAULT_MAX_MESSAGES = 50; +const MESSAGE_TYPES = new Set(["user/message", "assistant/message"]); + +/** Implements cold-safe history operations delegated by the Session Controller. */ +export class SessionHistoryController { + private readonly closeFollowers = new Set<() => void>(); + private readonly assistantStreams = new Map(); + + /** + * @param ctx - Host context carrying Session query and projection services. + * @param promote - starts ordinary Session activation after snapshot delivery. + */ + constructor( + private readonly ctx: Context, + private readonly promote: (observation: SessionObservation) => void, + ) { + ctx.on( + "agent/assistant-stream", + ({ agent, frame }) => { + let stream = this.assistantStreams.get(agent.session.id); + if (stream === undefined) { + stream = new SessionAssistantStreamAccumulator(); + this.assistantStreams.set(agent.session.id, stream); + } + stream.accept(frame, cursorBeforeNext(agent.session.seq)); + }, + { global: true }, + ); + ctx.on( + "agent/disposed", + ({ agent }) => { + this.assistantStreams.delete(agent.session.id); + }, + { global: true }, + ); + ctx.effect( + () => () => { + for (const close of this.closeFollowers) close(); + this.closeFollowers.clear(); + }, + "session-controller.history", + ); + } + + /** + * Read one message-aligned history page without activating an Agent. + * @param request - durable address and backwards-page cursor. + * @param signal - caller cancellation for persistence reads. + * @returns a contiguous event page. + */ + async page(request: SessionPageRequest, signal: AbortSignal): Promise { + validatePageRequest(request); + const throughSeq: SessionSeqCursor = + request.throughSeq === -1 ? -1 : SessionSeq(request.throughSeq); + const beforeSeq = + request.beforeSeq === undefined ? undefined : SessionLogOffset(request.beforeSeq); + using source = await this.sourceFor(request.address, signal, false); + signal.throwIfAborted(); + const sourceLog = source.events; + const sourceCursor: SessionSeqCursor = sourceLog.at(-1)?.seq ?? -1; + if (throughSeq > sourceCursor) { + throw new RemoteError( + "gateway/bad-request", + `session page through seq ${String(throughSeq)} is past cursor ${String(sourceCursor)}`, + {}, + ); + } + /* v8 ignore next -- Session and persistence validation guarantee a dense zero-based event prefix. */ + if (throughSeq >= 0 && sourceLog[throughSeq]?.seq !== throughSeq) { + throw new RemoteError( + "gateway/internal", + `session log does not contain through seq ${String(throughSeq)}`, + {}, + ); + } + const page = paginate( + sourceLog, + beforeSeq, + request.maxMessages ?? DEFAULT_MAX_MESSAGES, + throughSeq, + request.turnWindow, + ); + const records = pageRecords(page.events); + return { + records, + hasMore: page.hasMore, + }; + } + + /** + * Follow events appended after an initial cursor on one durable address. + * @param request - durable address and last committed sequence already held by the caller. + * @param signal - stream cancellation owned by the Remote carrier. + * @returns a complete opening snapshot followed by gap-free durable events and opted-in assistant frames. + */ + async *follow( + request: SessionFollowRequest, + signal: AbortSignal, + ): AsyncIterable { + validateHistoryWindow(request); + const { address } = request; + const target = addressId(address); + const buffered = new Deque< + | { readonly type: "event"; readonly event: SessionEvent } + | { + readonly type: "assistant-stream"; + readonly frame: SessionAssistantStreamFrame; + readonly ordinal: number; + } + >(); + let snapshotCursor: SessionSeqCursor | undefined; + let assistantStreamOrdinal = 0; + let wake: (() => void) | undefined; + const notify = (): void => { + const resume = wake; + wake = undefined; + resume?.(); + }; + const follower = { closed: false }; + const close = (): void => { + follower.closed = true; + notify(); + }; + this.closeFollowers.add(close); + const disposeEvent = this.ctx.on( + "session/event", + (session, event) => { + if (session.id !== target) return; + buffered.pushBack({ type: "event", event }); + notify(); + }, + { global: true }, + ); + const disposeCreated = this.ctx.on( + "session/created", + (session) => { + if (session.id !== target) return; + // Constructor seed events have no session/event notification. Normally + // only the end-seed suffix is new; if persistence advanced after the + // opening observation, replay everything beyond that snapshot cursor. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. + const suffix = session.snapshotEvents( + snapshotCursor === undefined + ? session.firstLiveSeq + : SessionLogOffset(snapshotCursor + 1), + ); + for (let index = suffix.length - 1; index >= 0; index -= 1) { + buffered.pushFront({ type: "event", event: suffix[index] as SessionEvent }); + } + notify(); + }, + { global: true }, + ); + const disposeAssistantStream = + request.assistantStream !== true + ? undefined + : this.ctx.on( + "agent/assistant-stream", + ({ agent, frame }) => { + if (agent.session.id !== target) return; + buffered.pushBack({ + type: "assistant-stream", + frame: wireAssistantStreamFrame(frame, cursorBeforeNext(agent.session.seq)), + ordinal: ++assistantStreamOrdinal, + }); + notify(); + }, + { global: true }, + ); + const onAbort = (): void => { + notify(); + }; + signal.addEventListener("abort", onAbort, { once: true }); + try { + using source = await this.sourceFor(address, signal, true); + const events = source.events; + signal.throwIfAborted(); + const cursor = source.cursor; + snapshotCursor = cursor; + const page = paginate( + events, + undefined, + request.maxMessages ?? DEFAULT_MAX_MESSAGES, + cursor, + request.turnWindow, + ); + const assistantStream = + request.assistantStream === true + ? (this.assistantStreams.get(target)?.snapshot() ?? { revision: 0 }) + : undefined; + // The accumulator snapshot and this watermark are synchronous. Frames + // through the cut are represented or superseded by that baseline, + // including larger revisions from a retired Agent; later revision + // resets reach Client continuity validation. + const assistantStreamOrdinalCut = assistantStreamOrdinal; + yield { + type: "snapshot", + header: wireHeader(source.header), + cursor, + records: pageRecords(page.events), + hasMore: page.hasMore, + projections: + source.projections === undefined + ? { asOfSeq: cursor, values: {} } + : projectionBlock(source.projections), + ...(assistantStream === undefined ? {} : { assistantStream }), + }; + if (address.kind === "session" && source.source === "prepared") { + const promotion = source.retain(); + try { + this.promote(promotion); + } catch (error: unknown) { + promotion[Symbol.dispose](); + throw error; + } + } + let nextOffset = SessionLogOffset(cursor + 1); + while (!follower.closed && !signal.aborted) { + const item = buffered.popFront(); + if (item === undefined) { + await new Promise((resolve) => { + wake = resolve; + }); + continue; + } + if (item.type === "assistant-stream") { + if (item.ordinal > assistantStreamOrdinalCut) { + yield { type: "assistant-stream", frame: item.frame }; + } + continue; + } + const expectedSeq = SessionSeq(nextOffset); + if (item.event.seq < expectedSeq) continue; + if (item.event.seq !== expectedSeq) { + throw new RemoteError( + "gateway/internal", + `session event stream skipped seq ${String(expectedSeq)}`, + {}, + ); + } + nextOffset = SessionLogOffset(nextOffset + 1); + yield entryFor(item.event); + } + } finally { + this.closeFollowers.delete(close); + signal.removeEventListener("abort", onAbort); + disposeCreated(); + disposeEvent(); + disposeAssistantStream?.(); + } + } + + private async sourceFor( + address: SessionAddress, + signal: AbortSignal, + withProjections: boolean, + ): Promise { + const sessionId = addressId(address); + try { + const observation = await this.ctx.sessionQuery.observeSession(sessionId, { + signal, + projectionMode: withProjections || address.kind === "subagent" ? "all" : "none", + }); + if (observation.header.cwd === undefined) { + observation[Symbol.dispose](); + rejectNotFound(address); + } + try { + validateAddress( + address, + observation.header, + observation.inheritedEventCount, + observation.projections, + ); + } catch (error: unknown) { + observation[Symbol.dispose](); + throw error; + } + return observation; + } catch (error: unknown) { + if (error instanceof SessionQueryError && error.code === "SESSION_QUERY_SESSION_NOT_FOUND") + rejectNotFound(address); + throw error; + } + } +} + +function cursorBeforeNext(nextSeq: SessionLogOffsetType): SessionSeqCursor { + return nextSeq === 0 ? -1 : SessionSeq(nextSeq - 1); +} + +function wireAssistantStreamFrame( + frame: AssistantStreamFrame, + durableCursor: SessionSeqCursor, +): SessionAssistantStreamFrame { + if (frame.type === "start") return { ...frame, startedAfterSeq: durableCursor }; + if (frame.type === "end") return frame; + return { + ...frame, + chunk: frame.chunk as JsonValue, + }; +} + +function projectionBlock( + snapshot: NonNullable, +): SessionProjectionBaseline { + return { + asOfSeq: snapshot.asOfSeq, + // Projection definitions validate whole JSON values before snapshot publication. + values: snapshot.values as SessionProjectionValues, + }; +} + +function validatePageRequest(request: SessionPageRequest): void { + if ( + !Number.isSafeInteger(request.throughSeq) || + request.throughSeq < -1 || + Object.is(request.throughSeq, -0) + ) { + throw new RemoteError( + "gateway/bad-request", + "throughSeq must be an integer greater than or equal to -1", + {}, + ); + } + if ( + request.beforeSeq !== undefined && + (!Number.isSafeInteger(request.beforeSeq) || + request.beforeSeq < 0 || + Object.is(request.beforeSeq, -0)) + ) { + throw new RemoteError( + "gateway/bad-request", + "beforeSeq must be a non-negative safe integer", + {}, + ); + } + validateHistoryWindow(request); +} + +function validateHistoryWindow( + request: Pick, +): void { + if ( + request.maxMessages !== undefined && + (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0) + ) { + throw new RemoteError("gateway/bad-request", "maxMessages must be a positive safe integer", {}); + } + const window = request.turnWindow; + if (window !== undefined) { + if ( + !Number.isSafeInteger(window.minMessages) || + window.minMessages <= 0 || + window.minMessages > (request.maxMessages ?? DEFAULT_MAX_MESSAGES) + ) { + throw new RemoteError( + "gateway/bad-request", + "turnWindow.minMessages must be a positive safe integer no greater than maxMessages", + {}, + ); + } + if (!Number.isSafeInteger(window.minTurns) || window.minTurns <= 0) { + throw new RemoteError( + "gateway/bad-request", + "turnWindow.minTurns must be a positive safe integer", + {}, + ); + } + } +} + +function addressId(address: SessionAddress): SessionId { + return address.kind === "session" ? address.sessionId : address.childSessionId; +} + +function validateAddress( + address: SessionAddress, + header: SessionHeader, + inheritedEventCount: SessionLogOffsetType, + projections: SessionObservation["projections"], +): void { + if (address.kind === "session") { + if (header.origin === "subagent") { + throw new RemoteError( + "session/agent-busy", + "subagent Sessions require their durable parent address", + { + reason: "use subagent delivery for this child session", + }, + ); + } + return; + } + if (header.origin !== "subagent" || header.parentSession !== address.parentSessionId) { + throw new RemoteError( + "subagent/unauthorized", + "subagent does not belong to the supplied parent", + { + childSessionId: address.childSessionId, + }, + ); + } + const identity = projections?.values.subagent; + if (identity === null) { + throw new RemoteError("subagent/catalog-diagnostic", "subagent descriptor is corrupt", { + parentSessionId: address.parentSessionId, + childSessionId: address.childSessionId, + reason: "corrupt", + }); + } + if (identity === undefined || identity.seq < inheritedEventCount) { + throw new RemoteError("subagent/catalog-diagnostic", "subagent descriptor is unavailable", { + parentSessionId: address.parentSessionId, + childSessionId: address.childSessionId, + reason: "unsupported", + }); + } + if (address.mode !== "unknown" && identity.mode !== address.mode) { + throw new RemoteError( + "subagent/unauthorized", + "subagent mode does not match the supplied address", + { + childSessionId: address.childSessionId, + }, + ); + } +} + +function rejectNotFound(address: SessionAddress): never { + if (address.kind === "session") { + throw new RemoteError("session/not-found", `session "${address.sessionId}" not found`, { + sessionId: address.sessionId, + }); + } + throw new RemoteError("subagent/not-found", "subagent is unavailable", { + parentSessionId: address.parentSessionId, + childSessionId: address.childSessionId, + }); +} + +function paginate( + events: readonly SessionEvent[], + beforeSeq: SessionLogOffsetType | undefined, + maxMessages: number, + throughSeq: SessionSeqCursor, + turnWindow?: SessionPageRequest["turnWindow"], +): { readonly events: SessionEvent[]; readonly hasMore: boolean } { + const end = SessionLogOffset(Math.min(throughSeq + 1, beforeSeq ?? throughSeq + 1)); + let count = 0; + let turns = 0; + let cut = SessionLogOffset(0); + for (let index = end - 1; index >= 0; index--) { + const event = events[index] as SessionEvent; + if (turnWindow !== undefined && event.type === "turn/start") { + turns++; + if (count >= turnWindow.minMessages && turns >= turnWindow.minTurns) { + cut = SessionLogOffset(index); + break; + } + } + if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) continue; + count++; + const sources = event.sourceEventSeqs; + let groupStart = event.seq; + if (sources !== undefined) { + for (const source of sources) { + if (source < groupStart) groupStart = source; + } + } + if (count >= maxMessages) { + cut = SessionLogOffset(groupStart); + break; + } + } + return { events: events.slice(cut, end), hasMore: cut > 0 }; +} + +/** Translate current logical Session metadata to the browser wire. */ +function wireHeader(header: SessionHeader): SessionWireHeader { + return { ...header }; +} + +function entryFor(event: SessionEvent): SessionEventEntry { + return { + type: "event", + // Session.append validates and freezes event data as JSON before publication. + event: event as unknown as SessionWireEvent, + }; +} + +/** Encode one bounded logical page without changing its pagination cut. */ +function pageRecords(events: readonly SessionEvent[]): SessionHistoryRecord[] { + return events.map(entryFor); +} diff --git a/apps/web-ui/packages/api/session-controller/src/index.ts b/apps/web-ui/packages/api/session-controller/src/index.ts new file mode 100644 index 000000000..dba0bb43c --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/index.ts @@ -0,0 +1,591 @@ +/** Session Remote owner: cold reads, explicit Agent commands, and live control state. */ + +import { hostname } from "node:os"; +import { resolve } from "node:path"; +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, ReasoningEffortId } from "@deepseek-ai/dsh-llm"; +import type {} from "@deepseek-ai/dsh-client-file-upload"; +import { + canOpenNativePath, + nativeFileManager, + nativeFileApplications, + openNativeFileApplication, + openNativeAssociatedPath, + revealNativePath, +} from "@deepseek-ai/dsh-native-command"; +import type { SessionId } from "@deepseek-ai/dsh-session"; +import type { SessionInspection } from "@deepseek-ai/dsh-session-persistence"; +import { SessionQueryError, type SessionObservation } from "@deepseek-ai/dsh-session-query"; +import { Remote, RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol"; +import { + ApiSessionAgentController, + inspectApiSession, + type ApiSessionAgentResult, +} from "./agent.ts"; +import { SessionCommandController } from "./commands.ts"; +import { SessionControlController } from "./control.ts"; +import { SessionHistoryController } from "./history.ts"; +import { SessionFileReferences } from "./file-references.ts"; +import { ApiSessionList } from "./list.ts"; +import { buildModelCatalog, hasProviderApiKey } from "./catalog.ts"; +import { installModelSelectionProjection } from "./model-selection-projection.ts"; +import { SessionSkillCatalog } from "./skill-catalog.ts"; +import { SessionMediaReferences } from "./media-references.ts"; +import { ArchivedSessionGate } from "./archived-session-gate.ts"; +import type { + ModelCatalog, + SessionWorkspacePathApplication, + SessionAttachmentRequest, + SessionAttachmentValue, + SessionCancelRequest, + SessionCancelValue, + SessionControlFrame, + SessionCreateRequest, + SessionCreateValue, + SessionFollowFrame, + SessionFollowRequest, + SessionForkRequest, + SessionForkValue, + SessionListRequest, + SessionListValue, + SessionOpenWorkspacePathRequest, + SessionOpenWorkspacePathValue, + SessionPage, + SessionPageRequest, + SessionPromptRequest, + SessionPromptValue, + SessionRenameRequest, + SessionRenameValue, + SessionSearchRequest, + SessionSearchValue, + SessionSelectModelRequest, + SessionSelectModelValue, + SessionProjectionsRequest, + SessionProjectionsValue, + SessionProjectionValues, + SessionUpdateQueueRequest, + SessionUpdateQueueValue, +} from "./types.ts"; + +export type * from "./types.ts"; +export { ApiSessionNotFound } from "./agent.ts"; +export { SessionFileReferences } from "./file-references.ts"; +export { SessionSkillCatalog } from "./skill-catalog.ts"; + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Host Session business API and Remote namespace owner. */ + sessionController: SessionController; + } +} + +/** Session Controller deployment policy. */ +export interface Config { + /** Override platform desktop-opener detection. */ + readonly nativeOpen?: boolean; + /** Positive integral milliseconds of list work before yielding between complete rows. */ + readonly listWorkSliceMs?: number; +} + +/** Deployment policy after schema defaults have been applied. */ +type ResolvedConfig = Config & { readonly listWorkSliceMs: number }; + +/** Host integrations replaceable by direct unit tests. */ +export interface SessionControllerInternals { + /** Native default-application handoff. */ + readonly openPath?: (path: string, signal: AbortSignal) => Promise; + /** Native file-association query. */ + readonly fileApplications?: typeof nativeFileApplications; + /** Explicit registered-application handoff. */ + readonly openFileApplication?: typeof openNativeFileApplication; + /** Native file-manager handoff. */ + readonly revealPath?: (path: string, signal: AbortSignal) => Promise; + /** Native handoff availability probe. */ + readonly canOpenPath?: () => boolean; +} + +/** Host service backing the generated `ctx.remote.session` namespace. */ +export class SessionController extends TypertRemoteService { + static inject = [ + "agentDefaultModel", + "agents", + "attachments", + "fileUploads", + "fs", + "llm", + "sessions", + "sessionProjections", + "sessionQuery", + "typert", + "workspaceRegistry", + ]; + + static Config: z = z.object({ + nativeOpen: z.boolean(), + listWorkSliceMs: z.natural().min(1).default(16), + }); + + private readonly agents: ApiSessionAgentController; + private readonly commands: SessionCommandController; + private readonly controlState: SessionControlController; + private readonly history: SessionHistoryController; + private readonly listState: ApiSessionList; + private readonly openPath: (path: string, signal: AbortSignal) => Promise; + private readonly fileApplications: typeof nativeFileApplications; + private readonly openFileApplication: typeof openNativeFileApplication; + private readonly revealPath: (path: string, signal: AbortSignal) => Promise; + private readonly canOpenPath: () => boolean; + private readonly promotions = new Set>(); + + /** + * @param ctx - Host context containing the Session capability assembly. + * @param config - native-opener and list-scheduling deployment policy. + * @param internals - host integrations replaceable by direct unit tests. + */ + constructor(ctx: Context, config: Config, internals: SessionControllerInternals = {}) { + super(ctx, "sessionController", { namespace: "session" }); + const resolved = SessionController.Config(config); + installModelSelectionProjection(ctx); + this.agents = new ApiSessionAgentController(ctx); + this.commands = new SessionCommandController(ctx, this.agents, process.cwd()); + ctx.effect( + () => + ctx.fileUploads.registerAgentResolver(async (sessionId) => { + const result = await this.agents.resolveAgent(sessionId); + if ("error" in result) throw result.error; + return result.agent; + }), + "session-controller: file-upload Agent resolver", + ); + this.controlState = new SessionControlController(ctx); + // Registered before history so reverse-order teardown closes every + // follower before waiting for already-admitted promotions. + ctx.effect( + () => async () => { + await Promise.allSettled([...this.promotions]); + }, + "session-controller.promotions", + ); + this.history = new SessionHistoryController(ctx, (observation) => { + this.promote(observation); + }); + this.listState = new ApiSessionList(ctx, resolved.listWorkSliceMs); + this.fileApplications = internals.fileApplications ?? nativeFileApplications; + this.openFileApplication = internals.openFileApplication ?? openNativeFileApplication; + this.openPath = internals.openPath ?? openNativeAssociatedPath; + this.revealPath = internals.revealPath ?? revealNativePath; + this.canOpenPath = + internals.canOpenPath ?? + (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath())); + ctx.plugin(SessionFileReferences); + ctx.plugin(SessionMediaReferences); + ctx.plugin(SessionSkillCatalog); + // An archived Session, or a subagent descendant of one, runs no model step + // until it is restored; what it still runs is stopped by the owners that + // answer the Workspace registry's archive-admission events. + ctx.plugin(ArchivedSessionGate); + + ctx.on("session/created", (session) => { + ctx.emit("api-session/added", this.listState.summaryFor(session)); + }); + ctx.on("session/disposed", (session) => { + ctx.emit("api-session/removed", session.id); + }); + const publishAgentAvailability = ({ agent }: { agent: Agent }): undefined => { + if (ctx.sessions.get(agent.id) === agent.session) { + ctx.emit("api-session/added", this.listState.summaryFor(agent.session)); + } + }; + ctx.on("agent/created", publishAgentAvailability); + ctx.on("agent/disposed", publishAgentAvailability); + ctx.on("agent/status", ({ agent, status }) => { + ctx.emit("api-session/status", agent.id, status === "running"); + }); + ctx.on("agent/error", ({ agent, error }) => { + ctx.emit("api-session/error", agent.id, errorChain(error)); + }); + ctx.on("session/event", (session, event) => { + if (event.type === "request/header") { + const agent = ctx.agents.get(session.id); + if (agent?.session === session) + this.agents.consumeSelection( + agent, + event.data.header.config.provider, + event.data.header.config.model, + event.data.header.config.reasoningEffort, + ); + } + if (event.type !== "user/message" || event.data.source.kind !== "user") return; + ctx.emit("api-session/activity", session.id, event.time); + }); + } + + private promote(observation: SessionObservation): void { + const sessionId = observation.header.id; + const task = (async () => { + using ownedObservation = observation; + const result = await this.agents.resolveObservedAgent(ownedObservation); + if ("error" in result) this.ctx.emit("api-session/error", sessionId, result.error.message); + })().catch((error: unknown) => { + this.ctx.logger.error( + `session-controller: background activation for "${sessionId}" failed: ${errorChain(error)}`, + ); + }); + this.promotions.add(task); + void task.finally(() => { + this.promotions.delete(task); + }); + } + + /** + * Resolve or resume one ordinary Session for another Host API domain. + * @param sessionId - Session identity whose Agent owns the operation. + * @returns the live Agent or the stable Session-domain failure. + */ + resolveAgent(sessionId: SessionId): Promise { + return this.agents.resolveAgent(sessionId); + } + + /** + * Inspect one attached or persisted Session without activating its Agent. + * @param sessionId - durable Session identity. + * @param signal - optional caller cancellation for persistence reads. + * @returns the current attached state or persisted header and event prefix. + */ + inspect(sessionId: SessionId, signal?: AbortSignal): Promise { + const attached = this.ctx.sessions.get(sessionId); + if (attached !== undefined) { + return Promise.resolve({ + meta: attached.header, + inheritedEventCount: attached.inheritedEventCount, + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. + events: attached.snapshotEvents(), + }); + } + return inspectApiSession(this.ctx, sessionId, signal); + } + + /** + * Read all visible Session rows without resuming an Agent. + * @param _request - reserved empty list request. + * @param signal - cancellation for persistence reads and summary generation. + * @returns visible Session summaries ordered by activity. + */ + @Remote("list") + async list(_request: SessionListRequest, signal: AbortSignal): Promise { + return { items: await this.listState.list(signal) }; + } + + /** + * Search visible Session content without resuming an Agent. + * @param request - literal message-content query. + * @param signal - cancellation for list and search reads. + * @returns authorized bounded Session search results. + */ + @Remote("search") + search(request: SessionSearchRequest, signal: AbortSignal): Promise { + return this.listState.search(request.query, signal); + } + + /** + * Create or idempotently adopt one ordinary Session. + * @param request - requested identity, location, and Agent preset. + * @returns the Session identity and resolved preset when configured. + */ + @Remote("create") + create(request: SessionCreateRequest): Promise { + return this.commands.create(request); + } + + /** + * Select one Session-local model after explicitly resuming the Session; save the default in the background. + * @param request - Session identity and requested model selection. + * @returns the normalized selection installed for the Session, without waiting for default persistence. + */ + @Remote("selectModel") + selectModel(request: SessionSelectModelRequest): Promise { + return this.commands.selectModel(request); + } + + /** + * Select the first available account model after login when no provider API key is configured. + * @returns after saving the first available model or retaining the existing default. + */ + @Remote + async initializeDefaultModel(): Promise { + const provider = "deepseek-account"; + if (await hasProviderApiKey(this.ctx)) return; + const catalog = await buildModelCatalog(this.ctx); + const model = catalog.groups.find((group) => group.id === provider)?.models[0]; + if (model === undefined) + throw new RemoteError( + "session/provider-models-unavailable", + `provider "${provider}" has no available models`, + { provider }, + ); + const selection = { + provider, + model: model.id, + ...(model.reasoning?.defaultEffort === undefined + ? {} + : { reasoningEffort: ReasoningEffortId(model.reasoning.defaultEffort) }), + }; + await this.ctx.agentDefaultModel.saveSelection(selection); + } + + /** + * Describe every currently routable model for Host-generation selectors. + * @returns provider-grouped models, the deployment default, and isolated provider failures. + */ + @Remote("modelCatalog") + modelCatalog(): Promise { + return buildModelCatalog(this.ctx); + } + + /** + * Report whether this deployment can hand a Session workspace path to a native desktop. + * @returns true when the matching open operation is available. + */ + @Remote + canOpenWorkspacePath(): boolean { + return this.canOpenPath(); + } + + /** + * Describe the serving desktop for authenticated file-action routes. + * @returns Host name, configured availability, and platform-specific file-manager behavior. + */ + workspaceDesktop(): { + name: string; + available: boolean; + fileManager: "finder" | "explorer" | "directory" | null; + } { + const fileManager = nativeFileManager(); + return { name: hostname(), available: fileManager !== null && this.canOpenPath(), fileManager }; + } + + /** + * Verify one path through the composed filesystem and open it on the Host desktop. + * @param request - path after best-effort Session workspace resolution. + * @param signal - caller lifetime; abort terminates the native command. + * @returns confirmation after the native opener accepts the path. + * @throws RemoteError when the request is invalid, has no verified Host mapping, is cancelled, or the opener fails. + */ + @Remote("openWorkspacePath") + async openWorkspacePath( + request: SessionOpenWorkspacePathRequest, + signal: AbortSignal, + ): Promise { + try { + const path = await this.verifyDesktopPath(request.path, signal); + if (request.action === "reveal") await this.revealPath(path, signal); + else if (request.application !== undefined) + await this.openFileApplication(path, request.application, signal); + else await this.openPath(path, signal); + return { opened: true }; + } catch (error: unknown) { + if (signal.aborted) throw new RemoteError("gateway/cancelled", "path open was aborted", {}); + if (error instanceof RemoteError) throw error; + throw new RemoteError("gateway/internal", "path open failed", {}, { cause: error }); + } + } + + /** + * Query current file handlers on the serving desktop without activating an Agent. + * @param request - file path in Host filesystem syntax. + * @param signal - caller lifetime, propagated to filesystem and desktop queries. + * @returns OS application names, icons, and default selection; empty when desktop opening is unavailable. + * @throws RemoteError when the path is invalid, the query is cancelled, or native discovery fails. + */ + @Remote("workspacePathApplications") + async workspacePathApplications( + request: { readonly path: string }, + signal: AbortSignal, + ): Promise { + if (!this.canOpenPath()) return []; + try { + const path = await this.verifyDesktopPath(request.path, signal); + return await this.fileApplications(path, signal); + } catch (error: unknown) { + if (signal.aborted) + throw new RemoteError("gateway/cancelled", "application query was aborted", {}); + if (error instanceof RemoteError) throw error; + throw new RemoteError( + "gateway/internal", + "file application query failed", + {}, + { cause: error }, + ); + } + } + + private async verifyDesktopPath(path: string, signal: AbortSignal): Promise { + if (path.length === 0) + throw new RemoteError("gateway/bad-request", "A non-empty file path is required", {}); + signal.throwIfAborted(); + const hostPath = resolve(path); + const { fs } = this.ctx; + const mapped = fs.processPathFromHostPath(hostPath); + if (mapped === undefined || fs.processPath(await fs.resolve(mapped, { signal })) !== hostPath) { + throw new RemoteError("gateway/bad-request", "Path has no verified Host path", {}); + } + signal.throwIfAborted(); + return hostPath; + } + + /** + * Rename one Session after explicitly resuming it. + * @param request - Session identity and proposed title. + * @returns the accepted title and durable event sequence. + */ + @Remote("rename") + rename(request: SessionRenameRequest): Promise { + return this.commands.rename(request); + } + + /** + * Fork one cold-readable exact event prefix into a new Session. An omitted + * boundary selects the latest completed-turn prefix; an open cut receives + * synthetic fork closers. + * @param request - source Session and optional exact inclusive event boundary. + * @returns the new Session identity. + */ + @Remote("fork") + fork(request: SessionForkRequest): Promise { + return this.commands.fork(request); + } + + /** + * Admit one prompt after explicitly resuming its Session. + * @param request - Session identity, prompt content, source metadata, and delivery mode. + * @param signal - caller cancellation before prompt admission begins. + * @returns acknowledgement that the Agent accepted the prompt. + */ + @Remote("prompt") + prompt(request: SessionPromptRequest, signal: AbortSignal): Promise { + signal.throwIfAborted(); + return this.commands.prompt(request); + } + + /** + * Read one image proven reachable from the addressed Session log. + * @param request - Session and attachment identities used for authorization. + * @returns the durable attachment reference and base64-encoded bytes. + */ + @Remote("attachment") + attachment(request: SessionAttachmentRequest): Promise { + return this.commands.attachment(request); + } + + /** + * Mutate one still-pending queue occurrence, resuming a cold Agent first. + * @param request - Session, queue item, and requested mutation. + * @returns acknowledgement that the queue mutation was applied. + */ + @Remote("updateQueue") + updateQueue(request: SessionUpdateQueueRequest): Promise { + return this.commands.updateQueue(request); + } + + /** + * Cancel one active Agent turn without dropping its pending inbox. + * @param request - Session whose active Agent turn is cancelled. + * @returns acknowledgement that cancellation was requested. + */ + @Remote("cancel") + cancel(request: SessionCancelRequest): SessionCancelValue { + return this.commands.cancel(request); + } + + /** + * Read one cold-safe, message-aligned Session history page. + * @param request - durable address, backward cursor, and page budget. + * @param signal - cancellation for persistence reads. + * @returns one chronological page. + */ + @Remote("page") + page(request: SessionPageRequest, signal: AbortSignal): Promise { + return this.history.page(request, signal); + } + + /** + * Follow one Session log from its opening or resume cursor. + * @param request - durable address and last committed sequence already held by the caller. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns a complete opening snapshot followed by gap-free durable event + * frames and optional cursorless assistant-stream frames. + */ + @Remote({ mode: "stream" }) + follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable { + return this.history.follow(request, signal); + } + + /** + * Read all registered projections without activating an Agent. + * @param request - Session whose current values are required. + * @param signal - cancellation for the Session observation. + * @returns complete baseline, or null when the Session does not exist. + */ + @Remote("projections") + async projections( + request: SessionProjectionsRequest, + signal: AbortSignal, + ): Promise { + const { sessionId } = request; + if (sessionId.length === 0) { + throw new RemoteError("gateway/bad-request", "sessionId must not be empty", {}); + } + try { + using observation = await this.ctx.sessionQuery.observeSession(sessionId, { signal }); + const projections = observation.projections; + if (projections === undefined) { + throw new RemoteError( + "session/projections-unavailable", + "Session projections are unavailable", + {}, + ); + } + return { + asOfSeq: projections.asOfSeq, + values: projections.values as SessionProjectionValues, + }; + } catch (error: unknown) { + if (error instanceof SessionQueryError && error.code === "SESSION_QUERY_SESSION_NOT_FOUND") + return null; + if ( + signal.aborted || + (error instanceof SessionQueryError && error.code === "SESSION_QUERY_ABORTED") + ) { + throw new RemoteError( + "gateway/cancelled", + "Session projection read was cancelled", + {}, + { cause: error }, + ); + } + if (error instanceof RemoteError) throw error; + throw new RemoteError( + "gateway/internal", + "Session projection read failed", + {}, + { cause: error }, + ); + } + } + + /** + * Stream a complete live-control baseline followed by replacement frames. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns one complete baseline followed by live replacement frames. + */ + @Remote({ mode: "stream" }) + control(signal: AbortSignal): AsyncIterable { + return this.controlState.control(signal); + } +} + +export { buildModelCatalog }; +export default SessionController; diff --git a/apps/web-ui/packages/api/session-controller/src/list.ts b/apps/web-ui/packages/api/session-controller/src/list.ts new file mode 100644 index 000000000..28a214427 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/list.ts @@ -0,0 +1,388 @@ +/** Cold-safe Session list and search projection. */ + +import { performance } from "node:perf_hooks"; +import { scheduler } from "node:timers/promises"; +import type { Context } from "@deepseek-ai/cordis"; +import type {} from "@deepseek-ai/dsh-agent-preset-registry"; +import type { ImageAttachmentLimits } from "@deepseek-ai/dsh-attachment"; +import type { Session, SessionEvent, SessionHeader, SessionId } from "@deepseek-ai/dsh-session"; +import type { ProjectionSnapshot } from "@deepseek-ai/dsh-session-projection"; +import type {} from "@deepseek-ai/dsh-session-projection-cache"; +import { SessionQueryError, type SessionSearchCursor } from "@deepseek-ai/dsh-session-query"; +import { RemoteError } from "@deepseek-ai/dsh-typert-protocol"; +import { z } from "zod"; +import { SESSION_SEARCH_RESULT_LIMIT, SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS } from "./types.ts"; +import type { + SessionListMetadata, + SessionProjectionHints, + SessionProjectionValues, + SessionSearchItem, + SessionSearchValue, + SessionSummary, +} from "./types.ts"; + +const SEARCH_PROVIDER_CALL_LIMIT = 100; +const SESSION_SEARCH_QUERY_MAX_CHARS = 500; +const MESSAGE_TYPES = new Set(["user/message", "assistant/message"]); + +const sessionListMetadataSchema: z.ZodType = z.object({ + blank: z.boolean(), + lastPromptAt: z.number().nullable(), +}); + +const imageLimitsSchema = z.object({ + maxImageBytes: z.number().int().positive(), + maxImagesPerMessage: z.number().int().positive(), + maxMessageImageBytes: z.number().int().positive(), + maxImagePixels: z.number().int().positive(), + maxImageDimension: z.number().int().positive(), + mediaTypes: z.array(z.string()), +}) as unknown as z.ZodType; + +/** + * Advance the Session-list metadata projection by one committed event. + * @param state - metadata before the event. + * @param event - next committed Session event. + * @returns the original or advanced metadata value. + */ +export function applySessionListMetadata( + state: SessionListMetadata, + event: SessionEvent, +): SessionListMetadata { + const blank = state.blank && event.type !== "turn/start"; + const lastPromptAt = + event.type === "user/message" && event.data.source.kind === "user" + ? event.time + : state.lastPromptAt; + return blank === state.blank && lastPromptAt === state.lastPromptAt + ? state + : { blank, lastPromptAt }; +} + +/** + * Return the longest prefix containing at most `maximum` Unicode code points. + * @param value - source text. + * @param maximum - maximum number of Unicode code points. + * @returns the source text or its longest allowed prefix. + */ +export function truncateUnicodeCodePoints(value: string, maximum: number): string { + let count = 0; + let end = 0; + for (const codePoint of value) { + if (count === maximum) return value.slice(0, end); + count++; + end += codePoint.length; + } + return value; +} + +/** Owns list projection registration, bounded cold summaries, and authorized search. */ +export class ApiSessionList { + /** + * @param ctx - Host context carrying Session, query, persistence, and projection services. + * @param workSliceMs - Resolved positive integral list-work budget in milliseconds. + */ + constructor( + private readonly ctx: Context, + private readonly workSliceMs: number, + ) { + ctx.sessionProjections.register<"sessionListMetadata", SessionListMetadata>({ + key: "sessionListMetadata", + stateSchema: sessionListMetadataSchema, + init: () => ({ blank: true, lastPromptAt: null }), + apply: applySessionListMetadata, + wire: { viewSchema: sessionListMetadataSchema, view: (state) => state }, + stateVersion: 1, + }); + ctx.inject(["attachments"], (attachmentCtx) => { + ctx.sessionProjections.register<"imageLimits", null>({ + key: "imageLimits", + stateSchema: z.null(), + init: () => null, + apply: (state) => state, + wire: { + viewSchema: imageLimitsSchema, + view: () => attachmentCtx.attachments.imageLimits, + }, + stateVersion: 1, + }); + }); + } + + /** + * Build one current attached-Session summary. + * @param session - attached Session to summarize. + * @returns current list metadata and available projections. + */ + summaryFor(session: Session): SessionSummary { + const projections = this.projectionsFor(session.header, session); + const metadata = projections?.values.sessionListMetadata; + return { + sessionId: session.id, + updatedAt: updatedAt(session.header, metadata), + agentAvailable: this.ctx.agents.get(session.id)?.session === session, + running: this.ctx.agents.get(session.id)?.status === "running", + blank: metadata?.blank ?? session.seq === 0, + ...listFields(session.header), + ...(projections === undefined ? {} : { projections }), + }; + } + + /** + * Read every visible attached and persisted Session without activating an Agent. + * @param signal - optional cancellation for persistence reads and summary generation. + * @returns visible Session summaries ordered by activity. + */ + async list(signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + const records = await this.ctx.sessionQuery.listSessions(signal); + signal?.throwIfAborted(); + const items: SessionSummary[] = []; + const cold: SessionHeader[] = []; + let yieldDeadline = performance.now() + this.workSliceMs; + for (const record of records) { + signal?.throwIfAborted(); + const live = this.ctx.sessions.get(record.header.id); + if (live !== undefined) { + items.push(this.summaryFor(live)); + } else if (record.header.cwd !== undefined) { + cold.push(record.header); + } + if (performance.now() >= yieldDeadline) { + await scheduler.yield(); + signal?.throwIfAborted(); + yieldDeadline = performance.now() + this.workSliceMs; + } + } + for (const header of cold) { + signal?.throwIfAborted(); + items.push(this.summarizeCold(header)); + if (performance.now() >= yieldDeadline) { + await scheduler.yield(); + signal?.throwIfAborted(); + yieldDeadline = performance.now() + this.workSliceMs; + } + } + signal?.throwIfAborted(); + items.sort((left, right) => right.updatedAt - left.updatedAt); + return items; + } + + private summarizeCold(header: SessionHeader): SessionSummary { + const projections = this.projectionsFor(header, undefined); + const metadata = projections?.values.sessionListMetadata; + return { + sessionId: header.id, + updatedAt: updatedAt(header, metadata), + agentAvailable: false, + running: false, + // A large, metadata-less, or inaccessible cache miss remains unknown and visible. + blank: metadata?.blank ?? false, + ...listFields(header), + ...(projections === undefined ? {} : { projections }), + }; + } + + /** + * Search current visible message content without activating any matching Session. + * @param query - literal message-content query. + * @param signal - cancellation for list and search reads. + * @returns authorized bounded Session search results. + */ + async search(query: string, signal: AbortSignal): Promise { + const normalizedQuery = normalizeSearchQuery(query); + signal.throwIfAborted(); + const provider = this.ctx.get("sessionQuery"); + if (provider === undefined) { + throw new RemoteError( + "gateway/internal", + "session search is unavailable: this deployment does not mount @deepseek-ai/dsh-session-query", + {}, + ); + } + try { + const visible = await provider.listSessions(signal); + signal.throwIfAborted(); + const visibleIds = new Set( + visible + .filter((record) => record.header.cwd !== undefined) + .map((record) => record.header.id), + ); + if (visibleIds.size === 0) return { items: [], hasMore: false }; + const authorized: SessionSearchItem[] = []; + const acceptedIds = new Set(); + const seenCursors = new Set(); + let cursor: SessionSearchCursor | undefined; + let providerCalls = 0; + let pageLimit = SESSION_SEARCH_RESULT_LIMIT; + while (authorized.length <= SESSION_SEARCH_RESULT_LIMIT) { + signal.throwIfAborted(); + if (providerCalls >= SEARCH_PROVIDER_CALL_LIMIT) { + throw new Error( + `session search provider exceeded the ${SEARCH_PROVIDER_CALL_LIMIT}-call work budget`, + ); + } + providerCalls++; + const requestedCursor = cursor; + const requestedLimit = pageLimit; + let page; + try { + page = await provider.searchSessions( + { + query: normalizedQuery, + eventFilters: [ + { kind: "type", values: ["user/message", "assistant/message"] }, + { kind: "surface", values: ["current"] }, + ], + limit: requestedLimit, + ...(requestedCursor === undefined ? {} : { cursor: requestedCursor }), + }, + { signal }, + ); + signal.throwIfAborted(); + } catch (error: unknown) { + signal.throwIfAborted(); + if ( + requestedCursor === undefined && + error instanceof SessionQueryError && + error.code === "SESSION_QUERY_INVALID_LIMIT" && + requestedLimit > 1 + ) { + pageLimit = Math.max(1, Math.floor(requestedLimit / 2)); + continue; + } + if ( + requestedCursor !== undefined && + error instanceof SessionQueryError && + error.code === "SESSION_QUERY_STALE_CURSOR" + ) { + authorized.length = 0; + acceptedIds.clear(); + seenCursors.clear(); + cursor = undefined; + continue; + } + throw error; + } + if (page.items.length > requestedLimit) { + throw new Error( + `session search provider returned ${String(page.items.length)} items; maximum is ${String(requestedLimit)}`, + ); + } + for (const hit of page.items) { + if (authorized.length > SESSION_SEARCH_RESULT_LIMIT) continue; + if ( + !visibleIds.has(hit.header.id) || + hit.bestMatch.sessionId !== hit.header.id || + hit.bestMatch.surface !== "current" || + !MESSAGE_TYPES.has(hit.bestMatch.type) || + acceptedIds.has(hit.header.id) + ) + continue; + acceptedIds.add(hit.header.id); + authorized.push({ + sessionId: hit.header.id, + snippet: truncateUnicodeCodePoints( + hit.bestMatch.snippet, + SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS, + ), + }); + } + if (page.nextCursor !== undefined) { + if (seenCursors.has(page.nextCursor)) { + throw new Error("session search provider repeated a continuation cursor"); + } + seenCursors.add(page.nextCursor); + } + if (authorized.length > SESSION_SEARCH_RESULT_LIMIT || page.nextCursor === undefined) break; + cursor = page.nextCursor; + } + return { + items: authorized.slice(0, SESSION_SEARCH_RESULT_LIMIT), + hasMore: authorized.length > SESSION_SEARCH_RESULT_LIMIT, + }; + } catch (error: unknown) { + signal.throwIfAborted(); + if (error instanceof SessionQueryError && error.code === "SESSION_QUERY_ABORTED") { + throw new RemoteError("gateway/cancelled", "session search was aborted", {}); + } + throw new RemoteError("gateway/internal", `session search failed: ${String(error)}`, {}); + } + } + + private projectionsFor( + header: SessionHeader, + session: Session | undefined, + ): SessionProjectionHints | undefined { + try { + if (session !== undefined) { + // The live registry computed the block for this Session: its watermark + // shares the sequence space of the Session's baselines and frames. + return hintsOf("sequenced", this.ctx.sessionProjections.cachedSnapshot(session)); + } + // A cold row reads the persisted cache by header alone; the cache serves + // seeded and unseeded lifecycles alike because a listing never seeds a + // fold. The watermark is the stored record's own. + const cache = this.ctx.get("sessionProjectionCache"); + return hintsOf( + "cached", + cache?.cachedSnapshot(header) ?? cache?.cachedPredecessorTitle(header), + ); + } catch (error) { + this.ctx.logger.warn( + `api-session.list: projection column for "${header.id}" failed; serving the row without it: ${String(error)}`, + ); + return undefined; + } + } +} + +/** + * Wrap one projection block as Session-list hints of the named sequence space. + * @param kind - which sequence space the block's watermark belongs to. + * @param block - the block, or `undefined` when no source served one. + * @returns the hints, or `undefined` when the block is absent or carries no value. + */ +function hintsOf( + kind: SessionProjectionHints["kind"], + block: ProjectionSnapshot | undefined, +): SessionProjectionHints | undefined { + if (block === undefined || Object.keys(block.values).length === 0) return undefined; + // Listing hints contain every wire value the source currently holds but + // remain partial: missing cells and cache rows are never materialized here. + return { kind, asOfSeq: block.asOfSeq, values: block.values as SessionProjectionValues }; +} + +function normalizeSearchQuery(query: string): string { + const normalized = query.trim(); + if (normalized.length === 0) { + throw new RemoteError("gateway/bad-request", "session search query must not be empty", {}); + } + if (normalized.length > SESSION_SEARCH_QUERY_MAX_CHARS) { + throw new RemoteError( + "gateway/bad-request", + `session search query must contain at most ${SESSION_SEARCH_QUERY_MAX_CHARS} UTF-16 code units`, + {}, + ); + } + if (normalized.includes("\0")) { + throw new RemoteError("gateway/bad-request", "session search query must not contain NUL", {}); + } + return normalized; +} + +function updatedAt(header: SessionHeader, metadata: SessionListMetadata | undefined): number { + return Math.max(header.createdAt, metadata?.lastPromptAt ?? 0); +} + +function listFields(header: SessionHeader): { + readonly parentSessionId?: SessionId; + readonly origin?: "subagent"; + readonly cwd?: string; +} { + return { + ...(header.parentSession === undefined ? {} : { parentSessionId: header.parentSession }), + ...(header.origin === undefined ? {} : { origin: header.origin }), + ...(header.cwd === undefined ? {} : { cwd: header.cwd }), + }; +} diff --git a/apps/web-ui/packages/api/session-controller/src/media-references.ts b/apps/web-ui/packages/api/session-controller/src/media-references.ts new file mode 100644 index 000000000..d38cda72e --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/media-references.ts @@ -0,0 +1,81 @@ +/** + * Authenticated GET/HEAD /api/file reads bounded file responses through + * the composed filesystem provider. Paths and MIME types do not restrict access; + * the connection service authenticates requests before this handler. + * @module @deepseek-ai/dsh-api-session-controller/media-references + */ + +import { isAbsolute } from "node:path"; +import type { Context } from "@deepseek-ai/cordis"; +import type {} from "@deepseek-ai/dsh-client-connection"; +import type {} from "@deepseek-ai/dsh-attachment"; +import { FsError, type FileSystem } from "@deepseek-ai/dsh-fs"; +import mime from "mime-types"; + +const BASE_HEADERS = { + "Cache-Control": "private, no-store", + "X-Content-Type-Options": "nosniff", + // HTML and SVG files may be opened directly on the authenticated API origin. + "Content-Security-Policy": "sandbox; default-src 'none'", +}; + +async function serveFile(request: Request, fs: FileSystem, maxBytes: number): Promise { + const fail = (status: number, text: string): Response => + new Response(request.method === "HEAD" ? null : text, { status, headers: BASE_HEADERS }); + const path = new URL(request.url).searchParams.get("path"); + if (path === null || path.length === 0) return fail(400, "missing path"); + if (path.includes("\0") || !isAbsolute(path)) return fail(400, "absolute path required"); + try { + const target = await fs.resolve(path, { signal: request.signal }); + const mediaType = mime.lookup(target.displayPath) || "application/octet-stream"; + const headers: Record = { + ...BASE_HEADERS, + "Content-Type": mediaType, + }; + if (request.method === "HEAD") { + const info = await fs.stat(target, request.signal); + if (info === undefined) return fail(404, "not found"); + if (info.type !== "file") return fail(403, "not a regular file"); + if (info.size !== undefined) { + if (info.size > maxBytes) return fail(413, "file exceeds byte limit"); + headers["Content-Length"] = String(info.size); + } + return new Response(null, { headers }); + } + const bytes = await fs.readBytes(target, request.signal, maxBytes); + headers["Content-Length"] = String(bytes.byteLength); + return new Response(bytes.slice(), { headers }); + } catch (error: unknown) { + if (!(error instanceof FsError)) throw error; + const statuses: Partial> = { + FS_NOT_FOUND: 404, + FS_NOT_REGULAR_FILE: 403, + FS_PERMISSION_DENIED: 403, + FS_SANDBOX_DENIED: 403, + FS_TOO_LARGE: 413, + FS_ABORTED: 499, + }; + return fail(statuses[error.code] ?? 500, error.code); + } +} + +/** + * File-display contribution. The connection service supplies authentication; + * `ctx.fs` supplies the execution world's paths, reads, and access policy. + */ +export const SessionMediaReferences = { + inject: ["connection", "fs", "attachments"], + apply(ctx: Context): void { + const maxBytes = ctx.attachments.imageLimits.maxImageBytes; + ctx.effect( + () => + ctx.connection.fetch.register({ + path: "/api/file", + methods: ["GET", "HEAD"], + requestBody: "buffered", + fetch: (request) => serveFile(request, ctx.fs, maxBytes), + }), + "session-controller: /api/file", + ); + }, +}; diff --git a/apps/web-ui/packages/api/session-controller/src/model-selection-projection.ts b/apps/web-ui/packages/api/session-controller/src/model-selection-projection.ts new file mode 100644 index 000000000..5a1b492f0 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/model-selection-projection.ts @@ -0,0 +1,87 @@ +/** Durable model-selection intent and request-use projection. */ + +import type { Context } from "@deepseek-ai/cordis"; +import type { SessionEvent } from "@deepseek-ai/dsh-session"; +import type { ProjectionDefinition } from "@deepseek-ai/dsh-session-projection"; +import { z } from "zod"; +import type { + ModelSelection, + ModelSelectionProjection, + ModelSelectionProjectionState, +} from "./types.ts"; + +const modelSelectionSchema = z.object({ + provider: z.string().min(1), + model: z.string().min(1), + reasoningEffort: z.string().min(1).optional(), +}) as unknown as z.ZodType; + +const modelSelectionProjectionStateSchema = z.object({ + lastUsed: modelSelectionSchema.nullable(), + pending: modelSelectionSchema.nullable(), +}) as z.ZodType; + +const modelSelectionProjectionSchema = z.object({ + lastUsed: modelSelectionSchema.nullable(), + next: modelSelectionSchema.nullable(), +}) as z.ZodType; + +/** + * Advance durable model-selection state by one Session event. + * @param state - selection state before the event. + * @param event - next committed Session event. + * @returns the original or advanced selection state. + */ +function applyModelSelectionProjection( + state: ModelSelectionProjectionState, + event: SessionEvent, +): ModelSelectionProjectionState { + if (event.type === "model/selection") { + return sameSelection(state.pending, event.data) + ? state + : { lastUsed: state.lastUsed, pending: event.data }; + } + if (event.type !== "request/header") return state; + const lastUsed: ModelSelection = { + provider: event.data.header.config.provider, + model: event.data.header.config.model, + ...(event.data.header.config.reasoningEffort === undefined + ? {} + : { reasoningEffort: String(event.data.header.config.reasoningEffort) }), + }; + const pending = sameSelection(state.pending, lastUsed) ? null : state.pending; + return sameSelection(state.lastUsed, lastUsed) && pending === state.pending + ? state + : { lastUsed, pending }; +} + +const modelSelectionProjection = { + key: "modelSelection", + stateSchema: modelSelectionProjectionStateSchema, + init: () => ({ lastUsed: null, pending: null }), + apply: applyModelSelectionProjection, + wire: { + viewSchema: modelSelectionProjectionSchema, + view: (state) => ({ lastUsed: state.lastUsed, next: state.pending ?? state.lastUsed }), + }, + stateVersion: 2, +} satisfies ProjectionDefinition<"modelSelection", ModelSelectionProjectionState>; + +function sameSelection(left: ModelSelection | null, right: ModelSelection | null): boolean { + return ( + left === right || + (left !== null && + right !== null && + left.provider === right.provider && + left.model === right.model && + left.reasoningEffort === right.reasoningEffort) + ); +} + +/** + * Register the durable model-selection projection when the registry is present. + * @param ctx - Session Controller context. + */ +export function installModelSelectionProjection(ctx: Context): void { + ctx.sessionProjections.register(modelSelectionProjection); +} diff --git a/apps/web-ui/packages/api/session-controller/src/remote-events.ts b/apps/web-ui/packages/api/session-controller/src/remote-events.ts new file mode 100644 index 000000000..002326f92 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/remote-events.ts @@ -0,0 +1,13 @@ +/** Session Controller events available to a Remote Event assembly. */ +type SessionControllerRemoteEvent = + | "api-session/activity" + | "api-session/added" + | "api-session/error" + | "api-session/removed" + | "api-session/status"; + +declare module "@deepseek-ai/dsh-typert-protocol" { + interface TypertRemoteEventSelection extends Record {} +} + +export {}; diff --git a/apps/web-ui/packages/api/session-controller/src/skill-catalog.ts b/apps/web-ui/packages/api/session-controller/src/skill-catalog.ts new file mode 100644 index 000000000..48d46395c --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/skill-catalog.ts @@ -0,0 +1,108 @@ +/** Session-addressed, cold-readable skill catalog Remote. */ + +import type { Context } from "@deepseek-ai/cordis"; +import type {} from "@deepseek-ai/dsh-agent-preset-registry/types"; +import { SessionQueryError } from "@deepseek-ai/dsh-session-query"; +import { isUserInvocable } from "@deepseek-ai/dsh-skill"; +import type { ScopeKey } from "@deepseek-ai/dsh-scope"; +import { Remote, RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol"; +import type { SkillListRequest, SkillListValue } from "./types.ts"; + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Host owner of the Session-addressed `skills` Remote namespace. */ + sessionSkillCatalog: SessionSkillCatalog; + } +} + +/** Host service backing `ctx.remote.skills` without activating a cold Agent. */ +export class SessionSkillCatalog extends TypertRemoteService { + static inject = ["agents", "sessionQuery", "typert"]; + + /** @param ctx - Host context carrying Session reads and optional skill/preset services. */ + constructor(ctx: Context) { + super(ctx, "sessionSkillCatalog", { namespace: "skills" }); + } + + /** + * List the user-invocable skills visible to one Session composition. + * @param request - Session identity whose cwd and preset select the catalog view. + * @param signal - caller lifetime carried by the Remote transport; admitted catalog reads retain their existing completion semantics. + * @returns user-invocable skill metadata without loading skill bodies. + * @throws RemoteError when the Session cannot be inspected or no registry can serve it. + */ + @Remote + async list(request: SkillListRequest, signal: AbortSignal): Promise { + void signal; + const { sessionId } = request; + let cwd: string | undefined; + let agentPreset: string | undefined; + try { + using observation = await this.ctx.sessionQuery.observeSession(sessionId); + if (observation.projections === undefined) { + throw new Error("skill catalog requires a projected Session observation"); + } + cwd = observation.header.cwd; + agentPreset = observation.projections.values.agentPreset ?? undefined; + } catch (error: unknown) { + if (error instanceof SessionQueryError && error.code === "SESSION_QUERY_SESSION_NOT_FOUND") { + throw new RemoteError("session/not-found", `session "${sessionId}" not found`, { + sessionId, + }); + } + throw new RemoteError( + "gateway/internal", + `session "${sessionId}" could not be inspected: ${String(error)}`, + {}, + ); + } + if (cwd === undefined) { + throw new RemoteError("gateway/internal", `session "${sessionId}" has no project cwd`, {}); + } + + const live = this.ctx.agents.get(sessionId); + const presets = this.ctx.get("agentPresets"); + const scoped = live === undefined ? undefined : presets?.serviceFor(live, "skills"); + const skillRegistry = scoped ?? this.ctx.get("skills"); + if (skillRegistry === undefined) { + throw new RemoteError( + "gateway/internal", + "skill registry is absent: neither this session's agent preset nor the host composition mounts @deepseek-ai/dsh-skill", + {}, + ); + } + + await using lease = live === undefined ? await this.scopeFor(agentPreset) : undefined; + const scope = live ?? lease?.key; + try { + const skills = (await skillRegistry.list({ cwd, scope })).filter(isUserInvocable); + return { + skills: skills.map((skill) => ({ + name: skill.name, + ...(skill.path === undefined ? {} : { path: skill.path }), + description: skill.description, + ...(skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse }), + modelInvocable: skill.invocation.modelInvocable, + })), + }; + } catch (error: unknown) { + throw new RemoteError("gateway/internal", `skill listing failed: ${String(error)}`, {}); + } + } + + /** Resolve a live or standing preset scope without creating an Agent. */ + private async scopeFor( + agentPreset: string | undefined, + ): Promise<({ key: ScopeKey } & AsyncDisposable) | undefined> { + const presets = this.ctx.get("agentPresets"); + if (presets === undefined) return undefined; + try { + return await presets.acquireScope(agentPreset); + } catch { + // An unknown or unusable recorded preset falls back to the global registry. + return undefined; + } + } +} + +export default SessionSkillCatalog; diff --git a/apps/web-ui/packages/api/session-controller/src/types.ts b/apps/web-ui/packages/api/session-controller/src/types.ts new file mode 100644 index 000000000..a0286f299 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/types.ts @@ -0,0 +1,649 @@ +/** Browser-safe request, result, and lifecycle vocabulary for the Session Remote service. */ +import type { NativeFileApplication } from "@deepseek-ai/dsh-native-command/types"; + +import type { + AttachmentIdType, + ImageAttachmentLimits, + ImageAttachmentRef, + ImageMediaType, +} from "@deepseek-ai/dsh-attachment"; +import type { Branded } from "@deepseek-ai/dsh-brand"; +import type { LlmAttemptId, MessageId } from "@deepseek-ai/dsh-llm/brand"; +import type { TextBlock } from "@deepseek-ai/dsh-llm"; +import type { SessionId, SessionSeqCursor } from "@deepseek-ai/dsh-session/types"; +import type { SessionProjectionMap } from "@deepseek-ai/dsh-session-projection/types"; +import type { JsonValue } from "@deepseek-ai/dsh-util-values"; +import type { WorkspaceId } from "@deepseek-ai/dsh-workspace/types"; + +declare module "@deepseek-ai/dsh-session-projection/types" { + interface SessionProjectionStateMap { + /** Host state persisted for cold Session list summaries. */ + sessionListMetadata: SessionListMetadata; + /** Host state for the boot-constant image-limit view. */ + imageLimits: null; + /** Transport-owned attachment intake availability. */ + attachmentInput: null; + /** Durable model selection already used by a request and still pending for a later request. */ + modelSelection: ModelSelectionProjectionState; + } + interface SessionProjectionMap { + /** Persisted facts used to summarize a Session without activating it. */ + sessionListMetadata: SessionListMetadata; + /** Image-intake limits enforced by the Session prompt endpoint. */ + imageLimits: ImageAttachmentLimits; + /** Whether this transport accepts attachments, not the native Harness's capabilities. */ + attachmentInput: { + readonly enabled: boolean; + readonly imagesOnly?: boolean; + }; + /** Durable model selection already used and selected for the next request. */ + modelSelection: ModelSelectionProjection; + } +} + +declare module "@deepseek-ai/dsh-session/types" { + interface SessionEventMap { + /** + * Complete validated model selection requested for subsequent prompt + * assembly. Log-only: it never enters derived model history. + */ + "model/selection": ModelSelection; + } +} + +/** Persisted hints used to summarize a cold Session. */ +export interface SessionListMetadata { + /** Whether the folded prefix contains no turn. */ + readonly blank: boolean; + /** Latest human-authored prompt time in the folded prefix. */ + readonly lastPromptAt: number | null; +} + +/** + * Every available wire value a Session-list row carries as partial, possibly + * stale hints. `kind` and `asOfSeq` are independent facts: `kind` says which + * sequence space `asOfSeq` belongs to, and therefore how a client may merge + * the block; `asOfSeq` is the producer's watermark in that space. + */ +export interface SessionProjectionHints { + /** + * `sequenced`: the Host's live registry produced the block for an attached + * Session, so `asOfSeq` is comparable with baselines and frames of the same + * connection. `cached`: a header-only listing viewed the block from the + * persisted projection cache, so `asOfSeq` is the stored record's own + * watermark and must not be compared with the connected Session's values. + */ + readonly kind: "cached" | "sequenced"; + /** Watermark of the block in the sequence space named by `kind`. */ + readonly asOfSeq: number; + /** Provider-validated values present in the block; omitted keys remain unknown. */ + readonly values: SessionProjectionValues; +} + +/** Complete projection values at an exact Session event cursor. */ +export interface SessionProjectionBaseline { + readonly asOfSeq: number; + /** Provider-validated values; omitted keys are absent capabilities at this cut. */ + readonly values: SessionProjectionValues; +} + +/** Typed known projections plus JSON-safe values contributed outside this compilation face. */ +export type SessionProjectionValues = Partial & + Readonly>; + +/** + * Browser-submitted prompt content; the Host promotes image bytes to durable + * references. File parts carry the opaque receipt returned by a preceding + * `uploadFile` call on the same Session. + */ +export type PromptContentPart = + | { readonly type: "text"; readonly text: string } + | { + readonly type: "image"; + readonly mediaType: ImageMediaType; + readonly data: string; + readonly name?: string; + } + | { readonly type: "file"; readonly receiptId: Branded<"file-upload-receipt-id"> }; + +/** Complete model selection for one Session. */ +export interface ModelSelection { + readonly provider: string; + readonly model: string; + readonly reasoningEffort?: string; +} + +/** Host fold state for durable model selection. */ +export interface ModelSelectionProjectionState { + /** Selection consumed by the latest recorded model request. */ + readonly lastUsed: ModelSelection | null; + /** Later user selection not yet consumed by a matching model request. */ + readonly pending: ModelSelection | null; +} + +/** Client view of the durable model-selection fold. */ +export interface ModelSelectionProjection { + /** Selection consumed by the latest recorded model request. */ + readonly lastUsed: ModelSelection | null; + /** Selection the next request should use, falling back to {@link lastUsed}. */ + readonly next: ModelSelection | null; +} + +/** One adapter-owned reasoning effort for an exact model route. */ +export interface ModelReasoningEffort { + readonly id: string; + readonly name: string; + readonly description?: string; +} + +/** Selectable reasoning metadata for one exact model route. */ +export interface ModelReasoning { + readonly efforts: readonly ModelReasoningEffort[]; + readonly defaultEffort?: string; +} + +/** One model displayed inside its provider group. */ +export interface ModelCatalogModel { + readonly id: string; + readonly name: string; + readonly description?: string; + /** Model id of this Model's Fast variant; absent when the Harness offers none. */ + readonly fastModel?: string; + readonly reasoning?: ModelReasoning; +} + +/** One provider and its successfully loaded model catalog. */ +export interface ModelProviderGroup { + readonly id: string; + readonly name: string; + readonly models: readonly ModelCatalogModel[]; +} + +/** One provider whose model catalog lookup failed. */ +export interface ModelCatalogFailure { + readonly id: string; + readonly name: string; + readonly message: string; +} + +/** Host-generation model catalog and the default used by unconfigured Sessions. */ +export interface ModelCatalog { + /** Available new-Session selection resolved from each Harness's saved preference. */ + readonly rememberedSelections?: Readonly>; + readonly modelFavorites?: Readonly>; + readonly preferencesAvailable?: boolean; + /** Presentation only, shared with Desktop; includes unavailable Harness rows. */ + readonly harnessOrder?: readonly string[]; + readonly default: ModelSelection; + /** Provider routes with at least one currently available catalog model. */ + readonly routableProviders: readonly string[]; + readonly groups: readonly ModelProviderGroup[]; + readonly failures: readonly ModelCatalogFailure[]; +} + +/** One client-requested mutation of a still-pending queue item. */ +export type QueueAction = + | { + readonly kind: "edit"; + /** Non-empty text-only replacement content. */ + readonly content: readonly TextBlock[]; + } + | { readonly kind: "remove" } + | { readonly kind: "steer" }; + +/** One Session list entry. */ +export interface SessionSummary { + /** Whether this Session currently owns a live Agent. */ + readonly agentAvailable: boolean; + readonly sessionId: SessionId; + readonly updatedAt: number; + readonly running: boolean; + readonly blank: boolean; + readonly parentSessionId?: SessionId; + readonly origin?: "subagent"; + readonly cwd?: string; + readonly projections?: SessionProjectionHints; +} + +/** One session-content search result. */ +export interface SessionSearchItem { + readonly sessionId: SessionId; + readonly snippet: string; +} + +/** Maximum number of Sessions returned by one search. */ +export const SESSION_SEARCH_RESULT_LIMIT = 20; + +/** Maximum search snippet length in Unicode code points. */ +export const SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS = 240; + +declare module "@deepseek-ai/dsh-typert-protocol" { + interface RemoteErrorDetailsMap { + "session/provider-credentials-unavailable": Record; + "session/provider-models-unavailable": { readonly provider: string }; + "session/model-unavailable": { readonly provider: string; readonly model: string }; + "session/conflict": { + readonly sessionId: SessionId; + readonly requestedCwd: string; + readonly existingCwd?: string; + }; + "session/projections-unavailable": Record; + "session/writer-held": { readonly sessionId: SessionId }; + "session/agent-busy": { readonly reason: string }; + "session/invalid-time-zone": { readonly value: string }; + "session/workspace-attach-failed": { + readonly sessionId: SessionId; + readonly workspaceId: string; + }; + "agent-preset/conflict": { + readonly sessionId: SessionId; + readonly requestedPreset: string; + readonly existingPreset?: string; + }; + "session/attachment-invalid": { readonly reason: string }; + "session/queue-item-not-found": { readonly itemId: MessageId }; + "session/steer-unavailable": { readonly itemId: MessageId }; + "session/title-invalid": { readonly sessionId: SessionId }; + "session/fork-unavailable": { readonly sessionId: SessionId }; + "subagent/not-found": { + readonly parentSessionId: SessionId; + readonly childSessionId: SessionId; + }; + "subagent/catalog-diagnostic": { + readonly parentSessionId: SessionId; + readonly childSessionId: SessionId; + readonly reason: "corrupt" | "unsupported" | "unavailable"; + }; + } +} + +/** Session-addressed request for the human-invocable skill catalog. */ +export interface SkillListRequest { + readonly sessionId: SessionId; +} + +/** One skill available to the Session's human-facing composer. */ +export interface SkillEntry { + /** Absolute SKILL.md path when supplied by a filesystem provider. */ + readonly path?: string; + /** Kebab-case identifier referenced as `/name`. */ + readonly name: string; + /** Short routing description. */ + readonly description: string; + /** Optional extra routing guidance. */ + readonly whenToUse?: string; + /** Whether the same skill is also advertised to the model. */ + readonly modelInvocable: boolean; +} + +/** Human-invocable skills visible through one Session's composition. */ +export interface SkillListValue { + readonly skills: readonly SkillEntry[]; +} + +/** Session list request. */ +export interface SessionListRequest { + readonly cursor?: string; +} + +/** Session list response value. */ +export interface SessionListValue { + readonly items: readonly SessionSummary[]; +} + +/** Session search request. */ +export interface SessionSearchRequest { + readonly query: string; +} + +/** Session search response value. */ +export interface SessionSearchValue { + readonly items: readonly SessionSearchItem[]; + readonly hasMore: boolean; +} + +/** Session creation or explicit-id adoption request. */ +export interface SessionCreateRequest { + readonly workspaceId?: WorkspaceId; + readonly cwd?: string; + readonly sessionId?: SessionId; + readonly agentPreset?: string; +} + +/** Session creation response value. */ +export interface SessionCreateValue { + readonly sessionId: SessionId; + readonly agentPreset?: string; +} + +/** Session model-selection request. */ +export interface SessionSelectModelRequest extends ModelSelection { + readonly sessionId: SessionId; +} + +/** Accepted model selection after Host resolution. */ +export interface SessionSelectModelValue { + readonly selected: ModelSelection; +} + +/** Session rename request. */ +export interface SessionRenameRequest { + readonly sessionId: SessionId; + readonly title: string; +} + +/** Normalized title and the durable event position that committed it. */ +export interface SessionRenameValue { + readonly title: string; + readonly seq: number; +} + +/** Session fork request. */ +export interface SessionForkRequest { + readonly sessionId: SessionId; + /** Exact inclusive source event seq; omission selects the latest completed-turn prefix. */ + readonly atSeq?: number; +} + +/** Identity of a newly forked Session. */ +export interface SessionForkValue { + readonly sessionId: SessionId; +} + +/** Session prompt request. */ +export interface SessionPromptRequest { + /** Client-minted identity persisted on the exact accepted user message. */ + readonly requestId: SessionRequestId; + readonly sessionId: SessionId; + readonly mode: "queue" | "steer"; + /** At least one non-whitespace text part or attachment. */ + readonly content: readonly PromptContentPart[]; + readonly clientTimeZone?: string; +} + +/** Receipt after one prompt enters the target Agent inbox. */ +export interface SessionPromptValue { + readonly accepted: true; +} + +/** Durable image read request. */ +export interface SessionAttachmentRequest { + readonly sessionId: SessionId; + readonly attachmentId: AttachmentIdType; +} + +/** Durable image read response value. */ +export interface SessionAttachmentValue { + readonly attachment: ImageAttachmentRef; + readonly data: string; +} + +/** Pending queue mutation request. */ +export interface SessionUpdateQueueRequest { + readonly sessionId: SessionId; + readonly itemId: MessageId; + readonly action: QueueAction; +} + +/** Receipt after one pending queue mutation commits. */ +export interface SessionUpdateQueueValue { + readonly accepted: true; +} + +/** Active-turn cancellation request. */ +export interface SessionCancelRequest { + readonly sessionId: SessionId; +} + +/** Receipt after cancellation is admitted to the live Agent. */ +export interface SessionCancelValue { + readonly accepted: true; +} + +/** Request to open one path prepared by a Session-aware caller on the Host desktop. */ +export interface SessionOpenWorkspacePathRequest { + /** File-manager navigation when requested; omission uses the default application. */ + readonly action?: "reveal"; + /** Registered application identifier; ignored for reveal. Omission preserves the operating system default. */ + readonly application?: string; + /** Path after best-effort Session workspace resolution, in Host filesystem syntax. */ + readonly path: string; +} + +/** Confirmation that the Host handed a workspace path to its native opener. */ +export interface SessionOpenWorkspacePathValue { + readonly opened: true; +} + +/** Client-minted prompt identity used to reconcile optimistic and durable messages. */ +export type SessionRequestId = Branded<"session-request-id">; + +declare module "@deepseek-ai/dsh-llm" { + interface MessageSourceMap { + /** Browser prompt correlation and optional Host-validated time zone. */ + "user-rpc": { kind: "user"; rpcId: SessionRequestId; clientTimeZone?: string }; + } +} + +/** Durable identity selecting an ordinary Session or one direct subagent child. */ +export type SessionAddress = + | { readonly kind: "session"; readonly sessionId: SessionId } + | { + readonly kind: "subagent"; + readonly parentSessionId: SessionId; + readonly childSessionId: SessionId; + readonly mode: "one-shot" | "continuable" | "unknown"; + }; + +/** One non-activating Session projection read. */ +export interface SessionProjectionsRequest { + readonly sessionId: SessionId; +} + +/** Complete Session projection baseline; null when the Session does not exist. */ +export type SessionProjectionsValue = SessionProjectionBaseline | null; + +/** One raw Session event in the Remote journal. */ +export interface SessionEventEntry { + readonly type: "event"; + readonly event: SessionWireEvent; +} + +/** Current logical Session metadata carried on the browser wire. */ +export interface SessionWireHeader { + readonly version: number; + readonly id: SessionId; + readonly createdAt: number; + readonly cwd?: string; + readonly parentSession?: SessionId; + /** Whether the Session contains a fork-inherited prefix. */ + readonly isSeeded: boolean; + readonly origin?: "subagent"; + readonly delegationDepth?: number; + readonly agentPreset?: string; +} + +/** Browser wire surface operation; replacement endpoints are earlier event seqs in surface order. */ +export type SessionWireSurfaceOp = + "append" | { readonly op: "replace"; readonly startSeq: number; readonly endSeq: number }; + +/** One history-page record with compact Assistant streams embedded inside events. */ +export type SessionHistoryRecord = SessionEventEntry; + +/** + * Exact Session event envelope accepted by the Client journal adapter. + * Surface events require surfaceOp; only non-Assistant surface events may cite earlier sources. + * Durable readers own recognition of merge-extensible event names. + */ +export interface SessionWireEvent { + readonly type: string; + readonly seq: number; + readonly time: number; + readonly data: JsonValue; + readonly ignorable?: true; + /** Earlier sources on current surface events; opaque JSON on unknown ignorable events. */ + readonly sourceEventSeqs?: JsonValue; + /** Canonical placement on current surface events; opaque JSON on unknown ignorable events. */ + readonly surfaceOp?: JsonValue; +} + +/** One message-aligned backwards-history request. */ +export interface SessionPageRequest { + readonly address: SessionAddress; + /** Inclusive log cut obtained from the corresponding follow opening frame. */ + readonly throughSeq: number; + readonly beforeSeq?: number; + readonly maxMessages?: number; + /** Stop at a Turn start after both minima, unless maxMessages or history exhaustion wins. */ + readonly turnWindow?: { + /** Minimum append-origin user/assistant messages; must not exceed maxMessages. */ + readonly minMessages: number; + /** Minimum Turn starts crossed, including the partial Turn at beforeSeq. */ + readonly minTurns: number; + }; +} + +/** One live event request for a durable Session address. */ +export interface SessionFollowRequest extends Pick< + SessionPageRequest, + "maxMessages" | "turnWindow" +> { + readonly address: SessionAddress; + /** Include process-local assistant presentation frames for the Web client. */ + readonly assistantStream?: true; +} + +/** One active assistant attempt in a reconnect opening snapshot. */ +export interface SessionAssistantStreamAttempt { + readonly attemptId: LlmAttemptId; + /** Last durable Session seq observed when this attempt started. */ + readonly startedAfterSeq: SessionSeqCursor; + readonly turn: number; + readonly step: number; + /** Dense position expected for the next live chunk frame. */ + readonly nextIndex: number; + /** Compact detached stream accumulated at this opening revision. */ + readonly stream: readonly JsonValue[]; +} + +/** Complete process-local assistant state at one follow opening. */ +export interface SessionAssistantStreamBaseline { + readonly revision: number; + readonly activeAttempt?: SessionAssistantStreamAttempt; +} + +/** Browser wire form of one process-local assistant frame. */ +export type SessionAssistantStreamFrame = + | { + readonly type: "start"; + readonly attemptId: LlmAttemptId; + readonly revision: number; + readonly startedAfterSeq: SessionSeqCursor; + readonly turn: number; + readonly step: number; + } + | { + readonly type: "chunk"; + readonly attemptId: LlmAttemptId; + readonly revision: number; + readonly index: number; + readonly time: number; + readonly chunk: JsonValue; + } + | { + readonly type: "end"; + readonly attemptId: LlmAttemptId; + readonly revision: number; + /** Number of chunk frames represented by this terminal marker. */ + readonly index: number; + readonly outcome: + | { + readonly kind: "committed"; + readonly eventType: "assistant/message" | "assistant/attempt"; + readonly seq: number; + } + | { readonly kind: "abandoned" }; + }; + +/** One contiguous backwards page of a Session log. */ +export interface SessionPage { + readonly records: readonly SessionHistoryRecord[]; + readonly hasMore: boolean; +} + +/** Complete opening window followed by ordered durable events and opted-in assistant frames. */ +export type SessionFollowFrame = + | { + readonly type: "snapshot"; + readonly header: SessionWireHeader; + readonly cursor: number; + readonly records: readonly SessionHistoryRecord[]; + readonly hasMore: boolean; + readonly projections: SessionProjectionBaseline; + readonly assistantStream?: SessionAssistantStreamBaseline; + } + | SessionEventEntry + | { readonly type: "assistant-stream"; readonly frame: SessionAssistantStreamFrame }; + +/** Complete live control baseline emitted once per control stream generation. */ +export interface SessionControlBaseline { + readonly projections: Readonly>; +} + +/** One finished projection value and its durable watermark. */ +export interface SessionProjectionUpdate { + readonly sessionId: SessionId; + readonly key: string; + readonly value: JsonValue; + readonly seq: number; +} + +/** Host-wide live state stream. Each generation starts with exactly one baseline. */ +export type SessionControlFrame = + | { readonly type: "baseline"; readonly value: SessionControlBaseline } + | ({ readonly type: "projection" } & SessionProjectionUpdate); + +declare module "@deepseek-ai/cordis" { + interface Events { + /** + * A Session became visible or its Agent was created or disposed. + * Consumers upsert the summary and replace its current running and availability state. + * @mode emit + * @param summary - current list row for the Session. + */ + "api-session/added"(summary: SessionSummary): void; + /** + * A Session left the live Host registry. + * @mode emit + * @param sessionId - removed Session identity. + */ + "api-session/removed"(sessionId: SessionId): void; + /** + * One Agent changed running state. + * @mode emit + * @param sessionId - Agent and Session identity. + * @param running - whether the Agent is running. + */ + "api-session/status"(sessionId: SessionId, running: boolean): void; + /** + * One user-authored durable message advanced Session list activity. + * @mode emit + * @param sessionId - addressed Session identity. + * @param updatedAt - durable message time used for list ordering. + */ + "api-session/activity"(sessionId: SessionId, updatedAt: number): void; + /** + * One Agent failed outside a durable turn position. + * @mode emit + * @param sessionId - Agent and Session identity. + * @param message - user-safe failure chain. + */ + "api-session/error"(sessionId: SessionId, message: string): void; + } +} + +/** JSON-compatible projection value accepted by list consumers. */ +export type SessionProjectionValue = JsonValue; + +/** Application metadata returned by the serving desktop for one file. */ +export type SessionWorkspacePathApplication = NativeFileApplication; diff --git a/apps/web-ui/packages/api/session-controller/tsdown.config.ts b/apps/web-ui/packages/api/session-controller/tsdown.config.ts new file mode 100644 index 000000000..a3694b27e --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/tsdown.config.ts @@ -0,0 +1,5 @@ +import { clientBundle } from "../../client/tsdown.client.ts"; + +export default clientBundle("@deepseek-ai/dsh-api-session-controller", ["lib/types/index.js"], { + hostPhase: true, +}); diff --git a/apps/web-ui/packages/api/settings-controller/generated/remote.d.ts b/apps/web-ui/packages/api/settings-controller/generated/remote.d.ts new file mode 100644 index 000000000..3824f2cae --- /dev/null +++ b/apps/web-ui/packages/api/settings-controller/generated/remote.d.ts @@ -0,0 +1,42 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import type { + RemoteResult, + TypertRemoteContribution, +} from '@deepseek-ai/dsh-typert-protocol' +import type { SettingsDocumentOpenValue } from '@deepseek-ai/dsh-api-settings-controller/types' +import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types' +import type { SettingsDescribeValue, SettingsNamespaceView, SettingsPathOpView } from '@deepseek-ai/dsh-settings/types' +import type { JsonValue } from '@deepseek-ai/dsh-util-values' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertRemoteNamespace$63726564656e7469616c73 { + describe: (refs: string[]) => Promise>> + set: (ref: string, value: string) => Promise> + unset: (ref: string) => Promise> + } + interface TypertRemoteNamespace$73657474696e6773 { + describe: () => Promise> + mutate: (ns: string, ops: SettingsPathOpView[], expectedRevision: number | undefined) => Promise> + openSettingsDocument: (signal?: AbortSignal) => Promise> + replace: (ns: string, section: Record, expectedRevision: number | undefined) => Promise> + update: (ns: string, patch: Record, expectedRevision: number | undefined) => Promise> + } + interface TypertRemoteMap { + 'credentials/describe': (refs: string[]) => Promise>> + 'credentials/set': (ref: string, value: string) => Promise> + 'credentials/unset': (ref: string) => Promise> + 'settings/describe': () => Promise> + 'settings/mutate': (ns: string, ops: SettingsPathOpView[], expectedRevision: number | undefined) => Promise> + 'settings/openSettingsDocument': (signal?: AbortSignal) => Promise> + 'settings/replace': (ns: string, section: Record, expectedRevision: number | undefined) => Promise> + 'settings/update': (ns: string, patch: Record, expectedRevision: number | undefined) => Promise> + } + interface TypertRemoteNamespaceMap { + 'credentials': TypertRemoteNamespace$63726564656e7469616c73 + 'settings': TypertRemoteNamespace$73657474696e6773 + } +} + +export declare const TYPERT_REMOTE: TypertRemoteContribution +export default TYPERT_REMOTE +//# sourceMappingURL=typert.remote-client.d.ts.map diff --git a/apps/web-ui/packages/api/settings-controller/generated/remote.js b/apps/web-ui/packages/api/settings-controller/generated/remote.js new file mode 100644 index 000000000..dbe06191c --- /dev/null +++ b/apps/web-ui/packages/api/settings-controller/generated/remote.js @@ -0,0 +1,390 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import { z } from 'zod' + +let JsonValueRemoteCodec$schema$value +const JsonValueRemoteCodec$schema = () => (JsonValueRemoteCodec$schema$value ??= z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema()))])) +let JsonValueRemoteCodec$schema2$value +const JsonValueRemoteCodec$schema2 = () => (JsonValueRemoteCodec$schema2$value ??= z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema2())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema2()))])) +let JsonValueRemoteCodec$schema3$value +const JsonValueRemoteCodec$schema3 = () => (JsonValueRemoteCodec$schema3$value ??= z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema3())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema3()))])) +let JsonValueRemoteCodec$schema4$value +const JsonValueRemoteCodec$schema4 = () => (JsonValueRemoteCodec$schema4$value ??= z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema4())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema4()))])) +let JsonValueRemoteCodec$schema5$value +const JsonValueRemoteCodec$schema5 = () => (JsonValueRemoteCodec$schema5$value ??= z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema5())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema5()))])) +let JsonValueRemoteCodec$schema6$value +const JsonValueRemoteCodec$schema6 = () => (JsonValueRemoteCodec$schema6$value ??= z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema6())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema6()))])) +let JsonValueRemoteCodec$schema7$value +const JsonValueRemoteCodec$schema7 = () => (JsonValueRemoteCodec$schema7$value ??= z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema7())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema7()))])) +let _deepseek_ai_dsh_api_settings_controller_credentials_describe_parameter_0$schema$value +const _deepseek_ai_dsh_api_settings_controller_credentials_describe_parameter_0$schema = () => (_deepseek_ai_dsh_api_settings_controller_credentials_describe_parameter_0$schema$value ??= z.array(z.string())) +let _deepseek_ai_dsh_api_settings_controller_credentials_describe_result$schema$value +const _deepseek_ai_dsh_api_settings_controller_credentials_describe_result$schema = () => (_deepseek_ai_dsh_api_settings_controller_credentials_describe_result$schema$value ??= z.record(z.string(), z.object({ + 'configured': z.boolean(), + 'source': z.string().optional(), + 'writable': z.boolean(), +}))) +let _deepseek_ai_dsh_api_settings_controller_credentials_set_parameter_0$schema$value +const _deepseek_ai_dsh_api_settings_controller_credentials_set_parameter_0$schema = () => (_deepseek_ai_dsh_api_settings_controller_credentials_set_parameter_0$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_settings_controller_credentials_set_parameter_1$schema$value +const _deepseek_ai_dsh_api_settings_controller_credentials_set_parameter_1$schema = () => (_deepseek_ai_dsh_api_settings_controller_credentials_set_parameter_1$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_settings_controller_credentials_set_result$schema$value +const _deepseek_ai_dsh_api_settings_controller_credentials_set_result$schema = () => (_deepseek_ai_dsh_api_settings_controller_credentials_set_result$schema$value ??= z.void()) +let _deepseek_ai_dsh_api_settings_controller_credentials_unset_parameter_0$schema$value +const _deepseek_ai_dsh_api_settings_controller_credentials_unset_parameter_0$schema = () => (_deepseek_ai_dsh_api_settings_controller_credentials_unset_parameter_0$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_settings_controller_credentials_unset_result$schema$value +const _deepseek_ai_dsh_api_settings_controller_credentials_unset_result$schema = () => (_deepseek_ai_dsh_api_settings_controller_credentials_unset_result$schema$value ??= z.void()) +let _deepseek_ai_dsh_api_settings_controller_settings_describe_result$schema$value +const _deepseek_ai_dsh_api_settings_controller_settings_describe_result$schema = () => (_deepseek_ai_dsh_api_settings_controller_settings_describe_result$schema$value ??= z.object({ + 'writable': z.boolean(), + 'hasDocument': z.boolean(), + 'namespaces': z.array(z.object({ + 'autoGenerate': z.boolean(), + 'ns': z.string(), + 'schema': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema7())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema7()))]), + 'value': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema7())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema7()))]), + 'base': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema7())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema7()))]).optional(), + 'user': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema7())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema7()))]).optional(), + 'applies': z.literal("live"), + 'secrets': z.array(z.object({ + 'path': z.array(z.string()), + 'set': z.boolean(), +})), + 'revision': z.number(), +})), +})) +let _deepseek_ai_dsh_api_settings_controller_settings_mutate_parameter_0$schema$value +const _deepseek_ai_dsh_api_settings_controller_settings_mutate_parameter_0$schema = () => (_deepseek_ai_dsh_api_settings_controller_settings_mutate_parameter_0$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_settings_controller_settings_mutate_parameter_1$schema$value +const _deepseek_ai_dsh_api_settings_controller_settings_mutate_parameter_1$schema = () => (_deepseek_ai_dsh_api_settings_controller_settings_mutate_parameter_1$schema$value ??= z.array(z.union([z.object({ + 'op': z.literal("set"), + 'path': z.array(z.string()), + 'value': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema5())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema5()))]), +}), z.object({ + 'op': z.literal("unset"), + 'path': z.array(z.string()), +})]))) +let _deepseek_ai_dsh_api_settings_controller_settings_mutate_parameter_2$schema$value +const _deepseek_ai_dsh_api_settings_controller_settings_mutate_parameter_2$schema = () => (_deepseek_ai_dsh_api_settings_controller_settings_mutate_parameter_2$schema$value ??= z.union([z.undefined(), z.number()])) +let _deepseek_ai_dsh_api_settings_controller_settings_mutate_result$schema$value +const _deepseek_ai_dsh_api_settings_controller_settings_mutate_result$schema = () => (_deepseek_ai_dsh_api_settings_controller_settings_mutate_result$schema$value ??= z.object({ + 'autoGenerate': z.boolean(), + 'ns': z.string(), + 'schema': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema6())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema6()))]), + 'value': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema6())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema6()))]), + 'base': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema6())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema6()))]).optional(), + 'user': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema6())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema6()))]).optional(), + 'applies': z.literal("live"), + 'secrets': z.array(z.object({ + 'path': z.array(z.string()), + 'set': z.boolean(), +})), + 'revision': z.number(), +})) +let _deepseek_ai_dsh_api_settings_controller_settings_openSettingsDocument_result$schema$value +const _deepseek_ai_dsh_api_settings_controller_settings_openSettingsDocument_result$schema = () => (_deepseek_ai_dsh_api_settings_controller_settings_openSettingsDocument_result$schema$value ??= z.object({ + 'opened': z.literal(true).readonly(), +})) +let _deepseek_ai_dsh_api_settings_controller_settings_replace_parameter_0$schema$value +const _deepseek_ai_dsh_api_settings_controller_settings_replace_parameter_0$schema = () => (_deepseek_ai_dsh_api_settings_controller_settings_replace_parameter_0$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_settings_controller_settings_replace_parameter_1$schema$value +const _deepseek_ai_dsh_api_settings_controller_settings_replace_parameter_1$schema = () => (_deepseek_ai_dsh_api_settings_controller_settings_replace_parameter_1$schema$value ??= z.record(z.string(), z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema3())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema3()))]))) +let _deepseek_ai_dsh_api_settings_controller_settings_replace_parameter_2$schema$value +const _deepseek_ai_dsh_api_settings_controller_settings_replace_parameter_2$schema = () => (_deepseek_ai_dsh_api_settings_controller_settings_replace_parameter_2$schema$value ??= z.union([z.undefined(), z.number()])) +let _deepseek_ai_dsh_api_settings_controller_settings_replace_result$schema$value +const _deepseek_ai_dsh_api_settings_controller_settings_replace_result$schema = () => (_deepseek_ai_dsh_api_settings_controller_settings_replace_result$schema$value ??= z.object({ + 'autoGenerate': z.boolean(), + 'ns': z.string(), + 'schema': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema4())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema4()))]), + 'value': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema4())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema4()))]), + 'base': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema4())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema4()))]).optional(), + 'user': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema4())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema4()))]).optional(), + 'applies': z.literal("live"), + 'secrets': z.array(z.object({ + 'path': z.array(z.string()), + 'set': z.boolean(), +})), + 'revision': z.number(), +})) +let _deepseek_ai_dsh_api_settings_controller_settings_update_parameter_0$schema$value +const _deepseek_ai_dsh_api_settings_controller_settings_update_parameter_0$schema = () => (_deepseek_ai_dsh_api_settings_controller_settings_update_parameter_0$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_settings_controller_settings_update_parameter_1$schema$value +const _deepseek_ai_dsh_api_settings_controller_settings_update_parameter_1$schema = () => (_deepseek_ai_dsh_api_settings_controller_settings_update_parameter_1$schema$value ??= z.record(z.string(), z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema()))]))) +let _deepseek_ai_dsh_api_settings_controller_settings_update_parameter_2$schema$value +const _deepseek_ai_dsh_api_settings_controller_settings_update_parameter_2$schema = () => (_deepseek_ai_dsh_api_settings_controller_settings_update_parameter_2$schema$value ??= z.union([z.undefined(), z.number()])) +let _deepseek_ai_dsh_api_settings_controller_settings_update_result$schema$value +const _deepseek_ai_dsh_api_settings_controller_settings_update_result$schema = () => (_deepseek_ai_dsh_api_settings_controller_settings_update_result$schema$value ??= z.object({ + 'autoGenerate': z.boolean(), + 'ns': z.string(), + 'schema': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema2())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema2()))]), + 'value': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema2())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema2()))]), + 'base': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema2())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema2()))]).optional(), + 'user': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema2())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema2()))]).optional(), + 'applies': z.literal("live"), + 'secrets': z.array(z.object({ + 'path': z.array(z.string()), + 'set': z.boolean(), +})), + 'revision': z.number(), +})) + +export const TYPERT_REMOTE = { + package: '@deepseek-ai/dsh-api-settings-controller', + descriptors: [ + { + id: '@deepseek-ai/dsh-api-settings-controller#credentials/describe', + service: 'credentialsController', + namespace: 'credentials', + method: 'describe', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'refs', + wire: 'refs', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-settings-controller#credentials/describe:refs', + create: _deepseek_ai_dsh_api_settings_controller_credentials_describe_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-settings-controller#credentials/describe:result', + create: _deepseek_ai_dsh_api_settings_controller_credentials_describe_result$schema, + }, + sourceLocation: {"file":"packages/api/settings-controller/src/credentials.ts","line":83,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-settings-controller#credentials/set', + service: 'credentialsController', + namespace: 'credentials', + method: 'set', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'ref', + wire: 'ref', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-settings-controller#credentials/set:ref', + create: _deepseek_ai_dsh_api_settings_controller_credentials_set_parameter_0$schema, + }, + }, + { + name: 'value', + wire: 'value', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-settings-controller#credentials/set:value', + create: _deepseek_ai_dsh_api_settings_controller_credentials_set_parameter_1$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-settings-controller#credentials/set:result', + create: _deepseek_ai_dsh_api_settings_controller_credentials_set_result$schema, + }, + sourceLocation: {"file":"packages/api/settings-controller/src/credentials.ts","line":100,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-settings-controller#credentials/unset', + service: 'credentialsController', + namespace: 'credentials', + method: 'unset', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'ref', + wire: 'ref', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-settings-controller#credentials/unset:ref', + create: _deepseek_ai_dsh_api_settings_controller_credentials_unset_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-settings-controller#credentials/unset:result', + create: _deepseek_ai_dsh_api_settings_controller_credentials_unset_result$schema, + }, + sourceLocation: {"file":"packages/api/settings-controller/src/credentials.ts","line":113,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-settings-controller#settings/describe', + service: 'settingsController', + namespace: 'settings', + method: 'describe', + invocation: { kind: 'direct' }, + parameters: [ + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-settings/types#SettingsDescribeValue', + create: _deepseek_ai_dsh_api_settings_controller_settings_describe_result$schema, + }, + sourceLocation: {"file":"packages/api/settings-controller/src/index.ts","line":98,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-settings-controller#settings/mutate', + service: 'settingsController', + namespace: 'settings', + method: 'mutate', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'ns', + wire: 'ns', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-settings-controller#settings/mutate:ns', + create: _deepseek_ai_dsh_api_settings_controller_settings_mutate_parameter_0$schema, + }, + }, + { + name: 'ops', + wire: 'ops', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-settings-controller#settings/mutate:ops', + create: _deepseek_ai_dsh_api_settings_controller_settings_mutate_parameter_1$schema, + }, + }, + { + name: 'expectedRevision', + wire: 'expectedRevision', + source: 'json', + acceptsUndefined: true, + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-settings-controller#settings/mutate:expectedRevision', + create: _deepseek_ai_dsh_api_settings_controller_settings_mutate_parameter_2$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-settings/types#SettingsNamespaceView', + create: _deepseek_ai_dsh_api_settings_controller_settings_mutate_result$schema, + }, + sourceLocation: {"file":"packages/api/settings-controller/src/index.ts","line":152,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-settings-controller#settings/openSettingsDocument', + service: 'settingsController', + namespace: 'settings', + method: 'openSettingsDocument', + invocation: { kind: 'direct' }, + parameters: [ + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-settings-controller/types#SettingsDocumentOpenValue', + create: _deepseek_ai_dsh_api_settings_controller_settings_openSettingsDocument_result$schema, + }, + sourceLocation: {"file":"packages/api/settings-controller/src/index.ts","line":167,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-settings-controller#settings/replace', + service: 'settingsController', + namespace: 'settings', + method: 'replace', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'ns', + wire: 'ns', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-settings-controller#settings/replace:ns', + create: _deepseek_ai_dsh_api_settings_controller_settings_replace_parameter_0$schema, + }, + }, + { + name: 'section', + wire: 'section', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-settings-controller#settings/replace:section', + create: _deepseek_ai_dsh_api_settings_controller_settings_replace_parameter_1$schema, + }, + }, + { + name: 'expectedRevision', + wire: 'expectedRevision', + source: 'json', + acceptsUndefined: true, + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-settings-controller#settings/replace:expectedRevision', + create: _deepseek_ai_dsh_api_settings_controller_settings_replace_parameter_2$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-settings/types#SettingsNamespaceView', + create: _deepseek_ai_dsh_api_settings_controller_settings_replace_result$schema, + }, + sourceLocation: {"file":"packages/api/settings-controller/src/index.ts","line":133,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-settings-controller#settings/update', + service: 'settingsController', + namespace: 'settings', + method: 'update', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'ns', + wire: 'ns', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-settings-controller#settings/update:ns', + create: _deepseek_ai_dsh_api_settings_controller_settings_update_parameter_0$schema, + }, + }, + { + name: 'patch', + wire: 'patch', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-settings-controller#settings/update:patch', + create: _deepseek_ai_dsh_api_settings_controller_settings_update_parameter_1$schema, + }, + }, + { + name: 'expectedRevision', + wire: 'expectedRevision', + source: 'json', + acceptsUndefined: true, + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-settings-controller#settings/update:expectedRevision', + create: _deepseek_ai_dsh_api_settings_controller_settings_update_parameter_2$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-settings/types#SettingsNamespaceView', + create: _deepseek_ai_dsh_api_settings_controller_settings_update_result$schema, + }, + sourceLocation: {"file":"packages/api/settings-controller/src/index.ts","line":116,"column":3}, + }, + ], +} + +export default TYPERT_REMOTE diff --git a/apps/web-ui/packages/api/settings-controller/package.json b/apps/web-ui/packages/api/settings-controller/package.json new file mode 100644 index 000000000..f1b150fa7 --- /dev/null +++ b/apps/web-ui/packages/api/settings-controller/package.json @@ -0,0 +1,30 @@ +{ + "name": "@deepseek-ai/dsh-api-settings-controller", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./types": { + "types": "./src/types.ts", + "default": "./src/types.ts" + }, + "./typert": { + "types": "./src/typert.host.ts", + "default": "./src/typert.host.ts" + }, + "./remote": { + "types": "./generated/remote.d.ts", + "default": "./generated/remote.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dependencies": { + "zod": "^4.4.3" + } +} diff --git a/apps/web-ui/packages/api/settings-controller/src/credentials.ts b/apps/web-ui/packages/api/settings-controller/src/credentials.ts new file mode 100644 index 000000000..fb9adbf5e --- /dev/null +++ b/apps/web-ui/packages/api/settings-controller/src/credentials.ts @@ -0,0 +1,161 @@ +/** + * Host owner of the `credentials` Remote namespace: the reference half of + * `ctx.credentials` as a browser configuration page reads and writes it. + * + * @module @deepseek-ai/dsh-api-settings-controller/src/credentials.ts + */ + +import { Context } from "@deepseek-ai/cordis"; +import { credentialRef } from "@deepseek-ai/dsh-credentials"; +import type { CredentialProvider } from "@deepseek-ai/dsh-credentials"; +import type { CredentialInfo } from "@deepseek-ai/dsh-credentials/types"; +import { Remote, RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol"; +import { z } from "zod"; + +/** + * Fan-out bound on one remote `describe` batch. A settings page asks about the + * references its own rows name, so this is far above any real page and still + * keeps one authenticated request from starting unbounded provider work. + */ +const MAX_DESCRIBE_REFS = 64; + +const credentialRefSchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/); +const describeRequestSchema = z.object({ + refs: z.array(credentialRefSchema).max(MAX_DESCRIBE_REFS), +}); +const setRequestSchema = z.object({ ref: credentialRefSchema, value: z.string().min(1) }); +const unsetRequestSchema = z.object({ ref: credentialRefSchema }); + +/** Parse the domain constraints that are more specific than generated TypeScript codecs. */ +function parseRequest(method: string, schema: z.ZodType, value: unknown): T { + const parsed = schema.safeParse(value); + if (!parsed.success) { + throw new RemoteError("gateway/bad-request", `invalid payload for ${method}`, { + issues: parsed.error.issues, + }); + } + return parsed.data; +} + +/** + * Copy exactly the fields {@link CredentialInfo} declares. The Gateway returns + * a business result without decoding it, so a provider whose `describe` carried + * extra enumerable properties would otherwise serialize them to the caller. + * @param info - the provider's answer for one reference. + * @returns the same facts with nothing else attached. + */ +function projectCredentialInfo(info: CredentialInfo): CredentialInfo { + return { + configured: info.configured, + ...(info.source === undefined ? {} : { source: info.source }), + writable: info.writable, + }; +} + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Host owner of the `credentials` Remote namespace. */ + credentialsController: CredentialsController; + } +} + +/** + * Host service backing the generated `ctx.remote.credentials` namespace. It + * carries every wire obligation the credential seam itself does not: the batch + * fan-out bound, the field-by-field view projection, the reference-grammar + * guard, and the refusal mapping. Secret values cross in one direction only — + * no method here returns one. + */ +export class CredentialsController extends TypertRemoteService { + /** @param ctx - Host context where a credential provider may be mounted. */ + constructor(ctx: Context) { + super(ctx, "credentialsController", { namespace: "credentials" }); + } + + /** + * Describe several references for one configuration surface. Batched because + * a settings page describes every reference its rows name at once, and one + * round trip keeps those rows from settling separately. + * @param refs - reference names, at most {@link MAX_DESCRIBE_REFS}; a name outside the grammar + * rejects the whole call as `gateway/bad-request`. + * @returns one view per requested name, keyed by that name. + * @throws RemoteError when the request is invalid or no credential provider is mounted. + */ + @Remote + async describe(refs: string[]): Promise> { + const request = parseRequest("credentials.describe", describeRequestSchema, { refs }); + const branded = request.refs.map((ref) => [ref, credentialRef(ref)] as const); + const credentials = this.provider(); + const entries = await Promise.all( + branded.map( + async ([ref, key]) => + [ref, projectCredentialInfo(await credentials.describe(key))] as const, + ), + ); + return Object.fromEntries(entries); + } + + /** + * Store one value from a configuration surface. The value crosses the wire in + * this direction only: no read path returns it. + * @param ref - reference name to store under. + * @param value - the non-empty secret value. + * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. + */ + @Remote + async set(ref: string, value: string): Promise { + const request = parseRequest("credentials.set", setRequestSchema, { ref, value }); + const branded = credentialRef(request.ref); + const credentials = this.provider(); + await this.write(request.ref, () => credentials.set(branded, request.value)); + } + + /** + * Remove one reference from a configuration surface. + * @param ref - reference name to remove. + * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. + */ + @Remote + async unset(ref: string): Promise { + const request = parseRequest("credentials.unset", unsetRequestSchema, { ref }); + const branded = credentialRef(request.ref); + const credentials = this.provider(); + await this.write(request.ref, () => credentials.unset(branded)); + } + + /** Resolve the optional provider or report how to supply it. */ + private provider(): CredentialProvider { + const credentials = this.ctx.get("credentials"); + if (credentials === undefined) { + throw new RemoteError( + "gateway/internal", + "credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition", + {}, + ); + } + return credentials; + } + + /** + * Run one remote write and report every refusal as `credential/rejected` + * carrying the seam's own message: a read-only source shadowing the reference + * is what a configuration surface must show verbatim. Callers brand the + * reference before entering, so a name outside the grammar never reaches this + * path and fails the same way it does on the read side. The details name only + * the reference, so no failure path can carry the value back out. + */ + private async write(ref: string, write: () => Promise): Promise { + try { + await write(); + } catch (error: unknown) { + throw new RemoteError( + "credential/rejected", + error instanceof Error ? error.message : String(error), + { ref }, + { cause: error }, + ); + } + } +} + +export default CredentialsController; diff --git a/apps/web-ui/packages/api/settings-controller/src/index.ts b/apps/web-ui/packages/api/settings-controller/src/index.ts new file mode 100644 index 000000000..a46a096c1 --- /dev/null +++ b/apps/web-ui/packages/api/settings-controller/src/index.ts @@ -0,0 +1,298 @@ +/** + * Host Remote owner for the configuration surfaces over the settings-domain + * seams. Two namespaces: `settings`, the redacted reads and writes of + * `ctx.settings`, owned by the class below; and `credentials`, mounted from + * here as its own plugin. + * + * @module @deepseek-ai/dsh-api-settings-controller + */ + +import { Context } from "@deepseek-ai/cordis"; +import { openNativeTextFile } from "@deepseek-ai/dsh-native-command"; +import type { SettingsDescriptor, SettingsPathOp, SettingsForms } from "@deepseek-ai/dsh-settings"; +import type { + SettingsDescribeValue, + SettingsNamespaceView, + SettingsPathOpView, +} from "@deepseek-ai/dsh-settings/types"; +import { Remote, RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol"; +import type { JsonValue } from "@deepseek-ai/dsh-util-values"; +import { z } from "zod"; +import { CredentialsController } from "./credentials.ts"; +import type { SettingsDocumentOpenValue } from "./types.ts"; + +export { CredentialsController } from "./credentials.ts"; +export type * from "./types.ts"; + +const settingsNamespaceRequestSchema = z.object({ ns: z.string().min(1) }); + +/** Read abort state afresh after an awaited provider or opener call. */ +function isAborted(signal: AbortSignal): boolean { + return signal.aborted; +} + +/** Host integrations replaceable by direct unit tests. */ +export interface SettingsControllerInternals { + /** Host text-editor integration used to open the settings document. */ + readonly openTextFile?: (path: string, signal: AbortSignal) => Promise; +} + +/** + * Project one redacted descriptor onto its wire view, field by field. The + * Gateway returns a business result without decoding it, so a provider whose + * descriptor carried extra enumerable properties would otherwise serialize them + * to the caller. + * @param descriptor - one descriptor read under `redactSecrets`. + * @returns the same facts with nothing else attached. + */ +function namespaceView(descriptor: SettingsDescriptor): SettingsNamespaceView { + return { + ns: String(descriptor.ns), + autoGenerate: descriptor.autoGenerate, + schema: descriptor.schema as JsonValue, + value: descriptor.value as JsonValue, + ...(descriptor.base === undefined ? {} : { base: descriptor.base as JsonValue }), + ...(descriptor.user === undefined ? {} : { user: descriptor.user as JsonValue }), + applies: descriptor.applies, + secrets: (descriptor.secrets ?? []).map((secret) => ({ + path: [...secret.path], + set: secret.set, + })), + revision: descriptor.revision, + }; +} + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Host owner of the `settings` Remote namespace. */ + settingsController: SettingsController; + } +} + +/** + * Host service backing the generated `ctx.remote.settings` namespace. Every + * remote read uses `redactSecrets: true`, so a `role('secret')` field cannot + * ride a response. Writes expose the settings service's merge, replacement, + * and path-addressed operations, and classify every provider refusal as + * `settings/conflict` or `settings/rejected` with the service's message. + */ +export class SettingsController extends TypertRemoteService { + private readonly openTextFile: (path: string, signal: AbortSignal) => Promise; + + /** + * Register the settings namespace and mount the credentials namespace beside + * it. Both namespaces stay registered when a provider is absent so calls can + * return the configuration API's actionable missing-provider diagnostic. + * @param ctx - Host context where settings and credential providers may be mounted. + */ + constructor(ctx: Context, internals: SettingsControllerInternals = {}) { + super(ctx, "settingsController", { namespace: "settings" }); + this.openTextFile = internals.openTextFile ?? openNativeTextFile; + ctx.plugin(CredentialsController); + } + + /** + * Describe every registered namespace for a configuration page: redacted + * layered values plus the serialized schema the page renders its form from. + * @returns provider writability, local-document presence, and one view per namespace. + * @throws RemoteError when no settings provider is mounted. + */ + @Remote + describe(): SettingsDescribeValue { + const settings = this.provider(); + return { + writable: settings.writable, + hasDocument: true, + namespaces: settings.describe({ redactSecrets: true }).map(namespaceView), + }; + } + + /** + * Merge a patch into one namespace's stored user section. + * @param ns - namespace key to write. + * @param patch - fields to merge into the user section. + * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. + * @returns the namespace's redacted view after the write. + * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. + */ + @Remote + update( + ns: string, + patch: Record, + expectedRevision: number | undefined, + ): Promise { + return this.write(ns, "update", patch, expectedRevision); + } + + /** + * Replace one namespace's stored user section wholesale. + * @param ns - namespace key to write. + * @param section - complete replacement user section. + * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. + * @returns the namespace's redacted view after the write. + * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. + */ + @Remote + replace( + ns: string, + section: Record, + expectedRevision: number | undefined, + ): Promise { + return this.write(ns, "replace", section, expectedRevision); + } + + /** + * Apply path-addressed edits to one namespace's user section, resolved against + * the section as stored rather than against whatever the caller last read, + * then answer with that namespace's new redacted view. + * @param ns - namespace key to write. + * @param ops - the edits to apply, in order. + * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. + * @returns the namespace's redacted view after the write. + * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. + */ + @Remote + async mutate( + ns: string, + ops: SettingsPathOpView[], + expectedRevision: number | undefined, + ): Promise { + return this.write(ns, "mutate", ops, expectedRevision); + } + + /** + * Materialize the provider-owned settings document and open it in a native text editor. + * @param signal - caller lifetime; abort terminates preparation or the native command. + * @returns confirmation after the native opener accepts the document. + * @throws RemoteError when no document exists, preparation fails, or opening fails. + */ + @Remote + async openSettingsDocument(signal: AbortSignal): Promise { + const settings = this.provider(); + if (isAborted(signal)) + throw new RemoteError("gateway/cancelled", "settings document open was aborted", {}); + let path: string; + try { + path = await settings.prepareDocument(); + } catch (error: unknown) { + if (isAborted(signal)) + throw new RemoteError("gateway/cancelled", "settings document preparation was aborted", {}); + throw new RemoteError( + "gateway/internal", + `settings document preparation failed: ${messageOf(error)}`, + {}, + { cause: error }, + ); + } + if (isAborted(signal)) + throw new RemoteError("gateway/cancelled", "settings document open was aborted", {}); + try { + await this.openTextFile(path, signal); + return { opened: true }; + } catch (error: unknown) { + if (isAborted(signal)) + throw new RemoteError("gateway/cancelled", "settings document open was aborted", {}); + throw new RemoteError( + "gateway/internal", + `path open failed: ${messageOf(error)}`, + {}, + { cause: error }, + ); + } + } + + private async write( + ns: string, + mode: "update" | "replace" | "mutate", + input: Record | SettingsPathOpView[], + expectedRevision: number | undefined, + ): Promise { + const parsed = settingsNamespaceRequestSchema.safeParse({ ns }); + if (!parsed.success) { + throw new RemoteError("gateway/bad-request", `invalid payload for settings.${mode}`, { + issues: parsed.error.issues, + }); + } + const settings = this.provider(); + const namespace = parsed.data.ns; + try { + if (mode === "update") await settings.update(namespace, input, expectedRevision); + else if (mode === "replace") await settings.replace(namespace, input, expectedRevision); + else await settings.mutate(namespace, input as SettingsPathOp[], expectedRevision); + } catch (error: unknown) { + throw rejected(ns, error); + } + const descriptor = settings + .describe({ redactSecrets: true }) + .find((candidate) => candidate.ns === namespace); + if (descriptor === undefined) { + // The write committed but the namespace vanished before this read: only a + // concurrent registrant disposal can produce it. + throw new RemoteError( + "gateway/internal", + `settings namespace "${ns}" was disposed after the ${mode}`, + {}, + ); + } + return namespaceView(descriptor); + } + + /** Resolve the optional provider or report how to supply it. */ + private provider(): SettingsForms { + const settings = this.ctx.get("settings"); + if (settings === undefined) { + throw new RemoteError( + "gateway/internal", + "settings service is absent: mount @deepseek-ai/dsh-settings with @deepseek-ai/dsh-config-editor in the profile composition", + {}, + ); + } + return settings; + } +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +interface SettingsConflict { + readonly code: "SETTINGS_CONFLICT"; + readonly message: string; + readonly expected: number; + readonly actual: number; +} + +function settingsConflictOf(error: unknown): SettingsConflict | undefined { + if (typeof error !== "object" || error === null) return undefined; + if ( + Reflect.get(error, "code") !== "SETTINGS_CONFLICT" || + typeof Reflect.get(error, "message") !== "string" || + typeof Reflect.get(error, "expected") !== "number" || + typeof Reflect.get(error, "actual") !== "number" + ) + return undefined; + return error as SettingsConflict; +} + +/** + * Classify one seam refusal. A stale writer is its own outcome, not a malformed + * request: the client must re-read and re-apply rather than treat the write as + * invalid. + * @param ns - the namespace the write addressed. + * @param error - whatever the seam threw. + * @returns the failure to raise for that refusal. + */ +function rejected(ns: string, error: unknown): RemoteError { + const conflict = settingsConflictOf(error); + if (conflict !== undefined) { + return new RemoteError( + "settings/conflict", + conflict.message, + { ns, expected: conflict.expected, actual: conflict.actual }, + { cause: error }, + ); + } + return new RemoteError("settings/rejected", messageOf(error), { ns }, { cause: error }); +} + +export default SettingsController; diff --git a/apps/web-ui/packages/api/settings-controller/src/types.ts b/apps/web-ui/packages/api/settings-controller/src/types.ts new file mode 100644 index 000000000..56ef03e19 --- /dev/null +++ b/apps/web-ui/packages/api/settings-controller/src/types.ts @@ -0,0 +1,38 @@ +/** + * Browser-safe failure vocabulary of the configuration surfaces this package + * serves. The redacted views themselves live with their seam in + * `@deepseek-ai/dsh-settings/types`, whose Cordis event declarations already + * register that file for the Client compilation face. + * + * @module @deepseek-ai/dsh-api-settings-controller/types + */ + +declare module "@deepseek-ai/dsh-typert-protocol" { + interface RemoteErrorDetailsMap { + /** + * Every seam refusal that is not a stale write: an unregistered or malformed + * namespace, a read-only provider, schema validation, storage. + */ + "settings/rejected": { readonly ns: string }; + /** + * The stored revision moved after the caller read it. Its own outcome rather + * than an invalid request: the caller must re-read and re-apply. + */ + "settings/conflict": { + readonly ns: string; + readonly expected: number; + readonly actual: number; + }; + /** + * The provider refused a valid credential write, for example because a + * read-only source shadows the reference. The details name only the + * reference, never the value. + */ + "credential/rejected": { readonly ref: string }; + } +} + +/** Confirmation that the settings document was handed to the native editor. */ +export interface SettingsDocumentOpenValue { + readonly opened: true; +} diff --git a/apps/web-ui/packages/api/terminal-controller/generated/remote.d.ts b/apps/web-ui/packages/api/terminal-controller/generated/remote.d.ts new file mode 100644 index 000000000..829c46a71 --- /dev/null +++ b/apps/web-ui/packages/api/terminal-controller/generated/remote.d.ts @@ -0,0 +1,52 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import type { + RemoteResult, + RemoteStreamHandle, + TypertRemoteContribution, +} from '@deepseek-ai/dsh-typert-protocol' +import type { TerminalAttachmentId, TerminalCreateRequest, TerminalEnvironment, TerminalFrame, TerminalRetentionFrame, TerminalShell, WebTerminalId, WebTerminalInfo } from '@deepseek-ai/dsh-api-terminal-controller/types' +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertRemoteNamespace$7465726d696e616c { + close: (agentId: SessionId, id: WebTerminalId) => Promise> + create: (agentId: SessionId, request: TerminalCreateRequest, signal?: AbortSignal) => Promise> + environment: (agentId: SessionId, signal?: AbortSignal) => Promise> + follow: (agentId: SessionId, id: WebTerminalId, attachmentId: TerminalAttachmentId, signal?: AbortSignal) => RemoteStreamHandle + list: (sessionId: SessionId) => Promise> + rename: (agentId: SessionId, id: WebTerminalId, title: string) => Promise> + resize: (agentId: SessionId, id: WebTerminalId, attachmentId: TerminalAttachmentId, cols: number, rows: number) => Promise> + retain: (sessionId: SessionId, id: WebTerminalId, signal?: AbortSignal) => RemoteStreamHandle + shells: (agentId: SessionId, signal?: AbortSignal) => Promise> + write: (agentId: SessionId, id: WebTerminalId, attachmentId: TerminalAttachmentId, data: string) => Promise> + } + interface TypertRemoteMap { + 'terminal/close': (agentId: SessionId, id: WebTerminalId) => Promise> + 'terminal/create': (agentId: SessionId, request: TerminalCreateRequest, signal?: AbortSignal) => Promise> + 'terminal/environment': (agentId: SessionId, signal?: AbortSignal) => Promise> + 'terminal/follow': (agentId: SessionId, id: WebTerminalId, attachmentId: TerminalAttachmentId, signal?: AbortSignal) => RemoteStreamHandle + 'terminal/list': (sessionId: SessionId) => Promise> + 'terminal/rename': (agentId: SessionId, id: WebTerminalId, title: string) => Promise> + 'terminal/resize': (agentId: SessionId, id: WebTerminalId, attachmentId: TerminalAttachmentId, cols: number, rows: number) => Promise> + 'terminal/retain': (sessionId: SessionId, id: WebTerminalId, signal?: AbortSignal) => RemoteStreamHandle + 'terminal/shells': (agentId: SessionId, signal?: AbortSignal) => Promise> + 'terminal/write': (agentId: SessionId, id: WebTerminalId, attachmentId: TerminalAttachmentId, data: string) => Promise> + } + interface TypertRemoteNamespaceMap { + 'terminal': TypertRemoteNamespace$7465726d696e616c + } + interface TypertRemoteScopeMap { + 'agent:terminal/close': (id: WebTerminalId) => Promise> + 'agent:terminal/create': (request: TerminalCreateRequest, signal?: AbortSignal) => Promise> + 'agent:terminal/environment': (signal?: AbortSignal) => Promise> + 'agent:terminal/follow': (id: WebTerminalId, attachmentId: TerminalAttachmentId, signal?: AbortSignal) => RemoteStreamHandle + 'agent:terminal/rename': (id: WebTerminalId, title: string) => Promise> + 'agent:terminal/resize': (id: WebTerminalId, attachmentId: TerminalAttachmentId, cols: number, rows: number) => Promise> + 'agent:terminal/shells': (signal?: AbortSignal) => Promise> + 'agent:terminal/write': (id: WebTerminalId, attachmentId: TerminalAttachmentId, data: string) => Promise> + } +} + +export declare const TYPERT_REMOTE: TypertRemoteContribution +export default TYPERT_REMOTE +//# sourceMappingURL=typert.remote-client.d.ts.map diff --git a/apps/web-ui/packages/api/terminal-controller/generated/remote.js b/apps/web-ui/packages/api/terminal-controller/generated/remote.js new file mode 100644 index 000000000..9486c277d --- /dev/null +++ b/apps/web-ui/packages/api/terminal-controller/generated/remote.js @@ -0,0 +1,605 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import { z } from 'zod' + +let _deepseek_ai_dsh_api_terminal_controller_terminal_close_parameter_0$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_close_parameter_0$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_close_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_close_parameter_1$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_close_parameter_1$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_close_parameter_1$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_close_result$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_close_result$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_close_result$schema$value ??= z.void()) +let _deepseek_ai_dsh_api_terminal_controller_terminal_create_parameter_0$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_create_parameter_0$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_create_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_create_parameter_1$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_create_parameter_1$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_create_parameter_1$schema$value ??= z.object({ + 'shellPath': z.string().readonly().optional(), + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'cols': z.number().readonly(), + 'rows': z.number().readonly(), +})) +let _deepseek_ai_dsh_api_terminal_controller_terminal_create_result$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_create_result$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_create_result$schema$value ??= z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'title': z.string().readonly(), + 'shell': z.object({ + 'path': z.string().readonly(), + 'args': z.array(z.string()).readonly(), + 'name': z.string().readonly(), +}).readonly(), + 'cwd': z.string().readonly(), + 'cols': z.number().readonly(), + 'rows': z.number().readonly(), + 'state': z.union([z.literal("failed"), z.literal("running"), z.literal("exited")]).readonly(), + 'exitCode': z.union([z.literal(null), z.number()]).readonly(), + 'error': z.string().readonly().optional(), + 'controllerId': z.intersection(z.string(), z.unknown()).readonly().optional(), +})) +let _deepseek_ai_dsh_api_terminal_controller_terminal_environment_parameter_0$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_environment_parameter_0$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_environment_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_environment_result$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_environment_result$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_environment_result$schema$value ??= z.object({ + 'cwd': z.string().readonly(), + 'maxInputBytes': z.number().readonly(), + 'maxCols': z.number().readonly(), + 'maxRows': z.number().readonly(), + 'scrollback': z.number().readonly(), +})) +let _deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_0$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_0$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_1$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_1$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_1$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_2$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_2$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_2$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_follow_result$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_follow_result$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_follow_result$schema$value ??= z.union([z.object({ + 'type': z.literal("snapshot").readonly(), + 'sequence': z.number().readonly(), + 'screen': z.string().readonly(), + 'info': z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'title': z.string().readonly(), + 'shell': z.object({ + 'path': z.string().readonly(), + 'args': z.array(z.string()).readonly(), + 'name': z.string().readonly(), +}).readonly(), + 'cwd': z.string().readonly(), + 'cols': z.number().readonly(), + 'rows': z.number().readonly(), + 'state': z.union([z.literal("failed"), z.literal("running"), z.literal("exited")]).readonly(), + 'exitCode': z.union([z.literal(null), z.number()]).readonly(), + 'error': z.string().readonly().optional(), + 'controllerId': z.intersection(z.string(), z.unknown()).readonly().optional(), +}).readonly(), +}), z.object({ + 'type': z.literal("output").readonly(), + 'sequence': z.number().readonly(), + 'data': z.string().readonly(), +}), z.object({ + 'type': z.literal("state").readonly(), + 'info': z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'title': z.string().readonly(), + 'shell': z.object({ + 'path': z.string().readonly(), + 'args': z.array(z.string()).readonly(), + 'name': z.string().readonly(), +}).readonly(), + 'cwd': z.string().readonly(), + 'cols': z.number().readonly(), + 'rows': z.number().readonly(), + 'state': z.union([z.literal("failed"), z.literal("running"), z.literal("exited")]).readonly(), + 'exitCode': z.union([z.literal(null), z.number()]).readonly(), + 'error': z.string().readonly().optional(), + 'controllerId': z.intersection(z.string(), z.unknown()).readonly().optional(), +}).readonly(), +})])) +let _deepseek_ai_dsh_api_terminal_controller_terminal_list_parameter_0$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_list_parameter_0$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_list_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_list_result$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_list_result$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_list_result$schema$value ??= z.array(z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'title': z.string().readonly(), + 'shell': z.object({ + 'path': z.string().readonly(), + 'args': z.array(z.string()).readonly(), + 'name': z.string().readonly(), +}).readonly(), + 'cwd': z.string().readonly(), + 'cols': z.number().readonly(), + 'rows': z.number().readonly(), + 'state': z.union([z.literal("failed"), z.literal("running"), z.literal("exited")]).readonly(), + 'exitCode': z.union([z.literal(null), z.number()]).readonly(), + 'error': z.string().readonly().optional(), + 'controllerId': z.intersection(z.string(), z.unknown()).readonly().optional(), +}))) +let _deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_0$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_0$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_1$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_1$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_1$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_2$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_2$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_2$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_terminal_controller_terminal_rename_result$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_rename_result$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_rename_result$schema$value ??= z.void()) +let _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_0$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_0$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_1$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_1$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_1$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_2$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_2$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_2$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_3$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_3$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_3$schema$value ??= z.number()) +let _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_4$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_4$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_4$schema$value ??= z.number()) +let _deepseek_ai_dsh_api_terminal_controller_terminal_resize_result$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_resize_result$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_resize_result$schema$value ??= z.void()) +let _deepseek_ai_dsh_api_terminal_controller_terminal_retain_parameter_0$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_retain_parameter_0$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_retain_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_retain_parameter_1$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_retain_parameter_1$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_retain_parameter_1$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_retain_result$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_retain_result$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_retain_result$schema$value ??= z.object({ + 'type': z.literal("retained").readonly(), +})) +let _deepseek_ai_dsh_api_terminal_controller_terminal_shells_parameter_0$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_shells_parameter_0$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_shells_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_shells_result$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_shells_result$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_shells_result$schema$value ??= z.array(z.object({ + 'path': z.string().readonly(), + 'args': z.array(z.string()).readonly(), + 'name': z.string().readonly(), +}))) +let _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_0$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_0$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_1$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_1$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_1$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_2$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_2$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_2$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_3$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_3$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_3$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_terminal_controller_terminal_write_result$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_write_result$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_write_result$schema$value ??= z.void()) + +export const TYPERT_REMOTE = { + package: '@deepseek-ai/dsh-api-terminal-controller', + descriptors: [ + { + id: '@deepseek-ai/dsh-api-terminal-controller#terminal/close', + service: 'terminalController', + namespace: 'terminal', + method: 'close', + invocation: { kind: 'direct' }, + scope: { + context: 'agent', + wire: 'agentId', + }, + parameters: [ + { + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'agent', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_close_parameter_0$schema, + }, + }, + { + name: 'id', + wire: 'id', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller/types#WebTerminalId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_close_parameter_1$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller#terminal/close:result', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_close_result$schema, + }, + sourceLocation: {"file":"packages/api/terminal-controller/src/index.ts","line":269,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-terminal-controller#terminal/create', + service: 'terminalController', + namespace: 'terminal', + method: 'create', + invocation: { kind: 'direct' }, + scope: { + context: 'agent', + wire: 'agentId', + }, + parameters: [ + { + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'agent', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_create_parameter_0$schema, + }, + }, + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller/types#TerminalCreateRequest', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_create_parameter_1$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller/types#WebTerminalInfo', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_create_result$schema, + }, + sourceLocation: {"file":"packages/api/terminal-controller/src/index.ts","line":158,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-terminal-controller#terminal/environment', + service: 'terminalController', + namespace: 'terminal', + method: 'environment', + invocation: { kind: 'direct' }, + scope: { + context: 'agent', + wire: 'agentId', + }, + parameters: [ + { + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'agent', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_environment_parameter_0$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller/types#TerminalEnvironment', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_environment_result$schema, + }, + sourceLocation: {"file":"packages/api/terminal-controller/src/index.ts","line":118,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-terminal-controller#terminal/follow', + service: 'terminalController', + namespace: 'terminal', + method: 'follow', + mode: 'stream', + invocation: { kind: 'direct' }, + scope: { + context: 'agent', + wire: 'agentId', + }, + parameters: [ + { + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'agent', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_0$schema, + }, + }, + { + name: 'id', + wire: 'id', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller/types#WebTerminalId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_1$schema, + }, + }, + { + name: 'attachmentId', + wire: 'attachmentId', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller/types#TerminalAttachmentId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_2$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller/types#TerminalFrame', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_follow_result$schema, + }, + sourceLocation: {"file":"packages/api/terminal-controller/src/index.ts","line":216,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-terminal-controller#terminal/list', + service: 'terminalController', + namespace: 'terminal', + method: 'list', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'sessionId', + wire: 'sessionId', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_list_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller#terminal/list:result', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_list_result$schema, + }, + sourceLocation: {"file":"packages/api/terminal-controller/src/index.ts","line":144,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-terminal-controller#terminal/rename', + service: 'terminalController', + namespace: 'terminal', + method: 'rename', + invocation: { kind: 'direct' }, + scope: { + context: 'agent', + wire: 'agentId', + }, + parameters: [ + { + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'agent', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_0$schema, + }, + }, + { + name: 'id', + wire: 'id', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller/types#WebTerminalId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_1$schema, + }, + }, + { + name: 'title', + wire: 'title', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller#terminal/rename:title', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_2$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller#terminal/rename:result', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_rename_result$schema, + }, + sourceLocation: {"file":"packages/api/terminal-controller/src/index.ts","line":257,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-terminal-controller#terminal/resize', + service: 'terminalController', + namespace: 'terminal', + method: 'resize', + invocation: { kind: 'direct' }, + scope: { + context: 'agent', + wire: 'agentId', + }, + parameters: [ + { + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'agent', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_0$schema, + }, + }, + { + name: 'id', + wire: 'id', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller/types#WebTerminalId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_1$schema, + }, + }, + { + name: 'attachmentId', + wire: 'attachmentId', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller/types#TerminalAttachmentId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_2$schema, + }, + }, + { + name: 'cols', + wire: 'cols', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller#terminal/resize:cols', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_3$schema, + }, + }, + { + name: 'rows', + wire: 'rows', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller#terminal/resize:rows', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_4$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller#terminal/resize:result', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_resize_result$schema, + }, + sourceLocation: {"file":"packages/api/terminal-controller/src/index.ts","line":245,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-terminal-controller#terminal/retain', + service: 'terminalController', + namespace: 'terminal', + method: 'retain', + mode: 'stream', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'sessionId', + wire: 'sessionId', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_retain_parameter_0$schema, + }, + }, + { + name: 'id', + wire: 'id', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller/types#WebTerminalId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_retain_parameter_1$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller/types#TerminalRetentionFrame', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_retain_result$schema, + }, + sourceLocation: {"file":"packages/api/terminal-controller/src/index.ts","line":198,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-terminal-controller#terminal/shells', + service: 'terminalController', + namespace: 'terminal', + method: 'shells', + invocation: { kind: 'direct' }, + scope: { + context: 'agent', + wire: 'agentId', + }, + parameters: [ + { + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'agent', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_shells_parameter_0$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller#terminal/shells:result', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_shells_result$schema, + }, + sourceLocation: {"file":"packages/api/terminal-controller/src/index.ts","line":133,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-terminal-controller#terminal/write', + service: 'terminalController', + namespace: 'terminal', + method: 'write', + invocation: { kind: 'direct' }, + scope: { + context: 'agent', + wire: 'agentId', + }, + parameters: [ + { + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'agent', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_0$schema, + }, + }, + { + name: 'id', + wire: 'id', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller/types#WebTerminalId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_1$schema, + }, + }, + { + name: 'attachmentId', + wire: 'attachmentId', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller/types#TerminalAttachmentId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_2$schema, + }, + }, + { + name: 'data', + wire: 'data', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller#terminal/write:data', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_3$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller#terminal/write:result', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_write_result$schema, + }, + sourceLocation: {"file":"packages/api/terminal-controller/src/index.ts","line":230,"column":9}, + }, + ], +} + +export default TYPERT_REMOTE diff --git a/apps/web-ui/packages/api/terminal-controller/host/typert.host.d.ts b/apps/web-ui/packages/api/terminal-controller/host/typert.host.d.ts new file mode 100644 index 000000000..d612f57e6 --- /dev/null +++ b/apps/web-ui/packages/api/terminal-controller/host/typert.host.d.ts @@ -0,0 +1,3 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from FaceModel — do not edit. */ + +export declare const TYPERT: unknown; diff --git a/apps/web-ui/packages/api/terminal-controller/host/typert.host.js b/apps/web-ui/packages/api/terminal-controller/host/typert.host.js new file mode 100644 index 000000000..81b53501d --- /dev/null +++ b/apps/web-ui/packages/api/terminal-controller/host/typert.host.js @@ -0,0 +1,1534 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from FaceModel — do not edit. */ +import { z } from "zod"; + +let _deepseek_ai_dsh_api_terminal_controller_terminal_close_parameter_0$schema$value; +const _deepseek_ai_dsh_api_terminal_controller_terminal_close_parameter_0$schema = () => + (_deepseek_ai_dsh_api_terminal_controller_terminal_close_parameter_0$schema$value ??= + z.intersection(z.string(), z.unknown())); +let _deepseek_ai_dsh_api_terminal_controller_terminal_close_parameter_1$schema$value; +const _deepseek_ai_dsh_api_terminal_controller_terminal_close_parameter_1$schema = () => + (_deepseek_ai_dsh_api_terminal_controller_terminal_close_parameter_1$schema$value ??= + z.intersection(z.string(), z.unknown())); +let _deepseek_ai_dsh_api_terminal_controller_terminal_close_result$schema$value; +const _deepseek_ai_dsh_api_terminal_controller_terminal_close_result$schema = () => + (_deepseek_ai_dsh_api_terminal_controller_terminal_close_result$schema$value ??= z.void()); +let _deepseek_ai_dsh_api_terminal_controller_terminal_create_parameter_0$schema$value; +const _deepseek_ai_dsh_api_terminal_controller_terminal_create_parameter_0$schema = () => + (_deepseek_ai_dsh_api_terminal_controller_terminal_create_parameter_0$schema$value ??= + z.intersection(z.string(), z.unknown())); +let _deepseek_ai_dsh_api_terminal_controller_terminal_create_parameter_1$schema$value; +const _deepseek_ai_dsh_api_terminal_controller_terminal_create_parameter_1$schema = () => + (_deepseek_ai_dsh_api_terminal_controller_terminal_create_parameter_1$schema$value ??= z.object({ + shellPath: z.string().readonly().optional(), + id: z.intersection(z.string(), z.unknown()).readonly(), + cols: z.number().readonly(), + rows: z.number().readonly(), + })); +let _deepseek_ai_dsh_api_terminal_controller_terminal_create_result$schema$value; +const _deepseek_ai_dsh_api_terminal_controller_terminal_create_result$schema = () => + (_deepseek_ai_dsh_api_terminal_controller_terminal_create_result$schema$value ??= z.object({ + id: z.intersection(z.string(), z.unknown()).readonly(), + title: z.string().readonly(), + shell: z + .object({ + path: z.string().readonly(), + args: z.array(z.string()).readonly(), + name: z.string().readonly(), + }) + .readonly(), + cwd: z.string().readonly(), + cols: z.number().readonly(), + rows: z.number().readonly(), + state: z.union([z.literal("failed"), z.literal("running"), z.literal("exited")]).readonly(), + exitCode: z.union([z.literal(null), z.number()]).readonly(), + error: z.string().readonly().optional(), + controllerId: z.intersection(z.string(), z.unknown()).readonly().optional(), + })); +let _deepseek_ai_dsh_api_terminal_controller_terminal_environment_parameter_0$schema$value; +const _deepseek_ai_dsh_api_terminal_controller_terminal_environment_parameter_0$schema = () => + (_deepseek_ai_dsh_api_terminal_controller_terminal_environment_parameter_0$schema$value ??= + z.intersection(z.string(), z.unknown())); +let _deepseek_ai_dsh_api_terminal_controller_terminal_environment_result$schema$value; +const _deepseek_ai_dsh_api_terminal_controller_terminal_environment_result$schema = () => + (_deepseek_ai_dsh_api_terminal_controller_terminal_environment_result$schema$value ??= z.object({ + cwd: z.string().readonly(), + maxInputBytes: z.number().readonly(), + maxCols: z.number().readonly(), + maxRows: z.number().readonly(), + scrollback: z.number().readonly(), + })); +let _deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_0$schema$value; +const _deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_0$schema = () => + (_deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_0$schema$value ??= + z.intersection(z.string(), z.unknown())); +let _deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_1$schema$value; +const _deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_1$schema = () => + (_deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_1$schema$value ??= + z.intersection(z.string(), z.unknown())); +let _deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_2$schema$value; +const _deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_2$schema = () => + (_deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_2$schema$value ??= + z.intersection(z.string(), z.unknown())); +let _deepseek_ai_dsh_api_terminal_controller_terminal_follow_result$schema$value; +const _deepseek_ai_dsh_api_terminal_controller_terminal_follow_result$schema = () => + (_deepseek_ai_dsh_api_terminal_controller_terminal_follow_result$schema$value ??= z.union([ + z.object({ + type: z.literal("snapshot").readonly(), + sequence: z.number().readonly(), + screen: z.string().readonly(), + info: z + .object({ + id: z.intersection(z.string(), z.unknown()).readonly(), + title: z.string().readonly(), + shell: z + .object({ + path: z.string().readonly(), + args: z.array(z.string()).readonly(), + name: z.string().readonly(), + }) + .readonly(), + cwd: z.string().readonly(), + cols: z.number().readonly(), + rows: z.number().readonly(), + state: z + .union([z.literal("failed"), z.literal("running"), z.literal("exited")]) + .readonly(), + exitCode: z.union([z.literal(null), z.number()]).readonly(), + error: z.string().readonly().optional(), + controllerId: z.intersection(z.string(), z.unknown()).readonly().optional(), + }) + .readonly(), + }), + z.object({ + type: z.literal("output").readonly(), + sequence: z.number().readonly(), + data: z.string().readonly(), + }), + z.object({ + type: z.literal("state").readonly(), + info: z + .object({ + id: z.intersection(z.string(), z.unknown()).readonly(), + title: z.string().readonly(), + shell: z + .object({ + path: z.string().readonly(), + args: z.array(z.string()).readonly(), + name: z.string().readonly(), + }) + .readonly(), + cwd: z.string().readonly(), + cols: z.number().readonly(), + rows: z.number().readonly(), + state: z + .union([z.literal("failed"), z.literal("running"), z.literal("exited")]) + .readonly(), + exitCode: z.union([z.literal(null), z.number()]).readonly(), + error: z.string().readonly().optional(), + controllerId: z.intersection(z.string(), z.unknown()).readonly().optional(), + }) + .readonly(), + }), + ])); +let _deepseek_ai_dsh_api_terminal_controller_terminal_list_parameter_0$schema$value; +const _deepseek_ai_dsh_api_terminal_controller_terminal_list_parameter_0$schema = () => + (_deepseek_ai_dsh_api_terminal_controller_terminal_list_parameter_0$schema$value ??= + z.intersection(z.string(), z.unknown())); +let _deepseek_ai_dsh_api_terminal_controller_terminal_list_result$schema$value; +const _deepseek_ai_dsh_api_terminal_controller_terminal_list_result$schema = () => + (_deepseek_ai_dsh_api_terminal_controller_terminal_list_result$schema$value ??= z.array( + z.object({ + id: z.intersection(z.string(), z.unknown()).readonly(), + title: z.string().readonly(), + shell: z + .object({ + path: z.string().readonly(), + args: z.array(z.string()).readonly(), + name: z.string().readonly(), + }) + .readonly(), + cwd: z.string().readonly(), + cols: z.number().readonly(), + rows: z.number().readonly(), + state: z.union([z.literal("failed"), z.literal("running"), z.literal("exited")]).readonly(), + exitCode: z.union([z.literal(null), z.number()]).readonly(), + error: z.string().readonly().optional(), + controllerId: z.intersection(z.string(), z.unknown()).readonly().optional(), + }), + )); +let _deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_0$schema$value; +const _deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_0$schema = () => + (_deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_0$schema$value ??= + z.intersection(z.string(), z.unknown())); +let _deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_1$schema$value; +const _deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_1$schema = () => + (_deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_1$schema$value ??= + z.intersection(z.string(), z.unknown())); +let _deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_2$schema$value; +const _deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_2$schema = () => + (_deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_2$schema$value ??= + z.string()); +let _deepseek_ai_dsh_api_terminal_controller_terminal_rename_result$schema$value; +const _deepseek_ai_dsh_api_terminal_controller_terminal_rename_result$schema = () => + (_deepseek_ai_dsh_api_terminal_controller_terminal_rename_result$schema$value ??= z.void()); +let _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_0$schema$value; +const _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_0$schema = () => + (_deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_0$schema$value ??= + z.intersection(z.string(), z.unknown())); +let _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_1$schema$value; +const _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_1$schema = () => + (_deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_1$schema$value ??= + z.intersection(z.string(), z.unknown())); +let _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_2$schema$value; +const _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_2$schema = () => + (_deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_2$schema$value ??= + z.intersection(z.string(), z.unknown())); +let _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_3$schema$value; +const _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_3$schema = () => + (_deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_3$schema$value ??= + z.number()); +let _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_4$schema$value; +const _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_4$schema = () => + (_deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_4$schema$value ??= + z.number()); +let _deepseek_ai_dsh_api_terminal_controller_terminal_resize_result$schema$value; +const _deepseek_ai_dsh_api_terminal_controller_terminal_resize_result$schema = () => + (_deepseek_ai_dsh_api_terminal_controller_terminal_resize_result$schema$value ??= z.void()); +let _deepseek_ai_dsh_api_terminal_controller_terminal_retain_parameter_0$schema$value; +const _deepseek_ai_dsh_api_terminal_controller_terminal_retain_parameter_0$schema = () => + (_deepseek_ai_dsh_api_terminal_controller_terminal_retain_parameter_0$schema$value ??= + z.intersection(z.string(), z.unknown())); +let _deepseek_ai_dsh_api_terminal_controller_terminal_retain_parameter_1$schema$value; +const _deepseek_ai_dsh_api_terminal_controller_terminal_retain_parameter_1$schema = () => + (_deepseek_ai_dsh_api_terminal_controller_terminal_retain_parameter_1$schema$value ??= + z.intersection(z.string(), z.unknown())); +let _deepseek_ai_dsh_api_terminal_controller_terminal_retain_result$schema$value; +const _deepseek_ai_dsh_api_terminal_controller_terminal_retain_result$schema = () => + (_deepseek_ai_dsh_api_terminal_controller_terminal_retain_result$schema$value ??= z.object({ + type: z.literal("retained").readonly(), + })); +let _deepseek_ai_dsh_api_terminal_controller_terminal_shells_parameter_0$schema$value; +const _deepseek_ai_dsh_api_terminal_controller_terminal_shells_parameter_0$schema = () => + (_deepseek_ai_dsh_api_terminal_controller_terminal_shells_parameter_0$schema$value ??= + z.intersection(z.string(), z.unknown())); +let _deepseek_ai_dsh_api_terminal_controller_terminal_shells_result$schema$value; +const _deepseek_ai_dsh_api_terminal_controller_terminal_shells_result$schema = () => + (_deepseek_ai_dsh_api_terminal_controller_terminal_shells_result$schema$value ??= z.array( + z.object({ + path: z.string().readonly(), + args: z.array(z.string()).readonly(), + name: z.string().readonly(), + }), + )); +let _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_0$schema$value; +const _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_0$schema = () => + (_deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_0$schema$value ??= + z.intersection(z.string(), z.unknown())); +let _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_1$schema$value; +const _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_1$schema = () => + (_deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_1$schema$value ??= + z.intersection(z.string(), z.unknown())); +let _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_2$schema$value; +const _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_2$schema = () => + (_deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_2$schema$value ??= + z.intersection(z.string(), z.unknown())); +let _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_3$schema$value; +const _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_3$schema = () => + (_deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_3$schema$value ??= z.string()); +let _deepseek_ai_dsh_api_terminal_controller_terminal_write_result$schema$value; +const _deepseek_ai_dsh_api_terminal_controller_terminal_write_result$schema = () => + (_deepseek_ai_dsh_api_terminal_controller_terminal_write_result$schema$value ??= z.void()); + +export const TYPERT = { + package: "@deepseek-ai/dsh-api-terminal-controller", + face: "host", + schemas: [], + invocations: [ + { + id: "@deepseek-ai/dsh-api-terminal-controller#terminal/close", + service: "terminalController", + namespace: "terminal", + method: "close", + invocation: { kind: "direct" }, + scope: { + context: "agent", + wire: "agentId", + }, + parameters: [ + { + name: "agent", + wire: "agentId", + source: "lookup", + lookup: "agent", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-session/types#SessionId", + create: _deepseek_ai_dsh_api_terminal_controller_terminal_close_parameter_0$schema, + }, + }, + { + name: "id", + wire: "id", + source: "json", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-terminal-controller/types#WebTerminalId", + create: _deepseek_ai_dsh_api_terminal_controller_terminal_close_parameter_1$schema, + }, + }, + ], + result: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-terminal-controller#terminal/close:result", + create: _deepseek_ai_dsh_api_terminal_controller_terminal_close_result$schema, + }, + sourceLocation: { + file: "packages/api/terminal-controller/src/index.ts", + line: 269, + column: 9, + }, + }, + { + id: "@deepseek-ai/dsh-api-terminal-controller#terminal/create", + service: "terminalController", + namespace: "terminal", + method: "create", + invocation: { kind: "direct" }, + scope: { + context: "agent", + wire: "agentId", + }, + parameters: [ + { + name: "agent", + wire: "agentId", + source: "lookup", + lookup: "agent", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-session/types#SessionId", + create: _deepseek_ai_dsh_api_terminal_controller_terminal_create_parameter_0$schema, + }, + }, + { + name: "request", + wire: "request", + source: "json", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-terminal-controller/types#TerminalCreateRequest", + create: _deepseek_ai_dsh_api_terminal_controller_terminal_create_parameter_1$schema, + }, + }, + ], + cancellation: { parameter: "signal" }, + result: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-terminal-controller/types#WebTerminalInfo", + create: _deepseek_ai_dsh_api_terminal_controller_terminal_create_result$schema, + }, + sourceLocation: { + file: "packages/api/terminal-controller/src/index.ts", + line: 158, + column: 9, + }, + }, + { + id: "@deepseek-ai/dsh-api-terminal-controller#terminal/environment", + service: "terminalController", + namespace: "terminal", + method: "environment", + invocation: { kind: "direct" }, + scope: { + context: "agent", + wire: "agentId", + }, + parameters: [ + { + name: "agent", + wire: "agentId", + source: "lookup", + lookup: "agent", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-session/types#SessionId", + create: + _deepseek_ai_dsh_api_terminal_controller_terminal_environment_parameter_0$schema, + }, + }, + ], + cancellation: { parameter: "signal" }, + result: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-terminal-controller/types#TerminalEnvironment", + create: _deepseek_ai_dsh_api_terminal_controller_terminal_environment_result$schema, + }, + sourceLocation: { + file: "packages/api/terminal-controller/src/index.ts", + line: 118, + column: 3, + }, + }, + { + id: "@deepseek-ai/dsh-api-terminal-controller#terminal/follow", + service: "terminalController", + namespace: "terminal", + method: "follow", + mode: "stream", + invocation: { kind: "direct" }, + scope: { + context: "agent", + wire: "agentId", + }, + parameters: [ + { + name: "agent", + wire: "agentId", + source: "lookup", + lookup: "agent", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-session/types#SessionId", + create: _deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_0$schema, + }, + }, + { + name: "id", + wire: "id", + source: "json", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-terminal-controller/types#WebTerminalId", + create: _deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_1$schema, + }, + }, + { + name: "attachmentId", + wire: "attachmentId", + source: "json", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-terminal-controller/types#TerminalAttachmentId", + create: _deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_2$schema, + }, + }, + ], + cancellation: { parameter: "signal" }, + result: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-terminal-controller/types#TerminalFrame", + create: _deepseek_ai_dsh_api_terminal_controller_terminal_follow_result$schema, + }, + sourceLocation: { + file: "packages/api/terminal-controller/src/index.ts", + line: 216, + column: 3, + }, + }, + { + id: "@deepseek-ai/dsh-api-terminal-controller#terminal/list", + service: "terminalController", + namespace: "terminal", + method: "list", + invocation: { kind: "direct" }, + parameters: [ + { + name: "sessionId", + wire: "sessionId", + source: "json", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-session/types#SessionId", + create: _deepseek_ai_dsh_api_terminal_controller_terminal_list_parameter_0$schema, + }, + }, + ], + result: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-terminal-controller#terminal/list:result", + create: _deepseek_ai_dsh_api_terminal_controller_terminal_list_result$schema, + }, + sourceLocation: { + file: "packages/api/terminal-controller/src/index.ts", + line: 144, + column: 3, + }, + }, + { + id: "@deepseek-ai/dsh-api-terminal-controller#terminal/rename", + service: "terminalController", + namespace: "terminal", + method: "rename", + invocation: { kind: "direct" }, + scope: { + context: "agent", + wire: "agentId", + }, + parameters: [ + { + name: "agent", + wire: "agentId", + source: "lookup", + lookup: "agent", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-session/types#SessionId", + create: _deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_0$schema, + }, + }, + { + name: "id", + wire: "id", + source: "json", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-terminal-controller/types#WebTerminalId", + create: _deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_1$schema, + }, + }, + { + name: "title", + wire: "title", + source: "json", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-terminal-controller#terminal/rename:title", + create: _deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_2$schema, + }, + }, + ], + result: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-terminal-controller#terminal/rename:result", + create: _deepseek_ai_dsh_api_terminal_controller_terminal_rename_result$schema, + }, + sourceLocation: { + file: "packages/api/terminal-controller/src/index.ts", + line: 257, + column: 3, + }, + }, + { + id: "@deepseek-ai/dsh-api-terminal-controller#terminal/resize", + service: "terminalController", + namespace: "terminal", + method: "resize", + invocation: { kind: "direct" }, + scope: { + context: "agent", + wire: "agentId", + }, + parameters: [ + { + name: "agent", + wire: "agentId", + source: "lookup", + lookup: "agent", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-session/types#SessionId", + create: _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_0$schema, + }, + }, + { + name: "id", + wire: "id", + source: "json", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-terminal-controller/types#WebTerminalId", + create: _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_1$schema, + }, + }, + { + name: "attachmentId", + wire: "attachmentId", + source: "json", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-terminal-controller/types#TerminalAttachmentId", + create: _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_2$schema, + }, + }, + { + name: "cols", + wire: "cols", + source: "json", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-terminal-controller#terminal/resize:cols", + create: _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_3$schema, + }, + }, + { + name: "rows", + wire: "rows", + source: "json", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-terminal-controller#terminal/resize:rows", + create: _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_4$schema, + }, + }, + ], + result: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-terminal-controller#terminal/resize:result", + create: _deepseek_ai_dsh_api_terminal_controller_terminal_resize_result$schema, + }, + sourceLocation: { + file: "packages/api/terminal-controller/src/index.ts", + line: 245, + column: 9, + }, + }, + { + id: "@deepseek-ai/dsh-api-terminal-controller#terminal/retain", + service: "terminalController", + namespace: "terminal", + method: "retain", + mode: "stream", + invocation: { kind: "direct" }, + parameters: [ + { + name: "sessionId", + wire: "sessionId", + source: "json", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-session/types#SessionId", + create: _deepseek_ai_dsh_api_terminal_controller_terminal_retain_parameter_0$schema, + }, + }, + { + name: "id", + wire: "id", + source: "json", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-terminal-controller/types#WebTerminalId", + create: _deepseek_ai_dsh_api_terminal_controller_terminal_retain_parameter_1$schema, + }, + }, + ], + cancellation: { parameter: "signal" }, + result: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-terminal-controller/types#TerminalRetentionFrame", + create: _deepseek_ai_dsh_api_terminal_controller_terminal_retain_result$schema, + }, + sourceLocation: { + file: "packages/api/terminal-controller/src/index.ts", + line: 198, + column: 3, + }, + }, + { + id: "@deepseek-ai/dsh-api-terminal-controller#terminal/shells", + service: "terminalController", + namespace: "terminal", + method: "shells", + invocation: { kind: "direct" }, + scope: { + context: "agent", + wire: "agentId", + }, + parameters: [ + { + name: "agent", + wire: "agentId", + source: "lookup", + lookup: "agent", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-session/types#SessionId", + create: _deepseek_ai_dsh_api_terminal_controller_terminal_shells_parameter_0$schema, + }, + }, + ], + cancellation: { parameter: "signal" }, + result: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-terminal-controller#terminal/shells:result", + create: _deepseek_ai_dsh_api_terminal_controller_terminal_shells_result$schema, + }, + sourceLocation: { + file: "packages/api/terminal-controller/src/index.ts", + line: 133, + column: 3, + }, + }, + { + id: "@deepseek-ai/dsh-api-terminal-controller#terminal/write", + service: "terminalController", + namespace: "terminal", + method: "write", + invocation: { kind: "direct" }, + scope: { + context: "agent", + wire: "agentId", + }, + parameters: [ + { + name: "agent", + wire: "agentId", + source: "lookup", + lookup: "agent", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-session/types#SessionId", + create: _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_0$schema, + }, + }, + { + name: "id", + wire: "id", + source: "json", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-terminal-controller/types#WebTerminalId", + create: _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_1$schema, + }, + }, + { + name: "attachmentId", + wire: "attachmentId", + source: "json", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-terminal-controller/types#TerminalAttachmentId", + create: _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_2$schema, + }, + }, + { + name: "data", + wire: "data", + source: "json", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-terminal-controller#terminal/write:data", + create: _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_3$schema, + }, + }, + ], + result: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-terminal-controller#terminal/write:result", + create: _deepseek_ai_dsh_api_terminal_controller_terminal_write_result$schema, + }, + sourceLocation: { + file: "packages/api/terminal-controller/src/index.ts", + line: 230, + column: 9, + }, + }, + ], + model: { + services: [ + { + description: "Typed Remote control of transient Session-owned terminal processes.", + summary: "Typed Remote control of transient Session-owned terminal processes.", + tags: [], + jsDoc: "/** Typed Remote control of transient Session-owned terminal processes. */", + key: "terminalController", + exportName: "TerminalController", + members: [ + { + kind: "method", + name: "environment", + signature: + "@Remote environment(agent: Agent, signal: AbortSignal): TerminalEnvironment", + summary: + "Read the Session working directory and terminal limits without resolving a shell.", + jsDoc: + "/**\n * Read the Session working directory and terminal limits without resolving a shell.\n * @param agent - Session owner supplied by the Gateway.\n * @param signal - request cancellation.\n * @returns the Session workspace directory and terminal limits.\n */", + }, + { + kind: "method", + name: "shells", + signature: + "@Remote shells(agent: Agent, signal: AbortSignal): Promise", + summary: "Discover installed shells in the Session's execution environment.", + jsDoc: + "/**\n * Discover installed shells in the Session's execution environment.\n * @param agent - Session owner supplied by the Gateway.\n * @param signal - request cancellation.\n * @returns verified profiles, with the configured or system default first.\n */", + }, + { + kind: "method", + name: "list", + signature: "@Remote list(sessionId: SessionId): WebTerminalInfo[]", + summary: "List retained terminals without resolving or activating an Agent.", + jsDoc: + "/**\n * List retained terminals without resolving or activating an Agent.\n * @param sessionId - displayed Session identity, including offline history.\n * @returns terminals retained for this Host lifetime.\n */", + }, + { + kind: "method", + name: "create", + signature: + "@Remote async create(agent: Agent, request: TerminalCreateRequest, signal: AbortSignal): Promise", + summary: + "Allocate a user shell once for a caller-generated identity, without Agent sandbox or approval restrictions.", + jsDoc: + "/**\n * Allocate a user shell once for a caller-generated identity, without Agent sandbox or approval restrictions.\n * @param agent - Session owner supplied by the Gateway.\n * @param request - initial dimensions and idempotency identity.\n * @param signal - allocation cancellation; committed terminals survive disconnection.\n * @returns the existing or newly committed terminal.\n */", + }, + { + kind: "method", + name: "retain", + signature: + "@Remote({ mode: 'stream' }) retain(sessionId: SessionId, id: WebTerminalId, signal: AbortSignal): AsyncIterable", + summary: + "Retain an existing terminal for a window without activating its Agent or taking input control.", + jsDoc: + "/**\n * Retain an existing terminal for a window without activating its Agent or taking input control.\n * @param sessionId - owning Session identity, including an inactive saved layout.\n * @param id - retained Host terminal identity.\n * @param signal - physical Remote stream cancellation.\n * @returns a hold acknowledgement followed by an open lifetime stream.\n */", + }, + { + kind: "method", + name: "follow", + signature: + "@Remote({ mode: 'stream' }) follow(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, signal: AbortSignal): AsyncIterable", + summary: "Attach to a terminal without binding its process lifetime to the transport.", + jsDoc: + "/**\n * Attach to a terminal without binding its process lifetime to the transport.\n * @param agent - Session owner supplied by the Gateway.\n * @param id - terminal identity.\n * @param attachmentId - new exclusive input attachment.\n * @param signal - physical stream cancellation.\n * @returns screen recovery followed by output and metadata changes.\n */", + }, + { + kind: "method", + name: "write", + signature: + "@Remote async write(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, data: string): Promise", + summary: "Deliver raw input, including Tab completion and control characters.", + jsDoc: + "/**\n * Deliver raw input, including Tab completion and control characters.\n * @param agent - Session owner supplied by the Gateway.\n * @param id - terminal identity.\n * @param attachmentId - current writable attachment.\n * @param data - input bytes represented as UTF-8 text.\n * @returns after provider input acceptance.\n */", + }, + { + kind: "method", + name: "resize", + signature: + "@Remote async resize(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, cols: number, rows: number): Promise", + summary: "Update the dimensions of the PTY and recovery screen.", + jsDoc: + "/**\n * Update the dimensions of the PTY and recovery screen.\n * @param agent - Session owner supplied by the Gateway.\n * @param id - terminal identity.\n * @param attachmentId - current writable attachment.\n * @param cols - column count.\n * @param rows - row count.\n * @returns after the resize completes.\n */", + }, + { + kind: "method", + name: "rename", + signature: "@Remote rename(agent: Agent, id: WebTerminalId, title: string): void", + summary: "Rename a terminal without changing its shell.", + jsDoc: + "/**\n * Rename a terminal without changing its shell.\n * @param agent - Session owner supplied by the Gateway.\n * @param id - terminal identity.\n * @param title - nonempty display title, at most 120 characters.\n */", + }, + { + kind: "method", + name: "close", + signature: "@Remote async close(agent: Agent, id: WebTerminalId): Promise", + summary: + "Close an identity to future creation and kill its process range; repeated closes succeed.", + jsDoc: + "/**\n * Close an identity to future creation and kill its process range; repeated closes succeed.\n * @param agent - Session owner supplied by the Gateway.\n * @param id - terminal identity.\n * @returns after provider cleanup succeeds. A failure retains the terminal for retry.\n */", + }, + ], + types: [ + { + name: "AfterScheduleRecord", + declaration: + "export interface AfterScheduleRecord {\n readonly id: ScheduleId;\n readonly kind: 'after';\n readonly title: string;\n readonly prompt: string;\n readonly afterSeconds: number;\n readonly scheduledAt: string;\n}", + }, + { + name: "Agent", + declaration: + "export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n}", + }, + { + name: "AgentCancelCause", + declaration: + "export type AgentCancelCause = { readonly kind: 'user'; } | { readonly kind: 'parent'; } | { readonly kind: 'hook'; readonly reason: string; } | { readonly kind: 'disposed'; };", + }, + { + name: "AgentMessageSource", + declaration: + "export interface AgentMessageSource {\n readonly kind: 'agent-message';\n readonly form: 'relay';\n readonly senderSessionId: SessionId;\n}", + }, + { + name: "AgentOptions", + declaration: + "export interface AgentOptions {\n provider?: string;\n model?: string;\n reasoningEffort?: ReasoningEffortId;\n maxTokens?: number;\n subagentDepth?: number;\n}", + }, + { + name: "AgentStatus", + declaration: "export type AgentStatus = 'idle' | 'running';", + }, + { + name: "ApprovalOutcome", + declaration: + "export type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable';", + }, + { + name: "ApprovalPolicy", + declaration: "export type ApprovalPolicy = 'ask' | 'never';", + }, + { + name: "ApprovalRequestId", + declaration: "export type ApprovalRequestId = Branded<'ApprovalRequestId'>;", + }, + { + name: "AssistantMessage", + declaration: + "export interface AssistantMessage extends MessageBase {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n}", + }, + { + name: "AssistantProviderMetadata", + declaration: + "export interface AssistantProviderMetadata {\n provider: string;\n model: string;\n replayState?: unknown;\n}", + }, + { + name: "AssistantStreamRecord", + declaration: + "export type AssistantStreamRecord = { readonly type: 'text-chunks'; readonly time0: number; readonly index: number; readonly dt: readonly number[]; readonly texts: readonly string[]; } | { readonly type: 'reasoning-chunks'; readonly time0: number; readonly index: number; readonly dt: readonly number[]; readonly texts: readonly string[]; } | { readonly type: 'tool-call-chunks'; readonly time0: number; readonly index: number; readonly dt: readonly number[]; readonly id: ToolCallId; readonly name?: string; readonly args: readonly string[]; } | { readonly type: 'chunk'; readonly time: number; readonly chunk: StreamChunk; };", + }, + { + name: "AtScheduleRecord", + declaration: + "export interface AtScheduleRecord {\n readonly id: ScheduleId;\n readonly kind: 'at';\n readonly title: string;\n readonly prompt: string;\n readonly scheduledAt: string;\n}", + }, + { + name: "AttachmentId", + declaration: "export type AttachmentId = Branded<'AttachmentId'>;", + }, + { + name: "Branded", + declaration: + "export type Branded = string & { readonly [BRAND]: B; };", + }, + { + name: "BrandedNumber", + declaration: + "export type BrandedNumber = number & { readonly [BRAND]: B; };", + }, + { + name: "CancelOptions", + declaration: + "export interface CancelOptions {\n keepInbox?: boolean | undefined;\n}", + }, + { + name: "CommandId", + declaration: "export type CommandId = Branded<'CommandId'>;", + }, + { + name: "CommandSource", + declaration: "export type CommandSource = CommandSourceMap[keyof CommandSourceMap];", + }, + { + name: "CommandSourceMap", + declaration: "export interface CommandSourceMap {\n user: { kind: 'user'; };\n}", + }, + { + name: "CompactionCheckpointSource", + declaration: + "export type CompactionCheckpointSource = typeof COMPACT_CHECKPOINT_MARKER & { readonly compactionId: CompactionId; readonly sourceCommandId?: CommandId; };", + }, + { + name: "CompactionId", + declaration: "export type CompactionId = Branded<'CompactionId'>;", + }, + { + name: "ContentBlock", + declaration: "export type ContentBlock = ContentBlockMap[ContentBlockType];", + }, + { + name: "ContentBlockMap", + declaration: + "export interface ContentBlockMap {\n text: TextBlock;\n reasoning: ReasoningBlock;\n image: ImageBlock;\n file: FileBlock;\n 'tool-call': ToolCallBlock;\n 'tool-addition': ToolAdditionBlock;\n 'tool-removal': ToolRemovalBlock;\n}", + }, + { + name: "ContentBlockType", + declaration: "export type ContentBlockType = keyof ContentBlockMap;", + }, + { + name: "ContextFormed", + declaration: + "export type ContextFormed = { readonly form?: never; } | { readonly form: 'instructions'; } | { readonly form: 'catalog'; } | { readonly form: 'snapshot'; readonly sections: readonly ContextSnapshotSection[]; } | { readonly form: 'notice'; readonly summary: string; } | { readonly form: 'relay'; } | { readonly form: 'recall'; };", + }, + { + name: "ContextSnapshotSection", + declaration: + "export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n}", + }, + { + name: "ContinuableSubagentDescriptorData", + declaration: + "export interface ContinuableSubagentDescriptorData extends SubagentDescriptorBase {\n readonly mode: 'continuable';\n readonly label: string;\n readonly agentProvider?: string;\n readonly agentModel?: string;\n readonly agentReasoningEffort?: ReasoningEffortId;\n readonly persona?: string;\n readonly toolFilter?: ToolRestriction;\n}", + }, + { + name: "DeveloperMessage", + declaration: + "export interface DeveloperMessage extends MessageBase {\n readonly role: 'developer';\n}", + }, + { + name: "EpochHeader", + declaration: + "export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n tools?: ToolSchema[];\n system?: never;\n}", + }, + { + name: "EveryScheduleDispatchChange", + declaration: + "export interface EveryScheduleDispatchChange {\n readonly version: 1;\n readonly operation: 'dispatch';\n readonly id: ScheduleId;\n readonly acceptedAt: string;\n}", + }, + { + name: "EveryScheduleRecord", + declaration: + "export interface EveryScheduleRecord {\n readonly id: ScheduleId;\n readonly kind: 'every';\n readonly title: string;\n readonly prompt: string;\n readonly everySeconds: number;\n readonly scheduledAt: string;\n}", + }, + { + name: "FeedbackCategory", + declaration: + "export type FeedbackCategory = 'task-result' | 'instruction-following' | 'product-interaction' | 'service-stability' | 'resource-cost' | 'security-privacy-permission' | 'other';", + }, + { + name: "FeedbackRecord", + declaration: + "export interface FeedbackRecord {\n readonly text?: string;\n readonly category?: FeedbackCategory;\n}", + }, + { + name: "FileAttachmentRef", + declaration: + "export interface FileAttachmentRef {\n attachmentId: AttachmentId;\n name: string;\n bytes: number;\n}", + }, + { + name: "FileBlock", + declaration: + "export interface FileBlock {\n type: 'file';\n attachment: FileAttachmentRef;\n}", + }, + { + name: "FinishReason", + declaration: "export type FinishReason = FinishReasonMap[keyof FinishReasonMap];", + }, + { + name: "FinishReasonMap", + declaration: + "export interface FinishReasonMap {\n stop: { kind: 'stop'; };\n 'tool-calls': { kind: 'tool-calls'; };\n 'max-tokens': { kind: 'max-tokens'; };\n aborted: { kind: 'aborted'; failure: LlmFailure; };\n error: { kind: 'error'; failure: LlmFailure; };\n}", + }, + { + name: "GoalBlockReason", + declaration: + "export interface GoalBlockReason {\n readonly code: string;\n readonly message: string;\n}", + }, + { + name: "GoalChangeMeta", + declaration: + "export type GoalChangeMeta = GoalSnapshotChangeMeta | GoalClearChangeMeta;", + }, + { + name: "GoalClearChangeMeta", + declaration: + "export interface GoalClearChangeMeta {\n readonly kind: 'goal/change';\n readonly version: 1;\n readonly operation: 'clear';\n readonly cleared: GoalRef;\n readonly clearedAt: number;\n}", + }, + { + name: "GoalId", + declaration: "export type GoalId = Branded<'GoalId'>;", + }, + { + name: "GoalMessageSource", + declaration: + "export interface GoalMessageSource {\n readonly kind: 'goal';\n readonly goalId: GoalId;\n readonly revision: number;\n readonly round: number;\n}", + }, + { + name: "GoalOperation", + declaration: + "export type GoalOperation = 'create' | 'edit' | 'pause' | 'resume' | 'complete' | 'block' | 'clear';", + }, + { + name: "GoalPhase", + declaration: "export type GoalPhase = 'active' | 'paused' | 'blocked' | 'complete';", + }, + { + name: "GoalRef", + declaration: + "export interface GoalRef {\n readonly id: GoalId;\n readonly revision: number;\n}", + }, + { + name: "GoalSnapshot", + declaration: + "export interface GoalSnapshot extends GoalRef {\n readonly objective: string;\n readonly phase: GoalPhase;\n readonly blockedReason?: GoalBlockReason;\n readonly maxGoalRounds: number;\n}", + }, + { + name: "GoalSnapshotChangeMeta", + declaration: + "export interface GoalSnapshotChangeMeta {\n readonly kind: 'goal/change';\n readonly version: 1;\n readonly operation: Exclude;\n readonly goal: GoalSnapshot;\n readonly roundsStarted: number;\n readonly createdAt: number;\n readonly updatedAt: number;\n}", + }, + { + name: "ImageAttachmentRef", + declaration: + "export interface ImageAttachmentRef {\n attachmentId: AttachmentId;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n name?: string;\n originalDimensions?: { width: number; height: number; };\n}", + }, + { + name: "ImageBlock", + declaration: + "export interface ImageBlock {\n type: 'image';\n attachment: ImageAttachmentRef;\n offloaded?: true;\n}", + }, + { + name: "ImageMediaType", + declaration: + "export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif';", + }, + { + name: "ImageOffloadTarget", + declaration: + "export interface ImageOffloadTarget {\n seq: SessionSeq;\n imageIndexes: number[];\n}", + }, + { + name: "Inbox", + declaration: + "export interface Inbox {\n readonly nextTurn: readonly UserMessage[];\n readonly nextStep: readonly UserMessage[];\n clear(): void;\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n}", + }, + { + name: "InboxTarget", + declaration: "export type InboxTarget = 'next-turn' | 'next-step';", + }, + { + name: "JsonValue", + declaration: + "export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue; };", + }, + { + name: "KnownCatalogMode", + declaration: + "export type KnownCatalogMode = { readonly mode: 'one-shot'; readonly label?: string; } | { readonly mode: 'continuable'; readonly label: string; };", + }, + { + name: "LegacyAfterScheduleRecord", + declaration: + "export interface LegacyAfterScheduleRecord extends Omit {\n readonly title?: string;\n}", + }, + { + name: "LegacyAtScheduleRecord", + declaration: + "export interface LegacyAtScheduleRecord extends Omit {\n readonly title?: string;\n}", + }, + { + name: "LegacyEveryScheduleRecord", + declaration: + "export interface LegacyEveryScheduleRecord extends Omit {\n readonly title?: string;\n}", + }, + { + name: "LegacyScheduleRecord", + declaration: + "export type LegacyScheduleRecord = LegacyAfterScheduleRecord | LegacyAtScheduleRecord | LegacyEveryScheduleRecord;", + }, + { + name: "LlmCallConfig", + declaration: + "export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n}", + }, + { + name: "LlmCallConfigAdapterDefaults", + declaration: + "export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n}", + }, + { + name: "LlmFailure", + declaration: + "export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n readonly offloadImages?: number;\n}", + }, + { + name: "LlmRetryEventData", + declaration: + "export type LlmRetryEventData = { retryId: RetryId; turn: number; step: number; provider: string; mode: 'normal'; policyKey: string; retry: number; maxRetries: number; delayMs: number; failure: LlmFailure; } | { retryId: RetryId; turn: number; step: number; provider: string; mode: 'always'; policyKey: string; retry: number; delayMs: number; failure: LlmFailure; };", + }, + { + name: "LlmRetryStartedEventData", + declaration: + "export interface LlmRetryStartedEventData {\n retryId: RetryId;\n turn: number;\n step: number;\n retry: number;\n}", + }, + { + name: "Message", + declaration: "export type Message = MessageRoleMap[keyof MessageRoleMap];", + }, + { + name: "MessageBase", + declaration: + "export interface MessageBase {\n readonly id: MessageId;\n readonly content: readonly ContentBlock[];\n readonly source: MessageSource;\n}", + }, + { + name: "MessageFeedbackDelete", + declaration: + "export interface MessageFeedbackDelete {\n readonly sessionId: SessionId;\n readonly messageId: MessageId;\n}", + }, + { + name: "MessageFeedbackItem", + declaration: + "export interface MessageFeedbackItem {\n readonly messageId: MessageId;\n readonly rating: MessageFeedbackRating;\n readonly note?: string;\n readonly category?: FeedbackCategory;\n readonly version: MessageFeedbackVersion;\n readonly createdAt: number;\n readonly updatedAt: number;\n}", + }, + { + name: "MessageFeedbackPut", + declaration: + "export interface MessageFeedbackPut {\n readonly sessionId: SessionId;\n readonly item: MessageFeedbackItem;\n}", + }, + { + name: "MessageFeedbackRating", + declaration: "export type MessageFeedbackRating = 'positive' | 'negative';", + }, + { + name: "MessageFeedbackVersion", + declaration: "export type MessageFeedbackVersion = Branded<'MessageFeedbackVersion'>;", + }, + { + name: "MessageId", + declaration: "export type MessageId = Branded<'MessageId'>;", + }, + { + name: "MessageRoleMap", + declaration: + "export interface MessageRoleMap {\n system: SystemMessage;\n developer: DeveloperMessage;\n user: UserMessage;\n assistant: AssistantMessage;\n tool: ToolResultMessage;\n}", + }, + { + name: "MessageSource", + declaration: "export type MessageSource = MessageSourceMap[keyof MessageSourceMap];", + }, + { + name: "MessageSourceMap", + declaration: + "export interface MessageSourceMap {\n user: { kind: 'user'; };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n 'system-prompt': SystemPromptMessageSource;\n 'model-selection': { kind: 'model-selection'; } & ContextFormed;\n 'user-approval': { kind: 'user-approval'; } & ContextFormed;\n 'ptc-mode': { kind: 'ptc-mode'; };\n 'tool-registry': { kind: 'tool-registry'; };\n 'user-rpc': { kind: 'user'; rpcId: SessionRequestId; clientTimeZone?: string; };\n 'agent-message': AgentMessageSource;\n 'subagent-settled': SubagentSettledMessageSource;\n 'skill-invocation': SkillInvocationSource;\n 'cordis-host-runner': { kind: 'cordis-host-runner'; };\n 'compact-checkpoint': CompactionCheckpointSource;\n 'user-question-reply': { kind: 'user-question-reply'; callId: ToolCallId; outcome: 'answered'; };\n goal: GoalMessageSource;\n schedule: { kind: 'schedule'; } & ContextFormed;\n 'session-reference': SessionReferenceSource;\n}", + }, + { + name: "ModelMessageSource", + declaration: + "export interface ModelMessageSource extends AssistantProviderMetadata {\n kind: 'model';\n}", + }, + { + name: "ModelSelection", + declaration: + "export interface ModelSelection {\n readonly provider: string;\n readonly model: string;\n readonly reasoningEffort?: string;\n}", + }, + { + name: "OneShotScheduleDispatchChange", + declaration: + "export interface OneShotScheduleDispatchChange {\n readonly version: 1;\n readonly operation: 'dispatch';\n readonly id: ScheduleId;\n}", + }, + { + name: "OneShotSubagentDescriptorData", + declaration: + "export interface OneShotSubagentDescriptorData extends SubagentDescriptorBase {\n readonly mode: 'one-shot';\n readonly label?: string;\n}", + }, + { + name: "OptionalSessionSeq", + declaration: "export type OptionalSessionSeq = SessionSeq | null;", + }, + { + name: "ProviderRequestId", + declaration: "export type ProviderRequestId = Branded<'ProviderRequestId'>;", + }, + { + name: "PtcDispatchEventData", + declaration: + "export interface PtcDispatchEventData extends PtcDispatchStartEventData {\n isError: boolean;\n content: ContentBlock[];\n error?: { name: string; code: string; reason?: string; };\n}", + }, + { + name: "PtcDispatchStartEventData", + declaration: + "export interface PtcDispatchStartEventData {\n rootCallId: ToolCallId;\n parentCallId: ToolCallId;\n subCallId: ToolCallId;\n name: string;\n arguments: unknown;\n}", + }, + { + name: "ReasoningBlock", + declaration: + "export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n}", + }, + { + name: "ReasoningEffortId", + declaration: "export type ReasoningEffortId = Branded<'ReasoningEffortId'>;", + }, + { + name: "ReplayEnvelope", + declaration: + "export interface ReplayEnvelope {\n response: unknown;\n blocks?: readonly unknown[];\n}", + }, + { + name: "RequestContext", + declaration: + "export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n systemPromptUpdate?: SystemPromptUpdate;\n}", + }, + { + name: "RequestHeaderReason", + declaration: + "export type RequestHeaderReason = 'initial' | 'resume' | 'change' | 'series';", + }, + { + name: "RetryId", + declaration: "export type RetryId = Branded<'RetryId'>;", + }, + { + name: "SandboxMode", + declaration: + "export type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access';", + }, + { + name: "ScheduleChange", + declaration: + "export type ScheduleChange = ScheduleCreateChange | ScheduleDeleteChange | ScheduleDispatchChange;", + }, + { + name: "ScheduleCreateChange", + declaration: + "export interface ScheduleCreateChange {\n readonly version: 1;\n readonly operation: 'create';\n readonly schedule: LegacyScheduleRecord;\n}", + }, + { + name: "ScheduleDeleteChange", + declaration: + "export interface ScheduleDeleteChange {\n readonly version: 1;\n readonly operation: 'delete';\n readonly id: ScheduleId;\n}", + }, + { + name: "ScheduleDispatchChange", + declaration: + "export type ScheduleDispatchChange = OneShotScheduleDispatchChange | EveryScheduleDispatchChange;", + }, + { + name: "ScheduleId", + declaration: "export type ScheduleId = Branded<'ScheduleId'>;", + }, + { + name: "Session", + declaration: + "export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n readonly inheritedEventCount: SessionLogOffset;\n get id(): SessionId;\n readonly firstLiveSeq: SessionLogOffset;\n readonly firstLifecycleSeq: SessionLogOffset;\n eventAt(seq: SessionSeq): SessionEvent | undefined;\n snapshotEvents(fromSeq: SessionLogOffset = SessionLogOffset(0), toSeqExclusive: SessionLogOffset = this.seq): readonly SessionEvent[];\n ownEvents(): readonly SessionEvent[];\n isOwnSeq(seq: SessionSeq): boolean;\n get seq(): SessionLogOffset;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : []): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n toolHistory(): ToolHistory;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}", + }, + { + name: "SessionEvent", + declaration: + "export type SessionEvent = { [K in SessionEventType]: { type: K; seq: SessionSeq; time: number; data: SessionEventMap[K]; ignorable?: true; } & (K extends SurfaceEventType ? SurfaceIntent : { surfaceOp?: never; sourceEventSeqs?: never; }) }[T];", + }, + { + name: "SessionEventMap", + declaration: + "export interface SessionEventMap {\n 'turn/start': { turn: number; };\n 'turn/end': { turn: number; reason: TurnEndReason; };\n 'step/start': { turn: number; step: number; };\n 'step/end': { turn: number; step: number; };\n 'user/message': UserMessage;\n 'developer/message': { turn: number; step: number; message: DeveloperMessage; headerSeq?: SessionSeq; };\n 'system/message': { turn: number; step: number; message: SystemMessage; };\n 'assistant/message': { turn: number; step: number; message: AssistantMessage; stream: AssistantStreamRecord[]; usage?: TokenUsage; interrupted?: true; };\n 'assistant/attempt': { turn: number; step: number; stream: AssistantStreamRecord[]; };\n 'tool/call': { turn: number; step: number; callId: ToolCallId; name: string; arguments: string; };\n 'tool/result': { turn: number; step: number; message: ToolResultMessage; error?: { name: string; code: string; reason?: string; }; meta?: JsonValue; };\n 'request/header': { header: EpochHeader; reason: RequestHeaderReason; startsSeries?: true; };\n 'request/context': RequestContext;\n 'session/end-seed': { inherited?: true; };\n 'agent/inbox/spliced': { target: InboxTarget; start: number; removedCount?: number; inserted: UserMessage[]; outcome?: 'canceled'; };\n 'sandbox/mode': { mode: SandboxMode; source?: 'delegation'; };\n 'approval/asked': { id: ApprovalRequestId; toolName: string; callId?: ToolCallId; reason?: string; };\n 'approval/decided': { id: ApprovalRequestId; outcome: ApprovalOutcome; };\n 'approval/policy': { policy: ApprovalPolicy; source?: 'delegation'; };\n 'tool/ptc-dispatch-start': PtcDispatchStartEventData;\n 'tool/ptc-dispatch': PtcDispatchEventData;\n 'agent-preset/selected': { agentPreset: string; };\n 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource; };\n 'command/done': { commandId: CommandId; kind: 'success' | 'error'; text?: string; sourceEventSeq?: import('@deepseek-ai/dsh-session/types').SessionSeq; };\n 'image/offload': { targets: ImageOffloadTarget[]; };\n 'session/title': SessionTitleEventData;\n 'todo/write': { todos: TodoItem[]; };\n 'model/selection': ModelSelection;\n 'subagent/descriptor': SubagentDescriptorData;\n 'permission/preset': { preset: string; };\n 'subagent/catalog': SubagentCatalogEvent;\n 'compaction/start': { compactionId: CompactionId; sourceCommandId?: CommandId; turn: number | null; };\n 'compaction/summary': { compactionId: CompactionId; sourceCommandId?: CommandId; summary: ContentBlock[]; shadowedRange: { start: SessionSeq; end: SessionSeq; }; shadowedSeqs: SessionSeq[]; shadowedTokenCount: number; provider: string; model: string; maxTokens?: number; usage?: TokenUsage; } & ({ rawOutput: ContentBlock[]; llmStreamCall: true; } | { rawOutput?: ContentBlock[]; llmStreamCall?: never; });\n 'compaction/end': { compactionId: CompactionId; sourceCommandId?: CommandId; turn: number | null; error?: string; };\n 'compaction/prune': { shadowedRange: { start: SessionSeq; end: SessionSeq; }; shadowedSeqs: SessionSeq[]; shadowedTokenCount: number; };\n 'feedback/record': FeedbackRecord;\n 'llm/retry': LlmRetryEventData;\n 'llm/retry-started': LlmRetryStartedEventData;\n 'goal/change': GoalChangeMeta;\n 'feedback/message-put': MessageFeedbackPut;\n 'feedback/message-delete': MessageFeedbackDelete;\n 'schedule/change': ScheduleChange;\n}", + }, + { + name: "SessionEventType", + declaration: "export type SessionEventType = keyof SessionEventMap;", + }, + { + name: "SessionHeader", + declaration: + "export interface SessionHeader {\n readonly version: typeof SESSION_FORMAT_VERSION;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly isSeeded: boolean;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n}", + }, + { + name: "SessionId", + declaration: "export type SessionId = Branded<'SessionId'>;", + }, + { + name: "SessionLogOffset", + declaration: "export type SessionLogOffset = BrandedNumber<'SessionLogOffset'>;", + }, + { + name: "SessionReferenceSource", + declaration: + "export interface SessionReferenceSource {\n kind: 'session-reference';\n form: 'recall';\n version: 1;\n references: { sessionId: string; label: string; capturedFormatVersion?: number; capturedThroughSeq: OptionalSessionSeq; compacted: boolean; originalMessages: number; retainedMessages: number; omittedMessages: number; omittedBytes: number; truncated: boolean; inputIndex: number; }[];\n}", + }, + { + name: "SessionRequestId", + declaration: "export type SessionRequestId = Branded<'session-request-id'>;", + }, + { + name: "SessionSeq", + declaration: "export type SessionSeq = BrandedNumber<'SessionSeq'>;", + }, + { + name: "SessionSurface", + declaration: + "export interface SessionSurface {\n readonly nodes: readonly SessionSeq[];\n readonly replaceGeneration: number;\n readonly contentGeneration: number;\n}", + }, + { + name: "SessionTitleEventData", + declaration: + "export interface SessionTitleEventData {\n readonly title: string;\n readonly messageSeqs: SessionSeq[];\n readonly source: SessionTitleSource;\n}", + }, + { + name: "SessionTitleModelIdentity", + declaration: + "export interface SessionTitleModelIdentity {\n readonly provider: string;\n readonly model: string;\n}", + }, + { + name: "SessionTitleProviderId", + declaration: "export type SessionTitleProviderId = Branded<'SessionTitleProviderId'>;", + }, + { + name: "SessionTitleSource", + declaration: + "export type SessionTitleSource = { readonly kind: 'fallback'; } | { readonly kind: 'provider'; readonly provider: SessionTitleProviderId; readonly model?: SessionTitleModelIdentity; } | { readonly kind: 'user'; };", + }, + { + name: "SkillInvocationSource", + declaration: + "export interface SkillInvocationSource {\n readonly kind: 'skill-invocation';\n readonly name: string;\n readonly form: 'instructions';\n}", + }, + { + name: "StreamChunk", + declaration: + "export type StreamChunk = { type: 'block-start'; index: number; blockType: ContentBlockType; } | { type: 'text-delta'; index: number; text: string; } | { type: 'reasoning-delta'; index: number; text: string; } | { type: 'tool-call-delta'; index: number; id: ToolCallId; name?: string; argumentsDelta: string; } | { type: 'block-end'; index: number; block: ContentBlock; } | { type: 'usage'; usage: TokenUsage; } | { type: 'finish'; reason: FinishReason; replayState?: ReplayEnvelope; };", + }, + { + name: "SubagentCatalogEvent", + declaration: + "export type SubagentCatalogEvent = { readonly childId: SessionId; readonly childCreatedAt: number; } & (({ readonly version: 0; } & KnownCatalogMode) | ({ readonly version: 1; } & (KnownCatalogMode | { readonly mode: 'unknown'; readonly label?: string; })));", + }, + { + name: "SubagentDescriptorBase", + declaration: + "export interface SubagentDescriptorBase {\n readonly version: number;\n readonly mode: 'one-shot' | 'continuable';\n readonly provider: string;\n}", + }, + { + name: "SubagentDescriptorData", + declaration: + "export type SubagentDescriptorData = OneShotSubagentDescriptorData | ContinuableSubagentDescriptorData;", + }, + { + name: "SubagentSettledMessageSource", + declaration: + "export interface SubagentSettledMessageSource {\n readonly kind: 'subagent-settled';\n readonly form: 'notice';\n readonly summary: string;\n readonly senderSessionId: SessionId;\n}", + }, + { + name: "SurfaceEventType", + declaration: + "export type SurfaceEventType = 'system/message' | 'developer/message' | 'user/message' | 'assistant/message' | 'tool/result';", + }, + { + name: "SurfaceIntent", + declaration: + "export type SurfaceIntent = { surfaceOp: SurfaceOp; } & (T extends 'assistant/message' ? { sourceEventSeqs?: never; } : { sourceEventSeqs?: SessionSeq[]; });", + }, + { + name: "SurfaceOp", + declaration: + "export type SurfaceOp = 'append' | { op: 'replace'; startSeq: SessionSeq; endSeq: SessionSeq; };", + }, + { + name: "SystemMessage", + declaration: + "export interface SystemMessage extends MessageBase {\n readonly role: 'system';\n readonly source: MessageSourceMap['system-prompt'];\n}", + }, + { + name: "SystemPromptMessageSource", + declaration: + "export interface SystemPromptMessageSource {\n kind: 'system-prompt';\n}", + }, + { + name: "SystemPromptUpdate", + declaration: "export type SystemPromptUpdate = 'in-history';", + }, + { + name: "TerminalAttachmentId", + declaration: "export type TerminalAttachmentId = Branded<'TerminalAttachmentId'>;", + }, + { + name: "TerminalCreateRequest", + declaration: + "export interface TerminalCreateRequest {\n readonly shellPath?: string;\n readonly id: WebTerminalId;\n readonly cols: number;\n readonly rows: number;\n}", + }, + { + name: "TerminalEnvironment", + declaration: + "export interface TerminalEnvironment {\n readonly cwd: string;\n readonly maxInputBytes: number;\n readonly maxCols: number;\n readonly maxRows: number;\n readonly scrollback: number;\n}", + }, + { + name: "TerminalFrame", + declaration: + "export type TerminalFrame = { readonly type: 'snapshot'; readonly sequence: number; readonly screen: string; readonly info: WebTerminalInfo; } | { readonly type: 'output'; readonly sequence: number; readonly data: string; } | { readonly type: 'state'; readonly info: WebTerminalInfo; };", + }, + { + name: "TerminalRetentionFrame", + declaration: + "export interface TerminalRetentionFrame {\n readonly type: 'retained';\n}", + }, + { + name: "TerminalShell", + declaration: + "export interface TerminalShell {\n readonly path: string;\n readonly args: readonly string[];\n readonly name: string;\n}", + }, + { + name: "TextBlock", + declaration: "export interface TextBlock {\n type: 'text';\n text: string;\n}", + }, + { + name: "TodoItem", + declaration: + "export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n}", + }, + { + name: "TokenUsage", + declaration: + "export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n totalTokens?: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n}", + }, + { + name: "ToolAdditionBlock", + declaration: + "export interface ToolAdditionBlock {\n type: 'tool-addition';\n toolName: string;\n tool?: never;\n}", + }, + { + name: "ToolCallBlock", + declaration: + "export interface ToolCallBlock {\n type: 'tool-call';\n id: ToolCallId;\n name: string;\n arguments: string;\n}", + }, + { + name: "ToolCallId", + declaration: "export type ToolCallId = Branded<'ToolCallId'>;", + }, + { + name: "ToolHistory", + declaration: + "export interface ToolHistory {\n readonly tools: readonly ToolSchema[];\n readonly updates: readonly { readonly messageId: MessageId; readonly additions: readonly ToolSchema[]; }[];\n}", + }, + { + name: "ToolMessageSource", + declaration: + "export interface ToolMessageSource {\n kind: 'tool';\n callId: ToolCallId;\n}", + }, + { + name: "ToolRemovalBlock", + declaration: + "export interface ToolRemovalBlock {\n type: 'tool-removal';\n toolName: string;\n}", + }, + { + name: "ToolRestriction", + declaration: + "export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n}", + }, + { + name: "ToolResultMessage", + declaration: + "export interface ToolResultMessage extends MessageBase {\n readonly role: 'tool';\n readonly source: ToolMessageSource;\n readonly toolCallId: ToolCallId;\n readonly isError?: boolean;\n}", + }, + { + name: "ToolSchema", + declaration: + "export interface ToolSchema {\n deferLoading?: true;\n name: string;\n description: string;\n parameters: Record;\n}", + }, + { + name: "TurnEndCancelCause", + declaration: + "export type TurnEndCancelCause = AgentCancelCause | { readonly kind: 'legacy'; };", + }, + { + name: "TurnEndReason", + declaration: "export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];", + }, + { + name: "TurnEndReasonMap", + declaration: + "export interface TurnEndReasonMap {\n completed: { kind: 'completed'; };\n aborted: { kind: 'aborted'; reason: TurnEndCancelCause; };\n blocked: { kind: 'blocked'; };\n error: { kind: 'error'; error: LlmFailure; };\n 'max-tokens': { kind: 'max-tokens'; };\n interrupted: { kind: 'interrupted'; };\n forked: { kind: 'forked'; };\n}", + }, + { + name: "UserMessage", + declaration: + "export interface UserMessage extends MessageBase {\n readonly role: 'user';\n}", + }, + { + name: "WebTerminalId", + declaration: "export type WebTerminalId = Branded<'WebTerminalId'>;", + }, + { + name: "WebTerminalInfo", + declaration: + "export interface WebTerminalInfo {\n readonly id: WebTerminalId;\n readonly title: string;\n readonly shell: TerminalShell;\n readonly cwd: string;\n readonly cols: number;\n readonly rows: number;\n readonly state: 'running' | 'exited' | 'failed';\n readonly exitCode: number | null;\n readonly error?: string;\n readonly controllerId?: TerminalAttachmentId;\n}", + }, + ], + }, + ], + events: [], + objects: [], + }, +}; diff --git a/apps/web-ui/packages/api/terminal-controller/package.json b/apps/web-ui/packages/api/terminal-controller/package.json new file mode 100644 index 000000000..1dfb194d9 --- /dev/null +++ b/apps/web-ui/packages/api/terminal-controller/package.json @@ -0,0 +1,45 @@ +{ + "name": "@deepseek-ai/dsh-api-terminal-controller", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./types": { + "types": "./src/types.ts", + "default": "./src/types.ts" + }, + "./client": { + "types": "./src/client/index.ts", + "default": "./lib/client.js" + }, + "./typert": { + "types": "./host/typert.host.d.ts", + "default": "./host/typert.host.js" + }, + "./remote": { + "types": "./generated/remote.d.ts", + "default": "./generated/remote.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dependencies": { + "zod": "^4.4.3" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-gateway" + ], + "external": [ + "@deepseek-ai/dsh-api-gateway/client" + ], + "platform": "web" + } + } +} diff --git a/apps/web-ui/packages/api/terminal-controller/src/client/bindings.ts b/apps/web-ui/packages/api/terminal-controller/src/client/bindings.ts new file mode 100644 index 000000000..4e303a9e5 --- /dev/null +++ b/apps/web-ui/packages/api/terminal-controller/src/client/bindings.ts @@ -0,0 +1,75 @@ +/** Independent content-to-terminal records keep concurrent browser writes disjoint. */ +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { WebTerminalId } from "../types.ts"; + +const PREFIX = "dsh.terminal.binding.v1."; + +/** Saved recovery targets keyed by Session and globally unique terminal content identity. */ +export class TerminalBindings { + private readonly memory = new Map(); + + /** + * Read a saved target, retaining this window's value when storage is unavailable. + * @param sessionId - owning Session. + * @param contentId - terminal content identity, shared only by deliberate copies. + * @returns the existing Host identity, if one has been saved. + */ + get(sessionId: SessionId, contentId: string): WebTerminalId | undefined { + const key = this.key(sessionId, contentId); + const known = this.memory.get(key); + if (known !== undefined) return known; + if (typeof localStorage === "undefined") return undefined; + try { + const raw = localStorage.getItem(key); + if (raw === null) return undefined; + const value: unknown = JSON.parse(raw); + if (typeof value !== "string" || !/^[\w-]{1,128}$/u.test(value)) return undefined; + this.memory.set(key, value as WebTerminalId); + return value as WebTerminalId; + } catch (_storageUnavailable) { + return undefined; + } + } + + /** + * Save an identity before its Host allocation begins. + * @param sessionId - owning Session. + * @param contentId - globally unique terminal content identity. + * @param id - existing or newly allocated Host identity. + */ + set(sessionId: SessionId, contentId: string, id: WebTerminalId): void { + const key = this.key(sessionId, contentId); + this.memory.set(key, id); + if (typeof localStorage === "undefined") return; + try { + localStorage.setItem(key, JSON.stringify(id)); + } catch (error) { + console.error("Terminal binding persistence failed:", error); + } + } + + /** + * Remove this content's target after its close intent has been saved. + * @param sessionId - owning Session. + * @param contentId - closing terminal content identity. + */ + delete(sessionId: SessionId, contentId: string): void { + const key = this.key(sessionId, contentId); + this.memory.delete(key); + if (typeof localStorage === "undefined") return; + try { + localStorage.removeItem(key); + } catch (error) { + console.error("Terminal binding cleanup failed:", error); + } + } + + /** Release cached values when the Client service is disposed. */ + clear(): void { + this.memory.clear(); + } + + private key(sessionId: SessionId, contentId: string): string { + return PREFIX + JSON.stringify([sessionId, contentId]); + } +} diff --git a/apps/web-ui/packages/api/terminal-controller/src/client/close-requests.ts b/apps/web-ui/packages/api/terminal-controller/src/client/close-requests.ts new file mode 100644 index 000000000..5b9487a70 --- /dev/null +++ b/apps/web-ui/packages/api/terminal-controller/src/client/close-requests.ts @@ -0,0 +1,89 @@ +/** Unfinished close requests survive reload independently of the removed sidebar tabs. */ +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { WebTerminalId } from "../types.ts"; + +/** An explicit cleanup request; no process or open-tab metadata is mirrored here. */ +export interface TerminalCloseRequest { + readonly sessionId: SessionId; + readonly id: WebTerminalId; + readonly title: string; +} + +const PREFIX = "dsh.terminal.close.v1."; + +/** Each request has its own storage key, so other browser windows cannot overwrite its cleanup. */ +export class TerminalCloseRequests { + private readonly requests = new Map(); + + constructor() { + try { + if (typeof localStorage === "undefined") return; + for (let index = 0; index < localStorage.length; index++) { + const key = localStorage.key(index); + if (key?.startsWith(PREFIX)) this.load(key); + } + } catch (error) { + console.error("Terminal cleanup recovery failed:", error); + } + } + + /** + * Read cleanup work still awaiting Host confirmation. + * @returns unfinished requests owned by this browser instance. + */ + pending(): readonly TerminalCloseRequest[] { + return [...this.requests.values()]; + } + + /** + * Retain cleanup across reload before removing a tab. + * @param request - close intent to save before removing its tab. + */ + save(request: TerminalCloseRequest): void { + this.requests.set(request.id, request); + try { + if (typeof localStorage !== "undefined") + localStorage.setItem(PREFIX + request.id, JSON.stringify(request)); + } catch (error) { + console.error("Terminal cleanup persistence failed:", error); + } + } + + /** + * Forget confirmed cleanup in memory and browser storage. + * @param id - terminal whose Host cleanup succeeded. + */ + remove(id: WebTerminalId): void { + this.requests.delete(id); + try { + if (typeof localStorage !== "undefined") localStorage.removeItem(PREFIX + id); + } catch (error) { + console.error("Terminal cleanup persistence failed:", error); + } + } + + private load(key: string): void { + try { + const raw = localStorage.getItem(key); + if (raw === null) return; + const parsed: unknown = JSON.parse(raw); + if (!isRequest(parsed) || key !== PREFIX + parsed.id) + throw new Error("Invalid terminal cleanup request"); + this.requests.set(parsed.id, parsed); + } catch (error) { + console.error("Terminal cleanup recovery failed:", error); + } + } +} + +function isRequest(value: unknown): value is TerminalCloseRequest { + if (typeof value !== "object" || value === null) return false; + const request = value as Record; + return ( + typeof request.sessionId === "string" && + request.sessionId.length > 0 && + typeof request.id === "string" && + /^[\w-]{1,128}$/u.test(request.id) && + typeof request.title === "string" + ); +} diff --git a/apps/web-ui/packages/api/terminal-controller/src/client/index.ts b/apps/web-ui/packages/api/terminal-controller/src/client/index.ts new file mode 100644 index 000000000..c4aa84466 --- /dev/null +++ b/apps/web-ui/packages/api/terminal-controller/src/client/index.ts @@ -0,0 +1,313 @@ +/** Client terminal model service; views are keyed independently from Host terminal identities. */ +import { Service, type Context } from "@deepseek-ai/cordis"; +import { remoteErrorOf } from "@deepseek-ai/dsh-typert-protocol"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type {} from "@deepseek-ai/dsh-api-gateway/client"; +import { TerminalView, type TerminalRemote } from "./model.ts"; +import { createSnapshotStore, type SnapshotStore } from "@deepseek-ai/dsh-client-store"; +import { randomUUID } from "@deepseek-ai/dsh-util-crypto"; +import type { TerminalShell, WebTerminalId, WebTerminalInfo } from "../types.ts"; +import { preferredShell, rememberShell } from "./shell-preference.ts"; +import { TerminalCloseRequests, type TerminalCloseRequest } from "./close-requests.ts"; +import { TerminalWindowHold } from "./retention.ts"; +import { TerminalBindings } from "./bindings.ts"; + +export type { + TerminalView, + TerminalViewState, + TerminalViewIssue, + TerminalRenderFrame, + TerminalRemote, +} from "./model.ts"; + +declare module "@deepseek-ai/cordis" { + interface Context { + /** React-free browser terminal views and explicit process cleanup. */ + webTerminals: ClientTerminals; + } +} + +/** Host-discovered shell menu with the browser's remembered available choice. */ +export interface TerminalLaunchShells { + readonly shells: readonly TerminalShell[]; + readonly selectedShell: string | undefined; +} + +/** A failed background close that can be retried without restoring its tab. */ +export interface TerminalCloseFailure { + readonly id: WebTerminalId; + readonly title: string; + readonly message: string; +} + +/** Session and occurrence lookup, independent tab and terminal identities and background cleanup. */ +export class ClientTerminals extends Service { + /** Failed cleanup tasks; successful and in-progress closes have no visible notification. */ + readonly closeFailures: SnapshotStore = createSnapshotStore< + readonly TerminalCloseFailure[] + >([]); + private readonly requests = new TerminalCloseRequests(); + private readonly closing = new Map>(); + private readonly closed = new Set( + this.requests.pending().map((request) => request.id), + ); + private disposed = false; + private readonly views = new Map>(); + private readonly bindings = new TerminalBindings(); + private readonly holds = new Map>(); + private readonly releasing = new Set>(); + private openTabs: readonly { sessionId: SessionId; tabId: string; contentId: string }[] = []; + + /** + * @param ctx - Client root Context with Gateway and terminal Remote namespace. + * @param remote - generated terminal namespace. + */ + constructor( + ctx: Context, + private readonly remote: TerminalRemote, + ) { + super(ctx, "webTerminals"); + ctx.effect( + () => async () => { + this.disposed = true; + const detaching = [...this.views.values()].flatMap((views) => + [...views.values()].map((view) => view.dispose()), + ); + this.views.clear(); + this.bindings.clear(); + const holds = [...this.holds.values()].flatMap((holds) => + [...holds.values()].map((hold) => hold.dispose()), + ); + this.holds.clear(); + await Promise.all([...detaching, ...holds, ...this.releasing, ...this.closing.values()]); + }, + "terminal-controller.client.views", + ); + for (const request of this.requests.pending()) this.cleanup(request); + } + + /** + * Return the stable model for one sidebar occurrence. + * @param sessionId - owning Session. + * @param key - sidebar occurrence key. + * @param contentId - globally unique content identity; layout-local tab ids are not persistence keys. + * @param terminalId - existing Host identity when restoring a listed terminal; otherwise reuse the saved content identity. + * @param shellPath - explicit shell for a new terminal; restored terminals retain their own shell. + * @returns its observable state and terminal commands. + */ + view( + sessionId: SessionId, + key: string, + contentId: string, + terminalId?: WebTerminalId, + shellPath?: string, + ): TerminalView { + let views = this.views.get(sessionId); + if (views === undefined) { + views = new Map(); + this.views.set(sessionId, views); + } + let view = views.get(key); + if (view === undefined) { + const saved = terminalId ?? this.bindings.get(sessionId, contentId); + const id = saved ?? (randomUUID() as WebTerminalId); + this.bindings.set(sessionId, contentId, id); + view = new TerminalView( + sessionId, + this.remote, + this.ctx.remote, + id, + saved === undefined, + shellPath, + (signal) => this.hold(sessionId, id).ready(signal), + ); + views.set(key, view); + this.reconcileHolds(); + void view.refresh(); + } + return view; + } + + /** + * Discover available launch choices on demand without allocating a PTY. + * @param sessionId - target Session. + * @param signal - the menu request lifetime. + * @returns installed shells and the currently usable browser preference. + */ + async launchShells(sessionId: SessionId, signal: AbortSignal): Promise { + const result = await this.remote.shells(sessionId, signal); + if (!result.ok) throw new Error(result.error.message); + const previous = preferredShell(); + return { + shells: result.value, + selectedShell: + result.value.find((shell) => shell.path === previous)?.path ?? result.value[0]?.path, + }; + } + + /** + * Remember the guide selection before allocating its terminal tab. + * @param path - shell selected from Host discovery. + */ + selectShell(path: string): void { + rememberShell(path); + } + + /** + * Save a close intent and release the tab immediately; cleanup outlives DOM unmount and reload. + * @param sessionId - owning Session. + * @param key - sidebar occurrence key, including an inactive restored tab. + * @param contentId - globally unique content identity whose binding is removed. + * @param terminalId - restored identity if the tab has no model yet. + */ + close(sessionId: SessionId, key: string, contentId: string, terminalId?: WebTerminalId): void { + const views = this.views.get(sessionId); + const view = views?.get(key); + const id = view?.id ?? terminalId ?? this.bindings.get(sessionId, contentId); + if (id === undefined) return; + const request: TerminalCloseRequest = { + sessionId, + id, + title: view?.state.getSnapshot().title ?? key, + }; + this.closed.add(id); + this.requests.save(request); + this.bindings.delete(sessionId, contentId); + views?.delete(key); + if (views?.size === 0) this.views.delete(sessionId); + this.cleanup(request, view); + this.reconcileHolds(); + } + + /** + * Reconcile this window's open terminal occurrences, including dormant saved Sessions. + * @param tabs - terminal-kind membership supplied by the sidebar layout owner. + */ + retainTabs(tabs: readonly { sessionId: SessionId; tabId: string; contentId: string }[]): void { + this.openTabs = tabs; + this.reconcileHolds(); + } + + private hold(sessionId: SessionId, id: WebTerminalId): TerminalWindowHold { + let holds = this.holds.get(sessionId); + if (holds === undefined) { + holds = new Map(); + this.holds.set(sessionId, holds); + } + let hold = holds.get(id); + if (hold === undefined || hold.failed) { + if (hold !== undefined) this.release(hold); + hold = new TerminalWindowHold(this.ctx.remote, this.remote, sessionId, id); + holds.set(id, hold); + } + return hold; + } + + private reconcileHolds(): void { + if (this.disposed) return; + const wanted = new Map>(); + for (const tab of this.openTabs) { + const id = this.bindings.get(tab.sessionId, tab.contentId); + if (id === undefined || this.closed.has(id)) continue; + let ids = wanted.get(tab.sessionId); + if (ids === undefined) { + ids = new Set(); + wanted.set(tab.sessionId, ids); + } + ids.add(id); + const view = this.views.get(tab.sessionId)?.get(tab.tabId); + if (view === undefined || view.state.getSnapshot().info !== undefined) { + if (!this.holds.get(tab.sessionId)?.has(id)) this.hold(tab.sessionId, id); + } + } + for (const [sessionId, holds] of this.holds) { + for (const [id, hold] of holds) { + if (!wanted.get(sessionId)?.has(id)) { + holds.delete(id); + this.release(hold); + } + } + if (holds.size === 0) this.holds.delete(sessionId); + } + } + + private release(hold: TerminalWindowHold): void { + const releasing = hold.dispose().finally(() => { + this.releasing.delete(releasing); + }); + this.releasing.add(releasing); + void releasing.catch((error: unknown) => { + this.ctx.logger.warn("Terminal hold release failed", error); + }); + } + + /** + * Query Host terminals without a live view or unfinished close. + * @param sessionId - Session being displayed. + * @returns terminals available for opening as recovered tabs. + */ + async recover(sessionId: SessionId): Promise { + const result = await this.remote.list(sessionId); + if (!result.ok) throw new Error(result.error.message); + const held = new Set([...(this.views.get(sessionId)?.values() ?? [])].map((view) => view.id)); + const closing = new Set(this.requests.pending().map((request) => request.id)); + return result.value.filter( + (info) => !held.has(info.id) && !closing.has(info.id) && !this.closed.has(info.id), + ); + } + + /** + * Retry a saved close request without reopening its tab. + * @param id - failed terminal identity. + */ + retryClose(id: WebTerminalId): void { + const record = this.requests.pending().find((item) => item.id === id); + if (record !== undefined) this.cleanup(record); + } + + private cleanup(record: TerminalCloseRequest, view?: TerminalView): void { + if (this.closing.has(record.id) || this.disposed) return; + this.closeFailures.set( + this.closeFailures.getSnapshot().filter((failure) => failure.id !== record.id), + ); + const pending = (async () => { + if (view !== undefined) await view.close(); + else { + const result = await this.remote.close(record.sessionId, record.id); + if (!result.ok) throw result.error; + } + this.requests.remove(record.id); + })() + .catch((error: unknown) => { + if (remoteErrorOf(error)?.code === "session/not-found") { + this.requests.remove(record.id); + return; + } + if (!this.disposed) + this.closeFailures.set([ + ...this.closeFailures.getSnapshot(), + { + id: record.id, + title: record.title, + message: error instanceof Error ? error.message : String(error), + }, + ]); + }) + .then(async () => { + await view?.dispose(); + this.closing.delete(record.id); + }); + this.closing.set(record.id, pending); + } +} + +/** Required Client transport and terminal namespace. */ +export const inject = ["remote", "remote.terminal"]; + +/** + * Install the Client terminal models. + * @param ctx - Client root Context. + */ +export function apply(ctx: Context): void { + new ClientTerminals(ctx, ctx.remote.terminal); +} diff --git a/apps/web-ui/packages/api/terminal-controller/src/client/model.ts b/apps/web-ui/packages/api/terminal-controller/src/client/model.ts new file mode 100644 index 000000000..57fd95eec --- /dev/null +++ b/apps/web-ui/packages/api/terminal-controller/src/client/model.ts @@ -0,0 +1,459 @@ +/** React-free browser terminal state and reconnecting Remote-stream ownership. */ +import { preferredShell, rememberShell } from "./shell-preference.ts"; +import { randomUUID } from "@deepseek-ai/dsh-util-crypto"; +import { createSnapshotStore, type SnapshotStore } from "@deepseek-ai/dsh-client-store"; +import { + RemoteStreamCarrierError, + type ClientRemote, + type RemoteStream, +} from "@deepseek-ai/dsh-api-gateway/client"; +import { RemoteError, remoteErrorOf, type RemoteResult } from "@deepseek-ai/dsh-typert-protocol"; +import type {} from "@deepseek-ai/dsh-api-terminal-controller/remote"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { + TerminalAttachmentId, + TerminalEnvironment, + TerminalFrame, + WebTerminalId, + WebTerminalInfo, +} from "../types.ts"; + +/** The generated terminal namespace's browser-facing operations. */ +export type TerminalRemote = ClientRemote["terminal"]; + +/** Product error identifiers translated by the terminal UI. */ +export type TerminalViewIssue = + "missingTerminal" | "inputFull" | "attachmentEnded" | "invalidOutput" | "terminalLimit"; + +declare module "@deepseek-ai/dsh-typert-protocol" { + interface RemoteErrorDetailsMap { + /** Client terminal failure preserved through the Remote stream supervisor. */ + "terminal/view": { readonly issue: TerminalViewIssue }; + } +} + +class TerminalViewError extends RemoteError<"terminal/view"> { + constructor(issue: TerminalViewIssue, message: string = issue) { + super("terminal/view", message, { issue }); + } +} + +/** One screen write awaiting the DOM emulator's callback. */ +export interface TerminalRenderFrame { + readonly revision: number; + readonly frame: Extract; +} + +/** Observable state of one sidebar occurrence. */ +export interface TerminalViewState { + readonly phase: + | "idle" + | "loading" + | "creating" + | "connecting" + | "connected" + | "disconnected" + | "closing" + | "closed" + | "failed"; + readonly environment?: TerminalEnvironment | undefined; + readonly title?: string | undefined; + readonly info?: WebTerminalInfo | undefined; + readonly writable: boolean; + readonly render?: TerminalRenderFrame | undefined; + readonly error?: string | undefined; + readonly issue?: TerminalViewIssue | undefined; +} + +/** A view survives DOM unmount; its process only ends on explicit close. */ +export class TerminalView { + /** Observable controls, process metadata and the next screen update awaiting acknowledgement. */ + readonly state: SnapshotStore = createSnapshotStore({ + phase: "idle", + writable: false, + }); + private readonly lifetime = new AbortController(); + private stream: RemoteStream | undefined; + private mounted = false; + private attachmentId: TerminalAttachmentId | undefined; + private pendingRender: { revision: number; resolve: () => void } | undefined; + private revision = 0; + private creation: Promise | undefined; + private loading: Promise | undefined; + private closing: Promise | undefined; + private writes: Promise = Promise.resolve(); + private queuedInput = 0; + private readonly detaching = new Set>(); + + /** + * @param sessionId - Session owning the terminal. + * @param remote - typed terminal Remote operations. + * @param gateway - reconnecting stream factory. + * @param id - Host terminal identity, reused when recovering an item from its Session list. + * @param createWhenMissing - allow allocation only for a new tab, never a listed terminal. + * @param shellPath - explicit shell chosen at the guide; omission uses the remembered available shell. + * @param retain - window hold acknowledgement required before output attachment. + */ + constructor( + private readonly sessionId: SessionId, + private readonly remote: TerminalRemote, + private readonly gateway: Pick, + readonly id: WebTerminalId, + private readonly createWhenMissing = true, + private readonly shellPath?: string, + private readonly retain?: (signal: AbortSignal) => Promise, + ) {} + + /** + * Attach the DOM lifetime, starting the chosen shell or reconnecting the saved process. + * @returns a detach callback that leaves the terminal process alive. + */ + mount(): () => void { + this.mounted = true; + if (this.state.getSnapshot().info === undefined) void this.refresh(); + else this.connect(); + return () => { + this.mounted = false; + this.detach(); + }; + } + + /** + * Start or recover this tab, deduplicating mounts and retries during allocation. + * Only a new tab may allocate a shell; listed terminals cannot be silently replaced. + * @returns after environment lookup and creation or recovery settle. + */ + refresh(): Promise { + if (this.creation !== undefined) return this.creation; + if (this.loading !== undefined) return this.loading; + if (this.closing !== undefined || this.lifetime.signal.aborted) return Promise.resolve(); + this.patch({ phase: "loading", error: undefined, issue: undefined }); + this.loading = (async () => { + const [environment, available] = await Promise.all([ + this.remote.environment(this.sessionId, this.lifetime.signal), + this.remote.list(this.sessionId), + ]); + if (this.stopped()) return; + this.patch({ environment: valueOf(environment) }); + const info = valueOf(available).find((item) => item.id === this.id); + if (info !== undefined) this.adopt(info); + else if (this.createWhenMissing) { + let path = this.shellPath; + if (path === undefined) { + const shells = valueOf(await this.remote.shells(this.sessionId, this.lifetime.signal)); + const previous = preferredShell(); + path = shells.find((shell) => shell.path === previous)?.path ?? shells[0]?.path; + } + if (this.stopped()) return; + if (path !== undefined) rememberShell(path); + await this.create(valueOf(environment), path); + } else throw new TerminalViewError("missingTerminal"); + })() + .catch((error: unknown) => { + this.fail(error); + }) + .finally(() => { + this.loading = undefined; + }); + return this.loading; + } + + private stopped(): boolean { + return this.lifetime.signal.aborted || this.closing !== undefined; + } + + private async create(environment: TerminalEnvironment, shellPath?: string): Promise { + this.patch({ phase: "creating", error: undefined, issue: undefined }); + this.creation = (async () => { + const info = valueOf( + await this.remote.create( + this.sessionId, + { + id: this.id, + ...(shellPath === undefined ? {} : { shellPath }), + cols: Math.min(80, environment.maxCols), + rows: Math.min(24, environment.maxRows), + }, + this.lifetime.signal, + ), + ); + if (!this.lifetime.signal.aborted) { + this.adopt(info); + } + })() + .catch((error: unknown) => { + this.fail(error); + }) + .finally(() => { + this.creation = undefined; + }); + await this.creation; + } + + private adopt(info: WebTerminalInfo): void { + this.patch({ info, title: info.title }); + if (this.retain === undefined) { + if (this.mounted && this.closing === undefined) this.connect(); + return; + } + void this.retain(this.lifetime.signal) + .then(() => { + if (this.mounted && this.closing === undefined) this.connect(); + }) + .catch((error: unknown) => { + if (!this.stopped()) this.fail(error); + }); + } + + /** Reattach with a fresh screen and regain input control. */ + connect(): void { + const info = this.state.getSnapshot().info; + if ( + info === undefined || + !this.mounted || + this.closing !== undefined || + this.lifetime.signal.aborted + ) + return; + this.detach(); + const stream = this.gateway.$stream({ + name: "Browser terminal output", + open: async function* (this: TerminalView, signal: AbortSignal) { + await this.retain?.(signal); + signal.throwIfAborted(); + const attachmentId = randomUUID() as TerminalAttachmentId; + this.attachmentId = attachmentId; + yield* this.remote.follow(this.sessionId, info.id, attachmentId, signal); + }.bind(this), + ended: () => new TerminalViewError("attachmentEnded"), + carrierFailed: () => { + if (this.stream === stream) this.patch({ phase: "disconnected", writable: false }); + }, + }); + this.stream = stream; + this.patch({ + phase: "connecting", + writable: false, + error: undefined, + issue: undefined, + render: undefined, + }); + void this.consume(stream); + } + + /** + * Release the next stream item after xterm has parsed this frame. + * @param revision - locally delivered render revision. + */ + acknowledge(revision: number): void { + if (this.pendingRender?.revision !== revision) return; + this.pendingRender.resolve(); + this.pendingRender = undefined; + } + + /** + * Serialize raw input so concurrent RPC requests cannot reorder keystrokes. + * @param data - input from the terminal emulator. + */ + write(data: string): void { + const state = this.state.getSnapshot(); + const attachmentId = this.attachmentId; + if (!state.writable || state.info === undefined || attachmentId === undefined) return; + const bytes = new TextEncoder().encode(data).byteLength; + if (this.queuedInput + bytes > (state.environment?.maxInputBytes ?? 0)) { + this.fail(new TerminalViewError("inputFull")); + return; + } + this.queuedInput += bytes; + const id = state.info.id; + this.writes = this.writes + .then(async () => { + if (this.attachmentId !== attachmentId || !this.state.getSnapshot().writable) return; + valueOf(await this.remote.write(this.sessionId, id, attachmentId, data)); + }) + .catch((error: unknown) => { + if (this.attachmentId === attachmentId) this.fail(error); + }) + .finally(() => { + this.queuedInput -= bytes; + }); + } + + /** + * Resize only from the currently writable view. + * @param cols - measured column count. + * @param rows - measured row count. + */ + resize(cols: number, rows: number): void { + const state = this.state.getSnapshot(); + const attachmentId = this.attachmentId; + if (!state.writable || state.info === undefined || attachmentId === undefined) return; + if (state.info.cols === cols && state.info.rows === rows) return; + const id = state.info.id; + cols = Math.min(cols, state.environment?.maxCols ?? cols); + rows = Math.min(rows, state.environment?.maxRows ?? rows); + this.writes = this.writes + .then(async () => { + if (this.attachmentId !== attachmentId || !this.state.getSnapshot().writable) return; + valueOf(await this.remote.resize(this.sessionId, id, attachmentId, cols, rows)); + }) + .catch((error: unknown) => { + if (this.attachmentId === attachmentId) this.fail(error); + }); + } + + /** + * Update the Host terminal's display name. + * @param title - user-entered terminal title. + * @returns after the rename settles and its result is reflected in view state. + */ + async rename(title: string): Promise { + if (title.trim() === this.state.getSnapshot().title || this.lifetime.signal.aborted) return; + try { + valueOf(await this.remote.rename(this.sessionId, this.id, title)); + const current = this.state.getSnapshot().info; + this.patch({ + ...(current === undefined ? {} : { info: { ...current, title: title.trim() } }), + title: title.trim(), + }); + } catch (error) { + this.fail(error); + } + } + + /** + * Explicitly terminate this view's process independently of its DOM lifetime. + * @returns after Host process cleanup succeeds; failures remain retryable by the owner. + */ + close(): Promise { + if (this.closing !== undefined) return this.closing; + this.patch({ phase: "closing", writable: false, error: undefined, issue: undefined }); + this.detach(); + this.closing = (async () => { + // Even a refused or lost creation response may leave an allocation to close. + await this.creation; + valueOf(await this.remote.close(this.sessionId, this.id)); + this.detach(); + this.patch({ phase: "closed", writable: false }); + })().catch((error: unknown) => { + this.closing = undefined; + this.fail(error); + throw error; + }); + return this.closing; + } + + /** + * Stop Client work on plugin unload without closing Host terminals. + * @returns after active and previously detached stream iterators have closed. + */ + async dispose(): Promise { + this.mounted = false; + this.lifetime.abort(); + this.detach(); + await Promise.all(this.detaching); + } + + private detach(): void { + const previous = this.stream; + this.stream = undefined; + this.attachmentId = undefined; + this.pendingRender?.resolve(); + this.pendingRender = undefined; + if (previous !== undefined) { + const cleanup = previous.dispose().finally(() => { + this.detaching.delete(cleanup); + }); + this.detaching.add(cleanup); + } + } + + private async consume(stream: RemoteStream): Promise { + let generation = 0; + let sequence = 0; + try { + for await (const item of stream) { + if (this.stream !== stream) return; + const frame = item.value; + if (generation !== item.generation) { + if (frame.type !== "snapshot") + throw new TerminalViewError( + "invalidOutput", + "Terminal output generation is missing its screen snapshot", + ); + generation = item.generation; + sequence = frame.sequence; + item.accept(); + } else if (frame.type === "output") { + if (frame.sequence !== sequence + 1) + throw new TerminalViewError("invalidOutput", "Terminal output sequence has a gap"); + sequence = frame.sequence; + } else if (frame.type === "snapshot") + throw new TerminalViewError("invalidOutput", "Unexpected terminal screen snapshot"); + if (frame.type !== "output") { + this.patch({ + info: frame.info, + title: frame.info.title, + phase: "connected", + writable: + frame.info.state === "running" && frame.info.controllerId === this.attachmentId, + }); + } + if (frame.type !== "state") { + const revision = ++this.revision; + await new Promise((resolve) => { + this.pendingRender = { revision, resolve }; + const aborted = (): void => { + this.acknowledge(revision); + }; + item.signal.addEventListener("abort", aborted, { once: true }); + this.pendingRender.resolve = () => { + item.signal.removeEventListener("abort", aborted); + resolve(); + }; + this.patch({ render: { revision, frame } }); + if (item.signal.aborted) aborted(); + }); + } + } + } catch (error) { + if (this.stream === stream) { + if (this.state.getSnapshot().info?.state === "exited") + this.patch({ phase: "closed", writable: false }); + else this.fail(error); + } + } + } + + private patch(patch: Partial): void { + if (this.lifetime.signal.aborted) return; + this.state.set({ ...this.state.getSnapshot(), ...patch }); + } + + private fail(error: unknown): void { + const failure = remoteErrorOf(error); + if (failure?.code === "terminal/control-unavailable") { + this.patch({ writable: false, error: undefined, issue: undefined }); + return; + } + const issue = + failure?.code === "terminal/view" + ? failure.details.issue + : failure?.code === "terminal/limit-reached" + ? "terminalLimit" + : failure?.code === "terminal/unavailable" + ? "missingTerminal" + : undefined; + this.patch({ + phase: error instanceof RemoteStreamCarrierError ? "disconnected" : "failed", + writable: false, + issue, + error: error instanceof Error ? error.message : String(error), + }); + } +} + +function valueOf(result: RemoteResult): T { + if (!result.ok) throw result.error; + return result.value; +} diff --git a/apps/web-ui/packages/api/terminal-controller/src/client/retention.ts b/apps/web-ui/packages/api/terminal-controller/src/client/retention.ts new file mode 100644 index 000000000..12f196a82 --- /dev/null +++ b/apps/web-ui/packages/api/terminal-controller/src/client/retention.ts @@ -0,0 +1,92 @@ +/** One reconnecting window hold, shared by all occurrences of a terminal. */ +import type { ClientRemote, RemoteStream } from "@deepseek-ai/dsh-api-gateway/client"; +import { RemoteError } from "@deepseek-ai/dsh-typert-protocol"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { TerminalRetentionFrame, WebTerminalId } from "../types.ts"; +import type { TerminalRemote } from "./model.ts"; + +/** A stream acknowledgement gates output attachment for each physical connection. */ +export class TerminalWindowHold { + private readonly stream: RemoteStream; + private readonly waiters = new Set<{ resolve: () => void; reject: (error: unknown) => void }>(); + private generation: AbortSignal | undefined; + private failure: Error | undefined; + + /** + * @param gateway - reconnecting stream owner. + * @param remote - typed terminal namespace. + * @param sessionId - saved layout's Session, without Agent activation. + * @param id - existing Host terminal. + */ + constructor( + gateway: Pick, + remote: TerminalRemote, + sessionId: SessionId, + id: WebTerminalId, + ) { + this.stream = gateway.$stream({ + name: "Browser terminal window hold", + open: (signal) => remote.retain(sessionId, id, signal), + ended: () => new RemoteError("terminal/unavailable", "Terminal hold ended", {}), + }); + void this.consume(); + } + + /** Whether a terminal-domain failure ended this hold, allowing an explicit retry. */ + get failed(): boolean { + return this.failure !== undefined; + } + + /** + * Wait for an acknowledged current physical hold before following its screen. + * @param signal - output request or view lifetime. + * @returns after acknowledgement, or rejects on cancellation/unavailability. + */ + async ready(signal: AbortSignal): Promise { + signal.throwIfAborted(); + if (this.failure !== undefined) throw this.failure; + if (this.generation !== undefined && !this.generation.aborted) return; + const waiting = Promise.withResolvers(); + const abort = (): void => { + waiting.reject(signal.reason); + }; + this.waiters.add(waiting); + signal.addEventListener("abort", abort, { once: true }); + try { + await waiting.promise; + } finally { + this.waiters.delete(waiting); + signal.removeEventListener("abort", abort); + } + } + + /** + * Release this window's stream and all acknowledgement waiters. + * @returns after the stream consumer closes. + */ + dispose(): Promise { + this.reject(new Error("Terminal window hold released")); + return this.stream.dispose(); + } + + private async consume(): Promise { + try { + for await (const item of this.stream) { + item.accept(); + this.generation = item.signal; + for (const waiter of this.waiters) waiter.resolve(); + this.waiters.clear(); + } + } catch (error) { + this.reject(error); + } + } + + private reject(error: unknown): void { + this.failure = + error instanceof Error ? error : new Error("Terminal window hold failed", { cause: error }); + this.generation = undefined; + for (const waiter of this.waiters) waiter.reject(error); + this.waiters.clear(); + } +} diff --git a/apps/web-ui/packages/api/terminal-controller/src/client/shell-preference.ts b/apps/web-ui/packages/api/terminal-controller/src/client/shell-preference.ts new file mode 100644 index 000000000..da2144b1a --- /dev/null +++ b/apps/web-ui/packages/api/terminal-controller/src/client/shell-preference.ts @@ -0,0 +1,26 @@ +/** Browser-local shell preference; Host discovery decides whether the saved path is usable. */ +const KEY = "dsh.terminal.shell"; + +/** + * Read the browser preference. + * @returns the last selected shell path, or null when storage is unavailable. + */ +export function preferredShell(): string | null { + try { + return typeof localStorage === "undefined" ? null : localStorage.getItem(KEY); + } catch (_storageUnavailable) { + return null; + } +} + +/** + * Remember the selected shell without making storage a startup dependency. + * @param path - verified executable path offered by the Host. + */ +export function rememberShell(path: string): void { + try { + if (typeof localStorage !== "undefined") localStorage.setItem(KEY, path); + } catch (_storageUnavailable) { + /* Private browsing or quota failure leaves this launch usable. */ + } +} diff --git a/apps/web-ui/packages/api/terminal-controller/src/index.ts b/apps/web-ui/packages/api/terminal-controller/src/index.ts new file mode 100644 index 000000000..14c80311d --- /dev/null +++ b/apps/web-ui/packages/api/terminal-controller/src/index.ts @@ -0,0 +1,548 @@ +/** Session-owned user terminals with the execution environment's system-user permissions. */ +import type { Context } from "@deepseek-ai/cordis"; +import z from "@deepseek-ai/schemastery"; +import type { Agent } from "@deepseek-ai/dsh-agent"; +import type { SessionId } from "@deepseek-ai/dsh-session"; +import type {} from "@deepseek-ai/dsh-sandbox-policy"; +import { Remote, RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol"; +import { discoverShells, resolveShell } from "./shells.ts"; +import { BrowserTerminal } from "./terminal.ts"; +import { TerminalRetention } from "./retention.ts"; +import type { + TerminalShell, + TerminalAttachmentId, + TerminalCreateRequest, + TerminalEnvironment, + TerminalFrame, + TerminalRetentionFrame, + WebTerminalId, + WebTerminalInfo, +} from "./types.ts"; + +export type * from "./types.ts"; + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Interactive user terminals, separate from the Agent terminal tool registry. */ + terminalController: TerminalController; + } +} + +/** Deployment limits and an optional shell profile. */ +export interface Config { + /** Explicit shell profile; omission uses the execution environment's default shell. */ + readonly shell?: + | { + /** Executable path or PATH name, verified by the subprocess provider. */ + path: string; + /** User-visible profile name. */ + name: string; + /** Arguments passed to the interactive shell. */ + args: string[]; + } + | undefined; + /** Executable names or paths checked for the new-terminal shell selector. */ + readonly shellCandidates: string[]; + /** Maximum retained terminals and pending allocations per Session. */ + readonly maxTerminals: number; + /** Maximum terminal width in columns. */ + readonly maxCols: number; + /** Maximum terminal height in rows. */ + readonly maxRows: number; + /** Screen history rows retained for reconnecting clients. */ + readonly scrollback: number; + /** Maximum queued UTF-8 frame bytes per output follower before disconnection. */ + readonly maxBufferedBytes: number; + /** Maximum UTF-8 bytes in one input request. */ + readonly maxInputBytes: number; + /** Provider process-termination grace period in milliseconds. */ + readonly disposeGraceMs: number; + /** Continuous confirmed idle time without window holds before reclamation; zero disables reclamation. */ + readonly unattendedTimeoutMs: number; + /** Interval between unattended shell and process observations. */ + readonly activityPollIntervalMs: number; + /** Delay before retrying failed owned terminal cleanup. */ + readonly cleanupRetryMs: number; +} + +interface TerminalAllocation { + info: WebTerminalInfo; + readonly cleanup: TerminalRetention; +} + +interface OwnedSession { + readonly lifetime: AbortController; + readonly closedIds: Set; + cleanup?: Promise; + readonly terminals: Map; + readonly pending: Map>; + readonly allocations: Map; +} + +/** Typed Remote control of transient Session-owned terminal processes. */ +export class TerminalController extends TypertRemoteService { + static inject = ["subprocess", "sandboxPolicy", "typert"]; + static Config: z = z.object({ + shell: z.union([ + z.object({ + path: z.string().required(), + name: z.string().required(), + args: z.array(z.string()).default([]), + }), + z.const(undefined), + ]), + shellCandidates: z + .array(z.string().min(1)) + .default(["zsh", "bash", "fish", "pwsh", "powershell", "cmd"]), + maxTerminals: z.number().step(1).min(1).default(8), + maxCols: z.number().step(1).min(2).default(500), + maxRows: z.number().step(1).min(1).default(200), + scrollback: z.number().step(1).min(0).default(1000), + maxBufferedBytes: z + .number() + .step(1) + .min(1024) + .default(2 * 1024 * 1024), + maxInputBytes: z + .number() + .step(1) + .min(1) + .default(64 * 1024), + disposeGraceMs: z.number().step(1).min(1).default(1000), + unattendedTimeoutMs: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(7_200_000), + activityPollIntervalMs: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(30_000), + cleanupRetryMs: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(60_000), + }); + + private readonly owners = new Map(); + private readonly lifetime = new AbortController(); + + /** + * @param ctx - Host context carrying typed Remote and execution providers. + * @param config - validated terminal limits and optional shell profile. + */ + constructor( + ctx: Context, + private readonly config: Config, + ) { + super(ctx, "terminalController", { namespace: "terminal" }); + ctx.effect( + () => async () => { + this.lifetime.abort(new Error("Terminal controller disposed")); + const results = await Promise.allSettled( + [...this.owners].map(([id, owner]) => this.disposeOwner(id, owner)), + ); + const errors = results + .filter((result) => result.status === "rejected") + .map((result) => result.reason as unknown); + if (errors.length > 0) throw new AggregateError(errors, "Browser terminal cleanup failed"); + }, + "terminal-controller.processes", + ); + } + + /** + * Read the Session working directory and terminal limits without resolving a shell. + * @param agent - Session owner supplied by the Gateway. + * @param signal - request cancellation. + * @returns the Session workspace directory and terminal limits. + */ + @Remote + environment(agent: Agent, signal: AbortSignal): TerminalEnvironment { + signal.throwIfAborted(); + const { sandboxPolicy } = this.execution(agent); + return { + cwd: agent.session.header.cwd ?? sandboxPolicy.workspaceRoot, + maxInputBytes: this.config.maxInputBytes, + maxCols: this.config.maxCols, + maxRows: this.config.maxRows, + scrollback: this.config.scrollback, + }; + } + + /** + * Discover installed shells in the Session's execution environment. + * @param agent - Session owner supplied by the Gateway. + * @param signal - request cancellation. + * @returns verified profiles, with the configured or system default first. + */ + @Remote + shells(agent: Agent, signal: AbortSignal): Promise { + signal.throwIfAborted(); + return discoverShells( + this.execution(agent).subprocess, + this.config.shell, + this.config.shellCandidates, + signal, + ); + } + + /** + * List retained terminals without resolving or activating an Agent. + * @param sessionId - displayed Session identity, including offline history. + * @returns terminals retained for this Host lifetime. + */ + @Remote + list(sessionId: SessionId): WebTerminalInfo[] { + const owner = this.owners.get(sessionId); + if (owner === undefined) return []; + return [...owner.terminals.values(), ...owner.allocations.values()].map( + (terminal) => terminal.info, + ); + } + + /** + * Allocate a user shell once for a caller-generated identity, without Agent sandbox or approval restrictions. + * @param agent - Session owner supplied by the Gateway. + * @param request - initial dimensions and idempotency identity. + * @param signal - allocation cancellation; committed terminals survive disconnection. + * @returns the existing or newly committed terminal. + */ + @Remote + async create( + agent: Agent, + request: TerminalCreateRequest, + signal: AbortSignal, + ): Promise { + this.lifetime.signal.throwIfAborted(); + if (!/^[\w-]{1,128}$/u.test(request.id)) throw new Error("Invalid terminal identity"); + this.dimensions(request.cols, request.rows); + const owner = this.owner(agent); + owner.lifetime.signal.throwIfAborted(); + this.requireOpen(owner, request.id); + const existing = owner.terminals.get(request.id); + if (existing !== undefined) return existing.info; + const pending = owner.pending.get(request.id); + if (pending !== undefined) { + const terminal = await pending; + this.requireOpen(owner, request.id); + return terminal.info; + } + if ( + new Set([...owner.terminals.keys(), ...owner.pending.keys(), ...owner.allocations.keys()]) + .size >= this.config.maxTerminals + ) + throw new RemoteError("terminal/limit-reached", "Session terminal limit reached", { + limit: this.config.maxTerminals, + }); + const allocation = this.spawn( + agent, + owner, + request, + AbortSignal.any([signal, this.lifetime.signal, owner.lifetime.signal]), + ); + owner.pending.set(request.id, allocation); + try { + const terminal = await allocation; + owner.terminals.set(request.id, terminal); + owner.allocations.delete(request.id); + terminal.monitor( + this.config, + () => { + owner.closedIds.add(request.id); + }, + () => { + owner.terminals.delete(request.id); + }, + (error) => { + this.ctx.logger.error("Browser terminal cleanup failed", error); + }, + ); + this.requireOpen(owner, request.id); + return terminal.info; + } finally { + owner.pending.delete(request.id); + } + } + + /** + * Retain an existing terminal for a window without activating its Agent or taking input control. + * @param sessionId - owning Session identity, including an inactive saved layout. + * @param id - retained Host terminal identity. + * @param signal - physical Remote stream cancellation. + * @returns a hold acknowledgement followed by an open lifetime stream. + */ + @Remote({ mode: "stream" }) + retain( + sessionId: SessionId, + id: WebTerminalId, + signal: AbortSignal, + ): AsyncIterable { + const owner = this.owners.get(sessionId); + const terminal = owner?.terminals.get(id); + if ( + terminal === undefined || + owner?.closedIds.has(id) === true || + owner?.lifetime.signal.aborted === true + ) { + throw new RemoteError("terminal/unavailable", "Terminal is closing or unavailable", {}); + } + return terminal.retain(signal); + } + + /** + * Attach to a terminal without binding its process lifetime to the transport. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param attachmentId - new exclusive input attachment. + * @param signal - physical stream cancellation. + * @returns screen recovery followed by output and metadata changes. + */ + @Remote({ mode: "stream" }) + follow( + agent: Agent, + id: WebTerminalId, + attachmentId: TerminalAttachmentId, + signal: AbortSignal, + ): AsyncIterable { + if (!/^[\w-]{1,128}$/u.test(attachmentId)) + throw new Error("Invalid terminal attachment identity"); + return this.terminal(agent, id).follow(attachmentId, signal); + } + + /** + * Deliver raw input, including Tab completion and control characters. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param attachmentId - current writable attachment. + * @param data - input bytes represented as UTF-8 text. + * @returns after provider input acceptance. + */ + @Remote + async write( + agent: Agent, + id: WebTerminalId, + attachmentId: TerminalAttachmentId, + data: string, + ): Promise { + if (Buffer.byteLength(data, "utf8") > this.config.maxInputBytes) + throw new Error("Terminal input exceeds the configured limit"); + await this.terminal(agent, id).write(attachmentId, data); + } + + /** + * Update the dimensions of the PTY and recovery screen. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param attachmentId - current writable attachment. + * @param cols - column count. + * @param rows - row count. + * @returns after the resize completes. + */ + @Remote + async resize( + agent: Agent, + id: WebTerminalId, + attachmentId: TerminalAttachmentId, + cols: number, + rows: number, + ): Promise { + this.dimensions(cols, rows); + await this.terminal(agent, id).resize(attachmentId, cols, rows); + } + + /** + * Rename a terminal without changing its shell. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param title - nonempty display title, at most 120 characters. + */ + @Remote + rename(agent: Agent, id: WebTerminalId, title: string): void { + if (title.trim().length === 0 || title.length > 120) + throw new Error("Terminal title must contain 1–120 characters"); + this.terminal(agent, id).rename(title.trim()); + } + + /** + * Close an identity to future creation and kill its process range; repeated closes succeed. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @returns after provider cleanup succeeds. A failure retains the terminal for retry. + */ + @Remote + async close(agent: Agent, id: WebTerminalId): Promise { + const owner = this.owner(agent); + owner.closedIds.add(id); + // create publishes the allocation before this wait settles; close owns it even if create then rejects. + await owner.pending.get(id)?.catch(() => { + /* Creation reports its failure; close still owns any allocated process. */ + }); + const terminal = owner.terminals.get(id); + if (terminal !== undefined) { + await terminal.close(); + owner.terminals.delete(id); + } else { + const allocation = owner.allocations.get(id); + if (allocation === undefined) return; + await allocation.cleanup.close(); + owner.allocations.delete(id); + } + } + + private owner(agent: Agent): OwnedSession { + let owner = this.owners.get(agent.id); + if (owner === undefined) { + owner = { + terminals: new Map(), + pending: new Map(), + allocations: new Map(), + closedIds: new Set(), + lifetime: new AbortController(), + }; + this.owners.set(agent.id, owner); + const owned = owner; + agent.ctx.effect( + () => async () => { + await this.disposeOwner(agent.id, owned); + }, + "terminal-controller.owner", + ); + } + return owner; + } + + private disposeOwner(id: SessionId, owner: OwnedSession): Promise { + if (owner.cleanup !== undefined) return owner.cleanup; + owner.lifetime.abort(new Error("Terminal Session owner disposed")); + owner.cleanup = (async () => { + await Promise.allSettled(owner.pending.values()); + const results = await Promise.allSettled([ + ...[...owner.terminals.values()].map((terminal) => terminal.dispose()), + ...[...owner.allocations.values()].map((allocation) => allocation.cleanup.dispose()), + ]); + const errors = results + .filter((result) => result.status === "rejected") + .map((result) => result.reason as unknown); + if (errors.length > 0) throw new AggregateError(errors, "Session terminal cleanup failed"); + owner.terminals.clear(); + owner.allocations.clear(); + this.owners.delete(id); + })().catch((error: unknown) => { + delete owner.cleanup; + throw error; + }); + return owner.cleanup; + } + + private terminal(agent: Agent, id: WebTerminalId): BrowserTerminal { + const terminal = this.owners.get(agent.id)?.terminals.get(id); + if (terminal === undefined) + throw new RemoteError( + "terminal/unavailable", + "Terminal no longer exists in this Session", + {}, + ); + this.requireOpen(this.owners.get(agent.id) as OwnedSession, id); + return terminal; + } + + private requireOpen(owner: OwnedSession, id: WebTerminalId): void { + if (owner.closedIds.has(id)) + throw new RemoteError("terminal/unavailable", "Terminal was closed in this Session", {}); + } + + private dimensions(cols: number, rows: number): void { + if ( + !Number.isSafeInteger(cols) || + cols < 2 || + cols > this.config.maxCols || + !Number.isSafeInteger(rows) || + rows < 1 || + rows > this.config.maxRows + ) + throw new Error("Terminal dimensions exceed the configured limits"); + } + + private execution(agent: Agent): { + subprocess: Context["subprocess"]; + sandboxPolicy: Context["sandboxPolicy"]; + } { + // The Agent context selects execution providers but does not inject consumer services. + const subprocess = agent.ctx.get("subprocess"); + const sandboxPolicy = agent.ctx.get("sandboxPolicy"); + if (subprocess === undefined || sandboxPolicy === undefined) + throw new Error( + "The Session execution environment requires subprocess and sandbox policy providers", + ); + return { subprocess, sandboxPolicy }; + } + + private async spawn( + agent: Agent, + owner: OwnedSession, + request: TerminalCreateRequest, + signal: AbortSignal, + ): Promise { + const environment = this.environment(agent, signal); + const { subprocess } = this.execution(agent); + const shell = + request.shellPath === undefined + ? await resolveShell(subprocess, this.config.shell, signal) + : (await this.shells(agent, signal)).find( + (candidate) => candidate.path === request.shellPath, + ); + if (shell === undefined) + throw new Error("Selected shell is not available in this execution environment"); + const handle = await subprocess.spawnTerminal({ + argv: [shell.path, ...shell.args], + cwd: environment.cwd, + cols: request.cols, + rows: request.rows, + terminalType: "xterm-256color", + env: { DSH_SESSION_ID: agent.id }, + shellActivity: true, + graceMs: this.config.disposeGraceMs, + signal, + }); + const info: WebTerminalInfo = { + id: request.id, + shell, + title: shell.name, + cwd: environment.cwd, + cols: request.cols, + rows: request.rows, + state: "running", + exitCode: null, + }; + try { + signal.throwIfAborted(); + return new BrowserTerminal( + handle, + info, + this.config.scrollback, + this.config.maxBufferedBytes, + ); + } catch (error) { + const cleanup = new TerminalRetention( + this.config, + handle.inspectActivity.bind(handle), + async () => { + owner.closedIds.add(request.id); + await handle.terminate(); + owner.allocations.delete(request.id); + }, + (cleanupError) => { + this.ctx.logger.error("Browser terminal allocation cleanup failed", cleanupError); + }, + ); + owner.allocations.set(request.id, { + info: { + ...info, + state: "failed", + error: error instanceof Error ? error.message : String(error), + }, + cleanup, + }); + try { + await cleanup.close(); + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], "Terminal allocation cleanup failed"); + } + throw error; + } + } +} + +/** Browser terminal service plugin. */ +export default TerminalController; diff --git a/apps/web-ui/packages/api/terminal-controller/src/retention.ts b/apps/web-ui/packages/api/terminal-controller/src/retention.ts new file mode 100644 index 000000000..93a89d892 --- /dev/null +++ b/apps/web-ui/packages/api/terminal-controller/src/retention.ts @@ -0,0 +1,168 @@ +/** Window holds and conservative idle reclamation for one terminal owner. */ +import { RemoteError } from "@deepseek-ai/dsh-typert-protocol"; +import type { SubprocessTerminalActivity } from "@deepseek-ai/dsh-subprocess"; +import type { TerminalRetentionFrame } from "./types.ts"; + +/** Validated Host timing policy for unattended terminal cleanup. */ +export interface TerminalRetentionPolicy { + readonly unattendedTimeoutMs: number; + readonly activityPollIntervalMs: number; + readonly cleanupRetryMs: number; +} + +/** Exactly one owner orders holds, observation, and retryable process cleanup. */ +export class TerminalRetention { + private readonly lifetime = new AbortController(); + private readonly holders = new Set(); + private epoch = 0; + private timer: ReturnType | undefined; + private observation: Promise | undefined; + private idle: { since: number; revision: number; observedAt: number } | undefined; + private closing = false; + private disposed = false; + private cleanup: Promise | undefined; + + /** + * @param policy - deployment timing choices. + * @param inspect - fresh shell and owned-job observation. + * @param terminate - mark the identity closed, await process quiescence, and remove its owner record. + * @param failed - diagnostic sink for failed automatic cleanup. + */ + constructor( + private readonly policy: TerminalRetentionPolicy, + private readonly inspect: () => Promise, + private readonly terminate: () => Promise, + private readonly failed: (error: unknown) => void, + ) { + this.schedule(0); + } + + /** + * Hold one terminal for one physical Remote stream, independently of screen subscriptions. + * @param signal - transport generation lifetime. + * @returns acknowledgement followed by an open stream until cancellation or terminal closure. + */ + async *retain(signal: AbortSignal): AsyncIterable { + signal.throwIfAborted(); + if (this.closing || this.disposed) + throw new RemoteError("terminal/unavailable", "Terminal is closing or unavailable", {}); + const holder = {}; + const ended = Promise.withResolvers(); + const combined = AbortSignal.any([signal, this.lifetime.signal]); + const release = (): void => { + if (!this.holders.delete(holder)) return; + combined.removeEventListener("abort", release); + this.invalidate(); + ended.resolve(); + this.schedule(0); + }; + this.holders.add(holder); + this.invalidate(); + this.cancelTimer(); + combined.addEventListener("abort", release, { once: true }); + try { + yield { type: "retained" }; + await ended.promise; + } finally { + release(); + } + } + + /** Invalidate outstanding idle observations before accepting input. */ + invalidate(): void { + this.epoch++; + this.idle = undefined; + } + + /** + * Start or join cleanup; failure keeps the identity closed and schedules one retry. + * @returns after owned process cleanup succeeds, or rejects with its failure. + */ + close(): Promise { + if (this.cleanup !== undefined) return this.cleanup; + this.closing = true; + this.invalidate(); + this.lifetime.abort(new Error("Terminal closed")); + this.cancelTimer(); + this.cleanup = this.terminate().catch((error: unknown) => { + this.cleanup = undefined; + this.schedule(this.policy.cleanupRetryMs); + throw error; + }); + return this.cleanup; + } + + /** + * Stop timers and streams and await both observation and final cleanup. + * @returns after process quiescence; cleanup failure is reported to the disposing owner. + */ + async dispose(): Promise { + this.disposed = true; + this.cancelTimer(); + const observation = this.observation; + try { + await this.close(); + } finally { + await observation; + } + } + + private cancelTimer(): void { + clearTimeout(this.timer); + this.timer = undefined; + } + + private schedule(delay: number): void { + if (this.disposed || this.timer !== undefined) return; + if (!this.closing && (this.holders.size > 0 || this.policy.unattendedTimeoutMs === 0)) return; + const due = performance.now() + delay; + this.timer = setTimeout( + () => { + this.timer = undefined; + const remaining = due - performance.now(); + if (remaining > 0) { + this.schedule(remaining); + return; + } + if (this.closing) { + void this.close().catch(this.failed); + return; + } + this.observe(); + }, + Math.min(delay, 2_147_483_647), + ); + this.timer.unref(); + } + + private observe(): void { + if (this.observation !== undefined) return; + const epoch = this.epoch; + this.observation = (async () => { + let activity: SubprocessTerminalActivity; + try { + activity = await this.inspect(); + } catch (_activityUnavailable) { + activity = { state: "unknown", revision: 0 }; + } + if (this.disposed || this.closing || this.holders.size > 0 || epoch !== this.epoch) return; + const now = performance.now(); + if (activity.state !== "idle") { + this.idle = undefined; + return; + } + if ( + this.idle?.revision !== activity.revision || + now - this.idle.observedAt > this.policy.activityPollIntervalMs * 2 + ) { + this.idle = { since: now, observedAt: now, revision: activity.revision }; + } else this.idle.observedAt = now; + if (now - this.idle.since >= this.policy.unattendedTimeoutMs) await this.close(); + })() + .catch(this.failed) + .finally(() => { + this.observation = undefined; + if (!this.closing) this.schedule(this.policy.activityPollIntervalMs); + }); + } +} diff --git a/apps/web-ui/packages/api/terminal-controller/src/shells.ts b/apps/web-ui/packages/api/terminal-controller/src/shells.ts new file mode 100644 index 000000000..face12a0d --- /dev/null +++ b/apps/web-ui/packages/api/terminal-controller/src/shells.ts @@ -0,0 +1,86 @@ +/** Shell selection and executable verification use the target execution provider. */ +import type { SubprocessRuntime } from "@deepseek-ai/dsh-subprocess"; +import type { TerminalShell } from "./types.ts"; + +/** + * Resolve the configured shell or the execution environment's default shell. + * @param subprocess - target execution provider. + * @param configured - optional profile overriding the environment's default shell. + * @param signal - resolution cancellation. + * @returns one verified shell; a declared default that cannot resolve rejects. + */ +export async function resolveShell( + subprocess: SubprocessRuntime, + configured: TerminalShell | undefined, + signal: AbortSignal, +): Promise { + let shell = configured; + if (shell === undefined) { + const environment = await subprocess.terminalEnvironment(signal); + shell = profile( + environment.defaultShell ?? (environment.platform === "windows" ? "cmd.exe" : "/bin/sh"), + ); + } + const path = await subprocess.resolveExecutable(shell.path, undefined, signal); + return { ...shell, path }; +} + +function profile(path: string): TerminalShell { + const kind = shellKind(path); + return { + path, + name: executableName(path), + args: kind === "cmd" ? [] : kind === "pwsh" || kind === "powershell" ? ["-NoLogo"] : ["-i"], + }; +} + +function executableName(path: string): string { + return path.slice(Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + 1); +} + +function shellKind(path: string): string { + return executableName(path) + .toLowerCase() + .replace(/\.exe$/u, ""); +} + +/** + * List verified candidates after the configured or environment-default shell, one per executable name ignoring case and `.exe`. + * PATH lookup can reach the default through another directory, such as `/usr/bin/bash` for `/bin/bash` on merged-`/usr` systems. + * @param subprocess - target execution provider. + * @param configured - optional default profile. + * @param candidates - executable names or paths permitted for shell selection. + * @param signal - discovery cancellation. + * @returns installed shells with the default first, keeping the earliest entry per name; transport failures reject. + */ +export async function discoverShells( + subprocess: SubprocessRuntime, + configured: TerminalShell | undefined, + candidates: readonly string[], + signal: AbortSignal, +): Promise { + const preferred = await resolveShell(subprocess, configured, signal); + const found = await Promise.all( + candidates.map(async (candidate) => { + try { + return await resolveShell(subprocess, profile(candidate), signal); + } catch (error) { + if ( + typeof error === "object" && + error !== null && + "name" in error && + error.name === "SubprocessExecutableNotFoundError" + ) + return undefined; + throw error; + } + }), + ); + const shells = new Map(); + for (const shell of [preferred, ...found]) { + if (shell === undefined) continue; + const kind = shellKind(shell.path); + if (!shells.has(kind)) shells.set(kind, shell); + } + return [...shells.values()]; +} diff --git a/apps/web-ui/packages/api/terminal-controller/src/stream.ts b/apps/web-ui/packages/api/terminal-controller/src/stream.ts new file mode 100644 index 000000000..c77319dd7 --- /dev/null +++ b/apps/web-ui/packages/api/terminal-controller/src/stream.ts @@ -0,0 +1,81 @@ +/** A bounded output queue for one Remote stream generation. */ +import { Deque } from "@deepseek-ai/dsh-deque"; +import type { TerminalFrame } from "./types.ts"; + +/** Slow followers fail explicitly; a later attachment recovers from the screen. */ +export class TerminalFollower { + private readonly queue = new Deque<{ frame: TerminalFrame; bytes: number }>(); + private bytes = 0; + private wake: (() => void) | undefined; + private closed = false; + private finished = false; + private failure: Error | undefined; + + /** @param maxBytes - maximum queued UTF-8 bytes for this follower. */ + constructor(private readonly maxBytes: number) {} + + /** + * Queue a frame or fail this follower when its byte limit is exceeded. + * @param frame - next ordered frame. + */ + push(frame: TerminalFrame): void { + if (this.closed || this.finished) return; + const bytes = Buffer.byteLength(JSON.stringify(frame), "utf8"); + if (this.bytes + bytes > this.maxBytes) { + this.failure = new Error( + "Terminal output consumer exceeded its buffer; reconnect to recover the current screen", + ); + this.close(); + return; + } + this.queue.pushBack({ frame, bytes }); + this.bytes += bytes; + this.wake?.(); + } + + /** Finish after delivering every queued frame, including the final exit state. */ + finish(): void { + this.finished = true; + this.wake?.(); + } + + /** Stop this follower without stopping its terminal. */ + close(): void { + this.closed = true; + this.queue.clear(); + this.bytes = 0; + this.wake?.(); + } + + /** + * Drain until detached or failed. + * @param signal - Remote generation cancellation. + * @returns ordered terminal frames. + */ + async *read(signal: AbortSignal): AsyncIterable { + const abort = (): void => { + this.close(); + }; + signal.addEventListener("abort", abort, { once: true }); + if (signal.aborted) abort(); + try { + while (!this.closed) { + const next = this.queue.popFront(); + if (next !== undefined) { + this.bytes -= next.bytes; + yield next.frame; + } else { + if (this.finished) break; + await new Promise((resolve) => { + this.wake = resolve; + }); + this.wake = undefined; + } + } + if (this.failure !== undefined) throw this.failure; + } finally { + signal.removeEventListener("abort", abort); + this.close(); + } + } +} diff --git a/apps/web-ui/packages/api/terminal-controller/src/terminal.ts b/apps/web-ui/packages/api/terminal-controller/src/terminal.ts new file mode 100644 index 000000000..fecbc73a2 --- /dev/null +++ b/apps/web-ui/packages/api/terminal-controller/src/terminal.ts @@ -0,0 +1,266 @@ +/** One PTY, a bounded terminal emulator and its detachable browser followers. */ +import { RemoteError } from "@deepseek-ai/dsh-typert-protocol"; +import type { Terminal as HeadlessTerminal } from "@xterm/headless"; +import type { SerializeAddon as Serializer } from "@xterm/addon-serialize"; +import type { SubprocessTerminalHandle } from "@deepseek-ai/dsh-subprocess"; +import { createLazyRequire } from "@deepseek-ai/dsh-lazy-require"; +import { TerminalFollower } from "./stream.ts"; +import { TerminalRetention, type TerminalRetentionPolicy } from "./retention.ts"; +import type { + TerminalAttachmentId, + TerminalFrame, + TerminalRetentionFrame, + WebTerminalInfo, +} from "./types.ts"; + +const requireHeadless = createLazyRequire( + "@xterm/headless", + import.meta.url, +); +const requireSerialize = createLazyRequire( + "@xterm/addon-serialize", + import.meta.url, +); + +/** Process lifetime is independent of follower and component lifetimes. */ +export class BrowserTerminal { + private readonly screen: HeadlessTerminal; + private readonly serializer: Serializer; + private readonly followers = new Set(); + private sequence = 0; + private operations: Promise = Promise.resolve(); + private readonly drained: Promise; + private closing: Promise | undefined; + private retention: TerminalRetention | undefined; + private controller: { id: TerminalAttachmentId; follower: TerminalFollower } | undefined; + + /** + * @param handle - allocated terminal process range. + * @param info - initial metadata. + * @param scrollback - maximum retained scrollback rows. + * @param maxBufferedBytes - per-follower queue cap. + */ + constructor( + private readonly handle: SubprocessTerminalHandle, + public info: WebTerminalInfo, + scrollback: number, + private readonly maxBufferedBytes: number, + ) { + const { Terminal } = requireHeadless(); + const { SerializeAddon } = requireSerialize(); + this.screen = new Terminal({ + cols: info.cols, + rows: info.rows, + scrollback, + allowProposedApi: true, + }); + this.serializer = new SerializeAddon(); + this.screen.loadAddon(this.serializer); + this.drained = this.consume(); + } + + /** + * Start monitoring after this allocation is committed to its Session owner. + * @param policy - validated Host timing policy. + * @param closing - closes the id before any asynchronous termination. + * @param closed - removes the exact successfully terminated owner record. + * @param failed - diagnostic sink for background cleanup failure. + */ + monitor( + policy: TerminalRetentionPolicy, + closing: () => void, + closed: () => void, + failed: (error: unknown) => void, + ): void { + this.retention = new TerminalRetention( + policy, + () => this.handle.inspectActivity(), + async () => { + closing(); + await this.closeProcess(); + closed(); + }, + failed, + ); + } + + /** + * Retain this committed process independently of output attachment. + * @param signal - physical window stream lifetime. + * @returns its hold acknowledgement and lifetime. + */ + retain(signal: AbortSignal): AsyncIterable { + if (this.retention === undefined) throw new Error("Terminal has not been committed"); + return this.retention.retain(signal); + } + + /** + * Attach with exclusive input control; an older attachment becomes read-only. + * @param id - browser attachment identity. + * @param signal - attachment cancellation; never terminates the process. + * @returns a consistent screen followed by ordered output and state changes. + */ + async *follow(id: TerminalAttachmentId, signal: AbortSignal): AsyncIterable { + signal.throwIfAborted(); + const follower = new TerminalFollower(this.maxBufferedBytes); + const baseline = await this.enqueue(() => { + signal.throwIfAborted(); + this.controller = { id, follower }; + this.info = { ...this.info, controllerId: id }; + this.broadcast({ type: "state", info: this.info }); + const snapshot: TerminalFrame = { + type: "snapshot", + sequence: this.sequence, + screen: this.serializer.serialize(), + info: this.info, + }; + this.followers.add(follower); + return snapshot; + }); + try { + yield baseline; + yield* follower.read(signal); + } finally { + this.followers.delete(follower); + follower.close(); + if (this.controller?.follower === follower) { + this.controller = undefined; + const { controllerId: _controllerId, ...info } = this.info; + this.info = info; + this.broadcast({ type: "state", info }); + } + } + } + + /** + * Send raw terminal input without command interpretation. + * @param id - current writable attachment. + * @param data - UTF-8 input, including shell completion/control keys. + * @returns when the provider accepts the input. + */ + write(id: TerminalAttachmentId, data: string): Promise { + this.retention?.invalidate(); + return this.enqueue(async () => { + this.requireController(id); + await this.handle.write(data); + }); + } + + /** + * Resize the PTY and recovery screen in the same operation order as output. + * @param id - current writable attachment. + * @param cols - validated column count. + * @param rows - validated row count. + * @returns when the provider and emulator use the new dimensions. + */ + resize(id: TerminalAttachmentId, cols: number, rows: number): Promise { + return this.enqueue(async () => { + this.requireController(id); + await this.handle.resize(cols, rows); + this.screen.resize(cols, rows); + this.info = { ...this.info, cols, rows }; + this.broadcast({ type: "state", info: this.info }); + }); + } + + /** + * Publish a display name to every attached view. + * @param title - validated user title. + */ + rename(title: string): void { + this.info = { ...this.info, title }; + this.broadcast({ type: "state", info: this.info }); + } + + /** + * Terminate the complete provider-owned process range before releasing its screen. + * @returns after process cleanup and final output drainage; failures remain retryable. + */ + close(): Promise { + return this.retention?.close() ?? this.closeProcess(); + } + + /** + * Stop unattended cleanup scheduling and await final process cleanup. + * @returns after terminal and monitor quiescence. + */ + dispose(): Promise { + return this.retention?.dispose() ?? this.closeProcess(); + } + + private closeProcess(): Promise { + if (this.closing !== undefined) return this.closing; + this.closing = (async () => { + await this.handle.terminate(); + await this.drained; + for (const follower of this.followers) follower.finish(); + this.followers.clear(); + this.screen.dispose(); + })().catch((error: unknown) => { + this.closing = undefined; + throw error; + }); + return this.closing; + } + + private requireController(id: TerminalAttachmentId): void { + if (this.closing !== undefined || this.info.state !== "running") + throw new RemoteError("terminal/control-unavailable", "Terminal is not running", { + reason: "not-running", + }); + if (this.controller?.id !== id) + throw new RemoteError( + "terminal/control-unavailable", + "Terminal input is controlled by another attachment", + { reason: "read-only" }, + ); + } + + private broadcast(frame: TerminalFrame): void { + for (const follower of this.followers) follower.push(frame); + } + + private enqueue(operation: () => T | Promise): Promise { + const pending = this.operations.then(operation); + this.operations = pending.catch(() => { + /* The caller owns this operation's failure; later cleanup must still run. */ + }); + return pending; + } + + private async consume(): Promise { + const decoder = new TextDecoder("utf-8", { ignoreBOM: true }); + const outcome = this.handle.done.then( + (value) => ({ value }), + (error: unknown) => ({ error }), + ); + try { + for await (const chunk of this.handle.output) { + // Node Readable's iterator is untyped; this provider explicitly emits Buffer chunks. + const data = decoder.decode(chunk as Buffer, { stream: true }); + await this.output(data); + } + await this.output(decoder.decode()); + const result = await outcome; + if ("error" in result) throw result.error; + this.info = { ...this.info, state: "exited", exitCode: result.value.exitCode }; + } catch (error) { + this.info = { + ...this.info, + state: "failed", + error: error instanceof Error ? error.message : String(error), + }; + } + this.broadcast({ type: "state", info: this.info }); + } + + private async output(data: string): Promise { + if (data.length === 0) return; + await this.enqueue(async () => { + await new Promise((resolve) => { + this.screen.write(data, resolve); + }); + this.broadcast({ type: "output", sequence: ++this.sequence, data }); + }); + } +} diff --git a/apps/web-ui/packages/api/terminal-controller/src/types.ts b/apps/web-ui/packages/api/terminal-controller/src/types.ts new file mode 100644 index 000000000..8fc0e9f7b --- /dev/null +++ b/apps/web-ui/packages/api/terminal-controller/src/types.ts @@ -0,0 +1,75 @@ +/** Browser terminal identities, metadata and screen-stream frames. */ +import type { Branded } from "@deepseek-ai/dsh-brand"; +import type {} from "@deepseek-ai/dsh-typert-protocol"; + +declare module "@deepseek-ai/dsh-typert-protocol" { + interface RemoteErrorDetailsMap { + /** The terminal identity is missing or has begun process cleanup. */ + "terminal/unavailable": Record; + /** Input or resize was refused without invalidating the output attachment. */ + "terminal/control-unavailable": { readonly reason: "read-only" | "not-running" }; + /** Retained screens and pending allocations consume the Session's terminal quota. */ + "terminal/limit-reached": { readonly limit: number }; + } +} + +/** A terminal identity scoped to one Session and one Host lifetime. */ +export type WebTerminalId = Branded<"WebTerminalId">; +/** An attachment allowed to write and resize one terminal. */ +export type TerminalAttachmentId = Branded<"TerminalAttachmentId">; + +/** Acknowledges one physical window hold without taking screen or input control. */ +export interface TerminalRetentionFrame { + readonly type: "retained"; +} + +/** An executable shell verified in the subprocess provider's execution environment. */ +export interface TerminalShell { + readonly path: string; + readonly args: readonly string[]; + readonly name: string; +} + +/** Working directory and limits shared by new and restored terminals. */ +export interface TerminalEnvironment { + readonly cwd: string; + readonly maxInputBytes: number; + readonly maxCols: number; + readonly maxRows: number; + readonly scrollback: number; +} + +/** Host-owned terminal state; process exit never creates a replacement shell. */ +export interface WebTerminalInfo { + readonly id: WebTerminalId; + readonly title: string; + readonly shell: TerminalShell; + /** Initial working directory; shell directory changes do not update this field. */ + readonly cwd: string; + readonly cols: number; + readonly rows: number; + readonly state: "running" | "exited" | "failed"; + readonly exitCode: number | null; + readonly error?: string; + readonly controllerId?: TerminalAttachmentId; +} + +/** Create is idempotent for an open identity; closed identities cannot be recreated. */ +export interface TerminalCreateRequest { + /** A path returned by shell discovery; absent selects the execution default. */ + readonly shellPath?: string; + readonly id: WebTerminalId; + readonly cols: number; + readonly rows: number; +} + +/** Every attachment begins with a complete bounded screen, then ordered output. */ +export type TerminalFrame = + | { + readonly type: "snapshot"; + readonly sequence: number; + readonly screen: string; + readonly info: WebTerminalInfo; + } + | { readonly type: "output"; readonly sequence: number; readonly data: string } + | { readonly type: "state"; readonly info: WebTerminalInfo }; diff --git a/apps/web-ui/packages/api/terminal-controller/tsdown.config.ts b/apps/web-ui/packages/api/terminal-controller/tsdown.config.ts new file mode 100644 index 000000000..718035751 --- /dev/null +++ b/apps/web-ui/packages/api/terminal-controller/tsdown.config.ts @@ -0,0 +1,5 @@ +import { clientBundle } from "../../client/tsdown.client.ts"; + +export default clientBundle("@deepseek-ai/dsh-api-terminal-controller", ["lib/types/index.js"], { + hostPhase: true, +}); diff --git a/apps/web-ui/packages/api/workspace-controller/generated/remote.d.ts b/apps/web-ui/packages/api/workspace-controller/generated/remote.d.ts new file mode 100644 index 000000000..bffde02e4 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-controller/generated/remote.d.ts @@ -0,0 +1,53 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import type { + RemoteResult, + RemoteStreamHandle, + TypertRemoteContribution, +} from '@deepseek-ai/dsh-typert-protocol' +import type { WorkspaceArchiveSessionRequest, WorkspaceArchiveValue, WorkspaceCreateRequest, WorkspaceCreateValue, WorkspaceDeleteRequest, WorkspaceDeleteValue, WorkspaceFollowFrame, WorkspaceInsertBeforeRequest, WorkspaceInsertSessionBeforeRequest, WorkspaceOrderValue, WorkspacePinSessionRequest, WorkspacePinValue, WorkspaceRenameRequest, WorkspaceUnarchiveSessionRequest, WorkspaceUnpinSessionRequest, WorkspaceValue } from '@deepseek-ai/dsh-api-workspace-controller/types' +import type { DirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertRemoteNamespace$6469726563746f72795069636b6572 { + createDirectory: (path: string, name: string) => Promise> + list: (path: string | undefined, signal?: AbortSignal) => Promise> + pick: (signal?: AbortSignal) => Promise> + } + interface TypertRemoteNamespace$776f726b7370616365 { + archiveSession: (request: WorkspaceArchiveSessionRequest) => Promise> + create: (request: WorkspaceCreateRequest) => Promise> + delete: (request: WorkspaceDeleteRequest) => Promise> + follow: (signal?: AbortSignal) => RemoteStreamHandle + initializeDefault: (signal?: AbortSignal) => Promise> + insertBefore: (request: WorkspaceInsertBeforeRequest) => Promise> + insertSessionBefore: (request: WorkspaceInsertSessionBeforeRequest) => Promise> + pinSession: (request: WorkspacePinSessionRequest) => Promise> + rename: (request: WorkspaceRenameRequest) => Promise> + unarchiveSession: (request: WorkspaceUnarchiveSessionRequest) => Promise> + unpinSession: (request: WorkspaceUnpinSessionRequest) => Promise> + } + interface TypertRemoteMap { + 'directoryPicker/createDirectory': (path: string, name: string) => Promise> + 'directoryPicker/list': (path: string | undefined, signal?: AbortSignal) => Promise> + 'directoryPicker/pick': (signal?: AbortSignal) => Promise> + 'workspace/archiveSession': (request: WorkspaceArchiveSessionRequest) => Promise> + 'workspace/create': (request: WorkspaceCreateRequest) => Promise> + 'workspace/delete': (request: WorkspaceDeleteRequest) => Promise> + 'workspace/follow': (signal?: AbortSignal) => RemoteStreamHandle + 'workspace/initializeDefault': (signal?: AbortSignal) => Promise> + 'workspace/insertBefore': (request: WorkspaceInsertBeforeRequest) => Promise> + 'workspace/insertSessionBefore': (request: WorkspaceInsertSessionBeforeRequest) => Promise> + 'workspace/pinSession': (request: WorkspacePinSessionRequest) => Promise> + 'workspace/rename': (request: WorkspaceRenameRequest) => Promise> + 'workspace/unarchiveSession': (request: WorkspaceUnarchiveSessionRequest) => Promise> + 'workspace/unpinSession': (request: WorkspaceUnpinSessionRequest) => Promise> + } + interface TypertRemoteNamespaceMap { + 'directoryPicker': TypertRemoteNamespace$6469726563746f72795069636b6572 + 'workspace': TypertRemoteNamespace$776f726b7370616365 + } +} + +export declare const TYPERT_REMOTE: TypertRemoteContribution +export default TYPERT_REMOTE +//# sourceMappingURL=typert.remote-client.d.ts.map diff --git a/apps/web-ui/packages/api/workspace-controller/generated/remote.js b/apps/web-ui/packages/api/workspace-controller/generated/remote.js new file mode 100644 index 000000000..b7a1babd5 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-controller/generated/remote.js @@ -0,0 +1,521 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import { z } from 'zod' + +let _deepseek_ai_dsh_api_workspace_controller_directoryPicker_createDirectory_parameter_0$schema$value +const _deepseek_ai_dsh_api_workspace_controller_directoryPicker_createDirectory_parameter_0$schema = () => (_deepseek_ai_dsh_api_workspace_controller_directoryPicker_createDirectory_parameter_0$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_workspace_controller_directoryPicker_createDirectory_parameter_1$schema$value +const _deepseek_ai_dsh_api_workspace_controller_directoryPicker_createDirectory_parameter_1$schema = () => (_deepseek_ai_dsh_api_workspace_controller_directoryPicker_createDirectory_parameter_1$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_workspace_controller_directoryPicker_createDirectory_result$schema$value +const _deepseek_ai_dsh_api_workspace_controller_directoryPicker_createDirectory_result$schema = () => (_deepseek_ai_dsh_api_workspace_controller_directoryPicker_createDirectory_result$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_workspace_controller_directoryPicker_list_parameter_0$schema$value +const _deepseek_ai_dsh_api_workspace_controller_directoryPicker_list_parameter_0$schema = () => (_deepseek_ai_dsh_api_workspace_controller_directoryPicker_list_parameter_0$schema$value ??= z.union([z.undefined(), z.string()])) +let _deepseek_ai_dsh_api_workspace_controller_directoryPicker_list_result$schema$value +const _deepseek_ai_dsh_api_workspace_controller_directoryPicker_list_result$schema = () => (_deepseek_ai_dsh_api_workspace_controller_directoryPicker_list_result$schema$value ??= z.object({ + 'path': z.string(), + 'home': z.string(), + 'crumbs': z.array(z.object({ + 'name': z.string(), + 'path': z.string(), + 'hidden': z.boolean(), +})), + 'entries': z.array(z.object({ + 'name': z.string(), + 'path': z.string(), + 'hidden': z.boolean(), +})), + 'truncated': z.boolean(), +})) +let _deepseek_ai_dsh_api_workspace_controller_directoryPicker_pick_result$schema$value +const _deepseek_ai_dsh_api_workspace_controller_directoryPicker_pick_result$schema = () => (_deepseek_ai_dsh_api_workspace_controller_directoryPicker_pick_result$schema$value ??= z.union([z.literal(null), z.string()])) +let _deepseek_ai_dsh_api_workspace_controller_workspace_archiveSession_parameter_0$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_archiveSession_parameter_0$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_archiveSession_parameter_0$schema$value ??= z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), + 'stopActivity': z.boolean().readonly().optional(), +})) +let _deepseek_ai_dsh_api_workspace_controller_workspace_archiveSession_result$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_archiveSession_result$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_archiveSession_result$schema$value ??= z.object({ + 'archivedSessionIds': z.array(z.intersection(z.string(), z.unknown())).readonly(), +})) +let _deepseek_ai_dsh_api_workspace_controller_workspace_create_parameter_0$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_create_parameter_0$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_create_parameter_0$schema$value ??= z.object({ + 'path': z.string().readonly(), +})) +let _deepseek_ai_dsh_api_workspace_controller_workspace_create_result$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_create_result$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_create_result$schema$value ??= z.object({ + 'workspace': z.object({ + 'workspaceId': z.intersection(z.string(), z.unknown()).readonly(), + 'path': z.string().readonly(), + 'title': z.string().readonly(), + 'sessionIds': z.array(z.intersection(z.string(), z.unknown())).readonly(), + 'createdAt': z.string().readonly(), + 'updatedAt': z.string().readonly(), +}).readonly(), + 'created': z.boolean().readonly(), +})) +let _deepseek_ai_dsh_api_workspace_controller_workspace_delete_parameter_0$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_delete_parameter_0$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_delete_parameter_0$schema$value ??= z.object({ + 'workspaceId': z.intersection(z.string(), z.unknown()).readonly(), +})) +let _deepseek_ai_dsh_api_workspace_controller_workspace_delete_result$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_delete_result$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_delete_result$schema$value ??= z.object({ + 'deleted': z.literal(true).readonly(), +})) +let _deepseek_ai_dsh_api_workspace_controller_workspace_follow_result$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_follow_result$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_follow_result$schema$value ??= z.union([z.object({ + 'type': z.literal("baseline").readonly(), + 'value': z.object({ + 'items': z.array(z.object({ + 'workspaceId': z.intersection(z.string(), z.unknown()).readonly(), + 'path': z.string().readonly(), + 'title': z.string().readonly(), + 'sessionIds': z.array(z.intersection(z.string(), z.unknown())).readonly(), + 'createdAt': z.string().readonly(), + 'updatedAt': z.string().readonly(), +})).readonly(), + 'archivedSessionIds': z.array(z.intersection(z.string(), z.unknown())).readonly(), + 'pinnedSessionIds': z.array(z.intersection(z.string(), z.unknown())).readonly(), +}).readonly(), +}), z.object({ + 'type': z.literal("upsert").readonly(), + 'workspace': z.object({ + 'workspaceId': z.intersection(z.string(), z.unknown()).readonly(), + 'path': z.string().readonly(), + 'title': z.string().readonly(), + 'sessionIds': z.array(z.intersection(z.string(), z.unknown())).readonly(), + 'createdAt': z.string().readonly(), + 'updatedAt': z.string().readonly(), +}).readonly(), +}), z.object({ + 'type': z.literal("remove").readonly(), + 'workspaceId': z.intersection(z.string(), z.unknown()).readonly(), +}), z.object({ + 'type': z.literal("order").readonly(), + 'workspaceIds': z.array(z.intersection(z.string(), z.unknown())).readonly(), +}), z.object({ + 'type': z.literal("archived").readonly(), + 'archivedSessionIds': z.array(z.intersection(z.string(), z.unknown())).readonly(), +}), z.object({ + 'type': z.literal("pinned").readonly(), + 'pinnedSessionIds': z.array(z.intersection(z.string(), z.unknown())).readonly(), +})])) +let _deepseek_ai_dsh_api_workspace_controller_workspace_initializeDefault_result$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_initializeDefault_result$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_initializeDefault_result$schema$value ??= z.union([z.undefined(), z.object({ + 'workspace': z.object({ + 'workspaceId': z.intersection(z.string(), z.unknown()).readonly(), + 'path': z.string().readonly(), + 'title': z.string().readonly(), + 'sessionIds': z.array(z.intersection(z.string(), z.unknown())).readonly(), + 'createdAt': z.string().readonly(), + 'updatedAt': z.string().readonly(), +}).readonly(), +})])) +let _deepseek_ai_dsh_api_workspace_controller_workspace_insertBefore_parameter_0$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_insertBefore_parameter_0$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_insertBefore_parameter_0$schema$value ??= z.object({ + 'workspaceId': z.intersection(z.string(), z.unknown()).readonly(), + 'beforeWorkspaceId': z.intersection(z.string(), z.unknown()).readonly().optional(), +})) +let _deepseek_ai_dsh_api_workspace_controller_workspace_insertBefore_result$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_insertBefore_result$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_insertBefore_result$schema$value ??= z.object({ + 'workspaceIds': z.array(z.intersection(z.string(), z.unknown())).readonly(), +})) +let _deepseek_ai_dsh_api_workspace_controller_workspace_insertSessionBefore_parameter_0$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_insertSessionBefore_parameter_0$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_insertSessionBefore_parameter_0$schema$value ??= z.object({ + 'workspaceId': z.intersection(z.string(), z.unknown()).readonly(), + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), + 'beforeSessionId': z.intersection(z.string(), z.unknown()).readonly().optional(), +})) +let _deepseek_ai_dsh_api_workspace_controller_workspace_insertSessionBefore_result$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_insertSessionBefore_result$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_insertSessionBefore_result$schema$value ??= z.object({ + 'workspace': z.object({ + 'workspaceId': z.intersection(z.string(), z.unknown()).readonly(), + 'path': z.string().readonly(), + 'title': z.string().readonly(), + 'sessionIds': z.array(z.intersection(z.string(), z.unknown())).readonly(), + 'createdAt': z.string().readonly(), + 'updatedAt': z.string().readonly(), +}).readonly(), +})) +let _deepseek_ai_dsh_api_workspace_controller_workspace_pinSession_parameter_0$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_pinSession_parameter_0$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_pinSession_parameter_0$schema$value ??= z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), +})) +let _deepseek_ai_dsh_api_workspace_controller_workspace_pinSession_result$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_pinSession_result$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_pinSession_result$schema$value ??= z.object({ + 'pinnedSessionIds': z.array(z.intersection(z.string(), z.unknown())).readonly(), +})) +let _deepseek_ai_dsh_api_workspace_controller_workspace_rename_parameter_0$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_rename_parameter_0$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_rename_parameter_0$schema$value ??= z.object({ + 'workspaceId': z.intersection(z.string(), z.unknown()).readonly(), + 'title': z.string().readonly(), +})) +let _deepseek_ai_dsh_api_workspace_controller_workspace_rename_result$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_rename_result$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_rename_result$schema$value ??= z.object({ + 'workspace': z.object({ + 'workspaceId': z.intersection(z.string(), z.unknown()).readonly(), + 'path': z.string().readonly(), + 'title': z.string().readonly(), + 'sessionIds': z.array(z.intersection(z.string(), z.unknown())).readonly(), + 'createdAt': z.string().readonly(), + 'updatedAt': z.string().readonly(), +}).readonly(), +})) +let _deepseek_ai_dsh_api_workspace_controller_workspace_unarchiveSession_parameter_0$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_unarchiveSession_parameter_0$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_unarchiveSession_parameter_0$schema$value ??= z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), +})) +let _deepseek_ai_dsh_api_workspace_controller_workspace_unarchiveSession_result$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_unarchiveSession_result$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_unarchiveSession_result$schema$value ??= z.object({ + 'archivedSessionIds': z.array(z.intersection(z.string(), z.unknown())).readonly(), +})) +let _deepseek_ai_dsh_api_workspace_controller_workspace_unpinSession_parameter_0$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_unpinSession_parameter_0$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_unpinSession_parameter_0$schema$value ??= z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), +})) +let _deepseek_ai_dsh_api_workspace_controller_workspace_unpinSession_result$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_unpinSession_result$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_unpinSession_result$schema$value ??= z.object({ + 'pinnedSessionIds': z.array(z.intersection(z.string(), z.unknown())).readonly(), +})) + +export const TYPERT_REMOTE = { + package: '@deepseek-ai/dsh-api-workspace-controller', + descriptors: [ + { + id: '@deepseek-ai/dsh-api-workspace-controller#directoryPicker/createDirectory', + service: 'directoryPickerController', + namespace: 'directoryPicker', + method: 'createDirectory', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'path', + wire: 'path', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller#directoryPicker/createDirectory:path', + create: _deepseek_ai_dsh_api_workspace_controller_directoryPicker_createDirectory_parameter_0$schema, + }, + }, + { + name: 'name', + wire: 'name', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller#directoryPicker/createDirectory:name', + create: _deepseek_ai_dsh_api_workspace_controller_directoryPicker_createDirectory_parameter_1$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller#directoryPicker/createDirectory:result', + create: _deepseek_ai_dsh_api_workspace_controller_directoryPicker_createDirectory_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-controller/src/directory-picker.ts","line":88,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-workspace-controller#directoryPicker/list', + service: 'directoryPickerController', + namespace: 'directoryPicker', + method: 'list', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'path', + wire: 'path', + source: 'json', + acceptsUndefined: true, + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller#directoryPicker/list:path', + create: _deepseek_ai_dsh_api_workspace_controller_directoryPicker_list_parameter_0$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-host-directory-picker/types#DirectoryListing', + create: _deepseek_ai_dsh_api_workspace_controller_directoryPicker_list_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-controller/src/directory-picker.ts","line":72,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-workspace-controller#directoryPicker/pick', + service: 'directoryPickerController', + namespace: 'directoryPicker', + method: 'pick', + invocation: { kind: 'direct' }, + parameters: [ + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller#directoryPicker/pick:result', + create: _deepseek_ai_dsh_api_workspace_controller_directoryPicker_pick_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-controller/src/directory-picker.ts","line":55,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-workspace-controller#workspace/archiveSession', + service: 'workspaceController', + namespace: 'workspace', + method: 'archiveSession', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspaceArchiveSessionRequest', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_archiveSession_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspaceArchiveValue', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_archiveSession_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-controller/src/index.ts","line":155,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-workspace-controller#workspace/create', + service: 'workspaceController', + namespace: 'workspace', + method: 'create', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspaceCreateRequest', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_create_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspaceCreateValue', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_create_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-controller/src/index.ts","line":86,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-workspace-controller#workspace/delete', + service: 'workspaceController', + namespace: 'workspace', + method: 'delete', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspaceDeleteRequest', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_delete_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspaceDeleteValue', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_delete_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-controller/src/index.ts","line":125,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-workspace-controller#workspace/follow', + service: 'workspaceController', + namespace: 'workspace', + method: 'follow', + mode: 'stream', + invocation: { kind: 'direct' }, + parameters: [ + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspaceFollowFrame', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_follow_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-controller/src/index.ts","line":195,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-workspace-controller#workspace/initializeDefault', + service: 'workspaceController', + namespace: 'workspace', + method: 'initializeDefault', + invocation: { kind: 'direct' }, + parameters: [ + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller#workspace/initializeDefault:result', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_initializeDefault_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-controller/src/index.ts","line":99,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-workspace-controller#workspace/insertBefore', + service: 'workspaceController', + namespace: 'workspace', + method: 'insertBefore', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspaceInsertBeforeRequest', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_insertBefore_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspaceOrderValue', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_insertBefore_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-controller/src/index.ts","line":135,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-workspace-controller#workspace/insertSessionBefore', + service: 'workspaceController', + namespace: 'workspace', + method: 'insertSessionBefore', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspaceInsertSessionBeforeRequest', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_insertSessionBefore_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspaceValue', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_insertSessionBefore_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-controller/src/index.ts","line":145,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-workspace-controller#workspace/pinSession', + service: 'workspaceController', + namespace: 'workspace', + method: 'pinSession', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspacePinSessionRequest', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_pinSession_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspacePinValue', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_pinSession_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-controller/src/index.ts","line":175,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-workspace-controller#workspace/rename', + service: 'workspaceController', + namespace: 'workspace', + method: 'rename', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspaceRenameRequest', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_rename_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspaceValue', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_rename_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-controller/src/index.ts","line":115,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-workspace-controller#workspace/unarchiveSession', + service: 'workspaceController', + namespace: 'workspace', + method: 'unarchiveSession', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspaceUnarchiveSessionRequest', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_unarchiveSession_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspaceArchiveValue', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_unarchiveSession_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-controller/src/index.ts","line":165,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-workspace-controller#workspace/unpinSession', + service: 'workspaceController', + namespace: 'workspace', + method: 'unpinSession', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspaceUnpinSessionRequest', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_unpinSession_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspacePinValue', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_unpinSession_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-controller/src/index.ts","line":185,"column":3}, + }, + ], +} + +export default TYPERT_REMOTE diff --git a/apps/web-ui/packages/api/workspace-controller/package.json b/apps/web-ui/packages/api/workspace-controller/package.json new file mode 100644 index 000000000..741cbb8b0 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-controller/package.json @@ -0,0 +1,53 @@ +{ + "name": "@deepseek-ai/dsh-api-workspace-controller", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./types": { + "types": "./src/types.ts", + "default": "./src/types.ts" + }, + "./client": { + "types": "./src/client/index.ts", + "default": "./lib/client.js" + }, + "./default-workspace": { + "types": "./src/default-workspace.ts", + "default": "./src/default-workspace.ts" + }, + "./typert": { + "types": "./src/typert.host.ts", + "default": "./src/typert.host.ts" + }, + "./remote": { + "types": "./generated/remote.d.ts", + "default": "./generated/remote.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dependencies": { + "@deepseek-ai/dsh-api-gateway": "*", + "@deepseek-ai/cordis": "*", + "@deepseek-ai/dsh-client-store": "*", + "zod": "^4.4.3" + }, + "dsh": { + "client": { + "external": [ + "@deepseek-ai/dsh-api-gateway/client" + ], + "inject": [ + "@deepseek-ai/dsh-api-gateway", + "@deepseek-ai/dsh-client-connection" + ], + "platform": "web" + } + } +} diff --git a/apps/web-ui/packages/api/workspace-controller/src/client/index.ts b/apps/web-ui/packages/api/workspace-controller/src/client/index.ts new file mode 100644 index 000000000..a16533430 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-controller/src/client/index.ts @@ -0,0 +1,143 @@ +/** Workspace-specific adapter for the Gateway-owned snapshot stream lifecycle. */ + +import type { Context } from "@deepseek-ai/cordis"; +import { + RemoteSnapshotStream, + RemoteStreamCarrierError, + type ClientRemote, +} from "@deepseek-ai/dsh-api-gateway/client"; +import type { WorkspaceFollowFrame, WorkspaceFollowIncrement } from "../types.ts"; +import type { WorkspaceFollowSink } from "./model.ts"; +import { ClientWorkspaceModel } from "./model.ts"; +import { WorkspaceController } from "./service.ts"; + +export { ClientWorkspaceModel } from "./model.ts"; +export type { + WorkspaceFollowSink, + WorkspaceListPhase, + WorkspaceRemote, + WorkspaceSnapshot, +} from "./model.ts"; +export { WorkspaceArchiveError, WorkspaceController, WorkspaceCreateError } from "./service.ts"; +export type { IWorkspaces, WorkspaceSource } from "./service.ts"; +export type { + SessionActivity, + SessionActivityItem, + SessionActivityKind, + SessionActivityKindMap, + WorkspaceId, + WorkspaceView, +} from "../types.ts"; + +type WorkspaceBaselineFrame = Extract; + +/** Gateway-owned snapshot stream configured for Workspace state. */ +export type WorkspaceStateStream = RemoteSnapshotStream< + WorkspaceBaselineFrame, + WorkspaceFollowIncrement +>; + +declare module "@deepseek-ai/cordis" { + interface Context { + /** React-free Client Workspace state and commands. */ + workspaces: import("./service.ts").IWorkspaces; + } +} + +/** Required Client Remote services. */ +export const inject = ["remote", "remote.workspace"]; + +/** + * Install Client Workspace state, commands, and reconnecting follow control. + * @param ctx - Client root Context. + */ +export function apply(ctx: Context): void { + const model = new ClientWorkspaceModel(ctx.remote.workspace); + new WorkspaceController(ctx, model); + const control = createWorkspaceStateStream(ctx.remote, { + accept: model, + carrierFailed: () => { + model.handleCarrierFailure(); + }, + failed: (error) => { + model.handleStreamFailure(error); + }, + }); + control.start(); + ctx.effect( + () => async () => { + await control.dispose(); + }, + "workspace-controller.client.control", + ); +} + +/** Domain sinks used by the Workspace state stream. */ +export interface WorkspaceStateStreamOptions { + /** Destinations for decoded Workspace state operations. */ + readonly accept: WorkspaceFollowSink; + /** Observe a retryable carrier loss before reconnection. */ + readonly carrierFailed?: (error: RemoteStreamCarrierError) => void; + /** Publish a terminal business or protocol failure. */ + readonly failed: (error: unknown) => void; +} + +/** + * Create the reconnecting Workspace state stream. + * @param remote - Client Remote face carrying the Workspace namespace and the stream factory. + * @param options - Workspace state destinations. + * @returns an unstarted stream owned by the Client Workspace runtime. + */ +export function createWorkspaceStateStream( + remote: ClientRemote, + options: WorkspaceStateStreamOptions, +): WorkspaceStateStream { + const stream = remote.$stream({ + name: "Workspace state stream", + open: (signal) => remote.workspace.follow(signal), + ended: (accepted) => + accepted + ? new RemoteStreamCarrierError("Workspace state stream ended without a terminal result") + : new Error("Workspace state stream ended before its opening snapshot"), + ...(options.carrierFailed === undefined ? {} : { carrierFailed: options.carrierFailed }), + }); + return new RemoteSnapshotStream(stream, { + name: "Workspace state stream", + isSnapshot: (frame): frame is WorkspaceBaselineFrame => frame.type === "baseline", + replace: (frame) => { + options.accept.replaceBaseline(frame.value); + }, + update: (frame) => { + acceptIncrement(options.accept, frame); + }, + failed: options.failed, + }); +} + +function acceptIncrement(accept: WorkspaceFollowSink, frame: WorkspaceFollowIncrement): void { + switch (frame.type) { + case "upsert": + accept.upsertView(frame.workspace); + return; + case "remove": + accept.removeView(frame.workspaceId); + return; + case "order": + accept.replaceOrder(frame.workspaceIds); + return; + case "archived": + accept.replaceArchived(frame.archivedSessionIds); + return; + case "pinned": + accept.replacePinned(frame.pinnedSessionIds); + return; + /* v8 ignore next -- the generated Remote codec validates this closed union */ + default: + return assertNever(frame); + } +} + +/* v8 ignore next 3 -- closed-union backstop after generated Remote validation */ +function assertNever(value: never): never { + throw new Error(`unreachable Workspace increment: ${JSON.stringify(value)}`); +} diff --git a/apps/web-ui/packages/api/workspace-controller/src/client/model.ts b/apps/web-ui/packages/api/workspace-controller/src/client/model.ts new file mode 100644 index 000000000..2fa9fdd47 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-controller/src/client/model.ts @@ -0,0 +1,489 @@ +/** Client-side Workspace state model shared by Remote transport and UI projection. */ + +import { notifySubscribers } from "@deepseek-ai/dsh-client-store"; +import type {} from "@deepseek-ai/dsh-api-workspace-controller/remote"; +import { isRemoteFailure } from "@deepseek-ai/dsh-api-gateway/client"; +import type { + RemoteFailure, + RemoteResult, + TypertClientRemote, +} from "@deepseek-ai/dsh-typert-protocol"; +import type { + WorkspaceArchiveSessionRequest, + WorkspaceArchiveValue, + WorkspaceBaseline, + WorkspaceCreateRequest, + WorkspaceCreateValue, + WorkspaceDeleteValue, + WorkspaceInsertSessionBeforeRequest, + WorkspaceOrderValue, + WorkspacePinSessionRequest, + WorkspacePinValue, + WorkspaceUnarchiveSessionRequest, + WorkspaceUnpinSessionRequest, + WorkspaceValue, + WorkspaceId, + WorkspaceView, +} from "../types.ts"; + +/** Complete generated `ctx.remote.workspace` namespace. */ +export type WorkspaceRemote = TypertClientRemote["workspace"]; + +/** Monotone Workspace-list arrival lifecycle. */ +export type WorkspaceListPhase = "pending" | "ready"; + +/** Immutable Client Workspace state. */ +export interface WorkspaceSnapshot { + readonly canStartProjectless?: boolean; + readonly items: readonly WorkspaceView[]; + /** Complete registry-global archive set in Host order. */ + readonly archivedSessionIds: WorkspaceArchiveValue["archivedSessionIds"]; + /** Complete registry-global pin set, most recently pinned first. */ + readonly pinnedSessionIds: WorkspacePinValue["pinnedSessionIds"]; + readonly state: "idle" | "loading" | "error"; + readonly phase: WorkspaceListPhase; + readonly error: RemoteFailure | null; +} + +/** State operations emitted by a decoded Workspace follow generation. */ +export interface WorkspaceFollowSink { + /** Replace all state from the generation baseline. */ + replaceBaseline(value: WorkspaceBaseline): void; + /** Merge one Workspace row. */ + upsertView(workspace: WorkspaceView): void; + /** Remove one Workspace row. */ + removeView(workspaceId: WorkspaceId): void; + /** Replace the Host-confirmed Workspace order. */ + replaceOrder(workspaceIds: readonly WorkspaceId[]): void; + /** Replace the complete archived Session set. */ + replaceArchived(sessionIds: WorkspaceArchiveValue["archivedSessionIds"]): void; + /** Replace the complete pinned Session set. */ + replacePinned(pinnedSessionIds: WorkspacePinValue["pinnedSessionIds"]): void; +} + +/** + * Owns the Client Workspace projection, mutation echoes, and stream/unary race resolution. + */ +export class ClientWorkspaceModel implements WorkspaceFollowSink { + private canStartProjectless = false; + private items: readonly WorkspaceView[] = []; + private archivedSessionIds: WorkspaceArchiveValue["archivedSessionIds"] = []; + private pinnedSessionIds: WorkspacePinValue["pinnedSessionIds"] = []; + private state: WorkspaceSnapshot["state"] = "loading"; + private phase: WorkspaceListPhase = "pending"; + private error: RemoteFailure | null = null; + /** Latest local reorder request; only its unary echo may install order. */ + private orderRequestGeneration = 0; + /** Increments on stream orders so a later remote commit outranks an older unary echo. */ + private orderFrameGeneration = 0; + /** Last complete order accepted from a baseline, increment, or current unary echo. */ + private committedOrder: WorkspaceId[] = []; + /** Latest archive-set request; a later request or a pushed set supersedes it. */ + private archiveRequestSeq = 0; + /** Latest pin-set request; a later request or a pushed set supersedes it. */ + private pinRequestSeq = 0; + /** Host Workspace ids are never reused, so delayed data cannot resurrect a removed row. */ + private readonly removedIds = new Set(); + private readonly listeners = new Set<() => void>(); + private snapshotCache: WorkspaceSnapshot; + private snapshotDirty = false; + private notificationPending = false; + private notificationScheduled = false; + private notificationGeneration = 0; + + /** @param remote - generated Workspace Remote namespace. */ + constructor(private readonly remote: WorkspaceRemote) { + this.snapshotCache = this.buildSnapshot(); + } + + /** + * Create or resolve a Workspace and merge the unary result immediately. + * @param input - existing absolute path to adopt. + * @returns generated Remote result. + */ + async create(input: WorkspaceCreateRequest): Promise> { + const result = await this.remote.create(input); + if (result.ok) this.upsert(result.value.workspace); + return result; + } + + /** + * Initialize the default Workspace and merge its authoritative row. + * @param signal - caller lifetime. + * @returns generated Remote result. + */ + async initializeDefault(signal?: AbortSignal): Promise> { + const result = await this.remote.initializeDefault(signal); + if (result.ok && result.value !== undefined) this.upsert(result.value.workspace); + return result; + } + + /** + * Rename a Workspace and merge the unary result immediately. + * @param workspaceId - target Workspace. + * @param title - new display title. + * @returns generated Remote result. + */ + async rename(workspaceId: WorkspaceId, title: string): Promise> { + const result = await this.remote.rename({ workspaceId, title }); + if (result.ok) this.upsert(result.value.workspace); + return result; + } + + /** + * Delete a Workspace and remove it from the local projection immediately. + * @param workspaceId - target Workspace. + * @returns generated Remote result. + */ + async delete(workspaceId: WorkspaceId): Promise> { + const result = await this.remote.delete({ workspaceId }); + if (result.ok) this.remove(workspaceId, true); + return result; + } + + /** + * Optimistically move a Workspace and reconcile the returned complete order. + * @param workspaceId - Workspace to move. + * @param beforeWorkspaceId - anchor Workspace; omitted appends. + * @returns generated Remote result. + */ + async insertBefore( + workspaceId: WorkspaceId, + beforeWorkspaceId?: WorkspaceId, + ): Promise> { + const requestGeneration = ++this.orderRequestGeneration; + const frameGeneration = this.orderFrameGeneration; + const localOrder = this.items.map((workspace) => workspace.workspaceId); + this.installOrder(insertIdBefore(localOrder, workspaceId, beforeWorkspaceId)); + const result = await this.remote.insertBefore({ + workspaceId, + ...(beforeWorkspaceId === undefined ? {} : { beforeWorkspaceId }), + }); + if ( + requestGeneration === this.orderRequestGeneration && + frameGeneration === this.orderFrameGeneration + ) { + this.installOrder(result.ok ? result.value.workspaceIds : this.committedOrder, result.ok); + } + return result; + } + + /** + * Move a Session within its Workspace and merge the returned row. + * @param workspaceId - owning Workspace. + * @param sessionId - accounted Session to move. + * @param beforeSessionId - accounted anchor; omitted appends. + * @returns generated Remote result. + */ + async insertSessionBefore( + workspaceId: WorkspaceInsertSessionBeforeRequest["workspaceId"], + sessionId: WorkspaceInsertSessionBeforeRequest["sessionId"], + beforeSessionId?: WorkspaceInsertSessionBeforeRequest["beforeSessionId"], + ): Promise> { + const result = await this.remote.insertSessionBefore({ + workspaceId, + sessionId, + ...(beforeSessionId === undefined ? {} : { beforeSessionId }), + }); + if (result.ok) this.upsert(result.value.workspace); + return result; + } + + /** + * Archive one Session and install the returned complete archive set. + * A reply superseded by a later archive request or a pushed set installs nothing. + * @param sessionId - Session to archive. + * @param options - Whether the Host stops the Session's running work instead of refusing. + * @returns generated Remote result. + */ + async archiveSession( + sessionId: WorkspaceArchiveSessionRequest["sessionId"], + options: Pick = {}, + ): Promise> { + const requestSeq = ++this.archiveRequestSeq; + const result = await this.remote.archiveSession({ + sessionId, + ...(options.stopActivity === true ? { stopActivity: true } : {}), + }); + if (result.ok && requestSeq === this.archiveRequestSeq) { + this.installArchived(result.value.archivedSessionIds); + // The Host drops an archived session's pin in the same durable write; + // mirror that locally so no frame shows the row both archived and pinned. + this.installPinned(this.pinnedSessionIds.filter((id) => id !== sessionId)); + } + return result; + } + + /** + * Unarchive one Session and install the returned complete archive set. + * A reply superseded by a later archive request or a pushed set installs nothing. + * @param sessionId - Session to unarchive. + * @returns generated Remote result. + */ + async unarchiveSession( + sessionId: WorkspaceUnarchiveSessionRequest["sessionId"], + ): Promise> { + const requestSeq = ++this.archiveRequestSeq; + const result = await this.remote.unarchiveSession({ sessionId }); + if (result.ok && requestSeq === this.archiveRequestSeq) { + this.installArchived(result.value.archivedSessionIds); + } + return result; + } + + /** + * Pin one Session and install the returned complete pin set. + * A reply superseded by a later pin request or a pushed set installs nothing. + * @param sessionId - Session to pin. + * @returns generated Remote result. + */ + async pinSession( + sessionId: WorkspacePinSessionRequest["sessionId"], + ): Promise> { + const requestSeq = ++this.pinRequestSeq; + const result = await this.remote.pinSession({ sessionId }); + if (result.ok && requestSeq === this.pinRequestSeq) { + this.installPinned(result.value.pinnedSessionIds); + } + return result; + } + + /** + * Unpin one Session and install the returned complete pin set. + * A reply superseded by a later pin request or a pushed set installs nothing. + * @param sessionId - Session to unpin. + * @returns generated Remote result. + */ + async unpinSession( + sessionId: WorkspaceUnpinSessionRequest["sessionId"], + ): Promise> { + const requestSeq = ++this.pinRequestSeq; + const result = await this.remote.unpinSession({ sessionId }); + if (result.ok && requestSeq === this.pinRequestSeq) { + this.installPinned(result.value.pinnedSessionIds); + } + return result; + } + + /** + * Replace the projection from one complete stream-generation baseline. + * @param baseline - complete Workspace and archive projection. + */ + replaceBaseline(baseline: WorkspaceBaseline): void { + this.orderFrameGeneration++; + this.archiveRequestSeq++; + this.pinRequestSeq++; + this.canStartProjectless = baseline.canStartProjectless === true; + this.installViews(baseline.items); + this.installArchived(baseline.archivedSessionIds); + this.installPinned(baseline.pinnedSessionIds); + this.state = "idle"; + this.phase = "ready"; + this.error = null; + this.invalidate(); + } + + /** Merge one decoded Workspace upsert from the current follow generation. */ + upsertView(workspace: WorkspaceView): void { + this.upsert(workspace); + } + + /** Apply one decoded Workspace removal from the current follow generation. */ + removeView(workspaceId: WorkspaceId): void { + this.remove(workspaceId); + } + + /** Replace Host-confirmed order from the current follow generation. */ + replaceOrder(workspaceIds: readonly WorkspaceId[]): void { + this.orderFrameGeneration++; + this.installOrder(workspaceIds, true); + } + + /** + * Replace the archived Session set from the current follow generation. + * @param archivedSessionIds - complete Host-confirmed archive set. + */ + replaceArchived(archivedSessionIds: WorkspaceArchiveValue["archivedSessionIds"]): void { + this.archiveRequestSeq++; + this.installArchived(archivedSessionIds); + } + + /** + * Replace the pinned Session set from the current follow generation. + * @param pinnedSessionIds - complete Host-confirmed pin set, most recently pinned first. + */ + replacePinned(pinnedSessionIds: WorkspacePinValue["pinnedSessionIds"]): void { + this.pinRequestSeq++; + this.installPinned(pinnedSessionIds); + } + + /** Keep the last complete projection visible while a lost carrier reconnects. */ + handleCarrierFailure(): void { + this.state = "loading"; + this.error = null; + this.invalidate(); + } + + /** + * Publish a non-retryable stream or protocol failure. + * @param error - terminal stream failure. + */ + handleStreamFailure(error: unknown): void { + if (!isRemoteFailure(error)) throw error; + this.state = "error"; + this.error = error; + this.invalidate(); + } + + /** + * Subscribe to Workspace state invalidation. + * @param listener - invalidation callback. + * @returns unsubscribe function. + */ + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** + * Read the cached state, rebuilding it first when necessary. + * @returns the current stable Workspace list snapshot. + */ + getSnapshot(): WorkspaceSnapshot { + this.refreshSnapshot(); + return this.snapshotCache; + } + + private buildSnapshot(): WorkspaceSnapshot { + return { + items: this.items, + canStartProjectless: this.canStartProjectless, + archivedSessionIds: this.archivedSessionIds, + pinnedSessionIds: this.pinnedSessionIds, + state: this.state, + phase: this.phase, + error: this.error, + }; + } + + private installArchived(archivedSessionIds: WorkspaceArchiveValue["archivedSessionIds"]): void { + if ( + archivedSessionIds.length === this.archivedSessionIds.length && + archivedSessionIds.every((id, index) => id === this.archivedSessionIds[index]) + ) + return; + this.archivedSessionIds = [...archivedSessionIds]; + this.invalidate(); + } + + private installPinned(pinnedSessionIds: WorkspacePinValue["pinnedSessionIds"]): void { + if ( + pinnedSessionIds.length === this.pinnedSessionIds.length && + pinnedSessionIds.every((id, index) => id === this.pinnedSessionIds[index]) + ) + return; + this.pinnedSessionIds = [...pinnedSessionIds]; + this.invalidate(); + } + + private installOrder(workspaceIds: readonly WorkspaceId[], committed = false): void { + if (committed) this.committedOrder = [...workspaceIds]; + const rank = new Map(workspaceIds.map((id, index) => [id, index])); + const items = [...this.items].sort( + (left, right) => + (rank.get(left.workspaceId) ?? Number.MAX_SAFE_INTEGER) - + (rank.get(right.workspaceId) ?? Number.MAX_SAFE_INTEGER), + ); + if (items.every((item, index) => item === this.items[index])) return; + this.items = items; + this.invalidate(); + } + + private upsert(view: WorkspaceView): void { + if (this.removedIds.has(view.workspaceId)) return; + const index = this.items.findIndex((item) => item.workspaceId === view.workspaceId); + const installed = this.items[index]; + // Unary responses and stream increments race on separate requests. Keep + // the newest Host projection regardless of their arrival order. + if (installed !== undefined && Date.parse(view.updatedAt) < Date.parse(installed.updatedAt)) + return; + if (!this.committedOrder.includes(view.workspaceId)) { + this.committedOrder = [view.workspaceId, ...this.committedOrder]; + } + this.items = + index === -1 + ? [view, ...this.items] + : this.items.map((item, position) => (position === index ? view : item)); + this.invalidate(); + } + + private remove(workspaceId: WorkspaceId, immediate = false): void { + this.removedIds.add(workspaceId); + this.committedOrder = this.committedOrder.filter((id) => id !== workspaceId); + const items = this.items.filter((item) => item.workspaceId !== workspaceId); + if (items.length === this.items.length) { + // A successful unary echo still publishes an earlier increment's + // pending removal before the user operation resolves. + if (immediate) this.invalidate(true); + return; + } + this.items = items; + this.invalidate(immediate); + } + + private installViews(views: readonly WorkspaceView[]): void { + const installed = new Map(); + for (const view of views) { + if (!this.removedIds.has(view.workspaceId)) installed.set(view.workspaceId, view); + } + this.items = [...installed.values()]; + this.committedOrder = views.map((view) => view.workspaceId); + } + + private invalidate(immediate = false): void { + this.snapshotDirty = true; + this.notificationPending = true; + if (immediate) { + this.notificationGeneration++; + this.notificationScheduled = false; + this.flush(); + return; + } + if (this.notificationScheduled) return; + this.notificationScheduled = true; + const generation = ++this.notificationGeneration; + queueMicrotask(() => { + if (generation !== this.notificationGeneration) return; + this.notificationScheduled = false; + this.flush(); + }); + } + + private flush(): void { + if (!this.notificationPending || this.listeners.size === 0) return; + this.notificationPending = false; + this.refreshSnapshot(); + notifySubscribers(this.listeners, "[workspace-controller]"); + } + + private refreshSnapshot(): void { + if (!this.snapshotDirty) return; + this.snapshotDirty = false; + this.snapshotCache = this.buildSnapshot(); + } +} + +function insertIdBefore( + ids: readonly WorkspaceId[], + id: WorkspaceId, + beforeId?: WorkspaceId, +): WorkspaceId[] { + if (!ids.includes(id) || (beforeId !== undefined && !ids.includes(beforeId)) || beforeId === id) { + return [...ids]; + } + const without = ids.filter((candidate) => candidate !== id); + const at = beforeId === undefined ? without.length : without.indexOf(beforeId); + return [...without.slice(0, at), id, ...without.slice(at)]; +} diff --git a/apps/web-ui/packages/api/workspace-controller/src/client/service.ts b/apps/web-ui/packages/api/workspace-controller/src/client/service.ts new file mode 100644 index 000000000..4fce10e92 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-controller/src/client/service.ts @@ -0,0 +1,200 @@ +/** React-free Client Workspace service and command facade. */ + +import { Service, type Context } from "@deepseek-ai/cordis"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { RemoteFailure } from "@deepseek-ai/dsh-typert-protocol"; +import type { WorkspaceId } from "@deepseek-ai/dsh-workspace/types"; +import type { WorkspaceView } from "../types.ts"; +import type { ClientWorkspaceModel, WorkspaceSnapshot } from "./model.ts"; + +/** Structured create failure for callers that distinguish Host business errors. */ +export class WorkspaceCreateError extends Error { + override readonly name = "WorkspaceCreateError"; + + /** @param rpcError - Host business or folded carrier failure. */ + constructor(readonly rpcError: RemoteFailure) { + super(`workspace create failed: ${rpcError.code}: ${rpcError.message}`); + } +} + +/** + * Archive failed on the Host. `rpcError.code` distinguishes the active-session + * refusal (`workspace/session-active`, whose details name what still runs) + * from a missing session or a carrier fault. + */ +export class WorkspaceArchiveError extends Error { + override readonly name = "WorkspaceArchiveError"; + + /** @param rpcError - Host business or folded carrier failure. */ + constructor(readonly rpcError: RemoteFailure) { + super(`workspace session archive failed: ${rpcError.code}: ${rpcError.message}`); + } +} + +/** Bare observable source for the Workspace Controller snapshot. */ +export interface WorkspaceSource { + /** Read the identity-stable current snapshot. */ + getSnapshot(): WorkspaceSnapshot; + /** + * Subscribe to snapshot changes. + * @param listener - invalidation callback. + * @returns unsubscribe function. + */ + subscribe(listener: () => void): () => void; +} + +/** Workspace Controller's Client service face. */ +export interface IWorkspaces { + /** Host-authoritative Workspace rows, order, archive set, and follow lifecycle. */ + readonly list: WorkspaceSource; + /** + * Register an existing path as a Workspace. + * @param input - Host create payload. + * @returns the created or idempotently resolved Workspace. + */ + create(input: { path: string }): Promise; + /** + * Initialize or reuse the default Workspace. + * @param signal - caller lifetime. + * @returns the prepared Workspace, or undefined when first-use initialization is ineligible; rejects on preparation failure. + */ + initializeDefault(signal?: AbortSignal): Promise; + /** + * Rename a Workspace. + * @param workspaceId - target Workspace. + * @param title - new display title. + * @returns the renamed Workspace. + */ + rename(workspaceId: WorkspaceId, title: string): Promise; + /** + * Delete a Workspace registration without deleting Sessions or files. + * @param workspaceId - target Workspace. + */ + delete(workspaceId: WorkspaceId): Promise; + /** + * Move a Workspace within the Host registry order. + * @param workspaceId - Workspace to move. + * @param beforeWorkspaceId - anchor Workspace; omitted appends. + */ + insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise; + /** + * Archive a Session from Workspace grouping surfaces. + * @param sessionId - Session to archive. + * @param options - `stopActivity` asks the Host to stop the Session's running work instead of refusing. + * @throws {WorkspaceArchiveError} when the Host refuses; without `stopActivity` a Session with + * running work fails as `workspace/session-active`, its details naming what runs. + */ + archiveSession( + sessionId: SessionId, + options?: { readonly stopActivity?: boolean }, + ): Promise; + /** + * Unarchive a Session from the archived Session list. + * @param sessionId - Session to unarchive. + */ + unarchiveSession(sessionId: SessionId): Promise; + /** + * Pin a Session ahead of unpinned Sessions on Workspace grouping surfaces. + * @param sessionId - Session to pin. + */ + pinSession(sessionId: SessionId): Promise; + /** + * Remove a Session's pin without changing its saved Session order. + * @param sessionId - Session to unpin. + */ + unpinSession(sessionId: SessionId): Promise; + /** + * Move a Session within one Workspace account. + * @param workspaceId - owning Workspace. + * @param sessionId - Session to move. + * @param beforeSessionId - anchor Session; omitted appends. + * @returns the changed Workspace. + */ + insertSessionBefore( + workspaceId: WorkspaceId, + sessionId: SessionId, + beforeSessionId?: SessionId, + ): Promise; +} + +/** Owns the bare Workspace snapshot and Workspace-only commands. */ +export class WorkspaceController extends Service implements IWorkspaces { + readonly list: WorkspaceSource; + + /** + * @param ctx - Client root Context. + * @param model - Remote-backed Workspace state model. + */ + constructor( + ctx: Context, + private readonly model: ClientWorkspaceModel, + ) { + super(ctx, "workspaces"); + this.list = model; + } + + async create(input: { path: string }): Promise { + const result = await this.model.create(input); + if (!result.ok) throw new WorkspaceCreateError(result.error); + return result.value.workspace; + } + + async initializeDefault(signal?: AbortSignal): Promise { + const result = await this.model.initializeDefault(signal); + if (!result.ok) throw new WorkspaceCreateError(result.error); + return result.value?.workspace; + } + + async rename(workspaceId: WorkspaceId, title: string): Promise { + const result = await this.model.rename(workspaceId, title); + if (!result.ok) throw commandError("rename", result.error); + return result.value.workspace; + } + + async delete(workspaceId: WorkspaceId): Promise { + const result = await this.model.delete(workspaceId); + if (!result.ok) throw commandError("delete", result.error); + } + + async insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise { + const result = await this.model.insertBefore(workspaceId, beforeWorkspaceId); + if (!result.ok) throw commandError("reorder", result.error); + } + + async archiveSession( + sessionId: SessionId, + options: { readonly stopActivity?: boolean } = {}, + ): Promise { + const result = await this.model.archiveSession(sessionId, options); + if (!result.ok) throw new WorkspaceArchiveError(result.error); + } + + async unarchiveSession(sessionId: SessionId): Promise { + const result = await this.model.unarchiveSession(sessionId); + if (!result.ok) throw commandError("session unarchive", result.error); + } + + async pinSession(sessionId: SessionId): Promise { + const result = await this.model.pinSession(sessionId); + if (!result.ok) throw commandError("session pin", result.error); + } + + async unpinSession(sessionId: SessionId): Promise { + const result = await this.model.unpinSession(sessionId); + if (!result.ok) throw commandError("session unpin", result.error); + } + + async insertSessionBefore( + workspaceId: WorkspaceId, + sessionId: SessionId, + beforeSessionId?: SessionId, + ): Promise { + const result = await this.model.insertSessionBefore(workspaceId, sessionId, beforeSessionId); + if (!result.ok) throw commandError("move", result.error); + return result.value.workspace; + } +} + +function commandError(operation: string, failure: RemoteFailure): Error { + return new Error(`workspace ${operation} failed: ${failure.code}: ${failure.message}`); +} diff --git a/apps/web-ui/packages/api/workspace-controller/src/commands.ts b/apps/web-ui/packages/api/workspace-controller/src/commands.ts new file mode 100644 index 000000000..a5b725045 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-controller/src/commands.ts @@ -0,0 +1,270 @@ +/** Workspace command implementation and stable Remote failure mapping. */ + +import type { Context } from "@deepseek-ai/cordis"; +import type { Workspace } from "@deepseek-ai/dsh-workspace"; +import { + WorkspaceActiveSessionError, + WorkspaceArchivedSessionPinError, + WorkspaceId, + WorkspaceMoveInvalidError, + WorkspaceOrderInvalidError, + WorkspaceUnknownSessionError, +} from "@deepseek-ai/dsh-workspace"; +import { RemoteError, remoteErrorOf } from "@deepseek-ai/dsh-typert-protocol"; +import { workspaceView } from "./feed.ts"; +import type { + WorkspaceArchiveSessionRequest, + WorkspaceArchiveValue, + WorkspaceCreateRequest, + WorkspaceCreateValue, + WorkspaceDeleteRequest, + WorkspaceDeleteValue, + WorkspaceInsertBeforeRequest, + WorkspaceInsertSessionBeforeRequest, + WorkspaceOrderValue, + WorkspacePinSessionRequest, + WorkspacePinValue, + WorkspaceRenameRequest, + WorkspaceUnarchiveSessionRequest, + WorkspaceUnpinSessionRequest, + WorkspaceValue, +} from "./types.ts"; + +/** Implements Workspace mutations against the authoritative registry. */ +export class WorkspaceCommands { + private operationTail = Promise.resolve(); + + /** @param ctx - Host context containing the Workspace registry. */ + constructor(private readonly ctx: Context) {} + + /** + * Create or resolve one Workspace over an existing directory. + * @param request - directory path to register. + * @returns the Workspace and whether this call created it. + */ + create(request: WorkspaceCreateRequest): Promise { + return this.enqueue(async () => { + try { + const existing = await this.ctx.workspaceRegistry.resolveByPath(request.path); + if (existing !== undefined) { + return { workspace: workspaceView(existing), created: false }; + } + const workspace = await this.ctx.workspaceRegistry.create(request.path); + return { workspace: workspaceView(workspace), created: true }; + } catch (error) { + if (remoteErrorOf(error) !== undefined) throw error; + throw new RemoteError( + "workspace/invalid-path", + `cannot create a Workspace at "${request.path}": ${errorMessage(error)}`, + { path: request.path }, + { cause: error }, + ); + } + }); + } + + /** + * Rename one Workspace after serializing title ownership checks. + * @param request - Workspace identity and proposed title. + * @returns the updated Workspace projection. + */ + rename(request: WorkspaceRenameRequest): Promise { + const title = request.title.trim(); + if (title === "") { + return Promise.reject( + new RemoteError("gateway/bad-request", "Workspace rename requires a non-blank title", {}), + ); + } + return this.enqueue(async () => { + const workspace = this.requireWorkspace(request.workspaceId); + if (title !== workspace.title) { + if ( + this.ctx.workspaceRegistry + .list() + .some((candidate) => candidate.id !== workspace.id && candidate.title === title) + ) { + throw new RemoteError( + "workspace/name-conflict", + `Workspace name '${title}' is already in use`, + { name: title }, + ); + } + await workspace.setTitle(title); + } + return { workspace: workspaceView(workspace) }; + }); + } + + /** + * Delete one Workspace registration without deleting its directory or Sessions. + * @param request - Workspace identity to remove. + * @returns deletion confirmation. + */ + delete(request: WorkspaceDeleteRequest): Promise { + return this.enqueue(async () => { + if (!(await this.ctx.workspaceRegistry.delete(WorkspaceId(request.workspaceId)))) { + throw workspaceNotFound(request.workspaceId); + } + return { deleted: true }; + }); + } + + /** + * Move one Workspace within the durable registry order. + * @param request - moved Workspace and optional anchor. + * @returns the complete resulting Workspace order. + */ + async insertBefore(request: WorkspaceInsertBeforeRequest): Promise { + try { + const workspaceIds = await this.ctx.workspaceRegistry.insertBefore( + WorkspaceId(request.workspaceId), + request.beforeWorkspaceId === undefined + ? undefined + : WorkspaceId(request.beforeWorkspaceId), + ); + return { workspaceIds: [...workspaceIds] }; + } catch (error) { + if (!(error instanceof WorkspaceOrderInvalidError)) throw error; + throw workspaceNotFound(error.workspaceId); + } + } + + /** + * Move one accounted Session within a Workspace's manual order. + * @param request - Workspace, Session, and optional anchor identities. + * @returns the updated Workspace projection. + */ + async insertSessionBefore(request: WorkspaceInsertSessionBeforeRequest): Promise { + const workspace = this.requireWorkspace(request.workspaceId); + try { + await workspace.insertSessionBefore(request.sessionId, request.beforeSessionId); + } catch (error) { + if (!(error instanceof WorkspaceMoveInvalidError)) throw error; + throw new RemoteError( + "workspace/move-invalid", + error.message, + { + workspaceId: request.workspaceId, + sessionId: request.sessionId, + ...(request.beforeSessionId === undefined + ? {} + : { beforeSessionId: request.beforeSessionId }), + }, + { cause: error }, + ); + } + return { workspace: workspaceView(workspace) }; + } + + /** + * Add one known Session to the registry-global archive set. Without + * `stopActivity` a Session with running work is refused as + * `workspace/session-active` with the activity the registry's providers + * reported; with it, the providers stop that work first. + * @param request - Session identity to archive and whether to stop its work. + * @returns the complete resulting archive set. + */ + async archiveSession(request: WorkspaceArchiveSessionRequest): Promise { + try { + await this.ctx.workspaceRegistry.archiveSession( + request.sessionId, + request.stopActivity === true ? { stopActivity: true } : {}, + ); + } catch (error) { + if (error instanceof WorkspaceUnknownSessionError) { + throw new RemoteError( + "session/not-found", + error.message, + { sessionId: request.sessionId }, + { cause: error }, + ); + } + if (error instanceof WorkspaceActiveSessionError) { + throw new RemoteError( + "workspace/session-active", + error.message, + { sessionId: request.sessionId, activity: error.activity }, + { cause: error }, + ); + } + throw error; + } + return { archivedSessionIds: [...this.ctx.workspaceRegistry.archivedSessionIds] }; + } + + /** + * Drop one Session from the registry-global archive set. An id that is not + * archived is not an error: the call is idempotent, so a lost race with + * another surface resolves as a no-op. + * @param request - Session identity to unarchive. + * @returns the complete resulting archive set. + */ + async unarchiveSession( + request: WorkspaceUnarchiveSessionRequest, + ): Promise { + await this.ctx.workspaceRegistry.unarchiveSession(request.sessionId); + return { archivedSessionIds: [...this.ctx.workspaceRegistry.archivedSessionIds] }; + } + + /** + * Add one known unarchived Session to the registry-global pin set. + * @param request - Session identity to pin. + * @returns the complete resulting pin set, most recently pinned first. + */ + async pinSession(request: WorkspacePinSessionRequest): Promise { + try { + await this.ctx.workspaceRegistry.pinSession(request.sessionId); + } catch (error) { + if (error instanceof WorkspaceUnknownSessionError) { + throw new RemoteError( + "session/not-found", + error.message, + { sessionId: request.sessionId }, + { cause: error }, + ); + } + if (error instanceof WorkspaceArchivedSessionPinError) { + throw new RemoteError("gateway/bad-request", error.message, {}, { cause: error }); + } + throw error; + } + return { pinnedSessionIds: [...this.ctx.workspaceRegistry.pinnedSessionIds] }; + } + + /** + * Drop one Session from the registry-global pin set. An id that is not + * pinned is not an error: the call is idempotent, so a lost race with + * another surface resolves as a no-op. + * @param request - Session identity to unpin. + * @returns the complete resulting pin set, most recently pinned first. + */ + async unpinSession(request: WorkspaceUnpinSessionRequest): Promise { + await this.ctx.workspaceRegistry.unpinSession(request.sessionId); + return { pinnedSessionIds: [...this.ctx.workspaceRegistry.pinnedSessionIds] }; + } + + private requireWorkspace(workspaceId: WorkspaceId): Workspace { + const workspace = this.ctx.workspaceRegistry.get(WorkspaceId(workspaceId)); + if (workspace === undefined) throw workspaceNotFound(workspaceId); + return workspace; + } + + private enqueue(operation: () => Promise): Promise { + const result = this.operationTail.then(operation); + this.operationTail = result.then( + () => undefined, + () => undefined, + ); + return result; + } +} + +function workspaceNotFound(workspaceId: WorkspaceId): RemoteError<"workspace/not-found"> { + return new RemoteError("workspace/not-found", `Workspace "${workspaceId}" not found`, { + workspaceId, + }); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/web-ui/packages/api/workspace-controller/src/default-directory.ts b/apps/web-ui/packages/api/workspace-controller/src/default-directory.ts new file mode 100644 index 000000000..9792c6bbc --- /dev/null +++ b/apps/web-ui/packages/api/workspace-controller/src/default-directory.ts @@ -0,0 +1,98 @@ +/** Resolve the Host account's Documents directory for first-use Workspace creation. */ + +import { homedir } from "node:os"; +import { posix, win32 } from "node:path"; +import { runNativeCommand, type NativeCommandRunner } from "@deepseek-ai/dsh-native-command"; +import { DEFAULT_WORKSPACE_DIRECTORY } from "./default-workspace.ts"; + +/** Platform observations replaceable in directory-resolution tests. */ +interface DocumentsDirectoryInternals { + readonly platform?: NodeJS.Platform; + readonly home?: string; + readonly run?: NativeCommandRunner; +} + +/** + * Validate a configured or OS-returned Documents path without resolving it against cwd. + * @param directory - fully qualified directory spelling. + * @param platform - Host platform. + * @returns the normalized directory. + */ +export function validateDocumentsDirectory( + directory: string, + platform: NodeJS.Platform = process.platform, +): string { + const paths = platform === "win32" ? win32 : posix; + const root = paths.parse(directory).root; + if (!paths.isAbsolute(directory) || (platform === "win32" && (root === "\\" || root === "/"))) { + throw new Error(`Documents directory must be fully qualified: '${directory}'`); + } + return paths.normalize(directory); +} + +/** + * Resolve the first-use directory on the Host without creating files. + * @param documentsDirectory - explicit deployment override for the system Documents directory. + * @param signal - caller lifetime and lookup deadline. + * @param internals - platform facts and native command runner. + * @returns the absolute candidate path. + */ +export async function defaultWorkspaceDirectory( + documentsDirectory: string | undefined, + signal: AbortSignal, + internals: DocumentsDirectoryInternals = {}, +): Promise { + const platform = internals.platform ?? process.platform; + const paths = platform === "win32" ? win32 : posix; + signal.throwIfAborted(); + let directory = documentsDirectory; + if (directory === undefined) { + const run = internals.run ?? runNativeCommand; + let stdout: string; + switch (platform) { + case "darwin": + ({ stdout } = await run( + "osascript", + [ + "-e", + "POSIX path of (path to documents folder from user domain without folder creation)", + ], + signal, + "hidden", + )); + break; + case "win32": + ({ stdout } = await run( + "powershell.exe", + [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); " + + "[Environment]::GetFolderPath([Environment+SpecialFolder]::MyDocuments, " + + "[Environment+SpecialFolderOption]::DoNotVerify)", + ], + signal, + "hidden", + )); + break; + case "linux": + ({ stdout } = await run("xdg-user-dir", ["DOCUMENTS"], signal, "hidden")); + break; + default: + throw new Error(`system Documents directory is unavailable on ${platform}`); + } + directory = stdout.replace(/[\r\n]+$/, ""); + // XDG reports the home directory when this user directory is disabled. + if ( + directory === "" || + (platform === "linux" && paths.normalize(directory) === (internals.home ?? homedir())) + ) { + throw new Error("system Documents directory is unavailable"); + } + } + directory = validateDocumentsDirectory(directory, platform); + signal.throwIfAborted(); + return paths.join(directory, "deepseek-harness", DEFAULT_WORKSPACE_DIRECTORY); +} diff --git a/apps/web-ui/packages/api/workspace-controller/src/default-workspace.ts b/apps/web-ui/packages/api/workspace-controller/src/default-workspace.ts new file mode 100644 index 000000000..e1373cc04 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-controller/src/default-workspace.ts @@ -0,0 +1,30 @@ +/** + * Fixed first-use Workspace naming, shared by the Host that creates the + * directory and by browser consumers that label the resulting row. A pure fold + * with no imports, so client bundles inline it instead of requesting a + * module-table row this package does not publish. + * @module @deepseek-ai/dsh-api-workspace-controller/default-workspace + */ + +/** + * Leaf directory name of the first-use Workspace under + * `/deepseek-harness`. Language-neutral, so one installation keeps + * one on-disk path across language switches. The registry derives the initial + * title from this same segment, which is the title + * {@link workspaceDisplayTitle} recognizes as automatic. + */ +export const DEFAULT_WORKSPACE_DIRECTORY = "default-workspace"; + +/** + * The text one Workspace is labeled with. A Workspace still carrying the + * automatic first-use title reads as the caller's localized default name; every + * other title reads verbatim in every language. A title the user typed as + * exactly {@link DEFAULT_WORKSPACE_DIRECTORY} is labeled as the default too; + * nothing else depends on the distinction. + * @param title - stored Workspace title. + * @param localizedDefault - the default Workspace name in the active language. + * @returns the title to display. + */ +export function workspaceDisplayTitle(title: string, localizedDefault: string): string { + return title === DEFAULT_WORKSPACE_DIRECTORY ? localizedDefault : title; +} diff --git a/apps/web-ui/packages/api/workspace-controller/src/directory-picker.ts b/apps/web-ui/packages/api/workspace-controller/src/directory-picker.ts new file mode 100644 index 000000000..c594f7562 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-controller/src/directory-picker.ts @@ -0,0 +1,188 @@ +/** + * Host directory-picking Remote owner: capability gating, cancellation, and the + * stable wire failure vocabulary over the `ctx.directoryPicker` seam. + */ + +import { Context } from "@deepseek-ai/cordis"; +import { z } from "zod"; +import { DirectoryPickerError } from "@deepseek-ai/dsh-host-directory-picker"; +import type { + DirectoryPickerCapabilities, + DirectoryPickerErrorCode, +} from "@deepseek-ai/dsh-host-directory-picker"; +// The seam owns the listing declaration; the generator requires the reference +// site to name that package rather than this package's re-export of it. +import type { DirectoryListing } from "@deepseek-ai/dsh-host-directory-picker/types"; +import { Remote, RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol"; +import type { RemoteErrorCode } from "@deepseek-ai/dsh-typert-protocol"; + +const createDirectoryRequestSchema = z + .object({ + path: z.string(), + name: z.string(), + }) + .refine( + (request) => + request.name.trim() !== "" && + request.name !== "." && + request.name !== ".." && + !/[/\\]/.test(request.name), + { message: "host.createDirectory requires a single non-blank path segment name" }, + ); + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Host directory-picking Remote namespace owner. */ + directoryPickerController: DirectoryPickerController; + } +} + +/** + * Host service backing the generated `ctx.remote.directoryPicker` namespace. The + * seam it exports is abstract and therefore never a Loader entry of its own, so + * this controller carries the wire verbs: one composed backend serves either the + * native chooser or the browse primitives, and a verb the composition cannot + * serve is refused rather than approximated. + */ +export class DirectoryPickerController extends TypertRemoteService { + static inject = ["directoryPicker"]; + + /** @param ctx - Host context carrying the composed directory-picking backend. */ + constructor(ctx: Context) { + super(ctx, "directoryPickerController", { namespace: "directoryPicker" }); + } + + /** + * Open the host's OS chooser for a Remote caller. + * @param signal - caller lifetime; abort terminates the chooser. + * @returns the chosen absolute path, or null when the operator cancels. + */ + @Remote("pick") + async pick(signal: AbortSignal): Promise { + const capability = this.requireCapability("native", "pick"); + try { + return await capability.pick(signal); + } catch (error: unknown) { + throw cancellableFailure( + error, + signal, + "directory picker was aborted", + "directory picker failed", + ); + } + } + + /** + * List one directory level for a Remote caller's in-app browser. + * @param path - absolute directory to list; absent lists the home directory. + * @param signal - caller lifetime; abort stops the backend's scan instead of + * letting it outlive a disconnected caller. + * @returns the level's listing with its ancestry. + */ + @Remote("list") + async list(path: string | undefined, signal: AbortSignal): Promise { + const capability = this.requireCapability("browse", "list"); + try { + return await capability.list(path, signal); + } catch (error: unknown) { + throw cancellableFailure(error, signal, "directory listing was aborted"); + } + } + + /** + * Create one child directory for a Remote caller's in-app browser. + * @param path - absolute existing parent directory. + * @param name - single non-blank path segment. + * @returns the created directory's absolute path. + */ + @Remote("createDirectory") + async createDirectory(path: string, name: string): Promise { + const request = createDirectoryRequestSchema.safeParse({ path, name }); + if (!request.success) { + throw new RemoteError("gateway/bad-request", "invalid payload for host.createDirectory", { + issues: request.error.issues, + }); + } + const capability = this.requireCapability("browse", "createDirectory"); + try { + return await capability.createDirectory(request.data.path, request.data.name); + } catch (error: unknown) { + throw browseFailure(error); + } + } + + /** Resolve the capability one wire verb needs, or refuse with the kind this backend serves. */ + private requireCapability( + kind: Kind, + method: string, + ): DirectoryPickerCapabilities[Kind] { + const capability = this.ctx.directoryPicker.capability(); + if (capability.kind !== kind) { + throw new RemoteError( + "directory-picker/unavailable", + `directoryPicker.${method} needs the ${kind} capability; the composed picker serves "${capability.kind}"`, + { capability: capability.kind }, + ); + } + return capability as DirectoryPickerCapabilities[Kind]; + } +} + +/** + * Wire code answered for each seam browse failure. The seam's closed codes are + * its own local vocabulary, so this controller owns the projection onto the + * `directory-picker/*` codes a Remote caller discriminates on. + */ +const BROWSE_FAILURE_CODES = { + "directory-unreadable": "directory-picker/unreadable", + "directory-exists": "directory-picker/exists", + "directory-create-failed": "directory-picker/create-failed", +} as const satisfies Record; + +/** + * Classify a browse-primitive rejection: the seam's own closed codes carry the + * path they are about, and anything else stays an infrastructure failure. + * @param error - the primitive's rejection. + * @returns the failure to throw across the Remote boundary. + */ +function browseFailure(error: unknown): RemoteError { + if (error instanceof DirectoryPickerError) { + return new RemoteError( + BROWSE_FAILURE_CODES[error.code], + error.message, + { path: error.path }, + { cause: error }, + ); + } + return new RemoteError("gateway/internal", errorMessage(error), {}, { cause: error }); +} + +/** + * Classify a cancellable primitive's rejection. An abort is the caller's own + * timeout or disconnect, not a backend failure, so it answers `gateway/cancelled` + * before the business classification runs. + * @param error - the primitive's rejection. + * @param signal - the caller lifetime the primitive ran under. + * @param cancelled - operator-facing text for the abort outcome. + * @param failed - prefix for a non-seam failure, when the verb has no closed codes. + * @returns the failure to throw across the Remote boundary. + */ +function cancellableFailure( + error: unknown, + signal: AbortSignal, + cancelled: string, + failed?: string, +): RemoteError { + if (signal.aborted) return new RemoteError("gateway/cancelled", cancelled, {}, { cause: error }); + if (failed === undefined) return browseFailure(error); + return new RemoteError( + "gateway/internal", + `${failed}: ${errorMessage(error)}`, + {}, + { cause: error }, + ); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/web-ui/packages/api/workspace-controller/src/feed.ts b/apps/web-ui/packages/api/workspace-controller/src/feed.ts new file mode 100644 index 000000000..905b282ff --- /dev/null +++ b/apps/web-ui/packages/api/workspace-controller/src/feed.ts @@ -0,0 +1,190 @@ +/** Reconnect-safe Workspace baseline and increment producer. */ + +import type { Context } from "@deepseek-ai/cordis"; +import { Deque } from "@deepseek-ai/dsh-deque"; +import type { DomainChanged } from "@deepseek-ai/dsh-storage-domain"; +import type { Workspace, WorkspaceRecord } from "@deepseek-ai/dsh-workspace"; +import { workspaceDomainState, workspaceRecord, WorkspaceId } from "@deepseek-ai/dsh-workspace"; +import type { WorkspaceBaseline, WorkspaceFollowFrame, WorkspaceView } from "./types.ts"; + +/** + * Project one authoritative Workspace entity into its Remote value. + * @param workspace - authoritative registry entity. + * @returns detached Workspace projection for Remote consumers. + */ +export function workspaceView(workspace: Workspace): WorkspaceView { + return { + workspaceId: workspace.id, + path: workspace.path, + title: workspace.title, + sessionIds: [...workspace.sessionIds], + createdAt: workspace.createdAt, + updatedAt: workspace.updatedAt, + }; +} + +function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceView { + const record: WorkspaceRecord = workspaceRecord.parse(value); + return { + workspaceId: WorkspaceId(workspaceId), + path: record.path, + title: record.title, + sessionIds: [...record.sessionIds], + createdAt: record.createdAt, + updatedAt: record.updatedAt, + }; +} + +/** Owns Workspace domain observation and all active follow generations. */ +export class WorkspaceFeed { + private readonly followers = new Set(); + private knownIds: Set; + private order: readonly string[]; + private archived: readonly string[]; + private pinned: readonly string[]; + + /** @param ctx - Host context containing the authoritative Workspace registry. */ + constructor(private readonly ctx: Context) { + const baseline = ctx.workspaceRegistry.list(); + this.knownIds = new Set(baseline.map((workspace) => String(workspace.id))); + this.order = baseline.map((workspace) => String(workspace.id)); + this.archived = ctx.workspaceRegistry.archivedSessionIds.map(String); + this.pinned = ctx.workspaceRegistry.pinnedSessionIds.map(String); + ctx.on("domain/changed", (change: DomainChanged) => { + this.changed(change); + }); + ctx.effect( + () => () => { + for (const follower of this.followers) follower.close(); + this.followers.clear(); + }, + "workspace-controller.feed", + ); + } + + /** + * Read the complete current projection synchronously. + * @returns all active Workspaces plus archived and pinned Session identities. + */ + baseline(): WorkspaceBaseline { + return { + items: this.ctx.workspaceRegistry.list().map(workspaceView), + archivedSessionIds: [...this.ctx.workspaceRegistry.archivedSessionIds], + pinnedSessionIds: [...this.ctx.workspaceRegistry.pinnedSessionIds], + }; + } + + /** + * Open one generation beginning with a complete baseline. + * @param signal - generation cancellation. + * @returns baseline followed by ordered Workspace increments. + */ + async *follow(signal: AbortSignal): AsyncIterable { + signal.throwIfAborted(); + const follower = new WorkspaceFollower(); + this.followers.add(follower); + try { + yield { type: "baseline", value: this.baseline() }; + yield* follower.read(signal); + } finally { + this.followers.delete(follower); + follower.close(); + } + } + + private changed(change: DomainChanged): void { + if (change.domain !== "workspace") return; + if (change.table === "") { + if (change.operation !== "put") return; + const state = workspaceDomainState.parse(change.value); + const nextOrder = state.workspaceIds.map(String); + const orderChanged = !sameStrings(this.order, nextOrder); + for (const id of state.workspaceIds) { + if (this.knownIds.has(id)) continue; + const workspace = this.ctx.workspaceRegistry.get(id); + if (workspace === undefined) { + throw new Error(`committed Workspace registry references missing Workspace "${id}"`); + } + this.knownIds.add(id); + this.publish({ type: "upsert", workspace: workspaceView(workspace) }); + } + this.order = nextOrder; + if (orderChanged) this.publish({ type: "order", workspaceIds: [...state.workspaceIds] }); + const nextArchived = state.archivedSessionIds.map(String); + if (!sameStrings(this.archived, nextArchived)) { + this.archived = nextArchived; + this.publish({ type: "archived", archivedSessionIds: [...state.archivedSessionIds] }); + } + const nextPinned = state.pinnedSessionIds.map(String); + if (!sameStrings(this.pinned, nextPinned)) { + this.pinned = nextPinned; + this.publish({ type: "pinned", pinnedSessionIds: [...state.pinnedSessionIds] }); + } + return; + } + if (change.table !== "workspaces") return; + if (change.operation === "deleted") { + if (!this.knownIds.delete(change.key)) return; + this.publish({ type: "remove", workspaceId: WorkspaceId(change.key) }); + return; + } + if (!this.knownIds.has(change.key)) return; + this.publish({ + type: "upsert", + workspace: changedWorkspaceView(change.key, change.value), + }); + } + + private publish(frame: Exclude): void { + for (const follower of this.followers) follower.push(frame); + } +} + +function sameStrings(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +class WorkspaceFollower { + private readonly frames = new Deque(); + private waiting: (() => void) | undefined; + private closed = false; + + push(frame: WorkspaceFollowFrame): void { + /* v8 ignore next -- closed followers are removed before later publication can reach them. */ + if (this.closed) return; + this.frames.pushBack(frame); + this.waiting?.(); + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.waiting?.(); + } + + async *read(signal: AbortSignal): AsyncIterable { + while (!this.closed && !signal.aborted) { + const frame = this.frames.popFront(); + if (frame !== undefined) { + yield frame; + continue; + } + await this.wait(signal); + } + } + + private wait(signal: AbortSignal): Promise { + return new Promise((resolve) => { + const finish = (): void => { + signal.removeEventListener("abort", finish); + /* v8 ignore next -- one read owns the sole installed wait callback. */ + if (this.waiting === finish) this.waiting = undefined; + resolve(); + }; + this.waiting = finish; + signal.addEventListener("abort", finish, { once: true }); + /* v8 ignore next -- native signals and the private queue cannot change during this synchronous setup. */ + if (signal.aborted || this.closed || this.frames.size > 0) finish(); + }); + } +} diff --git a/apps/web-ui/packages/api/workspace-controller/src/index.ts b/apps/web-ui/packages/api/workspace-controller/src/index.ts new file mode 100644 index 000000000..a5a4f7d1b --- /dev/null +++ b/apps/web-ui/packages/api/workspace-controller/src/index.ts @@ -0,0 +1,202 @@ +/** Host Workspace Remote owner: explicit commands and reconnect-safe state. */ + +import { Context } from "@deepseek-ai/cordis"; +import z from "@deepseek-ai/schemastery"; +import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol"; +import { WorkspaceCommands } from "./commands.ts"; +import { DirectoryPickerController } from "./directory-picker.ts"; +import { WorkspaceFeed, workspaceView } from "./feed.ts"; +import { defaultWorkspaceDirectory, validateDocumentsDirectory } from "./default-directory.ts"; +import type { + WorkspaceArchiveSessionRequest, + WorkspaceArchiveValue, + WorkspaceCreateRequest, + WorkspaceCreateValue, + WorkspaceDeleteRequest, + WorkspaceDeleteValue, + WorkspaceFollowFrame, + WorkspaceInsertBeforeRequest, + WorkspaceInsertSessionBeforeRequest, + WorkspaceOrderValue, + WorkspacePinSessionRequest, + WorkspacePinValue, + WorkspaceRenameRequest, + WorkspaceUnarchiveSessionRequest, + WorkspaceUnpinSessionRequest, + WorkspaceValue, +} from "./types.ts"; + +export type * from "./types.ts"; +export { DirectoryPickerController } from "./directory-picker.ts"; + +/** First-use directory policy for the Host account. */ +export interface Config { + /** Override the system Documents directory with a fully qualified path. */ + documentsDirectory?: string; + /** Maximum duration of the operating system's Documents lookup. */ + documentsLookupTimeoutMs?: number; +} + +/** Directory policy after schema defaults have been applied. */ +type ResolvedConfig = Config & { documentsLookupTimeoutMs: number }; + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Host Workspace business API and Remote namespace owner. */ + workspaceController: WorkspaceController; + } +} + +/** Host service backing the generated `ctx.remote.workspace` namespace. */ +export class WorkspaceController extends TypertRemoteService { + static inject = ["typert", "workspaceRegistry"]; + + static Config: z = z.object({ + documentsDirectory: z.string(), + documentsLookupTimeoutMs: z.natural().min(1).default(10_000), + }); + + private readonly config: ResolvedConfig; + private readonly commands: WorkspaceCommands; + private readonly feed: WorkspaceFeed; + + /** + * @param ctx - Host context containing the Workspace registry. + * @param config - first-use directory policy. + */ + constructor(ctx: Context, config: Config = {}) { + super(ctx, "workspaceController", { namespace: "workspace" }); + this.config = WorkspaceController.Config(config); + if (this.config.documentsDirectory !== undefined) + validateDocumentsDirectory(this.config.documentsDirectory); + this.commands = new WorkspaceCommands(ctx); + this.feed = new WorkspaceFeed(ctx); + // This package is the Loader entry for both Remote owners it hosts: the + // directory-picking seam is abstract and never an entry itself. The child + // stays pending until a picking backend is composed, so a host without one + // registers no picking namespace instead of answering an unservable verb. + ctx.plugin(DirectoryPickerController); + } + + /** + * Create or idempotently resolve one Workspace over an existing directory. + * @param request - directory path to register. + * @returns the Workspace and whether this call created it. + */ + @Remote("create") + create(request: WorkspaceCreateRequest): Promise { + return this.commands.create(request); + } + + /** + * Initialize or reuse the default Workspace during first-use startup. The + * directory name is fixed, so the Host never renames or relocates an + * existing default; its initial title is that same name, which browser + * consumers label in the reader's language. + * @param signal - caller lifetime; cancels native directory lookup. + * @returns the durable Workspace, or undefined when first-use initialization is ineligible; creates no Session or message. + */ + @Remote("initializeDefault") + async initializeDefault(signal: AbortSignal): Promise { + const workspace = await this.ctx.workspaceRegistry.initializeDefault(async () => { + const timeout = AbortSignal.timeout(this.config.documentsLookupTimeoutMs); + return await defaultWorkspaceDirectory( + this.config.documentsDirectory, + AbortSignal.any([signal, timeout]), + ); + }); + return workspace === undefined ? undefined : { workspace: workspaceView(workspace) }; + } + + /** + * Rename one Workspace to a unique non-blank title. + * @param request - Workspace identity and proposed title. + * @returns the updated Workspace projection. + */ + @Remote("rename") + rename(request: WorkspaceRenameRequest): Promise { + return this.commands.rename(request); + } + + /** + * Remove one Workspace registration while retaining files and Sessions. + * @param request - Workspace identity to remove. + * @returns deletion confirmation. + */ + @Remote("delete") + delete(request: WorkspaceDeleteRequest): Promise { + return this.commands.delete(request); + } + + /** + * Move one Workspace within the registry display order. + * @param request - moved Workspace and optional anchor. + * @returns the complete resulting Workspace order. + */ + @Remote("insertBefore") + insertBefore(request: WorkspaceInsertBeforeRequest): Promise { + return this.commands.insertBefore(request); + } + + /** + * Move one accounted Session within a Workspace. + * @param request - Workspace, Session, and optional anchor identities. + * @returns the updated Workspace projection. + */ + @Remote("insertSessionBefore") + insertSessionBefore(request: WorkspaceInsertSessionBeforeRequest): Promise { + return this.commands.insertSessionBefore(request); + } + + /** + * Hide one known Session from Workspace grouping surfaces. + * @param request - Session identity to archive. + * @returns the complete resulting archive set. + */ + @Remote("archiveSession") + archiveSession(request: WorkspaceArchiveSessionRequest): Promise { + return this.commands.archiveSession(request); + } + + /** + * Restore one archived Session to Workspace grouping surfaces. + * @param request - Session identity to unarchive. + * @returns the complete resulting archive set. + */ + @Remote("unarchiveSession") + unarchiveSession(request: WorkspaceUnarchiveSessionRequest): Promise { + return this.commands.unarchiveSession(request); + } + + /** + * Surface one known unarchived Session ahead of unpinned Sessions. + * @param request - Session identity to pin. + * @returns the complete resulting pin set, most recently pinned first. + */ + @Remote("pinSession") + pinSession(request: WorkspacePinSessionRequest): Promise { + return this.commands.pinSession(request); + } + + /** + * Remove one Session's pin without changing its saved Session order. + * @param request - Session identity to unpin. + * @returns the complete resulting pin set, most recently pinned first. + */ + @Remote("unpinSession") + unpinSession(request: WorkspaceUnpinSessionRequest): Promise { + return this.commands.unpinSession(request); + } + + /** + * Stream a complete Workspace baseline followed by ordered increments. + * @param signal - generation cancellation. + * @returns baseline followed by ordered Workspace increments. + */ + @Remote({ mode: "stream" }) + follow(signal: AbortSignal): AsyncIterable { + return this.feed.follow(signal); + } +} + +export default WorkspaceController; diff --git a/apps/web-ui/packages/api/workspace-controller/src/types.ts b/apps/web-ui/packages/api/workspace-controller/src/types.ts new file mode 100644 index 000000000..f13ccc61b --- /dev/null +++ b/apps/web-ui/packages/api/workspace-controller/src/types.ts @@ -0,0 +1,178 @@ +/** + * Browser-safe request, result, and state-stream vocabulary for the Workspace + * and directory-picking Remote namespaces this package owns. The picking seam + * declares its own listing types, so they are re-exported here rather than + * restated: a browser consumer reads the very declaration the backend answers. + */ + +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { SessionActivity, WorkspaceId } from "@deepseek-ai/dsh-workspace/types"; + +export type { WorkspaceId } from "@deepseek-ai/dsh-workspace/types"; +export type { + SessionActivity, + SessionActivityItem, + SessionActivityKind, + SessionActivityKindMap, +} from "@deepseek-ai/dsh-workspace/types"; +export type { + DirectoryEntry, + DirectoryListing, +} from "@deepseek-ai/dsh-host-directory-picker/types"; + +/** One durable Workspace projected for browser consumers. */ +export interface WorkspaceView { + readonly workspaceId: WorkspaceId; + /** Canonical host directory path. */ + readonly path: string; + /** User-visible title. */ + readonly title: string; + /** Sessions accounted to this Workspace in manual order. */ + readonly sessionIds: readonly SessionId[]; + /** ISO-8601 creation instant. */ + readonly createdAt: string; + /** ISO-8601 last-mutation instant. */ + readonly updatedAt: string; +} + +declare module "@deepseek-ai/dsh-typert-protocol" { + interface RemoteErrorDetailsMap { + /** The requested directory cannot back a Workspace. */ + "workspace/invalid-path": { readonly path: string }; + /** Another Workspace already uses the requested name. */ + "workspace/name-conflict": { readonly name: string }; + /** + * The Session still has running work — its own turn, a subagent, a + * background job, or an active schedule — so archiving was refused + * without a write; `activity` names what must stop first. + */ + "workspace/session-active": { + readonly sessionId: SessionId; + readonly activity: readonly SessionActivity[]; + }; + /** The Session or its anchor is not in the Workspace's manual order. */ + "workspace/move-invalid": { + readonly workspaceId: WorkspaceId; + readonly sessionId: SessionId; + readonly beforeSessionId?: SessionId; + }; + /** The verb needs an interaction the composed backend does not serve. */ + "directory-picker/unavailable": { readonly capability: string }; + /** The target is not fully qualified, or the backend cannot list it. */ + "directory-picker/unreadable": { readonly path: string }; + /** A child of that name is already there. */ + "directory-picker/exists": { readonly path: string }; + /** The parent is not fully qualified, the name is not one segment, or creation failed. */ + "directory-picker/create-failed": { readonly path: string }; + } +} + +/** Existing directory requested for Workspace adoption. */ +export interface WorkspaceCreateRequest { + readonly path: string; +} + +/** Created or previously registered Workspace. */ +export interface WorkspaceCreateValue { + readonly workspace: WorkspaceView; + readonly created: boolean; +} + +/** Workspace title mutation. */ +export interface WorkspaceRenameRequest { + readonly workspaceId: WorkspaceId; + readonly title: string; +} + +/** Workspace mutation returning the complete changed row. */ +export interface WorkspaceValue { + readonly workspace: WorkspaceView; +} + +/** Workspace registration deletion. */ +export interface WorkspaceDeleteRequest { + readonly workspaceId: WorkspaceId; +} + +/** Receipt after one Workspace registration is deleted. */ +export interface WorkspaceDeleteValue { + readonly deleted: true; +} + +/** DOM-insertBefore-like Workspace order mutation. */ +export interface WorkspaceInsertBeforeRequest { + readonly workspaceId: WorkspaceId; + readonly beforeWorkspaceId?: WorkspaceId; +} + +/** Complete Workspace registry order after a mutation. */ +export interface WorkspaceOrderValue { + readonly workspaceIds: readonly WorkspaceId[]; +} + +/** DOM-insertBefore-like Session membership order mutation. */ +export interface WorkspaceInsertSessionBeforeRequest { + readonly workspaceId: WorkspaceId; + readonly sessionId: SessionId; + readonly beforeSessionId?: SessionId; +} + +/** Session requested for archival from Workspace grouping surfaces. */ +export interface WorkspaceArchiveSessionRequest { + readonly sessionId: SessionId; + /** + * Stop the Session's running work — its turn, subagent descendants, owned + * background jobs, and active schedules — instead of refusing the archive + * as `workspace/session-active`. The stops are requested before the + * archive write and are not awaited; the response arrives once the archive + * set is durable. + */ + readonly stopActivity?: boolean; +} + +/** Session requested for restoration from the archived Session list. */ +export interface WorkspaceUnarchiveSessionRequest { + readonly sessionId: SessionId; +} + +/** Complete archived Session set after a mutation. */ +export interface WorkspaceArchiveValue { + readonly archivedSessionIds: readonly SessionId[]; +} + +/** Session requested for pinning ahead of unpinned Sessions on grouping surfaces. */ +export interface WorkspacePinSessionRequest { + readonly sessionId: SessionId; +} + +/** Session requested for removal from the pin set. */ +export interface WorkspaceUnpinSessionRequest { + readonly sessionId: SessionId; +} + +/** Complete pinned Session set after a mutation, most recently pinned first. */ +export interface WorkspacePinValue { + readonly pinnedSessionIds: readonly SessionId[]; +} + +/** Complete reconnect baseline for Workspace browser state. */ +export interface WorkspaceBaseline { + /** Shared owner allocates no-project cwd on first submission. */ + readonly canStartProjectless?: boolean; + readonly items: readonly WorkspaceView[]; + readonly archivedSessionIds: readonly SessionId[]; + /** Registry-global pin set, most recently pinned first. */ + readonly pinnedSessionIds: readonly SessionId[]; +} + +/** One ordered Workspace change after a generation's baseline. */ +export type WorkspaceFollowIncrement = + | { readonly type: "upsert"; readonly workspace: WorkspaceView } + | { readonly type: "remove"; readonly workspaceId: WorkspaceId } + | { readonly type: "order"; readonly workspaceIds: readonly WorkspaceId[] } + | { readonly type: "archived"; readonly archivedSessionIds: readonly SessionId[] } + | { readonly type: "pinned"; readonly pinnedSessionIds: readonly SessionId[] }; + +/** Workspace state stream; every generation starts with exactly one baseline. */ +export type WorkspaceFollowFrame = + { readonly type: "baseline"; readonly value: WorkspaceBaseline } | WorkspaceFollowIncrement; diff --git a/apps/web-ui/packages/api/workspace-controller/tsdown.config.ts b/apps/web-ui/packages/api/workspace-controller/tsdown.config.ts new file mode 100644 index 000000000..dcf69e97c --- /dev/null +++ b/apps/web-ui/packages/api/workspace-controller/tsdown.config.ts @@ -0,0 +1,5 @@ +import { clientBundle } from "../../client/tsdown.client.ts"; + +export default clientBundle("@deepseek-ai/dsh-api-workspace-controller", ["lib/types/index.js"], { + hostPhase: true, +}); diff --git a/apps/web-ui/packages/api/workspace-files/generated/remote.d.ts b/apps/web-ui/packages/api/workspace-files/generated/remote.d.ts new file mode 100644 index 000000000..5877816ac --- /dev/null +++ b/apps/web-ui/packages/api/workspace-files/generated/remote.d.ts @@ -0,0 +1,36 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import type { + RemoteResult, + RemoteStreamHandle, + TypertRemoteContribution, +} from '@deepseek-ai/dsh-typert-protocol' +import type { WorkspaceByteReadOptions, WorkspaceDirectoryListing, WorkspaceFileBytes, WorkspaceFileRange, WorkspaceFileStat, WorkspaceFileText, WorkspaceFileWatchFrame } from '@deepseek-ai/dsh-api-workspace-files/types' +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +type RemoteDecoded = Value extends Uint8Array ? Uint8Array + : Value extends string | number | boolean | null | undefined ? Value + : { [Key in keyof Value]: RemoteDecoded } + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertRemoteNamespace$776f726b737061636546696c6573 { + changes: (workspaceFileScopeId: SessionId, path: string, signal?: AbortSignal) => RemoteStreamHandle + list: (workspaceFileScopeId: SessionId, path: string, signal?: AbortSignal) => Promise> + read: (workspaceFileScopeId: SessionId, path: string, range: WorkspaceFileRange, signal?: AbortSignal) => Promise> + readBytes: (workspaceFileScopeId: SessionId, path: string, options: WorkspaceByteReadOptions, signal?: AbortSignal) => Promise>> + stat: (workspaceFileScopeId: SessionId, path: string, signal?: AbortSignal) => Promise> + } + interface TypertRemoteMap { + 'workspaceFiles/changes': (workspaceFileScopeId: SessionId, path: string, signal?: AbortSignal) => RemoteStreamHandle + 'workspaceFiles/list': (workspaceFileScopeId: SessionId, path: string, signal?: AbortSignal) => Promise> + 'workspaceFiles/read': (workspaceFileScopeId: SessionId, path: string, range: WorkspaceFileRange, signal?: AbortSignal) => Promise> + 'workspaceFiles/readBytes': (workspaceFileScopeId: SessionId, path: string, options: WorkspaceByteReadOptions, signal?: AbortSignal) => Promise>> + 'workspaceFiles/stat': (workspaceFileScopeId: SessionId, path: string, signal?: AbortSignal) => Promise> + } + interface TypertRemoteNamespaceMap { + 'workspaceFiles': TypertRemoteNamespace$776f726b737061636546696c6573 + } +} + +export declare const TYPERT_REMOTE: TypertRemoteContribution +export default TYPERT_REMOTE +//# sourceMappingURL=typert.remote-client.d.ts.map diff --git a/apps/web-ui/packages/api/workspace-files/generated/remote.js b/apps/web-ui/packages/api/workspace-files/generated/remote.js new file mode 100644 index 000000000..4b92920b5 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-files/generated/remote.js @@ -0,0 +1,336 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import { z } from 'zod' + +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_parameter_0$schema$value +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_parameter_0$schema = () => (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_parameter_1$schema$value +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_parameter_1$schema = () => (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_parameter_1$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_result$schema$value +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_result$schema = () => (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_result$schema$value ??= z.union([z.object({ + 'kind': z.literal("ready").readonly(), +}), z.object({ + 'kind': z.literal("change").readonly(), + 'change': z.union([z.object({ + 'absolutePath': z.string().readonly(), + 'version': z.string().readonly(), +}), z.object({ + 'absolutePath': z.string().readonly(), + 'absent': z.literal(true).readonly(), +})]).readonly(), +})])) +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_parameter_0$schema$value +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_parameter_0$schema = () => (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_parameter_1$schema$value +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_parameter_1$schema = () => (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_parameter_1$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_result$schema$value +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_result$schema = () => (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_result$schema$value ??= z.object({ + 'path': z.string().readonly(), + 'entries': z.array(z.object({ + 'name': z.string().readonly(), + 'type': z.union([z.literal("file"), z.literal("directory"), z.literal("other")]).readonly(), + 'size': z.number().readonly().optional(), +})).readonly(), + 'truncated': z.boolean().readonly(), +})) +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_0$schema$value +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_0$schema = () => (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_1$schema$value +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_1$schema = () => (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_1$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_2$schema$value +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_2$schema = () => (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_2$schema$value ??= z.object({ + 'offset': z.number().readonly().optional(), + 'limit': z.number().readonly().optional(), +})) +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_result$schema$value +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_result$schema = () => (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_result$schema$value ??= z.object({ + 'offset': z.number().readonly(), + 'text': z.string().readonly(), + 'lines': z.number().readonly(), + 'eof': z.boolean().readonly(), + 'absolutePath': z.string().readonly(), + 'version': z.string().readonly(), + 'bytes': z.number().readonly().optional(), +})) +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_0$schema$value +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_0$schema = () => (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_1$schema$value +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_1$schema = () => (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_1$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_2$schema$value +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_2$schema = () => (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_2$schema$value ??= z.object({ + 'range': z.object({ + 'offset': z.number().readonly().optional(), + 'length': z.number().readonly().optional(), +}).readonly().optional(), + 'baseFile': z.string().readonly().optional(), +})) +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_result$schema$value +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_result$schema = () => (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_result$schema$value ??= z.object({ + 'offset': z.number().readonly(), + 'data': z.instanceof(Uint8Array), + 'eof': z.boolean().readonly(), + 'absolutePath': z.string().readonly(), + 'version': z.string().readonly(), + 'bytes': z.number().readonly().optional(), +})) +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_parameter_0$schema$value +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_parameter_0$schema = () => (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_parameter_1$schema$value +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_parameter_1$schema = () => (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_parameter_1$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_result$schema$value +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_result$schema = () => (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_result$schema$value ??= z.object({ + 'absolutePath': z.string().readonly(), + 'version': z.string().readonly(), + 'bytes': z.number().readonly().optional(), +})) +const $resultSnapshot = (input, path) => { + if (input === null || typeof input !== 'object' || input instanceof Uint8Array) return input + const toJSON = input.toJSON + const value = typeof toJSON === 'function' ? toJSON.call(input, path.at(-1)?.toString() ?? 'value') : input + if (value === null || typeof value !== 'object' || value instanceof Uint8Array) return value + if (Array.isArray(value)) { + const items = [] + for (let index = 0, length = value.length; index < length; index++) items.push(value[index]) + return items + } + const fields = {} + for (const key of Object.keys(value)) { + const item = key === 'toJSON' && value === input ? toJSON : value[key] + if (key === 'toJSON' && typeof item === 'function') continue + Object.defineProperty(fields, key, { value: item, enumerable: true, writable: true, configurable: true }) + } + return fields +} +const $resultContainer = (input, path, ancestors, project, snapshot) => { + if (input === null || typeof input !== 'object') return project(input) + const owner = !ancestors.has(input) + if (!owner && ancestors.get(input) !== path.length) throw new TypeError('Remote result contains a circular object') + if (owner) ancestors.set(input, path.length) + try { return project(snapshot ? $resultSnapshot(input, path) : input) } + finally { if (owner) ancestors.delete(input) } +} +const $encode1 = (value, writeBytes, path, ancestors) => { + return value instanceof Uint8Array ? writeBytes(value, path) : value +} +const $encode0 = (value, writeBytes, path, ancestors) => { + return $resultContainer(value, path, ancestors, value => { + if (value === null || typeof value !== 'object' || Array.isArray(value) || value instanceof Uint8Array) return value + if (Object.hasOwn(value, 'data')) value['data'] = $encode1(value['data'], writeBytes, [...path, 'data'], ancestors) + return value + }, true) +} + +export const TYPERT_REMOTE = { + package: '@deepseek-ai/dsh-api-workspace-files', + descriptors: [ + { + id: '@deepseek-ai/dsh-api-workspace-files#workspaceFiles/changes', + service: 'workspaceFiles', + namespace: 'workspaceFiles', + method: 'changes', + mode: 'stream', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'workspaceFileScope', + wire: 'workspaceFileScopeId', + source: 'lookup', + lookup: 'workspaceFileScope', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_parameter_0$schema, + }, + }, + { + name: 'path', + wire: 'path', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-files#workspaceFiles/changes:path', + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_parameter_1$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-files/types#WorkspaceFileWatchFrame', + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-files/src/index.ts","line":340,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-workspace-files#workspaceFiles/list', + service: 'workspaceFiles', + namespace: 'workspaceFiles', + method: 'list', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'workspaceFileScope', + wire: 'workspaceFileScopeId', + source: 'lookup', + lookup: 'workspaceFileScope', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_parameter_0$schema, + }, + }, + { + name: 'path', + wire: 'path', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-files#workspaceFiles/list:path', + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_parameter_1$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-files/types#WorkspaceDirectoryListing', + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-files/src/index.ts","line":303,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-workspace-files#workspaceFiles/read', + service: 'workspaceFiles', + namespace: 'workspaceFiles', + method: 'read', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'workspaceFileScope', + wire: 'workspaceFileScopeId', + source: 'lookup', + lookup: 'workspaceFileScope', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_0$schema, + }, + }, + { + name: 'path', + wire: 'path', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-files#workspaceFiles/read:path', + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_1$schema, + }, + }, + { + name: 'range', + wire: 'range', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-files/types#WorkspaceFileRange', + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_2$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-files/types#WorkspaceFileText', + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-files/src/index.ts","line":233,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-workspace-files#workspaceFiles/readBytes', + service: 'workspaceFiles', + namespace: 'workspaceFiles', + method: 'readBytes', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'workspaceFileScope', + wire: 'workspaceFileScopeId', + source: 'lookup', + lookup: 'workspaceFileScope', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_0$schema, + }, + }, + { + name: 'path', + wire: 'path', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-files#workspaceFiles/readBytes:path', + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_1$schema, + }, + }, + { + name: 'options', + wire: 'options', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-files/types#WorkspaceByteReadOptions', + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_2$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-files/types#WorkspaceFileBytes', + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_result$schema, + decode: value => _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_result$schema().parse(value), + encode: (value, writeBytes) => $encode0(value, writeBytes, [], new Map()), + }, + sourceLocation: {"file":"packages/api/workspace-files/src/index.ts","line":257,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-workspace-files#workspaceFiles/stat', + service: 'workspaceFiles', + namespace: 'workspaceFiles', + method: 'stat', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'workspaceFileScope', + wire: 'workspaceFileScopeId', + source: 'lookup', + lookup: 'workspaceFileScope', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_parameter_0$schema, + }, + }, + { + name: 'path', + wire: 'path', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-files#workspaceFiles/stat:path', + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_parameter_1$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-files/types#WorkspaceFileStat', + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-files/src/index.ts","line":290,"column":9}, + }, + ], +} + +export default TYPERT_REMOTE diff --git a/apps/web-ui/packages/api/workspace-files/host/typert.host.d.ts b/apps/web-ui/packages/api/workspace-files/host/typert.host.d.ts new file mode 100644 index 000000000..d612f57e6 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-files/host/typert.host.d.ts @@ -0,0 +1,3 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from FaceModel — do not edit. */ + +export declare const TYPERT: unknown; diff --git a/apps/web-ui/packages/api/workspace-files/host/typert.host.js b/apps/web-ui/packages/api/workspace-files/host/typert.host.js new file mode 100644 index 000000000..56562d818 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-files/host/typert.host.js @@ -0,0 +1,538 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from FaceModel — do not edit. */ +import { z } from "zod"; + +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_parameter_0$schema$value; +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_parameter_0$schema = () => + (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_parameter_0$schema$value ??= + z.intersection(z.string(), z.unknown())); +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_parameter_1$schema$value; +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_parameter_1$schema = () => + (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_parameter_1$schema$value ??= + z.string()); +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_result$schema$value; +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_result$schema = () => + (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_result$schema$value ??= z.union([ + z.object({ + kind: z.literal("ready").readonly(), + }), + z.object({ + kind: z.literal("change").readonly(), + change: z + .union([ + z.object({ + absolutePath: z.string().readonly(), + version: z.string().readonly(), + }), + z.object({ + absolutePath: z.string().readonly(), + absent: z.literal(true).readonly(), + }), + ]) + .readonly(), + }), + ])); +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_parameter_0$schema$value; +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_parameter_0$schema = () => + (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_parameter_0$schema$value ??= + z.intersection(z.string(), z.unknown())); +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_parameter_1$schema$value; +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_parameter_1$schema = () => + (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_parameter_1$schema$value ??= + z.string()); +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_result$schema$value; +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_result$schema = () => + (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_result$schema$value ??= z.object({ + path: z.string().readonly(), + entries: z + .array( + z.object({ + name: z.string().readonly(), + type: z.union([z.literal("file"), z.literal("directory"), z.literal("other")]).readonly(), + size: z.number().readonly().optional(), + }), + ) + .readonly(), + truncated: z.boolean().readonly(), + })); +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_0$schema$value; +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_0$schema = () => + (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_0$schema$value ??= + z.intersection(z.string(), z.unknown())); +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_1$schema$value; +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_1$schema = () => + (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_1$schema$value ??= + z.string()); +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_2$schema$value; +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_2$schema = () => + (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_2$schema$value ??= z.object({ + offset: z.number().readonly().optional(), + limit: z.number().readonly().optional(), + })); +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_result$schema$value; +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_result$schema = () => + (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_result$schema$value ??= z.object({ + offset: z.number().readonly(), + text: z.string().readonly(), + lines: z.number().readonly(), + eof: z.boolean().readonly(), + absolutePath: z.string().readonly(), + version: z.string().readonly(), + bytes: z.number().readonly().optional(), + })); +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_0$schema$value; +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_0$schema = () => + (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_0$schema$value ??= + z.intersection(z.string(), z.unknown())); +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_1$schema$value; +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_1$schema = () => + (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_1$schema$value ??= + z.string()); +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_2$schema$value; +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_2$schema = () => + (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_2$schema$value ??= + z.object({ + range: z + .object({ + offset: z.number().readonly().optional(), + length: z.number().readonly().optional(), + }) + .readonly() + .optional(), + baseFile: z.string().readonly().optional(), + })); +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_result$schema$value; +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_result$schema = () => + (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_result$schema$value ??= z.object({ + offset: z.number().readonly(), + data: z.instanceof(Uint8Array), + eof: z.boolean().readonly(), + absolutePath: z.string().readonly(), + version: z.string().readonly(), + bytes: z.number().readonly().optional(), + })); +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_parameter_0$schema$value; +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_parameter_0$schema = () => + (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_parameter_0$schema$value ??= + z.intersection(z.string(), z.unknown())); +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_parameter_1$schema$value; +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_parameter_1$schema = () => + (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_parameter_1$schema$value ??= + z.string()); +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_result$schema$value; +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_result$schema = () => + (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_result$schema$value ??= z.object({ + absolutePath: z.string().readonly(), + version: z.string().readonly(), + bytes: z.number().readonly().optional(), + })); +const $resultSnapshot = (input, path) => { + if (input === null || typeof input !== "object" || input instanceof Uint8Array) return input; + const toJSON = input.toJSON; + const value = + typeof toJSON === "function" ? toJSON.call(input, path.at(-1)?.toString() ?? "value") : input; + if (value === null || typeof value !== "object" || value instanceof Uint8Array) return value; + if (Array.isArray(value)) { + const items = []; + for (let index = 0, length = value.length; index < length; index++) items.push(value[index]); + return items; + } + const fields = {}; + for (const key of Object.keys(value)) { + const item = key === "toJSON" && value === input ? toJSON : value[key]; + if (key === "toJSON" && typeof item === "function") continue; + Object.defineProperty(fields, key, { + value: item, + enumerable: true, + writable: true, + configurable: true, + }); + } + return fields; +}; +const $resultContainer = (input, path, ancestors, project, snapshot) => { + if (input === null || typeof input !== "object") return project(input); + const owner = !ancestors.has(input); + if (!owner && ancestors.get(input) !== path.length) + throw new TypeError("Remote result contains a circular object"); + if (owner) ancestors.set(input, path.length); + try { + return project(snapshot ? $resultSnapshot(input, path) : input); + } finally { + if (owner) ancestors.delete(input); + } +}; +const $encode1 = (value, writeBytes, path, ancestors) => { + return value instanceof Uint8Array ? writeBytes(value, path) : value; +}; +const $encode0 = (value, writeBytes, path, ancestors) => { + return $resultContainer( + value, + path, + ancestors, + (value) => { + if ( + value === null || + typeof value !== "object" || + Array.isArray(value) || + value instanceof Uint8Array + ) + return value; + if (Object.hasOwn(value, "data")) + value["data"] = $encode1(value["data"], writeBytes, [...path, "data"], ancestors); + return value; + }, + true, + ); +}; + +export const TYPERT = { + package: "@deepseek-ai/dsh-api-workspace-files", + face: "host", + schemas: [], + invocations: [ + { + id: "@deepseek-ai/dsh-api-workspace-files#workspaceFiles/changes", + service: "workspaceFiles", + namespace: "workspaceFiles", + method: "changes", + mode: "stream", + invocation: { kind: "direct" }, + parameters: [ + { + name: "workspaceFileScope", + wire: "workspaceFileScopeId", + source: "lookup", + lookup: "workspaceFileScope", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-session/types#SessionId", + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_parameter_0$schema, + }, + }, + { + name: "path", + wire: "path", + source: "json", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-workspace-files#workspaceFiles/changes:path", + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_parameter_1$schema, + }, + }, + ], + cancellation: { parameter: "signal" }, + result: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-workspace-files/types#WorkspaceFileWatchFrame", + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_result$schema, + }, + sourceLocation: { file: "packages/api/workspace-files/src/index.ts", line: 340, column: 3 }, + }, + { + id: "@deepseek-ai/dsh-api-workspace-files#workspaceFiles/list", + service: "workspaceFiles", + namespace: "workspaceFiles", + method: "list", + invocation: { kind: "direct" }, + parameters: [ + { + name: "workspaceFileScope", + wire: "workspaceFileScopeId", + source: "lookup", + lookup: "workspaceFileScope", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-session/types#SessionId", + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_parameter_0$schema, + }, + }, + { + name: "path", + wire: "path", + source: "json", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-workspace-files#workspaceFiles/list:path", + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_parameter_1$schema, + }, + }, + ], + cancellation: { parameter: "signal" }, + result: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-workspace-files/types#WorkspaceDirectoryListing", + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_result$schema, + }, + sourceLocation: { file: "packages/api/workspace-files/src/index.ts", line: 303, column: 9 }, + }, + { + id: "@deepseek-ai/dsh-api-workspace-files#workspaceFiles/read", + service: "workspaceFiles", + namespace: "workspaceFiles", + method: "read", + invocation: { kind: "direct" }, + parameters: [ + { + name: "workspaceFileScope", + wire: "workspaceFileScopeId", + source: "lookup", + lookup: "workspaceFileScope", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-session/types#SessionId", + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_0$schema, + }, + }, + { + name: "path", + wire: "path", + source: "json", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-workspace-files#workspaceFiles/read:path", + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_1$schema, + }, + }, + { + name: "range", + wire: "range", + source: "json", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-workspace-files/types#WorkspaceFileRange", + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_2$schema, + }, + }, + ], + cancellation: { parameter: "signal" }, + result: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-workspace-files/types#WorkspaceFileText", + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_result$schema, + }, + sourceLocation: { file: "packages/api/workspace-files/src/index.ts", line: 233, column: 9 }, + }, + { + id: "@deepseek-ai/dsh-api-workspace-files#workspaceFiles/readBytes", + service: "workspaceFiles", + namespace: "workspaceFiles", + method: "readBytes", + invocation: { kind: "direct" }, + parameters: [ + { + name: "workspaceFileScope", + wire: "workspaceFileScopeId", + source: "lookup", + lookup: "workspaceFileScope", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-session/types#SessionId", + create: + _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_0$schema, + }, + }, + { + name: "path", + wire: "path", + source: "json", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-workspace-files#workspaceFiles/readBytes:path", + create: + _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_1$schema, + }, + }, + { + name: "options", + wire: "options", + source: "json", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-workspace-files/types#WorkspaceByteReadOptions", + create: + _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_2$schema, + }, + }, + ], + cancellation: { parameter: "signal" }, + result: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-workspace-files/types#WorkspaceFileBytes", + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_result$schema, + decode: (value) => + _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_result$schema().parse( + value, + ), + encode: (value, writeBytes) => $encode0(value, writeBytes, [], new Map()), + }, + sourceLocation: { file: "packages/api/workspace-files/src/index.ts", line: 257, column: 9 }, + }, + { + id: "@deepseek-ai/dsh-api-workspace-files#workspaceFiles/stat", + service: "workspaceFiles", + namespace: "workspaceFiles", + method: "stat", + invocation: { kind: "direct" }, + parameters: [ + { + name: "workspaceFileScope", + wire: "workspaceFileScopeId", + source: "lookup", + lookup: "workspaceFileScope", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-session/types#SessionId", + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_parameter_0$schema, + }, + }, + { + name: "path", + wire: "path", + source: "json", + codec: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-workspace-files#workspaceFiles/stat:path", + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_parameter_1$schema, + }, + }, + ], + cancellation: { parameter: "signal" }, + result: { + mode: "strict", + typeSymbol: "@deepseek-ai/dsh-api-workspace-files/types#WorkspaceFileStat", + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_result$schema, + }, + sourceLocation: { file: "packages/api/workspace-files/src/index.ts", line: 290, column: 9 }, + }, + ], + model: { + services: [ + { + description: + "Host Remote file reads and workspace directory observations over the composed filesystem.", + summary: + "Host Remote file reads and workspace directory observations over the composed filesystem.", + tags: [], + jsDoc: + "/** Host Remote file reads and workspace directory observations over the composed filesystem. */", + key: "workspaceFiles", + exportName: "WorkspaceFiles", + members: [ + { + kind: "method", + name: "read", + signature: + "@Remote async read( workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceFileRange, signal: AbortSignal, ): Promise", + summary: "Read one page of lines from a UTF-8 file readable by the filesystem backend.", + jsDoc: + "/**\n * Read one page of lines from a UTF-8 file readable by the filesystem backend.\n * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.\n * @param path - absolute path or path relative to the workspace root; files outside it are allowed.\n * @param range - the line window; omitted fields take the page defaults.\n * @param signal - caller cancellation.\n * @returns the page, the file's version at the stat before it, and whether it reaches the last line.\n */", + }, + { + kind: "method", + name: "readBytes", + signature: + "@Remote async readBytes( workspaceFileScope: WorkspaceFileScope, path: string, options: WorkspaceByteReadOptions, signal: AbortSignal, ): Promise", + summary: "Read a complete regular file or one byte range without text decoding.", + jsDoc: + "/**\n * Read a complete regular file or one byte range without text decoding.\n * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.\n * @param path - target path, absolute or workspace-relative; relative to the base file's directory when provided.\n * @param options - optional base file and range; without a range the complete-file cap applies.\n * @param signal - caller cancellation.\n * @returns native bytes with the file's version and size at the preceding stat, byte offset, and EOF marker.\n */", + }, + { + kind: "method", + name: "stat", + signature: + "@Remote async stat(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise", + summary: "Report one regular file's identity, version, and size without its content.", + jsDoc: + "/**\n * Report one regular file's identity, version, and size without its content.\n * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.\n * @param path - absolute path or path relative to the workspace root; files outside it are allowed.\n * @param signal - caller cancellation.\n * @returns the file's absolute path, current version, and byte size.\n */", + }, + { + kind: "method", + name: "list", + signature: + "@Remote async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise", + summary: "List the direct children of one directory inside the Session's workspace.", + jsDoc: + "/**\n * List the direct children of one directory inside the Session's workspace.\n * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.\n * @param path - workspace path, absolute or relative to the workspace root.\n * @param signal - caller cancellation.\n * @returns the directory's children in the backend's stable name order, bounded by the entry cap.\n */", + }, + { + kind: "method", + name: "changes", + signature: + "@Remote({ mode: 'stream' }) changes(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): AsyncIterable", + summary: "Watch one file or a directory's direct entries in the Session's filesystem.", + jsDoc: + "/**\n * Watch one file or a directory's direct entries in the Session's filesystem.\n * Files use the backend's read authority; directories remain workspace-scoped.\n * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.\n * @param path - target path; the Host determines its type and confines directories to the workspace.\n * @param signal - generation cancellation.\n * @returns `ready` once the target watch is active, then current metadata for queued and live invalidations.\n * @throws RemoteError when watching is unavailable or a directory is outside the workspace.\n */", + }, + ], + types: [ + { + name: "Branded", + declaration: + "export type Branded = string & { readonly [BRAND]: B; };", + }, + { + name: "SessionId", + declaration: "export type SessionId = Branded<'SessionId'>;", + }, + { + name: "WorkspaceByteRange", + declaration: + "export interface WorkspaceByteRange {\n readonly offset?: number;\n readonly length?: number;\n}", + }, + { + name: "WorkspaceByteReadOptions", + declaration: + "export interface WorkspaceByteReadOptions {\n readonly range?: WorkspaceByteRange;\n readonly baseFile?: string;\n}", + }, + { + name: "WorkspaceDirectoryEntry", + declaration: + "export interface WorkspaceDirectoryEntry {\n readonly name: string;\n readonly type: 'file' | 'directory' | 'other';\n readonly size?: number;\n}", + }, + { + name: "WorkspaceDirectoryListing", + declaration: + "export interface WorkspaceDirectoryListing {\n readonly path: string;\n readonly entries: readonly WorkspaceDirectoryEntry[];\n readonly truncated: boolean;\n}", + }, + { + name: "WorkspaceFileBytes", + declaration: + "export interface WorkspaceFileBytes extends WorkspaceFileStat {\n readonly offset: number;\n readonly data: Data;\n readonly eof: boolean;\n}", + }, + { + name: "WorkspaceFileChange", + declaration: + "export type WorkspaceFileChange = { readonly absolutePath: string; readonly version: string; } | { readonly absolutePath: string; readonly absent: true; };", + }, + { + name: "WorkspaceFileRange", + declaration: + "export interface WorkspaceFileRange {\n readonly offset?: number;\n readonly limit?: number;\n}", + }, + { + name: "WorkspaceFileScope", + declaration: + "export interface WorkspaceFileScope {\n readonly sessionId: SessionId;\n readonly workspaceRoot: string;\n}", + }, + { + name: "WorkspaceFileStat", + declaration: + "export interface WorkspaceFileStat {\n readonly absolutePath: string;\n readonly version: string;\n readonly bytes?: number;\n}", + }, + { + name: "WorkspaceFileText", + declaration: + "export interface WorkspaceFileText extends WorkspaceFileStat {\n readonly offset: number;\n readonly text: string;\n readonly lines: number;\n readonly eof: boolean;\n}", + }, + { + name: "WorkspaceFileWatchFrame", + declaration: + "export type WorkspaceFileWatchFrame = { readonly kind: 'ready'; } | { readonly kind: 'change'; readonly change: WorkspaceFileChange; };", + }, + ], + }, + ], + events: [], + objects: [], + }, +}; diff --git a/apps/web-ui/packages/api/workspace-files/package.json b/apps/web-ui/packages/api/workspace-files/package.json new file mode 100644 index 000000000..4501ca797 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-files/package.json @@ -0,0 +1,43 @@ +{ + "name": "@deepseek-ai/dsh-api-workspace-files", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./types": { + "types": "./src/types.ts", + "default": "./src/types.ts" + }, + "./client": { + "types": "./src/client/index.ts", + "default": "./lib/client.js" + }, + "./typert": { + "types": "./host/typert.host.d.ts", + "default": "./host/typert.host.js" + }, + "./remote": { + "types": "./generated/remote.d.ts", + "default": "./generated/remote.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dependencies": { + "zod": "^4.4.3" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-gateway", + "@deepseek-ai/dsh-client-resources" + ], + "platform": "web" + } + } +} diff --git a/apps/web-ui/packages/api/workspace-files/src/changes.ts b/apps/web-ui/packages/api/workspace-files/src/changes.ts new file mode 100644 index 000000000..301a40b69 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-files/src/changes.ts @@ -0,0 +1,187 @@ +/** + * Target-scoped filesystem watches and `fs/observed` invalidations for `changes`. + * Each generation sends `ready` after watcher initialization, then reads current + * target metadata for matching queued and live invalidations. + */ + +import type { Context } from "@deepseek-ai/cordis"; +import { Deque } from "@deepseek-ai/dsh-deque"; +import { RemoteError } from "@deepseek-ai/dsh-typert-protocol"; +import type { FsObservation, FsTarget } from "@deepseek-ai/dsh-fs"; +import type { WorkspaceFileWatchFrame } from "./types.ts"; + +/** One target invalidation, optionally carrying its instrumented observation. */ +type Observed = readonly [target: FsTarget, observation?: FsObservation]; + +/** Owns target watches, instrumented observations, and every open `changes` generation. */ +export class WorkspaceChangeFeed { + private readonly followers = new Set(); + + /** @param ctx - Host context carrying the filesystem the observations come from. */ + constructor(private readonly ctx: Context) { + ctx.on("fs/observed", (target, observation) => { + for (const follower of this.followers) follower.push([target, observation]); + }); + ctx.effect( + () => async () => { + const followers = [...this.followers]; + await Promise.all(followers.map((follower) => follower.close())); + this.followers.clear(); + }, + "workspace-files.changes", + ); + } + + /** + * Open one target watch; directory targets remain inside `workspaceRoot`. + * @param workspaceRoot - the session's workspace root path. + * @param path - target path, resolved relative to the workspace root; Host metadata determines its type. + * @param signal - generation cancellation. + * @returns `ready` after watching starts, then current metadata for target invalidations. + */ + async *follow( + workspaceRoot: string, + path: string, + signal: AbortSignal, + ): AsyncIterable { + signal.throwIfAborted(); + // Instrumented observations remain queued while the target resolves. + const follower = new ChangeFollower(() => { + signal.removeEventListener("abort", cancel); + this.followers.delete(follower); + }); + signal = AbortSignal.any([signal, follower.controller.signal]); + const aborted = (): boolean => signal.aborted; + const cancel = (): void => { + void follower.close().catch((error: unknown) => { + this.ctx.logger.error(error); + }); + }; + signal.addEventListener("abort", cancel, { once: true }); + this.followers.add(follower); + let unwatch: (() => Promise) | undefined; + try { + // Setup shares cancellation, so a late resolution cannot acquire a watcher. + const root = await this.ctx.fs.resolve(workspaceRoot, { signal }).catch((error: unknown) => { + if (aborted()) return undefined; + throw error; + }); + if (root === undefined || aborted() || follower.isClosed) return; + const target = await this.ctx.fs + .resolve(path, { cwd: workspaceRoot, signal }) + .catch((error: unknown) => { + if (aborted()) return undefined; + throw error; + }); + if (target === undefined || aborted()) return; + const stat = async () => { + const info = await this.ctx.fs.stat(target, signal).catch((error: unknown) => { + if (aborted()) return undefined; + throw error; + }); + if (info?.type === "directory" && !this.ctx.fs.contains(root, target)) { + throw new RemoteError( + "workspace-file/outside-workspace", + "Directory is outside the workspace", + { path }, + ); + } + return info; + }; + await stat(); + if (aborted()) return; + try { + unwatch = await this.ctx.fs.watch( + target, + (error) => { + if (error !== undefined) follower.fail(error); + else if (!follower.isClosed) follower.push([target]); + }, + signal, + ); + if (follower.error !== undefined) throw follower.error; + } catch (error) { + if (aborted() && follower.error === undefined) return; + const failure = follower.error ?? error; + throw new RemoteError( + "workspace-file/watch-unsupported", + failure instanceof Error ? failure.message : String(failure), + { path }, + ); + } + follower.initialized.resolve(unwatch); + if (aborted()) return; + yield { kind: "ready" }; + for await (const [observed] of follower.read()) { + if (observed.targetKey !== target.targetKey) continue; + const info = await stat(); + if (aborted()) return; + const absolutePath = this.ctx.fs.processPath(target); + yield { + kind: "change", + change: + info !== undefined + ? { absolutePath, version: info.version } + : { absolutePath, absent: true }, + }; + } + } finally { + follower.initialized.resolve(unwatch); + await follower.close(); + } + } +} + +/** One generation's queue: observations wait here until its consumer pulls them. */ +class ChangeFollower { + readonly controller = new AbortController(); + readonly initialized = Promise.withResolvers<(() => Promise) | undefined>(); + error: Error | undefined; + private readonly queue = new Deque(); + private wake: (() => void) | undefined; + private closed = false; + private closing: Promise | undefined; + + constructor(private readonly release: () => void) {} + + /** Whether this generation has stopped accepting invalidations. */ + get isClosed(): boolean { + return this.closed; + } + + push(observed: Observed): void { + if (this.closed) return; + this.queue.pushBack(observed); + this.wake?.(); + } + + close(): Promise { + if (this.closing !== undefined) return this.closing; + this.closed = true; + this.closing = this.initialized.promise.then((unwatch) => unwatch?.()).finally(this.release); + this.controller.abort(); + this.wake?.(); + return this.closing; + } + + fail(error: Error): void { + this.error = error; + this.controller.abort(); + } + + /** Drain until closed or aborted; anything still queued then is dropped with the generation. */ + async *read(): AsyncIterable { + while (!this.closed) { + const observed = this.queue.popFront(); + if (observed !== undefined) { + yield observed; + continue; + } + await new Promise((resolve) => { + this.wake = resolve; + }); + this.wake = undefined; + } + if (this.error !== undefined) throw this.error; + } +} diff --git a/apps/web-ui/packages/api/workspace-files/src/client/change-feed.ts b/apps/web-ui/packages/api/workspace-files/src/client/change-feed.ts new file mode 100644 index 000000000..db767e559 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-files/src/client/change-feed.ts @@ -0,0 +1,309 @@ +/** + * One Host `changes` subscription per Session and requested path, shared by + * that target's open resources. + * + * The Host reports invalidations from filesystem watches and instrumented writes. + * The feed opens the target stream when the first follower + * arrives, hands each frame to the followers of its path, and disposes the + * stream when the last follower leaves. A follower buffers target changes + * until `stat` supplies its Host absolute path, then filters queued and live + * frames by that path, with `\\` normalized to `/`. + */ +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { WorkspaceFileChange, WorkspaceFileWatchFrame } from "../types.ts"; +import type { SupervisedStream, WorkspaceFilesRemote } from "./remote.ts"; +import type { WorkspaceFileEdit, WorkspaceFileNotice } from "./types.ts"; + +/** + * The follower key of one absolute path. + * @param path - an absolute path from a Host stat or change frame. + * @returns the path with `\\` normalized to `/`. + */ +function keyOf(path: string): string { + return path.replace(/\\/g, "/"); +} + +/** Notices of one follower, delivered in order and pulled by its consumer. */ +class Follower implements AsyncIterable { + private readonly pending: Array<{ readonly key: string; readonly notice: WorkspaceFileNotice }> = + []; + private readonly started = Promise.withResolvers(); + private wake: (() => void) | undefined; + private ended = false; + private hostKey: string | undefined; + + /** + * Resolves true after the Host acknowledges its subscription, or false if + * this follower ends before acknowledgement. + */ + readonly ready = this.started.promise; + + /** + * @param leave - unregisters this follower and its abort listener. + */ + constructor(private readonly leave: () => void) {} + + /** + * Select the Host path for queued and future changes. + * @param absolutePath - the successful stat's absolute path. + */ + bind(absolutePath: string): void { + this.hostKey = keyOf(absolutePath); + } + + /** The Host acknowledged an active subscription and resolved workspace root. */ + start(): void { + this.started.resolve(true); + } + + /** + * Queue one notice. + * @param notice - what the consumer receives next. + * @param key - normalized Host path for the change. + */ + push(notice: WorkspaceFileNotice, key: string): void { + this.pending.push({ key, notice }); + this.wake?.(); + } + + /** Deliver what is queued, then finish. */ + end(): void { + this.ended = true; + this.started.resolve(false); + this.wake?.(); + } + + /** Unregister even when the consumer has not started pulling notices. */ + dispose(): void { + this.leave(); + } + + /** @inheritdoc */ + async *[Symbol.asyncIterator](): AsyncIterator { + try { + while (true) { + const next = this.pending.shift(); + if (next !== undefined) { + if ( + next.notice.kind === "refresh" || + this.hostKey === undefined || + next.key === this.hostKey + ) + yield next.notice; + continue; + } + if (this.ended) return; + await new Promise((resolve) => { + this.wake = resolve; + }); + this.wake = undefined; + } + } finally { + this.dispose(); + } + } +} + +/** The stream and followers of one Session and requested path. */ +class SessionFeed { + private readonly followers = new Set(); + private readonly stream: SupervisedStream; + private closed = false; + private started = false; + private acknowledged = false; + + /** + * @param remote - the Remote face carrying `workspaceFiles.changes`. + * @param sessionId - the Session whose filesystem this feed observes. + * @param path - target path submitted to the Host. + * @param after - the previous feed of this target still closing, if any; the stream opens once it has settled. + * @param onClose - called once when the stream is gone, whatever the cause, with the dispose that is closing it. + */ + constructor( + remote: WorkspaceFilesRemote, + sessionId: SessionId, + path: string, + after: Promise | undefined, + private readonly onClose: (closed: Promise) => void, + ) { + this.stream = remote.$stream({ + name: `workspace file changes of ${sessionId}`, + // A predecessor still closing finishes first, so one target never has + // two Host streams open at once. + open: (signal) => { + this.started = false; + return openAfter(after, () => remote.workspaceFiles.changes(sessionId, path, signal)); + }, + // A normal end means the Host closed the target's feed. + ended: () => new Error(`workspace file changes of ${sessionId} ended`), + }); + void this.pump(); + } + + /** + * Register one resource address before its Host path is known. + * @param follower - receives changes and binds its path after stat. + */ + add(follower: Follower): void { + this.followers.add(follower); + if (this.started) follower.start(); + } + + /** + * Unregister one follower; the last one leaving disposes the stream. + * @param follower - the follower to drop. + */ + remove(follower: Follower): void { + this.followers.delete(follower); + if (this.followers.size === 0) this.close(); + } + + private async pump(): Promise { + try { + for await (const item of this.stream) { + const frame = item.value; + switch (frame.kind) { + case "ready": + item.accept(); + this.started = true; + for (const follower of this.followers) { + follower.start(); + if (this.acknowledged) follower.push({ kind: "refresh" }, ""); + } + this.acknowledged = true; + break; + case "change": { + const key = keyOf(frame.change.absolutePath); + const notice = editOf(frame.change); + for (const follower of this.followers) follower.push(notice, key); + break; + } + default: + assertNever(frame); + } + } + } catch { + // A terminal stream failure or the Host's end: followers end quietly + // below, and the metadata they hold stays the last known. + } finally { + this.close(); + } + } + + private close(): void { + if (this.closed) return; + this.closed = true; + const closed = this.stream.dispose(); + for (const follower of this.followers) follower.end(); + this.followers.clear(); + this.onClose(closed); + } +} + +/** + * Open a Host stream once a predecessor has finished closing. + * @param after - the predecessor's dispose, or nothing to wait for. + * @param open - opens the stream. + * @returns the stream's items. + */ +async function* openAfter( + after: Promise | undefined, + open: () => AsyncIterable, +): AsyncIterable { + await after; + yield* open(); +} + +/** + * The write one Host frame reports. + * @param frame - the Host frame. + * @returns the edit notice followers receive. + */ +function editOf(frame: WorkspaceFileChange): WorkspaceFileEdit { + return "absent" in frame ? { kind: "absent" } : { kind: "changed", version: frame.version }; +} + +function assertNever(frame: never): never { + throw new Error(`Unexpected workspace file watch frame: ${JSON.stringify(frame)}`); +} + +/** + * Per-target sharing of the Host's workspace file change streams. + * + * Owned by the provider; one instance serves every session of the Client. + */ +export class ChangeFeed { + /** Live feeds only: a feed removes itself when its stream closes. */ + private readonly sessions = new Map(); + /** Streams still closing, by Session and path; a successor waits for its predecessor. */ + private readonly closing = new Map>(); + + /** + * @param remote - the Remote face carrying `$stream` and `workspaceFiles.changes`. + */ + constructor(private readonly remote: WorkspaceFilesRemote) {} + + /** + * Follow one resource in one session before its Host path is known. + * + * The follower is registered on call, not on first pull. Changes delivered + * to this Client are queued while stat is pending. The first follower starts + * target's local `changes` call. The iterable ends + * when `signal` aborts or when the target stream is gone; ending it early + * (`break`, `return`) unregisters the follower as well, and the last follower + * of a target disposes its stream. Await a true `ready` result before stat + * so the Host subscription is active, then bind each stat's absolute path. Until binding, + * any target invalidation can trigger a retry; after binding, only matching queued + * and live changes pass. + * @param sessionId - the Session providing the file's read authority. + * @param path - requested file path; followers share a stream only for the same Session and path. + * @param signal - ends the follow. + * @returns a single-consumer subscription with Host-path binding and explicit disposal. + */ + follow(sessionId: SessionId, path: string, signal: AbortSignal): Follower { + const feed = signal.aborted ? undefined : this.feedOf(sessionId, path); + const leave = (): void => { + signal.removeEventListener("abort", leave); + follower.end(); + feed?.remove(follower); + }; + const follower = new Follower(leave); + if (feed === undefined) { + follower.end(); + } else { + feed.add(follower); + signal.addEventListener("abort", leave, { once: true }); + } + return follower; + } + + /** + * Wait for every stream that is still closing, so an owner tearing down + * leaves no Host stream behind. + * @returns resolves once no stream of this feed is closing. + */ + async settle(): Promise { + await Promise.all(this.closing.values()); + } + + private feedOf(sessionId: SessionId, path: string): SessionFeed { + const key = JSON.stringify([sessionId, path]); + const existing = this.sessions.get(key); + if (existing !== undefined) return existing; + const feed = new SessionFeed(this.remote, sessionId, path, this.closing.get(key), (closed) => { + this.sessions.delete(key); + // A dispose that rejects is still a settled close: nothing remains to wait for. + const tracked: Promise = closed + .then( + () => undefined, + () => undefined, + ) + .then(() => { + if (this.closing.get(key) === tracked) this.closing.delete(key); + }); + this.closing.set(key, tracked); + }); + this.sessions.set(key, feed); + return feed; + } +} diff --git a/apps/web-ui/packages/api/workspace-files/src/client/index.ts b/apps/web-ui/packages/api/workspace-files/src/client/index.ts new file mode 100644 index 000000000..7020e7339 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-files/src/client/index.ts @@ -0,0 +1,35 @@ +/** + * Browser half: the `file` resource provider over `remote.workspaceFiles`. + * + * `types.ts` is what the protocol publishes, `change-feed.ts` shares one Host + * `changes` stream per session, `provider.ts` turns it and `stat` into a value + * stream, and this module only wires them into `ctx.resources`. + */ +import type { Context as ClientContext } from "@deepseek-ai/cordis"; +import type {} from "@deepseek-ai/dsh-api-gateway/client"; +import type {} from "@deepseek-ai/dsh-client-resources/client"; +import { ChangeFeed } from "./change-feed.ts"; +import { createFileResourceProvider } from "./provider.ts"; + +export type { WorkspaceFileParams } from "./types.ts"; + +/** Required browser services: the resource model, the Remote carrier and its namespace. */ +export const inject = ["resources", "remote", "remote.workspaceFiles"]; + +/** + * Client plugin body: register the `file` provider for this plugin's lifetime. + * @param ctx - client root context carrying `resources` and the Remote face. + */ +export function apply(ctx: ClientContext): void { + const changes = new ChangeFeed(ctx.remote); + const provider = createFileResourceProvider(ctx.remote, changes); + ctx.effect(() => { + const release = ctx.resources.register(provider); + // Teardown waits for every session stream still closing, so the plugin + // leaves no Host stream behind. + return async () => { + release(); + await changes.settle(); + }; + }, "workspace-files: file resource provider"); +} diff --git a/apps/web-ui/packages/api/workspace-files/src/client/provider.ts b/apps/web-ui/packages/api/workspace-files/src/client/provider.ts new file mode 100644 index 000000000..21db249ce --- /dev/null +++ b/apps/web-ui/packages/api/workspace-files/src/client/provider.ts @@ -0,0 +1,155 @@ +/** + * The `file` protocol's provider: a workspace file's metadata as a stream of + * `RemoteResult` frames. + * + * An address names the file in one of two scopes. A `session` address, + * `dsh-resource://file/session//`, carries an absolute path or one + * relative to that Session's workspace root: the Host receives the path as-is and + * resolves it against the root it holds. Only the Host's `stat.absolutePath` + * selects the change-feed key; no Client Session summary is needed. + * An `absolute` address, `dsh-resource://file/absolute/`, carries no + * Session and cannot authorize a Host call. An address neither scope + * resolves yields one failure frame — `workspace-file/unsupported-address` for + * a string outside the grammar, `workspace-file/unknown-workspace` when the + * address carries no Session — and ends. + * + * The first frame is the file's `stat`; subsequent invalidations run `stat` + * again unless their version or absence is already known. Reconnection also + * restats the file. Failures travel as `ok: false` frames, never as thrown errors: the + * Remote face does not reject, and anything thrown inside the stream is a + * programming error the resource model lets surface. A failed stat does not end + * the stream: the next write stats again. One {@link ChangeFeed} + * serves every open file of the Client. + */ +import type { ResourceProvider } from "@deepseek-ai/dsh-client-resources/client"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import { RemoteError } from "@deepseek-ai/dsh-typert-protocol"; +import type { RemoteResult } from "@deepseek-ai/dsh-typert-protocol"; +import { parseFileAddress } from "@deepseek-ai/dsh-util-workspace-path"; +import type { WorkspaceFileStat } from "../types.ts"; +import type { ChangeFeed } from "./change-feed.ts"; +import type { WorkspaceFilesRemote } from "./remote.ts"; + +/** The Session and unmodified path submitted to the Host. */ +interface HostFile { + readonly sessionId: SessionId; + /** The path the Host receives: absolute or workspace-relative. */ + readonly path: string; +} + +/** + * Build the `file` provider over one Remote face and one change feed. + * @param remote - the Remote face carrying `workspaceFiles.stat`. + * @param changes - target-scoped change streams shared by file resources. + * @returns the provider to register into `ctx.resources`. + */ +export function createFileResourceProvider( + remote: WorkspaceFilesRemote, + changes: ChangeFeed, +): ResourceProvider<"file"> { + return { + protocol: "file", + async *open(address, { signal }): AsyncIterable> { + const resolved = resolve(address); + if (!resolved.ok) { + yield resolved; + return; + } + const { sessionId, path } = resolved.value; + // Queue changes delivered to this Client while stat is pending. + const notices = changes.follow(sessionId, path, signal); + const stat = (): Promise> => + remote.workspaceFiles.stat(sessionId, path, signal); + // Read through a call: a plain `signal.aborted` is narrowed to `false` by + // the first check and would read as always-false after the later awaits. + const aborted = (): boolean => signal.aborted; + // Undefined while the last stat failed: the follow is on the address, not + // on the file, so a write can still bring the file live. + let current: WorkspaceFileStat | undefined; + try { + if (!(await notices.ready)) { + if (aborted()) return; + const result = await stat(); + if (!aborted()) yield result; + return; + } + if (aborted()) return; + const first = await stat(); + if (aborted()) return; + if (first.ok) { + notices.bind(first.value.absolutePath); + current = first.value; + yield { ok: true, value: current }; + } else { + yield first; + } + for await (const notice of notices) { + if (aborted()) return; + if (current === undefined) { + // Still gone: nothing new to report. + if (notice.kind === "absent") continue; + } else if (notice.kind === "changed") { + // Frames report observations: holding this version already means the + // consumer learns nothing new. + if (notice.version === current.version) continue; + } + // A Host notice may mean stale content. + const again = await stat(); + if (aborted()) return; + if (!again.ok) { + current = undefined; + yield again; + continue; + } + notices.bind(again.value.absolutePath); + current = again.value; + yield { ok: true, value: current }; + } + } finally { + notices.dispose(); + } + }, + }; +} + +/** + * Resolve one address to the Host call it stands for, or to the failure frame it earns. + * @param address - the full address, scheme included. + * @returns the Host file, or the `unsupported-address` / `unknown-workspace` failure. + */ +function resolve(address: string): RemoteResult { + const parsed = parseFileAddress(address); + if (parsed === undefined) return { ok: false, error: unsupportedAddress(address) }; + if (parsed.scope === "session") { + // The address is a string boundary: its id segment is the Session id it names. + const sessionId = parsed.sessionId as SessionId; + return { ok: true, value: { sessionId, path: parsed.path } }; + } + return { ok: false, error: unknownWorkspace(address) }; +} + +/** + * The failure frame's error for an address this provider does not serve. + * @param address - the offending address. + * @returns the typed error. + */ +function unsupportedAddress(address: string): RemoteError<"workspace-file/unsupported-address"> { + return new RemoteError( + "workspace-file/unsupported-address", + `${address} is not a dsh-resource://file/session// or dsh-resource://file/absolute/ address`, + { address }, + ); +} + +/** + * The failure frame's error for an absolute address with no Session. + * @param address - the offending address. + * @returns the typed error. + */ +function unknownWorkspace(address: string): RemoteError<"workspace-file/unknown-workspace"> { + return new RemoteError( + "workspace-file/unknown-workspace", + `${address} requires a dsh-resource://file/session// address`, + { address }, + ); +} diff --git a/apps/web-ui/packages/api/workspace-files/src/client/remote.ts b/apps/web-ui/packages/api/workspace-files/src/client/remote.ts new file mode 100644 index 000000000..7f32f57e1 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-files/src/client/remote.ts @@ -0,0 +1,50 @@ +/** + * The slice of the Client Remote this package calls: the generated + * `workspaceFiles` methods by name, and the stream supervisor structurally, so + * the feed and the provider are testable against a scripted face. + */ +import type { ClientRemote } from "@deepseek-ai/dsh-api-gateway/client"; +// Merges the generated `workspaceFiles` namespace into the Remote face. +import type {} from "@deepseek-ai/dsh-api-workspace-files/remote"; + +/** One item of a supervised stream; `accept` marks the delivering generation as healthy. */ +export interface SupervisedStreamItem { + /** The decoded frame. */ + readonly value: Item; + /** Reset the reconnect backoff: this generation is delivering. */ + accept(): void; +} + +/** A reconnecting single-consumer stream the Remote supervises. */ +export interface SupervisedStream extends AsyncIterable> { + /** + * Stop the stream for good. + * @returns once the active generation and the consumer iterator are closed. + */ + dispose(): Promise; +} + +/** What one supervised stream needs from its owner. */ +export interface SupervisedStreamOptions { + /** Diagnostic owner name. */ + readonly name: string; + /** Open one physical generation; `signal` aborts it. */ + readonly open: (signal: AbortSignal) => AsyncIterable; + /** The error a generation's normal end amounts to; a carrier error asks for a reopen, anything else is terminal. */ + readonly ended: (accepted: boolean) => Error; +} + +/** The `workspaceFiles` namespace methods this package calls, as the generated Remote declares them. */ +export type WorkspaceFilesNamespace = Pick; + +/** The Client Remote as this package sees it. */ +export interface WorkspaceFilesRemote { + /** + * Create one reconnecting stream. + * @param options - opener and end classification. + * @returns the supervised stream, unstarted until iterated. + */ + $stream(options: SupervisedStreamOptions): SupervisedStream; + /** The `workspaceFiles` namespace. */ + readonly workspaceFiles: WorkspaceFilesNamespace; +} diff --git a/apps/web-ui/packages/api/workspace-files/src/client/types.ts b/apps/web-ui/packages/api/workspace-files/src/client/types.ts new file mode 100644 index 000000000..b1a8e3a04 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-files/src/client/types.ts @@ -0,0 +1,49 @@ +/** + * The `file` protocol's resource metadata, navigation params, Client errors, + * and internal change-feed notices. + */ +// Bring the base `ResourceProtocolMap` declaration into this program so the +// augmentation below merges into it instead of declaring a second interface. +import type {} from "@deepseek-ai/dsh-client-resources/client"; +import type { WorkspaceFileStat } from "../types.ts"; + +declare module "@deepseek-ai/dsh-client-ui-slots" { + interface ResourceProtocolMap { + /** + * One workspace file's metadata, addressed as + * `dsh-resource://file/session//` (absolute or workspace-relative). + */ + file: WorkspaceFileStat; + } +} + +/** What a `file` tab is asked to reveal on open or navigation; JSON-shaped. */ +export interface WorkspaceFileParams { + /** 1-based line to scroll into view; absent leaves the position alone. */ + readonly line?: number; +} + +declare module "@deepseek-ai/dsh-typert-protocol" { + interface RemoteErrorDetailsMap { + /** + * The address is not a `dsh-resource://file/` address in a scope the + * provider recognizes: `session//` or + * `absolute/`. Raised by the Client provider; the Host never + * emits it. + */ + "workspace-file/unsupported-address": { readonly address: string }; + /** + * An `absolute` address has no Session to authorize its Host call. + * Raised by the Client provider; the Host never emits it. Session addresses + * are resolved by the Host without a Client Session summary. + */ + "workspace-file/unknown-workspace": { readonly address: string }; + } +} + +/** One Host-reported target change in the Session's filesystem. */ +export type WorkspaceFileEdit = + { readonly kind: "changed"; readonly version: string } | { readonly kind: "absent" }; + +/** A target change or a request to restat after reconnection. */ +export type WorkspaceFileNotice = WorkspaceFileEdit | { readonly kind: "refresh" }; diff --git a/apps/web-ui/packages/api/workspace-files/src/index.ts b/apps/web-ui/packages/api/workspace-files/src/index.ts new file mode 100644 index 000000000..4bad89320 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-files/src/index.ts @@ -0,0 +1,586 @@ +/** + * Workspace file service: read-only file previews, workspace directory + * listings, and the filesystem-observation change feed, exposed as + * `workspaceFiles`. + * + * File reads follow the composed filesystem's read access, including paths + * outside the workspace. The selected Session header supplies the base for + * relative paths, with the sandbox policy root as its no-cwd fallback, not a + * read-containment restriction. Directory listings and change observations + * remain workspace-scoped. File-kind checks and configured read caps apply to + * every preview; this service exposes no mutations. + * + * A page is cut from `streamText`, which decodes and rejects non-UTF-8 as it + * goes, so the file is read only up to the first character past the page and + * never held whole in memory; the NUL scan runs on the page itself. + * + * This is NOT modelled on `session.openWorkspacePath`. That endpoint hands a + * path to the local opener and leaves the effect on the machine; this one sends + * file content across the wire, which is a different level of exposure. + */ + +import { posix, win32 } from "node:path"; +import type { Context } from "@deepseek-ai/cordis"; +import z from "@deepseek-ai/schemastery"; +import type {} from "@deepseek-ai/dsh-fs"; +import type { FsDirEntry, FsInfo, FsPathInfo, FsTarget } from "@deepseek-ai/dsh-fs"; +import type {} from "@deepseek-ai/dsh-sandbox-policy"; +import type {} from "@deepseek-ai/dsh-session"; +import type {} from "@deepseek-ai/dsh-session-persistence"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import { + Remote, + RemoteError, + TypertRemoteService, + type TypertLookup, +} from "@deepseek-ai/dsh-typert-protocol"; +import { WorkspaceChangeFeed } from "./changes.ts"; +import type { + WorkspaceByteRange, + WorkspaceByteReadOptions, + WorkspaceDirectoryEntry, + WorkspaceDirectoryListing, + WorkspaceFileBytes, + WorkspaceFileRange, + WorkspaceFileStat, + WorkspaceFileText, + WorkspaceFileWatchFrame, +} from "./types.ts"; + +export type * from "./types.ts"; + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Host owner of the `workspaceFiles` Remote namespace. */ + workspaceFiles: WorkspaceFiles; + } +} + +/** Header-derived file resolution context for one Session identity. */ +export interface WorkspaceFileScope { + /** Session identity received on the wire. */ + readonly sessionId: SessionId; + /** Session workspace root, or the deployment fallback when its header has no cwd. */ + readonly workspaceRoot: string; +} + +declare module "@deepseek-ai/dsh-typert-protocol" { + interface TypertLookupMap { + /** Resolve a Session id to its workspace root without loading its event body or activating an Agent. */ + workspaceFileScope: TypertLookup; + } +} + +/** Deployment caps on one page or one listing. */ +export interface Config { + /** + * Inclusive byte cap on one page's text and on one byte window. + * + * A page above this fails; it is not shortened, because a silently cut page + * reads as the whole page. A byte window asking for more is refused the same + * way. The file itself has no size cap: a caller pages through it. + */ + readonly maxBytes: number; + /** Inclusive byte cap on a complete-file read; larger files are refused, never truncated. */ + readonly maxFileBytes: number; + /** Default and largest page size in lines; a request asking for more is refused. */ + readonly maxLines: number; + /** Cap on returned directory entries; the rest is dropped and reported cut. */ + readonly maxEntries: number; +} + +/** One page cut from a decoded text stream. */ +interface Page { + readonly text: string; + /** Lines in `text`; `0` for a page past the last line. */ + readonly lines: number; + readonly eof: boolean; +} + +/** The byte text never carries: its presence marks a page as binary. */ +const NUL = String.fromCharCode(0); + +/** Refuse anything the wire schema admits as a number but a window cannot use: only safe integers index a file. */ +function integerAtLeast(value: number, min: number, name: string): number { + if (!Number.isSafeInteger(value) || value < min) { + throw new RemoteError( + "gateway/bad-request", + `${name} must be a safe integer of at least ${min}`, + {}, + ); + } + return value; +} + +/** + * Cut lines `offset` through `offset + limit - 1` from decoded chunks, stopping + * at the first character past the page so the rest of the file is never read. + * Lines before the page are counted, not kept, and the page is refused the + * moment its bytes exceed `maxBytes`, so one giant line cannot grow memory past + * the cap either. + */ +async function cutPage( + chunks: AsyncIterable, + offset: number, + limit: number, + maxBytes: number, + path: string, +): Promise { + const last = offset + limit - 1; + const lines: string[] = []; + let current = ""; + let bytes = 0; + let lineNumber = 1; + const admit = (size: number): void => { + bytes += size; + if (bytes > maxBytes) { + throw new RemoteError( + "workspace-file/too-large", + `lines ${offset}-${last} of "${path}" exceed the ${maxBytes} byte cap`, + { path, limit: maxBytes }, + ); + } + }; + const complete = (): void => { + if (lines.length > 0) admit(1); + lines.push(current); + current = ""; + }; + for await (const chunk of chunks) { + let position = 0; + while (position < chunk.length) { + if (lineNumber > last) return { text: lines.join("\n"), lines: lines.length, eof: false }; + const newline = chunk.indexOf("\n", position); + const segment = newline === -1 ? chunk.slice(position) : chunk.slice(position, newline); + if (lineNumber >= offset) { + admit(Buffer.byteLength(segment, "utf8")); + current += segment; + } + if (newline === -1) break; + if (lineNumber >= offset) complete(); + lineNumber += 1; + position = newline + 1; + } + } + // Only an in-page line can be pending here: earlier lines were never kept, + // and a character past the page returned above. + if (current.length > 0) complete(); + return { text: lines.join("\n"), lines: lines.length, eof: true }; +} + +/** + * Workspace path of `target` relative to `root`, derived from the two canonical + * `file:` URIs so the answer is `/`-joined on every platform. Empty for the root. + */ +function workspacePathOf(rootUrl: string, targetUrl: string): string { + const root = new URL(rootUrl).pathname.replace(/\/+$/, ""); + const target = new URL(targetUrl).pathname; + if (target === root) return ""; + return target + .slice(root.length + 1) + .split("/") + .map(decodeURIComponent) + .join("/"); +} + +/** Strip the resolved child target: the wire carries names and metadata only. */ +function directoryEntry(child: FsDirEntry): WorkspaceDirectoryEntry { + return { + name: child.name, + type: child.type, + ...(child.size === undefined ? {} : { size: child.size }), + }; +} + +/** Host Remote file reads and workspace directory observations over the composed filesystem. */ +export class WorkspaceFiles extends TypertRemoteService { + static inject = ["fs", "sandboxPolicy", "sessions", "typert"]; + + static Config: z = z.object({ + maxBytes: z + .number() + .step(1) + .min(1) + .default(2 * 1024 * 1024), + maxFileBytes: z + .number() + .step(1) + .min(1) + .max(Number.MAX_SAFE_INTEGER - 1) + .default(32 * 1024 * 1024), + maxLines: z.number().step(1).min(1).default(5000), + maxEntries: z.number().step(1).min(1).default(2000), + }); + + private readonly feed: WorkspaceChangeFeed; + + /** + * @param ctx - Host context carrying the filesystem and the sandbox policy. + * @param config - deployment caps on one page or one listing. + */ + constructor( + ctx: Context, + private readonly config: Config, + ) { + super(ctx, "workspaceFiles"); + this.feed = new WorkspaceChangeFeed(ctx); + ctx.inject(["sessions", "typert"], (scope) => { + scope.typert.lookups.register("workspaceFileScope", { + parameter: "workspaceFileScope", + wire: "workspaceFileScopeId", + hostTypeSymbol: "@deepseek-ai/dsh-api-workspace-files#WorkspaceFileScope", + wireTypeSymbol: "@deepseek-ai/dsh-session/types#SessionId", + resolve: async (sessionId) => { + const live = scope.sessions.get(sessionId)?.header; + const stored = + live === undefined ? await scope.get("sessionPersistence")?.stat(sessionId) : undefined; + const header = live ?? stored?.header; + if (header === undefined) return undefined; + return { + sessionId, + workspaceRoot: header.cwd ?? scope.sandboxPolicy.workspaceRoot, + }; + }, + }); + }); + } + + /** + * Read one page of lines from a UTF-8 file readable by the filesystem backend. + * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. + * @param path - absolute path or path relative to the workspace root; files outside it are allowed. + * @param range - the line window; omitted fields take the page defaults. + * @param signal - caller cancellation. + * @returns the page, the file's version at the stat before it, and whether it reaches the last line. + */ + @Remote + async read( + workspaceFileScope: WorkspaceFileScope, + path: string, + range: WorkspaceFileRange, + signal: AbortSignal, + ): Promise { + const { offset, limit } = this.resolvePage(range); + const { target, info } = await this.locateFile(workspaceFileScope, path, signal); + const page = await this.cutPage(target, offset, limit, signal, path); + if (page.text.includes(NUL)) { + throw new RemoteError("workspace-file/not-text", `"${path}" contains NUL bytes`, { path }); + } + return { + ...this.statOf(target, info), + offset, + text: page.text, + lines: page.lines, + eof: page.eof, + }; + } + + /** + * Read a complete regular file or one byte range without text decoding. + * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. + * @param path - target path, absolute or workspace-relative; relative to the base file's directory when provided. + * @param options - optional base file and range; without a range the complete-file cap applies. + * @param signal - caller cancellation. + * @returns native bytes with the file's version and size at the preceding stat, byte offset, and EOF marker. + */ + @Remote + async readBytes( + workspaceFileScope: WorkspaceFileScope, + path: string, + options: WorkspaceByteReadOptions, + signal: AbortSignal, + ): Promise { + const window = + options.range === undefined ? undefined : this.resolveWindow(options.range, path); + const resolved = + options.baseFile === undefined + ? path + : await this.relativePath(workspaceFileScope, options.baseFile, path, signal); + const { target, info } = await this.locateFile(workspaceFileScope, resolved, signal); + if (window !== undefined) { + const { offset, length } = window; + const data = await this.ctx.fs.readByteRange(target, window, signal); + const eof = + info.size === undefined ? data.length < length : offset + data.length >= info.size; + return { ...this.statOf(target, info), offset, data, eof }; + } + const limit = this.config.maxFileBytes; + const data = await this.ctx.fs.readBytes(target, signal, limit).catch((cause: unknown) => { + if ( + typeof cause === "object" && + cause !== null && + "code" in cause && + cause.code === "FS_TOO_LARGE" + ) { + throw new RemoteError( + "workspace-file/too-large", + `"${path}" exceeds the ${limit} byte full-file cap`, + { path, limit }, + { cause }, + ); + } + throw cause; + }); + return { ...this.statOf(target, info), offset: 0, data, eof: true }; + } + + /** + * Report one regular file's identity, version, and size without its content. + * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. + * @param path - absolute path or path relative to the workspace root; files outside it are allowed. + * @param signal - caller cancellation. + * @returns the file's absolute path, current version, and byte size. + */ + @Remote + async stat( + workspaceFileScope: WorkspaceFileScope, + path: string, + signal: AbortSignal, + ): Promise { + const { target, info } = await this.locateFile(workspaceFileScope, path, signal); + return this.statOf(target, info); + } + + /** + * List the direct children of one directory inside the Session's workspace. + * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. + * @param path - workspace path, absolute or relative to the workspace root. + * @param signal - caller cancellation. + * @returns the directory's children in the backend's stable name order, bounded by the entry cap. + */ + @Remote + async list( + workspaceFileScope: WorkspaceFileScope, + path: string, + signal: AbortSignal, + ): Promise { + const { root, workspaceRoot, entry } = await this.inspect(workspaceFileScope, path, signal); + // A final link — a Windows junction or a symlink — is listed through the + // directory it resolves to, matching the child type `listDir` reports for + // that entry; `read` keeps its own no-follow gate on the final component. + if (entry.type !== "directory" && entry.type !== "symlink") { + throw new RemoteError("workspace-file/not-directory", `"${path}" is a ${entry.type}`, { + path, + kind: entry.type, + }); + } + const target = await this.confine(root, workspaceRoot, path, signal); + if (entry.type === "symlink") { + const info = await this.ctx.fs.stat(target, signal); + if (info?.type !== "directory") { + throw new RemoteError( + "workspace-file/not-directory", + `"${path}" does not resolve to a directory`, + { path, kind: "symlink" }, + ); + } + } + const children = await this.ctx.fs.listDir(target, signal); + return { + path: workspacePathOf(this.ctx.fs.fileUrl(root), this.ctx.fs.fileUrl(target)), + entries: children.slice(0, this.config.maxEntries).map(directoryEntry), + truncated: children.length > this.config.maxEntries, + }; + } + + /** + * Watch one file or a directory's direct entries in the Session's filesystem. + * Files use the backend's read authority; directories remain workspace-scoped. + * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. + * @param path - target path; the Host determines its type and confines directories to the workspace. + * @param signal - generation cancellation. + * @returns `ready` once the target watch is active, then current metadata for queued and live invalidations. + * @throws RemoteError when watching is unavailable or a directory is outside the workspace. + */ + @Remote({ mode: "stream" }) + changes( + workspaceFileScope: WorkspaceFileScope, + path: string, + signal: AbortSignal, + ): AsyncIterable { + return this.feed.follow(workspaceFileScope.workspaceRoot, path, signal); + } + + private async relativePath( + scope: WorkspaceFileScope, + baseFile: string, + path: string, + signal: AbortSignal, + ): Promise { + const relative = path.replace(/\\/g, "/"); + if ( + relative.length === 0 || + relative.startsWith("/") || + /^[a-z][a-z\d+.-]*:/iu.test(relative) || + relative.includes(NUL) + ) { + throw new RemoteError( + "gateway/bad-request", + "path must be relative when baseFile is provided", + {}, + ); + } + const { target } = await this.locateFile(scope, baseFile, signal); + const absolute = this.ctx.fs.processPath(target); + const paths = absolute.startsWith("/") ? posix : win32; + return paths.resolve(paths.dirname(absolute), relative); + } + + /** Apply the page defaults and caps here, so the request never carries them implicitly. */ + private resolvePage(range: WorkspaceFileRange): { offset: number; limit: number } { + const offset = range.offset === undefined ? 1 : integerAtLeast(range.offset, 1, "offset"); + const limit = + range.limit === undefined ? this.config.maxLines : integerAtLeast(range.limit, 1, "limit"); + if (limit > this.config.maxLines) { + throw new RemoteError( + "gateway/bad-request", + `limit must be at most ${this.config.maxLines}`, + {}, + ); + } + return { offset, limit }; + } + + /** Apply the byte-window defaults and cap; a window above the cap is refused, not shortened. */ + private resolveWindow( + range: WorkspaceByteRange, + path: string, + ): { offset: number; length: number } { + const offset = range.offset === undefined ? 0 : integerAtLeast(range.offset, 0, "offset"); + const length = + range.length === undefined ? this.config.maxBytes : integerAtLeast(range.length, 1, "length"); + if (offset + length > Number.MAX_SAFE_INTEGER) { + throw new RemoteError( + "gateway/bad-request", + "offset plus length must stay a safe integer", + {}, + ); + } + if (length > this.config.maxBytes) { + throw new RemoteError( + "workspace-file/too-large", + `${length} bytes of "${path}" exceed the ${this.config.maxBytes} byte cap`, + { path, limit: this.config.maxBytes }, + ); + } + return { offset, length }; + } + /** + * Inspect the requested path itself before resolution follows its final + * component. Directory containment is checked separately by `list`. + */ + private async inspect( + workspaceFileScope: WorkspaceFileScope, + path: string, + signal: AbortSignal, + ): Promise<{ root: FsTarget; workspaceRoot: string; entry: FsPathInfo }> { + if (path.length === 0) throw new RemoteError("gateway/bad-request", "path is required", {}); + const { workspaceRoot } = workspaceFileScope; + const root = await this.ctx.fs.resolve(workspaceRoot, { signal }); + // Gate on the path itself before anything follows it. + const entry = await this.ctx.fs.lstat(path, { cwd: workspaceRoot }, signal); + if (entry === undefined) { + throw new RemoteError("workspace-file/not-found", `no entry at "${path}"`, { path }); + } + return { root, workspaceRoot, entry }; + } + + /** Resolve an inspected path and refuse it unless the workspace contains it. */ + private async confine( + root: FsTarget, + workspaceRoot: string, + path: string, + signal: AbortSignal, + ): Promise { + const target = await this.ctx.fs.resolve(path, { cwd: workspaceRoot, signal }); + if (!this.ctx.fs.contains(root, target)) { + throw new RemoteError( + "workspace-file/outside-workspace", + `"${path}" is outside the workspace`, + { path }, + ); + } + return target; + } + + /** + * All gates for a regular file, ending in the one stat that names its version + * and size. The stat re-checks what `lstat` saw: the file may have gone or + * changed kind in between. + */ + private async locateFile( + workspaceFileScope: WorkspaceFileScope, + path: string, + signal: AbortSignal, + ): Promise<{ target: FsTarget; info: FsInfo }> { + const { workspaceRoot, entry } = await this.inspect(workspaceFileScope, path, signal); + if (entry.type !== "file") { + throw new RemoteError("workspace-file/not-regular-file", `"${path}" is a ${entry.type}`, { + path, + kind: entry.type, + }); + } + const target = await this.ctx.fs.resolve(path, { cwd: workspaceRoot, signal }); + const info = await this.ctx.fs.stat(target, signal); + if (info === undefined) { + throw new RemoteError("workspace-file/not-found", `no entry at "${path}"`, { path }); + } + if (info.type !== "file") { + throw new RemoteError("workspace-file/not-regular-file", `"${path}" is a ${info.type}`, { + path, + kind: info.type, + }); + } + return { target, info }; + } + + private statOf(target: FsTarget, info: FsInfo): WorkspaceFileStat { + return { + absolutePath: this.ctx.fs.processPath(target), + version: info.version, + ...(info.size === undefined ? {} : { bytes: info.size }), + }; + } + + /** Stream the file as text and cut the page, classifying the backend's non-text refusal. */ + private async cutPage( + target: FsTarget, + offset: number, + limit: number, + signal: AbortSignal, + path: string, + ): Promise { + try { + return await cutPage( + await this.ctx.fs.streamText(target, signal), + offset, + limit, + this.config.maxBytes, + path, + ); + } catch (error: unknown) { + if (isNotTextRefusal(error)) { + throw new RemoteError( + "workspace-file/not-text", + `"${path}" is not UTF-8 text`, + { path }, + { cause: error }, + ); + } + throw error; + } + } +} + +/** + * The backend's non-text refusal, recognized by its code alone: the error class + * belongs to whichever `dsh-fs` instance the provider loaded, so no class + * identity is shared across the package boundary. + */ +function isNotTextRefusal(error: unknown): boolean { + return ( + typeof error === "object" && error !== null && "code" in error && error.code === "FS_NOT_TEXT" + ); +} + +export default WorkspaceFiles; diff --git a/apps/web-ui/packages/api/workspace-files/src/types.ts b/apps/web-ui/packages/api/workspace-files/src/types.ts new file mode 100644 index 000000000..0db271038 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-files/src/types.ts @@ -0,0 +1,177 @@ +/** + * Wire types of the `workspaceFiles` Remote namespace. Types only: generated + * Remote clients consume this module without Host runtime code. + * + * Two path vocabularies leave here, and each method uses exactly one: + * + * - `read`, `readBytes`, `stat`, and `changes` name a file by its absolute path in the + * filesystem's execution world, because their consumer is the Client + * resource system, whose `dsh-resource://file/session//` address carries that + * same path. + * - `list` speaks workspace paths — the same syntax its `path` argument accepts — + * because its consumer is a tree rooted at the workspace root. + * + * @module @deepseek-ai/dsh-api-workspace-files/types + */ + +// Import the protocol module so the declaration at the end of this file +// augments its error map rather than defining an unrelated ambient module. +import type {} from "@deepseek-ai/dsh-typert-protocol"; + +/** Identity and freshness of one workspace file, without its content. */ +export interface WorkspaceFileStat { + /** + * Absolute path of the file in the filesystem's execution world, symlinks + * resolved: `/`-separated on POSIX, drive-rooted with the platform separator + * on Windows. What a `dsh-resource://file/absolute/…` address carries, and + * what a `dsh-resource://file/session//…` address's + * workspace-relative path resolves to against that Session's root. + */ + readonly absolutePath: string; + /** Opaque freshness token at the time of the stat; never parsed. */ + readonly version: string; + /** Byte size of the complete file, when the backend reports it. */ + readonly bytes?: number; +} + +/** + * The line window one `read` returns. Lines are 1-based and end at `\n`; a + * final `\n` terminates the last line rather than starting an empty one. + */ +export interface WorkspaceFileRange { + /** First line of the page. Defaults to 1. */ + readonly offset?: number; + /** Largest number of lines on the page. Defaults to, and may not exceed, the configured `maxLines`. */ + readonly limit?: number; +} + +/** One page of a workspace text file as a Client reads it. */ +export interface WorkspaceFileText extends WorkspaceFileStat { + /** First line of the page, as requested. */ + readonly offset: number; + /** + * The page's lines joined by `\n`, without a terminator after the last one. + * Empty for a page past the file's last line and for a page holding one + * empty line; `lines` tells them apart. + */ + readonly text: string; + /** How many lines the page holds; `0` when `offset` lies past the file's last line. */ + readonly lines: number; + /** Whether the page includes the file's last line. */ + readonly eof: boolean; +} + +/** The byte window one `readBytes` returns. Offsets are 0-based. */ +export interface WorkspaceByteRange { + /** First byte of the window. Defaults to 0. */ + readonly offset?: number; + /** Largest number of bytes in the window. Defaults to, and may not exceed, the configured `maxBytes`. */ + readonly length?: number; +} + +/** Target resolution and optional byte range for one binary file read. */ +export interface WorkspaceByteReadOptions { + /** Byte window; omit to read the complete file under the configured `maxFileBytes` cap. */ + readonly range?: WorkspaceByteRange; + /** Base file, absolute or workspace-relative; resolve the relative target from its directory. */ + readonly baseFile?: string; +} + +/** + * One byte window of a workspace file as a Client reads it: raw bytes, no text + * decoding and no binary rejection. `bytes` is the complete file's size. + */ +export interface WorkspaceFileBytes< + Data extends Uint8Array = Uint8Array, +> extends WorkspaceFileStat { + /** First byte of the window, as requested. */ + readonly offset: number; + /** Native file bytes; empty at or past EOF. */ + readonly data: Data; + /** Whether the window includes the file's last byte. */ + readonly eof: boolean; +} + +/** One direct child of a listed workspace directory. */ +export interface WorkspaceDirectoryEntry { + /** Basename inside the listed directory. */ + readonly name: string; + /** + * What the child resolves to. A symlink reports the type of its destination, + * and `other` covers everything that is neither a regular file nor a + * directory; `read` still refuses a symlink, so `file` here is a listing fact, + * not a promise that the content is readable. + */ + readonly type: "file" | "directory" | "other"; + /** Byte size, present only for a regular file whose backend reports it. */ + readonly size?: number; +} + +/** Direct children of one workspace directory. */ +export interface WorkspaceDirectoryListing { + /** + * The listed directory as a workspace path, relative to the workspace root + * and empty for the root itself. A child's path is this value joined with + * {@link WorkspaceDirectoryEntry.name} by `/`. + */ + readonly path: string; + /** + * Direct children in the backend's stable name order, cut to the configured + * entry cap. Presentation order is the caller's choice. + */ + readonly entries: readonly WorkspaceDirectoryEntry[]; + /** Whether the entry cap dropped children from {@link entries}. */ + readonly truncated: boolean; +} + +/** + * Current target metadata after a filesystem invalidation. File consumers may + * ignore an already known version; directory consumers relist on every frame + * because a child's metadata can change without changing the directory version. + */ +export type WorkspaceFileChange = + | { + /** Absolute target path, in the same form as {@link WorkspaceFileStat.absolutePath}. */ + readonly absolutePath: string; + /** Opaque freshness token from the current stat; never parsed. */ + readonly version: string; + } + | { + /** Absolute target path, in the same form as {@link WorkspaceFileStat.absolutePath}. */ + readonly absolutePath: string; + /** The target was observed to be gone. */ + readonly absent: true; + }; + +/** + * One frame of a workspace file watch generation. `ready` confirms that the + * Host has initialized the target watcher; queued and live invalidations + * follow as `change` frames with current target metadata. + */ +export type WorkspaceFileWatchFrame = + { readonly kind: "ready" } | { readonly kind: "change"; readonly change: WorkspaceFileChange }; + +declare module "@deepseek-ai/dsh-typert-protocol" { + interface RemoteErrorDetailsMap { + /** The filesystem provider cannot initialize a watch for this target. */ + "workspace-file/watch-unsupported": { readonly path: string }; + /** No entry exists at that path inside the workspace. */ + "workspace-file/not-found": { readonly path: string }; + /** The directory listing or watch path resolves outside the Session's workspace root. */ + "workspace-file/outside-workspace": { readonly path: string }; + /** The requested page exceeds the configured byte cap; nothing is returned. */ + "workspace-file/too-large": { readonly path: string; readonly limit: number }; + /** The content read so far is not decodable UTF-8 text, or the page carries NUL bytes. */ + "workspace-file/not-text": { readonly path: string }; + /** The path is not a regular file, so it has no text to read. */ + "workspace-file/not-regular-file": { + readonly path: string; + readonly kind: "directory" | "symlink" | "other"; + }; + /** The path is not a directory, so it has no children to list. */ + "workspace-file/not-directory": { + readonly path: string; + readonly kind: "file" | "symlink" | "other"; + }; + } +} diff --git a/apps/web-ui/packages/api/workspace-files/tsdown.config.ts b/apps/web-ui/packages/api/workspace-files/tsdown.config.ts new file mode 100644 index 000000000..1462a154a --- /dev/null +++ b/apps/web-ui/packages/api/workspace-files/tsdown.config.ts @@ -0,0 +1,5 @@ +import { clientBundle } from "../../client/tsdown.client.ts"; + +export default clientBundle("@deepseek-ai/dsh-api-workspace-files", ["lib/types/index.js"], { + hostPhase: true, +}); diff --git a/apps/web-ui/packages/boot/plugin-manager/generated/remote.d.ts b/apps/web-ui/packages/boot/plugin-manager/generated/remote.d.ts new file mode 100644 index 000000000..0310cbfd6 --- /dev/null +++ b/apps/web-ui/packages/boot/plugin-manager/generated/remote.d.ts @@ -0,0 +1,45 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import type { + RemoteResult, + TypertRemoteContribution, +} from '@deepseek-ai/dsh-typert-protocol' +import type { PluginEntryId } from '@deepseek-ai/dsh-host-plugin-inventory/types' +import type { BundleInfo, ChangeResult, InspectOptions, InstallBundleOptions, PluginInfo, PluginInstallCancellation, PluginInstallRequestId, PluginRegistries, PluginSpecInspection } from '@deepseek-ai/dsh-plugin-manager/types' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertRemoteNamespace$706c7567696e4d616e61676572 { + cancelInstall: (requestId: PluginInstallRequestId) => Promise> + inspect: (spec: string, options?: InspectOptions, signal?: AbortSignal) => Promise> + installBundle: (spec: string, options?: InstallBundleOptions) => Promise> + listBundles: () => Promise> + listPlugins: () => Promise> + listVersionExemptions: () => Promise; warnings: string[]; }>> + registries: () => Promise> + removeBundle: (name: string) => Promise> + setBundleEnabled: (name: string, enabled: boolean) => Promise> + setPluginEnabled: (id: PluginEntryId, enabled: boolean) => Promise> + setVersionExemption: (packageVersion: string, runtimeVersion: string, enabled: boolean, acceptRisk?: boolean) => Promise> + waitForInstall: (requestId: PluginInstallRequestId) => Promise> + } + interface TypertRemoteMap { + 'pluginManager/cancelInstall': (requestId: PluginInstallRequestId) => Promise> + 'pluginManager/inspect': (spec: string, options?: InspectOptions, signal?: AbortSignal) => Promise> + 'pluginManager/installBundle': (spec: string, options?: InstallBundleOptions) => Promise> + 'pluginManager/listBundles': () => Promise> + 'pluginManager/listPlugins': () => Promise> + 'pluginManager/listVersionExemptions': () => Promise; warnings: string[]; }>> + 'pluginManager/registries': () => Promise> + 'pluginManager/removeBundle': (name: string) => Promise> + 'pluginManager/setBundleEnabled': (name: string, enabled: boolean) => Promise> + 'pluginManager/setPluginEnabled': (id: PluginEntryId, enabled: boolean) => Promise> + 'pluginManager/setVersionExemption': (packageVersion: string, runtimeVersion: string, enabled: boolean, acceptRisk?: boolean) => Promise> + 'pluginManager/waitForInstall': (requestId: PluginInstallRequestId) => Promise> + } + interface TypertRemoteNamespaceMap { + 'pluginManager': TypertRemoteNamespace$706c7567696e4d616e61676572 + } +} + +export declare const TYPERT_REMOTE: TypertRemoteContribution +export default TYPERT_REMOTE +//# sourceMappingURL=typert.remote-client.d.ts.map diff --git a/apps/web-ui/packages/boot/plugin-manager/generated/remote.js b/apps/web-ui/packages/boot/plugin-manager/generated/remote.js new file mode 100644 index 000000000..64ae6269e --- /dev/null +++ b/apps/web-ui/packages/boot/plugin-manager/generated/remote.js @@ -0,0 +1,733 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import { z } from 'zod' + +let _deepseek_ai_dsh_plugin_manager_pluginManager_cancelInstall_parameter_0$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_cancelInstall_parameter_0$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_cancelInstall_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_plugin_manager_pluginManager_cancelInstall_result$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_cancelInstall_result$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_cancelInstall_result$schema$value ??= z.object({ + 'status': z.union([z.literal("cancelled"), z.literal("not-running"), z.literal("too-late")]).readonly(), +})) +let _deepseek_ai_dsh_plugin_manager_pluginManager_inspect_parameter_0$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_inspect_parameter_0$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_inspect_parameter_0$schema$value ??= z.string()) +let _deepseek_ai_dsh_plugin_manager_pluginManager_inspect_parameter_1$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_inspect_parameter_1$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_inspect_parameter_1$schema$value ??= z.union([z.undefined(), z.object({ + 'registry': z.union([z.literal(null), z.string()]).readonly().optional(), +})])) +let _deepseek_ai_dsh_plugin_manager_pluginManager_inspect_result$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_inspect_result$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_inspect_result$schema$value ??= z.union([z.object({ + 'status': z.literal("accepted").readonly(), + 'kind': z.union([z.literal("registry"), z.literal("path"), z.literal("git"), z.literal("tarball")]).readonly(), + 'name': z.string().readonly().optional(), + 'version': z.string().readonly().optional(), + 'description': z.string().readonly().optional(), + 'bundle': z.union([z.literal(null), z.literal(false), z.literal(true)]).readonly(), + 'registry': z.union([z.literal(null), z.string()]).readonly(), + 'host': z.string().readonly().optional(), +}), z.object({ + 'status': z.literal("refused").readonly(), + 'problem': z.union([z.literal("network"), z.literal("unknown"), z.literal("invalid-spec"), z.literal("not-found"), z.literal("already-installed"), z.literal("not-a-package"), z.literal("not-a-bundle")]).readonly(), + 'reason': z.string().readonly(), + 'registries': z.array(z.union([z.literal(null), z.string()])).readonly().optional(), +})])) +let _deepseek_ai_dsh_plugin_manager_pluginManager_installBundle_parameter_0$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_installBundle_parameter_0$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_installBundle_parameter_0$schema$value ??= z.string()) +let _deepseek_ai_dsh_plugin_manager_pluginManager_installBundle_parameter_1$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_installBundle_parameter_1$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_installBundle_parameter_1$schema$value ??= z.union([z.undefined(), z.object({ + 'enabled': z.boolean().optional(), + 'requestId': z.intersection(z.string(), z.unknown()).optional(), + 'approvedBuilds': z.array(z.string()).optional(), + 'registry': z.union([z.literal(null), z.string()]).optional(), +})])) +let _deepseek_ai_dsh_plugin_manager_pluginManager_installBundle_result$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_installBundle_result$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_installBundle_result$schema$value ??= z.object({ + 'changed': z.boolean(), + 'application': z.union([z.literal("cancelled"), z.literal("failed"), z.literal("applied"), z.literal("restart-required"), z.literal("overridden")]), + 'stage': z.union([z.literal("remove"), z.literal("install"), z.literal("enable")]), + 'target': z.string(), + 'enabled': z.boolean().optional(), + 'error': z.object({ + 'code': z.union([z.literal("management-required"), z.literal("unaddressable"), z.literal("unknown-plugin"), z.literal("invalid-spec"), z.literal("ambiguous-install"), z.literal("not-bundle"), z.literal("not-removable"), z.literal("stop-profile"), z.literal("bundle-in-use"), z.literal("stale-approval"), z.literal("incompatible-version"), z.literal("operation-error")]), + 'diagnostic': z.string().optional(), + 'incompatible': z.array(z.object({ + 'name': z.string(), + 'version': z.string(), + 'runtimeVersion': z.string(), + 'peers': z.record(z.string(), z.string()), +})).optional(), +}).optional(), + 'warnings': z.array(z.string()).optional(), + 'packageResult': z.object({ + 'exitCode': z.number(), + 'output': z.string(), + 'truncated': z.boolean(), + 'logPath': z.string(), + 'kind': z.union([z.literal("network"), z.literal("unknown"), z.literal("timeout"), z.literal("integrity"), z.literal("pnpm-missing"), z.literal("not-found"), z.literal("no-matching-version"), z.literal("disk-full"), z.literal("permission"), z.literal("build-blocked")]).optional(), + 'timedOut': z.boolean().optional(), + 'incompatible': z.array(z.object({ + 'name': z.string(), + 'version': z.string(), + 'runtimeVersion': z.string(), + 'peers': z.record(z.string(), z.string()), +})).optional(), +}).optional(), + 'bundle': z.string().optional(), + 'version': z.string().optional(), + 'pendingBuilds': z.array(z.string()).optional(), + 'approvedBuilds': z.array(z.string()).optional(), + 'registries': z.array(z.union([z.literal(null), z.string()])).optional(), + 'failedAt': z.union([z.literal("registry"), z.literal("spec-host")]).optional(), +})) +let _deepseek_ai_dsh_plugin_manager_pluginManager_listBundles_result$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_listBundles_result$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_listBundles_result$schema$value ??= z.array(z.object({ + 'name': z.string(), + 'version': z.string().optional(), + 'meta': z.object({ + 'title': z.union([z.string(), z.intersection(z.object({ + 'en': z.string().readonly(), +}), z.record(z.string(), z.string()).readonly())]).readonly().optional(), + 'description': z.union([z.string(), z.intersection(z.object({ + 'en': z.string().readonly(), +}), z.record(z.string(), z.string()).readonly())]).readonly().optional(), + 'icon': z.string().readonly().optional(), + 'error': z.string().readonly().optional(), +}).optional(), + 'description': z.string().optional(), + 'enabled': z.boolean(), + 'installed': z.boolean(), + 'source': z.string().optional(), + 'optional': z.boolean(), + 'removable': z.boolean(), + 'readOnlyReason': z.union([z.literal("management-required"), z.literal("unaddressable")]).optional(), + 'error': z.object({ + 'code': z.union([z.literal("management-required"), z.literal("unaddressable"), z.literal("unknown-plugin"), z.literal("invalid-spec"), z.literal("ambiguous-install"), z.literal("not-bundle"), z.literal("not-removable"), z.literal("stop-profile"), z.literal("bundle-in-use"), z.literal("stale-approval"), z.literal("incompatible-version"), z.literal("operation-error")]), + 'diagnostic': z.string().optional(), + 'incompatible': z.array(z.object({ + 'name': z.string(), + 'version': z.string(), + 'runtimeVersion': z.string(), + 'peers': z.record(z.string(), z.string()), +})).optional(), +}).optional(), + 'rows': z.array(z.object({ + 'rowId': z.string(), + 'moduleName': z.string(), + 'meta': z.object({ + 'title': z.union([z.string(), z.intersection(z.object({ + 'en': z.string().readonly(), +}), z.record(z.string(), z.string()).readonly())]).readonly().optional(), + 'description': z.union([z.string(), z.intersection(z.object({ + 'en': z.string().readonly(), +}), z.record(z.string(), z.string()).readonly())]).readonly().optional(), + 'icon': z.string().readonly().optional(), + 'error': z.string().readonly().optional(), +}).optional(), + 'entryId': z.intersection(z.string(), z.unknown()).optional(), +})), + 'overrides': z.array(z.string()), +}))) +let _deepseek_ai_dsh_plugin_manager_pluginManager_listPlugins_result$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_listPlugins_result$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_listPlugins_result$schema$value ??= z.array(z.union([z.intersection(z.object({ + 'entryId': z.intersection(z.string(), z.unknown()).readonly(), + 'moduleName': z.string().readonly(), + 'meta': z.object({ + 'title': z.union([z.string(), z.intersection(z.object({ + 'en': z.string().readonly(), +}), z.record(z.string(), z.string()).readonly())]).readonly().optional(), + 'description': z.union([z.string(), z.intersection(z.object({ + 'en': z.string().readonly(), +}), z.record(z.string(), z.string()).readonly())]).readonly().optional(), + 'icon': z.string().readonly().optional(), + 'error': z.string().readonly().optional(), +}).readonly().optional(), + 'enabled': z.boolean().readonly(), + 'fiberPhase': z.union([z.literal(null), z.literal("failed"), z.literal("pending"), z.literal("active"), z.literal("loading"), z.literal("unloading")]).readonly(), +}), z.object({ + 'patchId': z.string(), + 'readOnlyReason': z.never().optional(), +})), z.intersection(z.object({ + 'entryId': z.intersection(z.string(), z.unknown()).readonly(), + 'moduleName': z.string().readonly(), + 'meta': z.object({ + 'title': z.union([z.string(), z.intersection(z.object({ + 'en': z.string().readonly(), +}), z.record(z.string(), z.string()).readonly())]).readonly().optional(), + 'description': z.union([z.string(), z.intersection(z.object({ + 'en': z.string().readonly(), +}), z.record(z.string(), z.string()).readonly())]).readonly().optional(), + 'icon': z.string().readonly().optional(), + 'error': z.string().readonly().optional(), +}).readonly().optional(), + 'enabled': z.boolean().readonly(), + 'fiberPhase': z.union([z.literal(null), z.literal("failed"), z.literal("pending"), z.literal("active"), z.literal("loading"), z.literal("unloading")]).readonly(), +}), z.object({ + 'patchId': z.never().optional(), + 'readOnlyReason': z.union([z.literal("management-required"), z.literal("unaddressable")]), +}))]))) +let _deepseek_ai_dsh_plugin_manager_pluginManager_listVersionExemptions_result$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_listVersionExemptions_result$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_listVersionExemptions_result$schema$value ??= z.object({ + 'exemptions': z.record(z.string(), z.array(z.string())), + 'warnings': z.array(z.string()), +})) +let _deepseek_ai_dsh_plugin_manager_pluginManager_registries_result$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_registries_result$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_registries_result$schema$value ??= z.object({ + 'registry': z.union([z.literal(null), z.string()]).readonly(), + 'fallbackRegistries': z.array(z.string()).readonly(), + 'resolved': z.union([z.literal(null), z.string()]).readonly(), +})) +let _deepseek_ai_dsh_plugin_manager_pluginManager_removeBundle_parameter_0$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_removeBundle_parameter_0$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_removeBundle_parameter_0$schema$value ??= z.string()) +let _deepseek_ai_dsh_plugin_manager_pluginManager_removeBundle_result$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_removeBundle_result$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_removeBundle_result$schema$value ??= z.object({ + 'changed': z.boolean(), + 'application': z.union([z.literal("cancelled"), z.literal("failed"), z.literal("applied"), z.literal("restart-required"), z.literal("overridden")]), + 'stage': z.union([z.literal("remove"), z.literal("install"), z.literal("enable")]), + 'target': z.string(), + 'enabled': z.boolean().optional(), + 'error': z.object({ + 'code': z.union([z.literal("management-required"), z.literal("unaddressable"), z.literal("unknown-plugin"), z.literal("invalid-spec"), z.literal("ambiguous-install"), z.literal("not-bundle"), z.literal("not-removable"), z.literal("stop-profile"), z.literal("bundle-in-use"), z.literal("stale-approval"), z.literal("incompatible-version"), z.literal("operation-error")]), + 'diagnostic': z.string().optional(), + 'incompatible': z.array(z.object({ + 'name': z.string(), + 'version': z.string(), + 'runtimeVersion': z.string(), + 'peers': z.record(z.string(), z.string()), +})).optional(), +}).optional(), + 'warnings': z.array(z.string()).optional(), + 'packageResult': z.object({ + 'exitCode': z.number(), + 'output': z.string(), + 'truncated': z.boolean(), + 'logPath': z.string(), + 'kind': z.union([z.literal("network"), z.literal("unknown"), z.literal("timeout"), z.literal("integrity"), z.literal("pnpm-missing"), z.literal("not-found"), z.literal("no-matching-version"), z.literal("disk-full"), z.literal("permission"), z.literal("build-blocked")]).optional(), + 'timedOut': z.boolean().optional(), + 'incompatible': z.array(z.object({ + 'name': z.string(), + 'version': z.string(), + 'runtimeVersion': z.string(), + 'peers': z.record(z.string(), z.string()), +})).optional(), +}).optional(), + 'bundle': z.string().optional(), + 'version': z.string().optional(), + 'pendingBuilds': z.array(z.string()).optional(), + 'approvedBuilds': z.array(z.string()).optional(), + 'registries': z.array(z.union([z.literal(null), z.string()])).optional(), + 'failedAt': z.union([z.literal("registry"), z.literal("spec-host")]).optional(), +})) +let _deepseek_ai_dsh_plugin_manager_pluginManager_setBundleEnabled_parameter_0$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_setBundleEnabled_parameter_0$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_setBundleEnabled_parameter_0$schema$value ??= z.string()) +let _deepseek_ai_dsh_plugin_manager_pluginManager_setBundleEnabled_parameter_1$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_setBundleEnabled_parameter_1$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_setBundleEnabled_parameter_1$schema$value ??= z.boolean()) +let _deepseek_ai_dsh_plugin_manager_pluginManager_setBundleEnabled_result$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_setBundleEnabled_result$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_setBundleEnabled_result$schema$value ??= z.object({ + 'changed': z.boolean(), + 'application': z.union([z.literal("cancelled"), z.literal("failed"), z.literal("applied"), z.literal("restart-required"), z.literal("overridden")]), + 'stage': z.union([z.literal("remove"), z.literal("install"), z.literal("enable")]), + 'target': z.string(), + 'enabled': z.boolean().optional(), + 'error': z.object({ + 'code': z.union([z.literal("management-required"), z.literal("unaddressable"), z.literal("unknown-plugin"), z.literal("invalid-spec"), z.literal("ambiguous-install"), z.literal("not-bundle"), z.literal("not-removable"), z.literal("stop-profile"), z.literal("bundle-in-use"), z.literal("stale-approval"), z.literal("incompatible-version"), z.literal("operation-error")]), + 'diagnostic': z.string().optional(), + 'incompatible': z.array(z.object({ + 'name': z.string(), + 'version': z.string(), + 'runtimeVersion': z.string(), + 'peers': z.record(z.string(), z.string()), +})).optional(), +}).optional(), + 'warnings': z.array(z.string()).optional(), + 'packageResult': z.object({ + 'exitCode': z.number(), + 'output': z.string(), + 'truncated': z.boolean(), + 'logPath': z.string(), + 'kind': z.union([z.literal("network"), z.literal("unknown"), z.literal("timeout"), z.literal("integrity"), z.literal("pnpm-missing"), z.literal("not-found"), z.literal("no-matching-version"), z.literal("disk-full"), z.literal("permission"), z.literal("build-blocked")]).optional(), + 'timedOut': z.boolean().optional(), + 'incompatible': z.array(z.object({ + 'name': z.string(), + 'version': z.string(), + 'runtimeVersion': z.string(), + 'peers': z.record(z.string(), z.string()), +})).optional(), +}).optional(), + 'bundle': z.string().optional(), + 'version': z.string().optional(), + 'pendingBuilds': z.array(z.string()).optional(), + 'approvedBuilds': z.array(z.string()).optional(), + 'registries': z.array(z.union([z.literal(null), z.string()])).optional(), + 'failedAt': z.union([z.literal("registry"), z.literal("spec-host")]).optional(), +})) +let _deepseek_ai_dsh_plugin_manager_pluginManager_setPluginEnabled_parameter_0$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_setPluginEnabled_parameter_0$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_setPluginEnabled_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_plugin_manager_pluginManager_setPluginEnabled_parameter_1$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_setPluginEnabled_parameter_1$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_setPluginEnabled_parameter_1$schema$value ??= z.boolean()) +let _deepseek_ai_dsh_plugin_manager_pluginManager_setPluginEnabled_result$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_setPluginEnabled_result$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_setPluginEnabled_result$schema$value ??= z.object({ + 'changed': z.boolean(), + 'application': z.union([z.literal("cancelled"), z.literal("failed"), z.literal("applied"), z.literal("restart-required"), z.literal("overridden")]), + 'stage': z.union([z.literal("remove"), z.literal("install"), z.literal("enable")]), + 'target': z.string(), + 'enabled': z.boolean().optional(), + 'error': z.object({ + 'code': z.union([z.literal("management-required"), z.literal("unaddressable"), z.literal("unknown-plugin"), z.literal("invalid-spec"), z.literal("ambiguous-install"), z.literal("not-bundle"), z.literal("not-removable"), z.literal("stop-profile"), z.literal("bundle-in-use"), z.literal("stale-approval"), z.literal("incompatible-version"), z.literal("operation-error")]), + 'diagnostic': z.string().optional(), + 'incompatible': z.array(z.object({ + 'name': z.string(), + 'version': z.string(), + 'runtimeVersion': z.string(), + 'peers': z.record(z.string(), z.string()), +})).optional(), +}).optional(), + 'warnings': z.array(z.string()).optional(), + 'packageResult': z.object({ + 'exitCode': z.number(), + 'output': z.string(), + 'truncated': z.boolean(), + 'logPath': z.string(), + 'kind': z.union([z.literal("network"), z.literal("unknown"), z.literal("timeout"), z.literal("integrity"), z.literal("pnpm-missing"), z.literal("not-found"), z.literal("no-matching-version"), z.literal("disk-full"), z.literal("permission"), z.literal("build-blocked")]).optional(), + 'timedOut': z.boolean().optional(), + 'incompatible': z.array(z.object({ + 'name': z.string(), + 'version': z.string(), + 'runtimeVersion': z.string(), + 'peers': z.record(z.string(), z.string()), +})).optional(), +}).optional(), + 'bundle': z.string().optional(), + 'version': z.string().optional(), + 'pendingBuilds': z.array(z.string()).optional(), + 'approvedBuilds': z.array(z.string()).optional(), + 'registries': z.array(z.union([z.literal(null), z.string()])).optional(), + 'failedAt': z.union([z.literal("registry"), z.literal("spec-host")]).optional(), +})) +let _deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_parameter_0$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_parameter_0$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_parameter_0$schema$value ??= z.string()) +let _deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_parameter_1$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_parameter_1$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_parameter_1$schema$value ??= z.string()) +let _deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_parameter_2$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_parameter_2$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_parameter_2$schema$value ??= z.boolean()) +let _deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_parameter_3$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_parameter_3$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_parameter_3$schema$value ??= z.union([z.undefined(), z.literal(false), z.literal(true)])) +let _deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_result$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_result$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_result$schema$value ??= z.object({ + 'changed': z.boolean(), + 'application': z.union([z.literal("cancelled"), z.literal("failed"), z.literal("applied"), z.literal("restart-required"), z.literal("overridden")]), + 'stage': z.union([z.literal("remove"), z.literal("install"), z.literal("enable")]), + 'target': z.string(), + 'enabled': z.boolean().optional(), + 'error': z.object({ + 'code': z.union([z.literal("management-required"), z.literal("unaddressable"), z.literal("unknown-plugin"), z.literal("invalid-spec"), z.literal("ambiguous-install"), z.literal("not-bundle"), z.literal("not-removable"), z.literal("stop-profile"), z.literal("bundle-in-use"), z.literal("stale-approval"), z.literal("incompatible-version"), z.literal("operation-error")]), + 'diagnostic': z.string().optional(), + 'incompatible': z.array(z.object({ + 'name': z.string(), + 'version': z.string(), + 'runtimeVersion': z.string(), + 'peers': z.record(z.string(), z.string()), +})).optional(), +}).optional(), + 'warnings': z.array(z.string()).optional(), + 'packageResult': z.object({ + 'exitCode': z.number(), + 'output': z.string(), + 'truncated': z.boolean(), + 'logPath': z.string(), + 'kind': z.union([z.literal("network"), z.literal("unknown"), z.literal("timeout"), z.literal("integrity"), z.literal("pnpm-missing"), z.literal("not-found"), z.literal("no-matching-version"), z.literal("disk-full"), z.literal("permission"), z.literal("build-blocked")]).optional(), + 'timedOut': z.boolean().optional(), + 'incompatible': z.array(z.object({ + 'name': z.string(), + 'version': z.string(), + 'runtimeVersion': z.string(), + 'peers': z.record(z.string(), z.string()), +})).optional(), +}).optional(), + 'bundle': z.string().optional(), + 'version': z.string().optional(), + 'pendingBuilds': z.array(z.string()).optional(), + 'approvedBuilds': z.array(z.string()).optional(), + 'registries': z.array(z.union([z.literal(null), z.string()])).optional(), + 'failedAt': z.union([z.literal("registry"), z.literal("spec-host")]).optional(), +})) +let _deepseek_ai_dsh_plugin_manager_pluginManager_waitForInstall_parameter_0$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_waitForInstall_parameter_0$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_waitForInstall_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_plugin_manager_pluginManager_waitForInstall_result$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_waitForInstall_result$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_waitForInstall_result$schema$value ??= z.union([z.literal(null), z.object({ + 'changed': z.boolean(), + 'application': z.union([z.literal("cancelled"), z.literal("failed"), z.literal("applied"), z.literal("restart-required"), z.literal("overridden")]), + 'stage': z.union([z.literal("remove"), z.literal("install"), z.literal("enable")]), + 'target': z.string(), + 'enabled': z.boolean().optional(), + 'error': z.object({ + 'code': z.union([z.literal("management-required"), z.literal("unaddressable"), z.literal("unknown-plugin"), z.literal("invalid-spec"), z.literal("ambiguous-install"), z.literal("not-bundle"), z.literal("not-removable"), z.literal("stop-profile"), z.literal("bundle-in-use"), z.literal("stale-approval"), z.literal("incompatible-version"), z.literal("operation-error")]), + 'diagnostic': z.string().optional(), + 'incompatible': z.array(z.object({ + 'name': z.string(), + 'version': z.string(), + 'runtimeVersion': z.string(), + 'peers': z.record(z.string(), z.string()), +})).optional(), +}).optional(), + 'warnings': z.array(z.string()).optional(), + 'packageResult': z.object({ + 'exitCode': z.number(), + 'output': z.string(), + 'truncated': z.boolean(), + 'logPath': z.string(), + 'kind': z.union([z.literal("network"), z.literal("unknown"), z.literal("timeout"), z.literal("integrity"), z.literal("pnpm-missing"), z.literal("not-found"), z.literal("no-matching-version"), z.literal("disk-full"), z.literal("permission"), z.literal("build-blocked")]).optional(), + 'timedOut': z.boolean().optional(), + 'incompatible': z.array(z.object({ + 'name': z.string(), + 'version': z.string(), + 'runtimeVersion': z.string(), + 'peers': z.record(z.string(), z.string()), +})).optional(), +}).optional(), + 'bundle': z.string().optional(), + 'version': z.string().optional(), + 'pendingBuilds': z.array(z.string()).optional(), + 'approvedBuilds': z.array(z.string()).optional(), + 'registries': z.array(z.union([z.literal(null), z.string()])).optional(), + 'failedAt': z.union([z.literal("registry"), z.literal("spec-host")]).optional(), +})])) + +export const TYPERT_REMOTE = { + package: '@deepseek-ai/dsh-plugin-manager', + descriptors: [ + { + id: '@deepseek-ai/dsh-plugin-manager#pluginManager/cancelInstall', + service: 'pluginManager', + namespace: 'pluginManager', + method: 'cancelInstall', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'requestId', + wire: 'requestId', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager/types#PluginInstallRequestId', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_cancelInstall_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager/types#PluginInstallCancellation', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_cancelInstall_result$schema, + }, + sourceLocation: {"file":"packages/boot/plugin-manager/src/index.ts","line":600,"column":9}, + }, + { + id: '@deepseek-ai/dsh-plugin-manager#pluginManager/inspect', + service: 'pluginManager', + namespace: 'pluginManager', + method: 'inspect', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'spec', + wire: 'spec', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager#pluginManager/inspect:spec', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_inspect_parameter_0$schema, + }, + }, + { + name: 'options', + wire: 'options', + source: 'json', + acceptsUndefined: true, + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager/types#InspectOptions', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_inspect_parameter_1$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager/types#PluginSpecInspection', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_inspect_result$schema, + }, + sourceLocation: {"file":"packages/boot/plugin-manager/src/index.ts","line":350,"column":9}, + }, + { + id: '@deepseek-ai/dsh-plugin-manager#pluginManager/installBundle', + service: 'pluginManager', + namespace: 'pluginManager', + method: 'installBundle', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'spec', + wire: 'spec', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager#pluginManager/installBundle:spec', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_installBundle_parameter_0$schema, + }, + }, + { + name: 'options', + wire: 'options', + source: 'json', + acceptsUndefined: true, + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager/types#InstallBundleOptions', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_installBundle_parameter_1$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager/types#ChangeResult', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_installBundle_result$schema, + }, + sourceLocation: {"file":"packages/boot/plugin-manager/src/index.ts","line":473,"column":3}, + }, + { + id: '@deepseek-ai/dsh-plugin-manager#pluginManager/listBundles', + service: 'pluginManager', + namespace: 'pluginManager', + method: 'listBundles', + invocation: { kind: 'direct' }, + parameters: [ + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager#pluginManager/listBundles:result', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_listBundles_result$schema, + }, + sourceLocation: {"file":"packages/boot/plugin-manager/src/index.ts","line":280,"column":3}, + }, + { + id: '@deepseek-ai/dsh-plugin-manager#pluginManager/listPlugins', + service: 'pluginManager', + namespace: 'pluginManager', + method: 'listPlugins', + invocation: { kind: 'direct' }, + parameters: [ + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager#pluginManager/listPlugins:result', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_listPlugins_result$schema, + }, + sourceLocation: {"file":"packages/boot/plugin-manager/src/index.ts","line":256,"column":9}, + }, + { + id: '@deepseek-ai/dsh-plugin-manager#pluginManager/listVersionExemptions', + service: 'pluginManager', + namespace: 'pluginManager', + method: 'listVersionExemptions', + invocation: { kind: 'direct' }, + parameters: [ + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager#pluginManager/listVersionExemptions:result', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_listVersionExemptions_result$schema, + }, + sourceLocation: {"file":"packages/boot/plugin-manager/src/index.ts","line":232,"column":3}, + }, + { + id: '@deepseek-ai/dsh-plugin-manager#pluginManager/registries', + service: 'pluginManager', + namespace: 'pluginManager', + method: 'registries', + invocation: { kind: 'direct' }, + parameters: [ + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager/types#PluginRegistries', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_registries_result$schema, + }, + sourceLocation: {"file":"packages/boot/plugin-manager/src/index.ts","line":334,"column":9}, + }, + { + id: '@deepseek-ai/dsh-plugin-manager#pluginManager/removeBundle', + service: 'pluginManager', + namespace: 'pluginManager', + method: 'removeBundle', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'name', + wire: 'name', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager#pluginManager/removeBundle:name', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_removeBundle_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager/types#ChangeResult', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_removeBundle_result$schema, + }, + sourceLocation: {"file":"packages/boot/plugin-manager/src/index.ts","line":617,"column":3}, + }, + { + id: '@deepseek-ai/dsh-plugin-manager#pluginManager/setBundleEnabled', + service: 'pluginManager', + namespace: 'pluginManager', + method: 'setBundleEnabled', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'name', + wire: 'name', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager#pluginManager/setBundleEnabled:name', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_setBundleEnabled_parameter_0$schema, + }, + }, + { + name: 'enabled', + wire: 'enabled', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager#pluginManager/setBundleEnabled:enabled', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_setBundleEnabled_parameter_1$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager/types#ChangeResult', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_setBundleEnabled_result$schema, + }, + sourceLocation: {"file":"packages/boot/plugin-manager/src/index.ts","line":452,"column":3}, + }, + { + id: '@deepseek-ai/dsh-plugin-manager#pluginManager/setPluginEnabled', + service: 'pluginManager', + namespace: 'pluginManager', + method: 'setPluginEnabled', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'id', + wire: 'id', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-host-plugin-inventory/types#PluginEntryId', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_setPluginEnabled_parameter_0$schema, + }, + }, + { + name: 'enabled', + wire: 'enabled', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager#pluginManager/setPluginEnabled:enabled', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_setPluginEnabled_parameter_1$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager/types#ChangeResult', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_setPluginEnabled_result$schema, + }, + sourceLocation: {"file":"packages/boot/plugin-manager/src/index.ts","line":434,"column":3}, + }, + { + id: '@deepseek-ai/dsh-plugin-manager#pluginManager/setVersionExemption', + service: 'pluginManager', + namespace: 'pluginManager', + method: 'setVersionExemption', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'packageVersion', + wire: 'packageVersion', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager#pluginManager/setVersionExemption:packageVersion', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_parameter_0$schema, + }, + }, + { + name: 'runtimeVersion', + wire: 'runtimeVersion', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager#pluginManager/setVersionExemption:runtimeVersion', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_parameter_1$schema, + }, + }, + { + name: 'enabled', + wire: 'enabled', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager#pluginManager/setVersionExemption:enabled', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_parameter_2$schema, + }, + }, + { + name: 'acceptRisk', + wire: 'acceptRisk', + source: 'json', + acceptsUndefined: true, + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager#pluginManager/setVersionExemption:acceptRisk', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_parameter_3$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager/types#ChangeResult', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_result$schema, + }, + sourceLocation: {"file":"packages/boot/plugin-manager/src/index.ts","line":245,"column":3}, + }, + { + id: '@deepseek-ai/dsh-plugin-manager#pluginManager/waitForInstall', + service: 'pluginManager', + namespace: 'pluginManager', + method: 'waitForInstall', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'requestId', + wire: 'requestId', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager/types#PluginInstallRequestId', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_waitForInstall_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager#pluginManager/waitForInstall:result', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_waitForInstall_result$schema, + }, + sourceLocation: {"file":"packages/boot/plugin-manager/src/index.ts","line":590,"column":9}, + }, + ], +} + +export default TYPERT_REMOTE diff --git a/apps/web-ui/packages/boot/plugin-manager/package.json b/apps/web-ui/packages/boot/plugin-manager/package.json new file mode 100644 index 000000000..3c75d9f0b --- /dev/null +++ b/apps/web-ui/packages/boot/plugin-manager/package.json @@ -0,0 +1,42 @@ +{ + "name": "@deepseek-ai/dsh-plugin-manager", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./types": { + "types": "./src/types.ts", + "default": "./src/types.ts" + }, + "./registry": { + "types": "./src/registry.ts", + "default": "./src/registry.ts" + }, + "./typert": { + "types": "./src/typert.host.ts", + "default": "./src/typert.host.ts" + }, + "./remote": { + "types": "./generated/remote.d.ts", + "default": "./generated/remote.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json", + "./operations": { + "types": "./src/operations.ts", + "default": "./src/operations.ts" + }, + "./tools": { + "types": "./src/tools.ts", + "default": "./src/tools.ts" + } + }, + "dependencies": { + "zod": "^4.4.3" + } +} diff --git a/apps/web-ui/packages/boot/plugin-manager/src/build-approval.ts b/apps/web-ui/packages/boot/plugin-manager/src/build-approval.ts new file mode 100644 index 000000000..e9e5def4e --- /dev/null +++ b/apps/web-ui/packages/boot/plugin-manager/src/build-approval.ts @@ -0,0 +1,59 @@ +/** Approve pnpm's pending dependency scripts in the current profile's workspace settings. */ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { isAlias, isMap, isNode, isScalar, parseDocument, visit } from "yaml"; +import { ManagementFailure } from "./failure.ts"; +import { writeFileAtomic } from "@deepseek-ai/dsh-atomic-write"; + +async function readPolicy(dir: string) { + let text: string; + try { + text = await readFile(join(dir, "pnpm-workspace.yaml"), "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + text = "{}\n"; + } + const document = parseDocument(text); + if (document.errors[0] !== undefined) throw document.errors[0]; + if (!isMap(document.contents)) throw new Error("pnpm-workspace.yaml must be a YAML mapping"); + const builds = document.get("allowBuilds"); + if (builds !== undefined && !isMap(builds)) throw new Error("allowBuilds must be a YAML mapping"); + visit(builds ?? null, (_key, node) => { + if (isAlias(node) || (isNode(node) && "anchor" in node && node.anchor)) { + throw new Error("allowBuilds must not contain YAML anchors or aliases"); + } + }); + const pending = isMap(builds) + ? builds.items.flatMap(({ key, value }) => + isScalar(key) && + typeof key.value === "string" && + !/[*?]/.test(key.value) && + isScalar(value) && + value.value === "set this to true or false" + ? [key.value] + : [], + ) + : []; + return { document, pending }; +} + +/** Read package names left undecided by pnpm 11, including after installation cleanup. + * @param dir Current profile directory. + * @returns Exact package names awaiting a build decision; wildcard rules are excluded. + */ +export async function readPendingBuilds(dir: string): Promise { + return (await readPolicy(dir)).pending; +} + +/** Persist approval without running scripts; the caller holds the profile manifest lock. + * @param dir Current profile directory. + * @param names Explicit package names from the pending build list. + * @throws If a name is no longer pending or allowBuilds contains YAML anchors or aliases; no approvals are written. + */ +export async function approveBuilds(dir: string, names: readonly string[]): Promise { + const { document, pending } = await readPolicy(dir); + if (names.some((name) => !pending.includes(name))) throw new ManagementFailure("stale-approval"); + if (names.length === 0) return; + for (const name of names) document.setIn(["allowBuilds", name], true); + await writeFileAtomic(join(dir, "pnpm-workspace.yaml"), String(document), { mode: 0o600 }); +} diff --git a/apps/web-ui/packages/boot/plugin-manager/src/failure.ts b/apps/web-ui/packages/boot/plugin-manager/src/failure.ts new file mode 100644 index 000000000..de88b2ced --- /dev/null +++ b/apps/web-ui/packages/boot/plugin-manager/src/failure.ts @@ -0,0 +1,34 @@ +/** Localizable rejections shared by profile management operations. */ +import type { PluginCompatibility } from "@deepseek-ai/dsh-app-boot"; +import type { IncompatiblePlugin, ManagementError } from "./types.ts"; + +/** Expected management rejection; presentation belongs to the caller's locale. */ +export class ManagementFailure extends Error { + /** Code rendered by the caller's locale dictionary. */ + readonly code: ManagementError["code"]; + /** The packages an `incompatible-version` rejection names. */ + readonly incompatible: IncompatiblePlugin[] | undefined; + /** + * @param code Localizable management rejection. + * @param incompatible Packages the running DSH version rejects, for `incompatible-version`. + */ + constructor(code: ManagementError["code"], incompatible?: IncompatiblePlugin[]) { + super(code); + this.code = code; + this.incompatible = incompatible; + } +} + +/** + * Drop the exemption status from an unexempted compatibility result. + * @param issue Result whose exemption is not active. + * @returns The package, runtime, and rejected peer ranges. + */ +export function incompatiblePlugin(issue: PluginCompatibility): IncompatiblePlugin { + return { + name: issue.name, + version: issue.version, + runtimeVersion: issue.runtimeVersion, + peers: issue.peers, + }; +} diff --git a/apps/web-ui/packages/boot/plugin-manager/src/github-connection.ts b/apps/web-ui/packages/boot/plugin-manager/src/github-connection.ts new file mode 100644 index 000000000..52441485a --- /dev/null +++ b/apps/web-ui/packages/boot/plugin-manager/src/github-connection.ts @@ -0,0 +1,94 @@ +/** Bounded GitHub repository checks using the installer's Git configuration and environment. */ +import { mkdir, mkdtemp, open } from "node:fs/promises"; +import { join } from "node:path"; +import { execa } from "execa"; +import { scrubbedParentEnv } from "@deepseek-ai/dsh-subprocess"; +import { classifyInstallFailure } from "./install-failure.ts"; +import type { ParsedInstallSpec } from "./install-spec.ts"; +import type { PackageResult } from "./types.ts"; + +/** Deadline, diagnostics and cancellation for one read-only GitHub check. */ +export interface GithubConnectionOptions { + timeoutMs: number; + outputBytes: number; + signal: AbortSignal; + /** The application-owned package manager's environment, also used by its Git children. */ + env?: Readonly>; +} + +/** + * Check a GitHub repository before pnpm starts, without downloading or building its package. + * Git reads the profile's Git and proxy configuration without invoking credential helpers or prompting. + * Timeout and cancellation terminate its descendants too; pnpm owns authentication and transport fallback. + * @param spec The parsed installation address; npm packages, local paths and other hosts are not checked. + * @param dir The profile directory where installation runs. + * @param options The connection deadline, output bound and operation cancellation. + * @returns A failed check with bounded output and a complete log at logPath, or undefined for a reachable repository or an unhandled spec. + */ +export async function checkGithubConnection( + spec: ParsedInstallSpec, + dir: string, + options: GithubConnectionOptions, +): Promise { + if (spec.kind !== "git") return undefined; + const host = spec.host.toLowerCase().replace(/:.*$/, ""); + if (host !== "github.com" && !host.endsWith(".github.com")) return undefined; + const repository = spec.spec + .replace(/#.*$/s, "") + .replace(/^github:/i, "https://github.com/") + .replace(/^gist:/i, "https://gist.github.com/") + .replace(/^git\+/i, ""); + const logRoot = join(dir, ".plugin-manager", "logs"); + await mkdir(logRoot, { recursive: true, mode: 0o700 }); + const logDir = await mkdtemp(join(logRoot, "github-connection-")); + const logPath = join(logDir, "git.log"); + const log = await open(logPath, "ax+", 0o600); + try { + const result = await execa( + "git", + ["-c", "credential.helper=", "ls-remote", "--", repository, "HEAD"], + { + cwd: dir, + env: { + ...scrubbedParentEnv(), + ...options.env, + LC_ALL: "C", + GIT_TERMINAL_PROMPT: "0", + GIT_ASKPASS: "", + SSH_ASKPASS: "", + SSH_ASKPASS_REQUIRE: "never", + }, + extendEnv: false, + stdin: "ignore", + stdout: "ignore", + stderr: { file: logPath, append: true }, + buffer: false, + reject: false, + timeout: options.timeoutMs, + cancelSignal: options.signal, + killDescendants: true, + killSignal: "SIGKILL", + }, + ); + if (!result.failed) return undefined; + if (result.timedOut) + await log.write( + `dsh: connection to ${spec.host} timed out after ${String(options.timeoutMs)}ms\n`, + ); + if ((await log.stat()).size === 0) + await log.write(result.shortMessage ?? "GitHub connection check failed"); + const { size } = await log.stat(); + const bytes = Buffer.alloc(Math.min(size, options.outputBytes)); + await log.read(bytes, 0, bytes.length, size - bytes.length); + const output = bytes.toString("utf8"); + return { + exitCode: result.exitCode ?? (result.code === "ENOENT" ? 127 : 1), + output, + truncated: size > options.outputBytes, + logPath, + kind: classifyInstallFailure({ log: output, timedOut: result.timedOut }), + }; + } finally { + await log.close(); + } +} diff --git a/apps/web-ui/packages/boot/plugin-manager/src/index.ts b/apps/web-ui/packages/boot/plugin-manager/src/index.ts new file mode 100644 index 000000000..7853b2643 --- /dev/null +++ b/apps/web-ui/packages/boot/plugin-manager/src/index.ts @@ -0,0 +1,1191 @@ +/** Current-profile plugin and bundle management over shared dsh plugin operations. */ +import { randomUUID } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { readFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write"; +import { Context } from "@deepseek-ai/cordis"; +import type { EntryOptions } from "@deepseek-ai/cordis-plugin-loader"; +import type { PatchOptions } from "@deepseek-ai/cordis-plugin-include"; +import z from "@deepseek-ai/schemastery"; +import { TypertRemoteService, Remote } from "@deepseek-ai/dsh-typert-protocol"; +import { pluginEntryId, readPluginInventory } from "@deepseek-ai/dsh-host-plugin-inventory"; +import { + readPluginMeta, + readProfileManifest, + resolveBundleDir, + loadOverlayPatches, + composeEntries, + reconcileProfilePatches, + readProfilePatches, + OPTIONAL_BUNDLES, + bundlePatchPaths, + evaluatePluginCompatibility, + readProfileCompatibility, + readProfileVersionExemptions, + setProfileVersionExemption, + PROFILE_COMPATIBILITY_FILENAME, +} from "@deepseek-ai/dsh-app-boot"; +import type {} from "@deepseek-ai/dsh-hmr"; +import type { ProfileContext, ProfileManifest } from "@deepseek-ai/dsh-app-boot"; +import { + bundleManifest, + readProfileRegistry, + registryArguments, + runProfilePnpm, + saveManifest, + viewProfilePackage, +} from "./operations.ts"; +import { classifyInstallFailure } from "./install-failure.ts"; +import { + dependencySpec, + InvalidInstallSpecError, + parseInstallSpec, + type ParsedInstallSpec, +} from "./install-spec.ts"; +import { + attributeFailure, + normalizeRegistry, + NPMMIRROR_REGISTRY, + registryPlan, +} from "./registry.ts"; +import { writePluginEnabled } from "./patch.ts"; +import { incompatiblePlugin, ManagementFailure } from "./failure.ts"; +import { approveBuilds, readPendingBuilds } from "./build-approval.ts"; +import { checkGithubConnection } from "./github-connection.ts"; +import type { + BundleInfo, + BundleRowInfo, + ChangeResult, + InspectOptions, + InstallBundleOptions, + ManagementError, + PackageResult, + PluginChange, + PluginEntryId, + PluginInfo, + PluginInspectProblem, + PluginInstallCancellation, + PluginInstallProgress, + PluginInstallRequestId, + PluginRegistries, + PluginSpecInspection, + Registry, +} from "./types.ts"; +export type * from "./types.ts"; +export { classifyInstallFailure, type InstallFailureFacts } from "./install-failure.ts"; +export { + InvalidInstallSpecError, + parseInstallSpec, + type ParsedInstallSpec, +} from "./install-spec.ts"; + +/** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */ +export interface Config { + /** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */ + pnpmCommand?: string; + /** Maximum retained package-operation diagnostic bytes. */ + outputBytes?: number; + /** Maximum time to wait for another process's profile package operation. */ + lockWaitMs?: number; + /** Bound on one registry lookup an inspection runs, in milliseconds. */ + inspectTimeoutMs?: number; + /** Maximum duration of the GitHub repository connection check before installation, in milliseconds. */ + githubConnectionTimeoutMs?: number; + /** Maximum time one captured package run may print nothing before the manager terminates it, in milliseconds. */ + idleTimeoutMs?: number; + /** The registry lookups and installations ask first, as an http(s) URL; absent, the one pnpm's own configuration names. */ + registry?: string; + /** + * Registries asked in turn, as http(s) URLs, while the one before is unreachable or holds no copy of the package. + * A registry outside this set and `registry` is asked alone, and so is the one pnpm's own configuration names + * unless that is npm's own registry or one of these. + */ + fallbackRegistries?: string[]; +} + +/** An http(s) URL, as pnpm's `--registry` takes it. */ +const REGISTRY_URL = /^https?:\/\/\S+$/; + +const protectedModules = new Set([ + "@deepseek-ai/dsh-plugin-manager", + "@deepseek-ai/cordis-plugin-loader", + "@deepseek-ai/cordis-plugin-include", + "@deepseek-ai/dsh-api-gateway", + "@deepseek-ai/dsh-host-webserver", + "@deepseek-ai/dsh-client-modules", + "@deepseek-ai/dsh-client-ui-settings-plugin-inventory", + "@deepseek-ai/dsh-client-ui-plugin-manager", + "@deepseek-ai/dsh-host-plugin-inventory", + "@deepseek-ai/dsh-typert-registry", + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/cordis-plugin-timer", + "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-host-frontend-static", + "@deepseek-ai/dsh-tools", + "@deepseek-ai/dsh-hmr", +]); + +/** The profile files an installation writes and a failed or cancelled one restores. */ +const RESTORED_FILES = ["package.json", "pnpm-lock.yaml"] as const; + +/** pnpm's colour escapes, which a JSON answer may be wrapped in. */ +const ANSI_SEQUENCE = /\x1b\[[0-9;]*m/g; + +/** Flatten only the groups addressable by the profile's patch composer. */ +function flatten(rows: EntryOptions[]): EntryOptions[] { + return rows.flatMap((row) => [ + row, + ...(row.group && Array.isArray(row.config) ? flatten(row.config as EntryOptions[]) : []), + ]); +} + +/** Preserve the exact observed diagnostic, including non-Error failures. */ +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** An expected refusal keeps its code; anything else becomes an operation error carrying its exact diagnostic. */ +function managementError(error: unknown): ManagementError { + if (!(error instanceof ManagementFailure)) + return { code: "operation-error", diagnostic: messageOf(error) }; + return { + code: error.code, + ...(error.incompatible === undefined ? {} : { incompatible: error.incompatible }), + }; +} + +/** The caller stopped an installation; its files are restored before this is thrown. */ +class InstallCancelledError extends Error { + constructor() { + super("Installation cancelled"); + this.name = "InstallCancelledError"; + } +} + +/** One installation the manager owns until its call settles. */ +interface InstallControl { + readonly abort: AbortController; + /** `applying` once pnpm has exited and the bundle is being selected and loaded, which cannot be stopped. */ + phase: "installing" | "applying"; + /** The active install's result, shared with clients recovering a lost response. */ + result: Promise; +} + +/** A manifest field that is a string, when the manifest carries one. */ +function stringField(manifest: object, field: string): string | undefined { + const value = (manifest as Record)[field]; + return typeof value === "string" ? value : undefined; +} + +/** The fields of the dsh installation's own manifest the manager reads. */ +interface InstallationManifest { + dependencies?: Record; +} + +/** What a package manifest says about the package: identity, one-liner, and whether it is a bundle. */ +function inspectionOf( + kind: "registry" | "path", + manifest: object, + registry: Registry, +): Extract { + const dsh = (manifest as { dsh?: unknown }).dsh; + const declared = + typeof dsh === "object" && dsh !== null ? (dsh as { bundle?: unknown }) : undefined; + const bundle = + declared !== undefined && typeof declared.bundle === "object" && declared.bundle !== null; + const name = stringField(manifest, "name"); + const version = stringField(manifest, "version"); + const description = stringField(manifest, "description"); + return { + status: "accepted", + kind, + bundle, + registry, + ...(name === undefined ? {} : { name }), + ...(version === undefined ? {} : { version }), + ...(description === undefined || description === "" ? {} : { description }), + }; +} + +function refused(problem: PluginInspectProblem, reason: string): PluginSpecInspection { + return { status: "refused", problem, reason }; +} + +/** The refusal `pnpm view --json` prints on stdout, `{ error: { code, message } }`, as one log line; empty for anything else. */ +function printedError(printed: string): string { + let parsed: unknown; + try { + parsed = JSON.parse(printed || "null"); + } catch { + return ""; /* not JSON: nothing pnpm printed as a refusal */ + } + const error = + typeof parsed === "object" && parsed !== null + ? (parsed as { error?: unknown }).error + : undefined; + if (typeof error !== "object" || error === null) return ""; + const { code, message } = error as { code?: unknown; message?: unknown }; + return [code, message].filter((part): part is string => typeof part === "string").join(" "); +} + +/** The spec's form, for deciding whether a failed attempt was the registry's; a form the parser refuses has no host of its own. */ +function parsedForRegistry(spec: string): ParsedInstallSpec { + try { + return parseInstallSpec(spec); + } catch (error) { + /* v8 ignore next -- parseInstallSpec throws nothing but its own refusal */ + if (!(error instanceof InvalidInstallSpecError)) throw error; + return { kind: "registry", spec, name: spec }; + } +} + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Persistent management of the current profile's composition and packages. */ + pluginManager: PluginManager; + } +} + +/** Manage profile files and apply their declared reload lifecycle. */ +export class PluginManager extends TypertRemoteService { + static inject = ["loader", "profileContext"]; + static Config: z = z.object({ + pnpmCommand: z.string().default("pnpm"), + outputBytes: z.number().step(1).min(1).default(16384), + lockWaitMs: z.number().step(1).min(0).default(120000), + inspectTimeoutMs: z.number().step(1).min(1000).default(20000), + githubConnectionTimeoutMs: z.number().step(1).min(1000).default(5000), + idleTimeoutMs: z.number().step(1).min(1000).default(600000), + registry: z.string().pattern(REGISTRY_URL), + fallbackRegistries: z.array(z.string().pattern(REGISTRY_URL)).default([NPMMIRROR_REGISTRY]), + }); + /** Management bundles remain protected if their files become unreadable. */ + private readonly managementBundles = new Set(); + private readonly ownerEntryId: string | undefined; + private readonly packageOperations = new Set>(); + private readonly profile: ProfileContext; + private readonly outputBytes: number; + private readonly lockWaitMs: number; + private readonly inspectTimeoutMs: number; + private readonly githubConnectionTimeoutMs: number; + private readonly idleTimeoutMs: number; + private readonly pnpmCommand: string; + private readonly configuredRegistries: Omit; + private readonly ownerContext: Context; + private readonly abort = new AbortController(); + /** Installations by request id, from their call until it settles. */ + private readonly installs = new Map(); + + constructor(ctx: Context, config: Config) { + super(ctx, "pluginManager"); + this.ownerEntryId = ctx.fiber.entry?.id; + this.ownerContext = ctx; + this.profile = ctx.profileContext; + for (const name of this.profile.startedBundles) this.protectsManager(name); + this.outputBytes = (config as Required).outputBytes; + this.lockWaitMs = (config as Required).lockWaitMs; + this.inspectTimeoutMs = (config as Required).inspectTimeoutMs; + this.githubConnectionTimeoutMs = (config as Required).githubConnectionTimeoutMs; + this.idleTimeoutMs = (config as Required).idleTimeoutMs; + this.pnpmCommand = (config as Required).pnpmCommand; + this.configuredRegistries = { + registry: config.registry === undefined ? null : normalizeRegistry(config.registry), + fallbackRegistries: (config as Required).fallbackRegistries.map(normalizeRegistry), + }; + ctx.effect( + () => async () => { + this.abort.abort(); + await Promise.allSettled([...this.packageOperations]); + }, + "plugin-manager: package cancellation", + ); + } + + /** Read exact plugin-version exemptions saved in this profile. + * @returns Accepted package-name@version keys with the runtime versions they may run on, and any + * record or file problem the reader rejected, which the caller reports instead of failing. + */ + @Remote + listVersionExemptions(): { exemptions: Record; warnings: string[] } { + const { exemptions, warnings } = readProfileCompatibility(this.profile.dir); + return { exemptions, warnings }; + } + + /** Grant or revoke one exact plugin/runtime exemption and reevaluate live plugins. + * @param packageVersion Exact manifest package name followed by @ and its version; never an installation spec or alias. + * @param runtimeVersion Exact current DSH version for grants; revocation may name a previous runtime. + * @param enabled Whether to grant rather than revoke the exemption. + * @param acceptRisk Required true for grants after the user accepts possible crashes and data loss. + * @returns Saved and runtime outcomes. Startup-only profiles require restart. + */ + @Remote + setVersionExemption( + packageVersion: string, + runtimeVersion: string, + enabled: boolean, + acceptRisk?: boolean, + ): Promise { + return this.change( + (result) => + this.configure(async () => { + await setProfileVersionExemption( + this.profile.dir, + packageVersion, + runtimeVersion, + enabled, + acceptRisk === true, + ); + result.warnings = await this.reload(); + }), + { stage: "enable", target: packageVersion, enabled }, + "bundle", + ); + } + + /** Read current plugins, including why a row cannot be changed through the profile patch. + * @returns Current runtime entries with persistent patch targets. + */ + @Remote + async listPlugins(): Promise { + const rows = flatten(composeEntries([readProfilePatches("dsh", this.profile)])); + const snapshot = await readPluginInventory(this.ctx); + return snapshot.entries.map((entry) => { + const actual = [...this.ctx.loader.entries()].find((row) => row.id === entry.entryId); + const candidates = rows.filter((row) => row.id === actual?.options.id); + const candidate = candidates[0]; + if (protectedModules.has(entry.moduleName) || entry.entryId === this.ownerEntryId) { + return { ...entry, readOnlyReason: "management-required" as const }; + } + if ( + candidate === undefined || + candidates.length > 1 || + candidate.name !== entry.moduleName || + actual?.parent.tree.ctx.fiber.entry?.id !== "include" + ) { + return { ...entry, readOnlyReason: "unaddressable" as const }; + } + return { ...entry, patchId: candidate.id }; + }); + } + + /** Read the profile's installed bundles, the bundles this dsh installation supplies, and the selected names that are not bundles. + * A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected. + * @returns Package versions, manifest descriptions, the installable spec of profile dependencies, rows, optional + * display metadata, activation selections, whether the installation offers the bundle, and removal availability. + */ + @Remote + listBundles(): Promise { + const manifest = readProfileManifest("dsh", this.profile.dir); + const exemptions = readProfileVersionExemptions(this.profile.dir); + const selected = manifest.dsh?.profile?.bundles ?? []; + const recorded = manifest.dependencies ?? {}; + const dependencies = Object.keys(recorded); + const installation = JSON.parse( + readFileSync(this.profile.installAnchor, "utf8"), + ) as InstallationManifest; + const names = [ + ...new Set([...selected, ...dependencies, ...Object.keys(installation.dependencies ?? {})]), + ]; + const bundles: BundleInfo[] = []; + for (const name of names) { + const installed = dependencies.includes(name); + const optional = OPTIONAL_BUNDLES.includes(name); + const enabled = selected.includes(name); + const shipped = Object.hasOwn(installation.dependencies ?? {}, name); + // Bundle resolution reads the installation first, so a profile dependency the installation manifest also names, + // like one it forbids removing, is not the loaded copy. + const owned = installed && !shipped; + // A selection neither the profile nor the installation holds, such as a retired bundle, is removed by deselecting it. + const removable = owned || (enabled && !installed && !shipped); + const sourceOf = (packageName?: string): { source?: string } => + owned + ? { + source: dependencySpec(name, recorded[name] as string, this.profile.dir, packageName), + } + : {}; + const readOnlyReason = this.protectsManager(name) + ? ("management-required" as const) + : undefined; + try { + const info = bundleManifest(name, this.profile.dir, this.profile.installAnchor); + if (info === undefined) { + if (enabled) + bundles.push({ + name, + ...sourceOf(), + enabled, + installed, + optional, + removable: removable && readOnlyReason === undefined, + ...(readOnlyReason === undefined ? {} : { readOnlyReason }), + error: { code: "not-bundle" }, + rows: [], + overrides: [], + }); + continue; + } + const compatibility = evaluatePluginCompatibility(info, exemptions); + if (compatibility !== undefined && !compatibility.exempted) + throw new ManagementFailure("incompatible-version", [incompatiblePlugin(compatibility)]); + const dir = resolveBundleDir("dsh", name, this.profile.installAnchor, this.profile.dir); + const meta = readPluginMeta( + info.name ?? name, + pathToFileURL(join(dir, "package.json")).href, + ); + bundles.push({ + name, + ...(info.version === undefined ? {} : { version: info.version }), + ...(info.description === undefined || info.description === "" + ? {} + : { description: info.description }), + ...(meta === undefined ? {} : { meta }), + ...sourceOf(info.name), + enabled, + installed, + optional, + removable: removable && readOnlyReason === undefined, + ...(readOnlyReason === undefined ? {} : { readOnlyReason }), + ...this.declaredRows(name, info), + }); + } catch (error) { + if (enabled || installed) { + bundles.push({ + name, + ...sourceOf(), + enabled, + installed, + optional, + removable: removable && readOnlyReason === undefined, + ...(readOnlyReason === undefined ? {} : { readOnlyReason }), + error: managementError(error), + rows: [], + overrides: [], + }); + } + } + } + return Promise.resolve(bundles); + } + + /** Read the registries this manager asks: the configured first one, its fallbacks in order, and what pnpm's own configuration names. + * @returns The registries in pnpm's comparison form; null is the one pnpm's own configuration names, `resolved` as pnpm reads it now. + */ + @Remote + async registries(): Promise { + return { + ...this.configuredRegistries, + fallbackRegistries: [...this.configuredRegistries.fallbackRegistries], + resolved: await readProfileRegistry(this.profile.dir, { + ...(this.profile.packageManager ?? { command: this.pnpmCommand }), + timeoutMs: this.inspectTimeoutMs, + }), + }; + } + + /** Read what a spec names before installing it. + * @param spec One package spec: a registry name, an absolute path, a git address, or a tarball. + * @param options The registry asked first. + * @param signal Ends a registry lookup early. + * @returns The package the spec names, or why it is refused. + */ + @Remote + async inspect( + spec: string, + options?: InspectOptions, + signal?: AbortSignal, + ): Promise { + let parsed; + try { + parsed = parseInstallSpec(spec); + } catch (error) { + /* v8 ignore next 2 -- parseInstallSpec throws nothing but its own refusal */ + if (!(error instanceof InvalidInstallSpecError)) throw error; + return refused("invalid-spec", error.reason); + } + const manifest = readProfileManifest("dsh", this.profile.dir); + const installation = JSON.parse( + readFileSync(this.profile.installAnchor, "utf8"), + ) as InstallationManifest; + const known = new Set([ + ...(manifest.dsh?.profile?.bundles ?? []), + ...Object.keys(manifest.dependencies ?? {}), + ...Object.keys(installation.dependencies ?? {}), + ]); + const plan = registryPlan(options?.registry, await this.registries()); + const registry = plan[0] as Registry; + switch (parsed.kind) { + case "git": + return { status: "accepted", kind: "git", bundle: null, registry, host: parsed.host }; + case "tarball": + if (parsed.path !== undefined && !existsSync(parsed.path)) + return refused("not-a-package", "the tarball does not exist"); + return { + status: "accepted", + kind: "tarball", + bundle: null, + registry, + ...(parsed.host === undefined ? {} : { host: parsed.host }), + }; + case "path": { + if (!existsSync(parsed.path)) return refused("not-a-package", "the path does not exist"); + let read: object; + try { + read = JSON.parse(await readFile(join(parsed.path, "package.json"), "utf8")) as object; + } catch (error) { + return refused( + "not-a-package", + `no readable package.json at the path: ${messageOf(error)}`, + ); + } + const inspection = inspectionOf("path", read, registry); + if (inspection.name === undefined) + return refused("not-a-package", "the package.json names no package"); + if (known.has(inspection.name)) + return refused("already-installed", `${inspection.name} is already installed`); + if (!inspection.bundle) + return refused("not-a-bundle", `${inspection.name} declares no dsh.bundle`); + return inspection; + } + case "registry": { + if (known.has(parsed.name)) + return refused("already-installed", `${parsed.name} is already installed`); + const registries: Registry[] = []; + const refusedBy = ( + problem: PluginInspectProblem, + reason: string, + ): PluginSpecInspection => ({ status: "refused", problem, reason, registries }); + for (const current of plan) { + registries.push(current); + const view = await viewProfilePackage(this.profile.dir, spec.trim(), { + ...(this.profile.packageManager ?? { command: this.pnpmCommand }), + timeoutMs: this.inspectTimeoutMs, + ...(signal === undefined ? {} : { signal }), + registry: current, + }); + const printed = view.stdout.replace(ANSI_SEQUENCE, "").trim(); + if (view.exitCode !== 0 || view.cause !== undefined || view.timedOut) { + // pnpm prints a refusal as `{ error: { code, message } }` on stdout, with nothing on stderr. + const log = [ + view.stderr.trim(), + printedError(printed), + view.cause === undefined ? "" : messageOf(view.cause), + ] + .filter(Boolean) + .join("\n"); + const kind = classifyInstallFailure({ + log, + timedOut: view.timedOut, + ...(view.cause === undefined ? {} : { cause: view.cause }), + }); + // A lookup the caller dropped is not carried to the next registry. + if ( + registries.length < plan.length && + signal?.aborted !== true && + attributeFailure(kind, log, parsed) === "registry" + ) + continue; + const reason = view.timedOut + ? `pnpm view timed out after ${String(this.inspectTimeoutMs)}ms` + : log || printed || `pnpm view exited with ${String(view.exitCode)}`; + if (kind === "not-found" || kind === "no-matching-version") + return refusedBy("not-found", reason); + if (kind === "network" || kind === "timeout") return refusedBy("network", reason); + return refusedBy("unknown", reason); + } + let answer: unknown; + try { + answer = JSON.parse(printed || "null"); + } catch (error) { + return refusedBy("unknown", `unreadable pnpm view output: ${messageOf(error)}`); + } + // A range answers one object per matching version, oldest first. + const latest: unknown = Array.isArray(answer) ? answer.at(-1) : answer; + if (typeof latest !== "object" || latest === null) + return refusedBy("unknown", "pnpm view answered no package"); + const inspection = inspectionOf("registry", latest, current); + const named = + inspection.name === undefined ? { ...inspection, name: parsed.name } : inspection; + if (!named.bundle) + return refusedBy("not-a-bundle", `${named.name} declares no dsh.bundle`); + return named; + } + /* v8 ignore next -- the plan is never empty: every attempt returns or continues to the next */ + throw new Error("no registry was asked"); + } + } + } + + /** Persist a plugin entry's desired enablement and apply it on live profiles. + * @param id Loader entry identity returned by listPlugins. + * @param enabled Whether the plugin should run. + * @returns Saved and runtime outcomes, including higher-priority overrides. + */ + @Remote + setPluginEnabled(id: PluginEntryId, enabled: boolean): Promise { + return this.change( + (result) => + this.configure(async () => { + const row = (await this.listPlugins()).find((item) => item.entryId === id); + if (row === undefined) throw new ManagementFailure("unknown-plugin"); + if (row.readOnlyReason !== undefined) throw new ManagementFailure(row.readOnlyReason); + await writePluginEnabled(this.profile.patchPath, row.patchId, row.moduleName, enabled); + result.warnings = await this.reload(enabled ? [row.patchId] : []); + const current = (await this.listPlugins()).find((item) => item.entryId === id); + return current?.enabled !== enabled && this.ownerContext.get("hmr") !== undefined + ? "overridden" + : undefined; + }), + { stage: "enable", target: id, enabled }, + "plugin", + ); + } + + /** Select or remove a bundle layer while retaining installed dependencies. + * @param name Bundle package name. + * @param enabled Whether the bundle contributes its patch layer. + * @returns Persisted and runtime outcomes. + */ + @Remote + setBundleEnabled(name: string, enabled: boolean): Promise { + return this.change( + (result) => + this.configure(async () => { + await this.selectBundle(name, enabled); + if (enabled) await this.refreshPackages(); + result.warnings = await this.reload( + enabled ? this.bundleRows(name).map((row) => row.id) : [], + ); + if (!enabled && this.ownerContext.get("hmr") !== undefined) await this.refreshPackages(); + }), + { stage: "enable", target: name, enabled }, + "bundle", + ); + } + + /** + * Install a package using the same pnpm implementation as dsh plugin. GitHub + * repositories get a connection check bounded by githubConnectionTimeoutMs before pnpm starts; + * only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. A run + * that fails, is cancelled, or adds a package without a bundle patch restores + * `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay. + * @param spec One package spec, including local paths relative to the invocation directory. + * @param options Whether to activate the installed bundle (defaults to true), the request id a cancellation names, + * the pending build scripts to allow for this profile before pnpm runs, and the registry asked first. + * @returns Package-manager diagnostics, the registries asked, and the observed activation outcome. + */ + @Remote + installBundle(spec: string, options?: InstallBundleOptions): Promise { + const requestId = options?.requestId; + const control: InstallControl = { + abort: new AbortController(), + phase: "installing", + result: Promise.resolve(null), + }; + const stopped = (): boolean => control.abort.signal.aborted || this.abort.signal.aborted; + if (requestId !== undefined) this.installs.set(requestId, control); + const announce = ( + phase: PluginInstallProgress["phase"], + attempt?: PluginInstallProgress["attempt"], + ): void => { + if (requestId !== undefined) + this.ownerContext.emit("plugin-manager/install-state", { + requestId, + phase, + ...(attempt === undefined ? {} : { attempt }), + }); + }; + const result = this.change( + async (result) => { + if (spec.trim() === "" || spec.startsWith("-")) throw new ManagementFailure("invalid-spec"); + if (stopped()) throw new InstallCancelledError(); + if (options?.approvedBuilds !== undefined) { + await approveBuilds(this.profile.dir, options.approvedBuilds); + result.approvedBuilds = options.approvedBuilds; + } + const files = await this.readRestoredFiles(); + const before = readProfileManifest("dsh", this.profile.dir).dependencies ?? {}; + let name: string; + let version: string | undefined; + try { + result.registries = []; + const connection = checkGithubConnection(parsedForRegistry(spec), this.profile.dir, { + timeoutMs: this.githubConnectionTimeoutMs, + outputBytes: this.outputBytes, + signal: AbortSignal.any([this.abort.signal, control.abort.signal]), + ...(this.profile.packageManager?.env === undefined + ? {} + : { env: this.profile.packageManager.env }), + }); + this.packageOperations.add(connection); + let connectionFailure: PackageResult | undefined; + try { + connectionFailure = await connection; + } finally { + this.packageOperations.delete(connection); + } + if (stopped()) throw new InstallCancelledError(); + if (connectionFailure?.kind === "network" || connectionFailure?.kind === "timeout") { + result.packageResult = connectionFailure; + result.failedAt = "spec-host"; + throw new Error(connectionFailure.output); + } + // The last run is the result's; the registries asked stay listed whatever the outcome. + const plan = registryPlan(options?.registry, await this.registries()); + let run: PackageResult | undefined; + for (const [index, registry] of plan.entries()) { + if (index > 0) await this.restoreFiles(files); + // A stop that landed while the files went back, or before the first run, starts no run with a dead signal. + if (stopped()) throw new InstallCancelledError(); + result.registries.push(registry); + announce("installing", { registry, index: index + 1, total: plan.length }); + run = await this.runPnpm( + ["add", spec, ...registryArguments(registry)], + control.abort.signal, + requestId, + ); + result.packageResult = run; + if (stopped()) throw new InstallCancelledError(); + // A compatibility refusal is the package's own answer, so no other registry is asked. + if (run.incompatible !== undefined) + throw new ManagementFailure("incompatible-version", run.incompatible); + // A run this manager terminated is not a success, even when pnpm trapped the signal and exited 0. + if (run.exitCode === 0 && run.timedOut !== true) break; + /* v8 ignore next 2 -- runPnpm classifies every run it does not report as succeeded */ + if (run.kind === undefined) break; + // What the last failed run could not reach; a later run that succeeds leaves nothing to say. + delete result.failedAt; + // A run this manager terminated got no answer from the registry at all, so no registry explains it. + if (run.timedOut === true) break; + const failedAt = attributeFailure(run.kind, run.output, parsedForRegistry(spec)); + if (failedAt !== "other") result.failedAt = failedAt; + if (failedAt !== "registry" || index === plan.length - 1) break; + } + /* v8 ignore next -- the plan is never empty, so a run always settled */ + if (run === undefined) throw new Error("no registry was asked"); + // A terminated run reports no usable exit status, so neither its files nor its bundle are trusted. + const succeeded = run.exitCode === 0 && run.timedOut !== true; + if (succeeded) delete result.failedAt; + if (!succeeded) { + // pnpm-workspace.yaml is not restored, so the names pnpm left undecided there can be offered for approval. + try { + result.pendingBuilds = await readPendingBuilds(this.profile.dir); + } catch (error) { + this.ownerContext.logger.warn( + "Could not read pending build approvals after pnpm failed", + error, + ); + } + throw new Error(run.output); + } + const after = readProfileManifest("dsh", this.profile.dir).dependencies ?? {}; + const installed = Object.keys(after).filter((name) => before[name] !== after[name]); + // Registry retries can retain the saved range after a partial installation. + if (installed.length === 0) + installed.push( + ...Object.keys(after).filter((name) => spec === name || spec.startsWith(`${name}@`)), + ); + const target = installed[0]; + if (installed.length !== 1 || target === undefined) + throw new ManagementFailure("ambiguous-install"); + name = target; + const dir = resolveBundleDir("dsh", name, this.profile.installAnchor, this.profile.dir); + const manifest = bundleManifest(name, this.profile.dir, this.profile.installAnchor); + if (manifest?.dsh?.bundle === undefined) throw new ManagementFailure("not-bundle"); + const compatibility = evaluatePluginCompatibility( + manifest, + readProfileVersionExemptions(this.profile.dir), + ); + if (compatibility !== undefined && !compatibility.exempted) + throw new ManagementFailure("incompatible-version", [ + incompatiblePlugin(compatibility), + ]); + for (const file of bundlePatchPaths(dir, manifest.dsh.bundle)) + loadOverlayPatches("dsh", file); + version = manifest.version; + } catch (error) { + // pnpm has exited by now, so the files it rewrote go back as they were. + await this.restoreFiles(files); + throw error; + } + control.phase = "applying"; + announce("applying"); + result.bundle = name; + if (version !== undefined) result.version = version; + result.target = name; + result.stage = "enable"; + return this.configure(async () => { + if (options?.enabled !== false) await this.selectBundle(name, true); + if (Object.hasOwn(before, name)) return "restart-required"; + await this.refreshPackages(); + if (options?.enabled !== false) result.warnings = await this.reload(); + }); + }, + { stage: "install", target: spec, enabled: options?.enabled !== false }, + "install", + ); + control.result = result; + return result.finally(() => { + if (requestId !== undefined) this.installs.delete(requestId); + }); + } + + /** Recover the result of an active installation without cancelling it. + * @param requestId The id supplied when installation started. + * @returns The installation's outcome after it settles, or null if no active request has that id. + * Completed results are not retained; null establishes neither success nor cancellation. + */ + @Remote + async waitForInstall(requestId: PluginInstallRequestId): Promise { + return this.installs.get(requestId)?.result ?? null; + } + + /** Stop an installation this manager owns and wait until its files are back. + * @param requestId The id the installation was started with. + * @returns `cancelled` once the Git check or pnpm exited and the files are restored, `too-late` once the bundle is being + * applied, `not-running` for any other id. + */ + @Remote + async cancelInstall(requestId: PluginInstallRequestId): Promise { + const control = this.installs.get(requestId); + if (control === undefined) return { status: "not-running" }; + if (control.phase === "applying") return { status: "too-late" }; + this.ownerContext.emit("plugin-manager/install-state", { requestId, phase: "cancelling" }); + control.abort.abort(); + /* v8 ignore next -- change() folds every failure into its result; only a lock or disposal error rejects */ + await control.result.then( + () => undefined, + () => undefined, + ); + return { status: "cancelled" }; + } + + /** Unload and remove a profile-owned bundle dependency through dsh plugin's pnpm path; a selected name no + * dependency holds is only deselected. + * @param name Installed dependency or selected bundle name. + * @returns Removal diagnostics and the remaining profile state. + */ + @Remote + removeBundle(name: string): Promise { + return this.change( + async (result) => { + const installed = await this.configure(async () => { + const bundle = (await this.listBundles()).find((item) => item.name === name); + if (bundle === undefined || !bundle.removable) + throw new ManagementFailure("not-removable"); + if ( + this.ownerContext.get("hmr") === undefined && + (this.profile.startedBundles.includes(name) || + (bundle.error === undefined && + this.bundleRows(name).some((row) => + [...this.ctx.loader.entries()].some( + (entry) => entry.options.id === row.id && entry.fiber !== undefined, + ), + ))) + ) { + throw new ManagementFailure("stop-profile"); + } + const contributions = bundle.error === undefined ? this.bundleRows(name) : []; + if (bundle.enabled) { + await this.selectBundle(name, false); + result.warnings = await this.reload(); + } + if ( + [...this.ctx.loader.entries()].some( + (entry) => + entry.fiber?.uid != null && + contributions.some( + (row) => row.id === entry.options.id && row.name === entry.options.name, + ), + ) + ) { + throw new ManagementFailure("bundle-in-use"); + } + return bundle.installed; + }); + if (!installed) return; + result.packageResult = await this.runPnpm(["remove", name]); + if (result.packageResult.exitCode !== 0 || result.packageResult.timedOut === true) { + throw new Error(result.packageResult.output); + } + await this.configure(() => this.refreshPackages()); + }, + { stage: "remove", target: name }, + "remove", + ); + } + + /** The rows a bundle's patch inserts and the existing rows it changes; an unreadable patch throws. */ + private declaredRows( + name: string, + info: ProfileManifest, + ): Pick { + const bundle = info.dsh?.bundle; + /* v8 ignore next -- bundleManifest answers only manifests that declare a patch */ + if (bundle === undefined) return { rows: [], overrides: [] }; + const dir = resolveBundleDir("dsh", name, this.profile.installAnchor, this.profile.dir); + const patches: PatchOptions[] = bundlePatchPaths(dir, bundle).flatMap((file) => + loadOverlayPatches("dsh", file), + ); + // One entry per row id: the Loader keeps a single entry for an id, whichever layer declared it last. + const live = new Map(); + for (const entry of this.ctx.loader.entries()) { + /* v8 ignore next -- the Loader gives every entry an id before it is listed */ + if (typeof entry.options.id === "string") + live.set(entry.options.id, { + entryId: pluginEntryId(entry.id), + baseUrl: entry.parent.tree.ctx.baseUrl, + }); + } + const rows: BundleRowInfo[] = []; + const packages = this.ctx.get("pluginPackages"); + for (const row of flatten( + composeEntries([patches.filter((item) => item.insert !== undefined)]), + )) { + if (typeof row.id !== "string" || typeof row.name !== "string") continue; + const active = live.get(row.id); + const entryId = active?.entryId; + const base = active?.baseUrl ?? pathToFileURL(join(dir, "package.json")).href; + const meta = packages?.metaOf(row.name, base); + rows.push({ + rowId: row.id, + moduleName: row.name, + ...(entryId === undefined ? {} : { entryId }), + ...(meta === undefined ? {} : { meta }), + }); + } + const declared = new Set(rows.map((row) => row.rowId)); + const overrides = [ + ...new Set( + patches.flatMap((item) => + item.insert === undefined && typeof item.id === "string" && !declared.has(item.id) + ? [item.id] + : [], + ), + ), + ]; + return { rows, overrides }; + } + + /** Run one pnpm command in the profile, streaming its output as install-log chunks. */ + private async runPnpm( + args: readonly string[], + signal?: AbortSignal, + requestId?: PluginInstallRequestId, + ): Promise { + const jobId = randomUUID(); + const argv = ["pnpm", ...args]; + const cwd = this.profile.dir; + const identity = requestId === undefined ? {} : { requestId }; + const task = runProfilePnpm({ ...this.profile, profile: this.profile.name }, args, { + execution: "service", + ...(this.profile.packageManager ?? { command: this.pnpmCommand }), + signal: + signal === undefined ? this.abort.signal : AbortSignal.any([this.abort.signal, signal]), + outputBytes: this.outputBytes, + activateNewBundles: false, + idleTimeoutMs: this.idleTimeoutMs, + lookupTimeoutMs: this.inspectTimeoutMs, + onOutput: (text, stream) => { + this.ownerContext.emit("plugin-manager/install-log", { + ...identity, + jobId, + argv, + cwd, + stream, + text, + }); + }, + }); + this.packageOperations.add(task); + try { + const result = await task; + this.ownerContext.emit("plugin-manager/install-log", { + ...identity, + jobId, + argv, + cwd, + stream: "stdout", + text: "", + exitCode: signal?.aborted === true ? null : result.exitCode, + }); + // A terminated run keeps its own kind even when pnpm trapped the signal and exited 0. + if (result.exitCode === 0 && result.timedOut !== true) return result; + return { + ...result, + kind: classifyInstallFailure({ + log: result.output, + ...(result.timedOut === true ? { timedOut: true } : {}), + }), + }; + } catch (error) { + this.ownerContext.emit("plugin-manager/install-log", { + ...identity, + jobId, + argv, + cwd, + stream: "stderr", + text: messageOf(error), + exitCode: null, + }); + throw error; + } finally { + this.packageOperations.delete(task); + } + } + + /** The profile files an installation may rewrite, as they are now; absent files read as undefined. */ + private async readRestoredFiles(): Promise> { + const files = new Map(); + for (const name of RESTORED_FILES) { + const path = join(this.profile.dir, name); + files.set(path, existsSync(path) ? await readFile(path, "utf8") : undefined); + } + return files; + } + + /** Put the profile files back; pnpm has exited by the time this runs. */ + private async restoreFiles(files: Map): Promise { + for (const [path, content] of files) { + if (content === undefined) await rm(path, { force: true }); + else await writeFileAtomic(path, content, { mode: 0o600 }); + } + } + + private async selectBundle(name: string, enabled: boolean): Promise { + const manifest = readProfileManifest("dsh", this.profile.dir); + const previous = manifest.dsh?.profile?.bundles ?? []; + if (enabled || !previous.includes(name)) { + const metadata = bundleManifest(name, this.profile.dir, this.profile.installAnchor); + if (metadata === undefined) throw new ManagementFailure("not-bundle"); + if (enabled) { + const compatibility = evaluatePluginCompatibility( + metadata, + readProfileVersionExemptions(this.profile.dir), + ); + if (compatibility !== undefined && !compatibility.exempted) + throw new ManagementFailure("incompatible-version", [incompatiblePlugin(compatibility)]); + this.bundleRows(name); + } + } + if (!enabled && previous.includes(name)) { + if (this.protectsManager(name)) throw new ManagementFailure("management-required"); + } + const bundles = enabled + ? [...previous, ...(previous.includes(name) ? [] : [name])] + : previous.filter((item) => item !== name); + if (JSON.stringify(previous) === JSON.stringify(bundles)) return; + manifest.dsh = { ...manifest.dsh, profile: { ...manifest.dsh?.profile, bundles } }; + await saveManifest(this.profile.dir, manifest); + if (enabled) this.protectsManager(name); + } + + private bundleRows(name: string): EntryOptions[] { + const info = bundleManifest(name, this.profile.dir, this.profile.installAnchor); + if (info?.dsh?.bundle === undefined) return []; + const dir = resolveBundleDir("dsh", name, this.profile.installAnchor, this.profile.dir); + return flatten( + composeEntries([ + bundlePatchPaths(dir, info.dsh.bundle).flatMap((file) => loadOverlayPatches("dsh", file)), + ]), + ); + } + + private protectsManager(name: string): boolean { + if (this.managementBundles.has(name)) return true; + let rows: EntryOptions[]; + try { + rows = this.bundleRows(name); + } catch (_error) { + // Unreadable bundles contribute no new rows; listBundles reports their diagnostics. + return false; + } + const protectedBundle = rows.some( + (row) => protectedModules.has(row.name) || `include:${row.id}` === this.ownerEntryId, + ); + if (protectedBundle) this.managementBundles.add(name); + return protectedBundle; + } + + private configure(operation: () => Promise): Promise { + const hmr = this.ownerContext.get("hmr"); + const apply = () => { + this.abort.signal.throwIfAborted(); + return operation(); + }; + return hmr === undefined ? apply() : hmr.runExclusive(apply); + } + + private async refreshPackages(): Promise { + if (this.ownerContext.get("hmr") === undefined) { + const selected = readProfileManifest("dsh", this.profile.dir).dsh?.profile?.bundles ?? []; + // Deselected startup bundles still run without HMR and need the existing package table. + if (this.profile.startedBundles.some((name) => !selected.includes(name))) return; + } + await this.ownerContext.get("pluginPackages")?.refresh(); + } + + private async reload(requiredIds: readonly string[] = []): Promise { + if (this.ownerContext.get("hmr") === undefined) return []; + return reconcileProfilePatches( + this.ownerContext.root, + readProfilePatches("dsh", this.profile), + "dsh", + requiredIds, + ); + } + + private async change( + operation: (result: ChangeResult) => Promise, + request: Pick, + reason: PluginChange["reason"], + ): Promise { + return withFileLock( + join(this.profile.dir, "package.json"), + async () => { + this.abort.signal.throwIfAborted(); + const before = this.diskState(); + const result: ChangeResult = { + ...request, + changed: false, + application: this.ownerContext.get("hmr") !== undefined ? "applied" : "restart-required", + }; + try { + result.application = (await operation(result)) ?? result.application; + } catch (error) { + if (error instanceof InstallCancelledError) { + result.application = "cancelled"; + } else { + result.application = "failed"; + result.error = managementError(error); + } + } + result.changed = before !== this.diskState(); + this.ownerContext.emit("plugin-manager/changed", { reason }); + return result; + }, + { waitMs: this.lockWaitMs }, + ); + } + + private diskState(): string { + return [ + "package.json", + "cordis.patch.yml", + "pnpm-workspace.yaml", + PROFILE_COMPATIBILITY_FILENAME, + ] + .map((file) => { + try { + return readFileSync(join(this.profile.dir, file), "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return ""; + throw error; + } + }) + .join("\0"); + } +} + +export default PluginManager; diff --git a/apps/web-ui/packages/boot/plugin-manager/src/install-failure.ts b/apps/web-ui/packages/boot/plugin-manager/src/install-failure.ts new file mode 100644 index 000000000..69f40009c --- /dev/null +++ b/apps/web-ui/packages/boot/plugin-manager/src/install-failure.ts @@ -0,0 +1,47 @@ +/** + * What a failed pnpm run was, read off how it ended and what it printed: + * pnpm names its failures with stable `ERR_PNPM_*` codes and Node's errno + * names, which the run's captured tail carries whatever the locale. + * @module @deepseek-ai/dsh-plugin-manager/install-failure + */ + +import type { PluginInstallFailureKind } from "./types.ts"; + +/** How a run ended, beyond its exit code. */ +export interface InstallFailureFacts { + /** The output tail the failure reports. */ + readonly log: string; + /** The spawn error, when the child never ran. */ + readonly cause?: unknown; + /** Whether the run outlived its bound. */ + readonly timedOut?: boolean; +} + +/** Patterns in the order they decide: a specific code before the generic network family. */ +const LOG_KINDS: readonly [PluginInstallFailureKind, RegExp][] = [ + ["build-blocked", /ERR_PNPM_IGNORED_BUILDS|Ignored build scripts/], + ["not-found", /ERR_PNPM_FETCH_404|\bE404\b|404 Not Found|Not Found - GET/], + ["no-matching-version", /ERR_PNPM_NO_MATCHING_VERSION|\bETARGET\b|No matching version/], + ["disk-full", /\bENOSPC\b|no space left on device/i], + ["permission", /\bEACCES\b|\bEPERM\b|permission denied/i], + ["integrity", /ERR_PNPM_TARBALL_INTEGRITY|ERR_PNPM_BAD_TARBALL_SIZE|\bEINTEGRITY\b/], + ["unknown", /The requested URL returned error: 40[134]\b/], + [ + "network", + /\bENOTFOUND\b|\bECONNRESET\b|\bETIMEDOUT\b|\bECONNREFUSED\b|\bEAI_AGAIN\b|ERR_PNPM_META_FETCH_FAIL|ERR_PNPM_FETCH_5\d\d|ERR_PNPM_FETCH_TIMEOUT|\bFETCH_ERROR\b|socket hang up|Could not resolve host|unable to access/, + ], +]; + +/** + * Classify a failed run. + * @param facts - how the run ended and what it printed. + * @returns the kind, `unknown` when nothing in the facts names one. + */ +export function classifyInstallFailure(facts: InstallFailureFacts): PluginInstallFailureKind { + if (facts.timedOut === true) return "timeout"; + if ((facts.cause as NodeJS.ErrnoException | undefined)?.code === "ENOENT") return "pnpm-missing"; + for (const [kind, pattern] of LOG_KINDS) { + if (pattern.test(facts.log)) return kind; + } + return "unknown"; +} diff --git a/apps/web-ui/packages/boot/plugin-manager/src/install-spec.ts b/apps/web-ui/packages/boot/plugin-manager/src/install-spec.ts new file mode 100644 index 000000000..a2dadab81 --- /dev/null +++ b/apps/web-ui/packages/boot/plugin-manager/src/install-spec.ts @@ -0,0 +1,148 @@ +/** + * Reading an install spec before pnpm sees it: which of pnpm's spec forms it + * takes, and for a registry name whether it is one the registry can accept. + * @module @deepseek-ai/dsh-plugin-manager/install-spec + */ + +import { homedir } from "node:os"; +import { isAbsolute, resolve } from "node:path"; + +/** + * One spec read into its form and the parts an inspection needs. A git spec + * and a tarball URL carry the `host` pnpm fetches them from, which no registry + * stands in for; only their dependencies come from the registry. + */ +export type ParsedInstallSpec = + | { + readonly kind: "registry"; + readonly spec: string; + readonly name: string; + readonly range?: string; + } + | { readonly kind: "path"; readonly spec: string; readonly path: string } + | { + readonly kind: "tarball"; + readonly spec: string; + readonly path?: string; + readonly host?: string; + } + | { readonly kind: "git"; readonly spec: string; readonly host: string }; + +/** The forms pnpm resolves through a git host: a host shorthand, a git URL, or a hosted repository URL. */ +const GIT_SHORTHAND = /^(?:github|gitlab|bitbucket|gist):/i; +const GIT_URL = /^git(?:\+[a-z]+)?:\/\/|^git@[^:]+:/i; +const HOSTED_REPOSITORY_URL = /^https?:\/\/[^/]+\/[^/]+\/[^/#]+(?:\.git)?(?:#.*)?$/i; +/** The hosts pnpm's shorthands stand for. */ +const GIT_SHORTHAND_HOSTS: Readonly> = { + github: "github.com", + gitlab: "gitlab.com", + bitbucket: "bitbucket.org", + gist: "gist.github.com", +}; +/** A tarball, on disk or over HTTP. */ +const TARBALL_SPEC = /\.(?:tgz|tar\.gz)(?:#.*)?$/i; +/** An npm package name: lowercase URL-safe segments, an optional scope, no leading dot or underscore. */ +const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/; +const PACKAGE_NAME_MAX_LENGTH = 214; + +/** A spec neither pnpm nor the registry would take; `reason` is what the person reads. */ +export class InvalidInstallSpecError extends Error { + /** + * @param spec - the spec as typed, trimmed. + * @param reason - why it is refused, as one sentence. + */ + constructor( + readonly spec: string, + readonly reason: string, + ) { + super(`plugin-manager: ${reason}: ${spec}`); + this.name = "InvalidInstallSpecError"; + } +} + +function invalid(spec: string, reason: string): InvalidInstallSpecError { + return new InvalidInstallSpecError(spec, reason); +} + +/** The host a git spec is cloned from: the shorthand's host, the scp-like user@host, or the URL's host with its port. */ +function gitHost(spec: string): string { + const shorthand = /^([a-z]+):/i.exec(spec)?.[1]?.toLowerCase(); + if (shorthand !== undefined && Object.hasOwn(GIT_SHORTHAND_HOSTS, shorthand)) + return GIT_SHORTHAND_HOSTS[shorthand] as string; + const scp = /^git@([^:]+):/i.exec(spec)?.[1]; + if (scp !== undefined) return scp.toLowerCase(); + return new URL(spec.replace(/^git\+/i, "")).host; +} + +/** + * Read a spec into its form. A path must be absolute: the Host's working + * directory means nothing to the person typing into a browser, and a + * relative path resolved against the profile would point inside it. + * @param raw - the spec as typed. + * @returns the parsed spec. + * @throws {InvalidInstallSpecError} for an empty spec, a relative path, a name the + * registry would refuse, or a URL that is neither a git host nor a tarball. + */ +export function parseInstallSpec(raw: string): ParsedInstallSpec { + const spec = raw.trim(); + if (spec === "") throw invalid(spec, "the package spec must not be empty"); + const path = spec.replace(/^(?:file|link):/, ""); + if (path !== spec || isAbsolute(path)) { + if (!isAbsolute(path)) throw invalid(spec, "a local path must be absolute"); + return TARBALL_SPEC.test(path) ? { kind: "tarball", spec, path } : { kind: "path", spec, path }; + } + if (/^\.{1,2}(?:[\\/]|$)/.test(spec)) throw invalid(spec, "a local path must be absolute"); + const git = GIT_SHORTHAND.test(spec) || GIT_URL.test(spec) || HOSTED_REPOSITORY_URL.test(spec); + if (git && !TARBALL_SPEC.test(spec)) return { kind: "git", spec, host: gitHost(spec) }; + if (/^https?:\/\//i.test(spec)) { + if (TARBALL_SPEC.test(spec)) return { kind: "tarball", spec, host: new URL(spec).host }; + throw invalid(spec, "a URL must point at a git repository or a tarball"); + } + const at = spec.indexOf("@", 1); + const name = at === -1 ? spec : spec.slice(0, at); + const range = at === -1 ? undefined : spec.slice(at + 1); + if (name.length > PACKAGE_NAME_MAX_LENGTH || !PACKAGE_NAME.test(name)) { + throw invalid(spec, "not a package name the registry accepts"); + } + if (range === "") throw invalid(spec, "a version after @ must not be empty"); + return range === undefined + ? { kind: "registry", spec, name } + : { kind: "registry", spec, name, range }; +} + +/** A recorded dependency value pnpm resolves outside a registry: a protocol other than a registry alias, or an scp-like git address. */ +const NON_REGISTRY_VALUE = + /^(?!(?:npm|jsr|workspace|catalog):)[a-z][a-z0-9+.-]*:|^[^@/:\s]+@[^:/\s]+:/i; +/** The user information of an http(s) URL, up to the last `@` before the path, which holds a password or access token when present. */ +const HTTP_USER_INFO = /^((?:git\+)?https?:\/\/)[^/]*@/i; + +/** + * The spec `pnpm add` accepts for a dependency the profile manifest records. A git address, URL, or other + * non-registry protocol is the recorded value without the user information of an http(s) URL, whose query string + * is kept; a `file:` or `link:` path, which pnpm records relative to the profile or with `~` for the home + * directory, becomes absolute. A registry range, tag, or alias follows the dependency name after `@`, as does a + * non-registry value installed under a name other than the package's own. + * @param name - the dependency name. + * @param recorded - the value the profile manifest records for it. + * @param profileDir - the profile directory a relative local path is resolved against. + * @param packageName - the name the installed package's manifest declares, when it could be read. + * @returns the installable spec. + */ +export function dependencySpec( + name: string, + recorded: string, + profileDir: string, + packageName = name, +): string { + const local = /^(file|link):(.*)$/s.exec(recorded); + let spec: string; + if (local !== null) { + const path = (local[2] as string).replace(/^~(?=$|[\\/])/, () => homedir()); + spec = `${local[1]}:${resolve(profileDir, path)}`; + } else if (NON_REGISTRY_VALUE.test(recorded)) { + spec = recorded.replace(HTTP_USER_INFO, "$1"); + } else { + return `${name}@${recorded}`; + } + return packageName === name ? spec : `${name}@${spec}`; +} diff --git a/apps/web-ui/packages/boot/plugin-manager/src/operations.ts b/apps/web-ui/packages/boot/plugin-manager/src/operations.ts new file mode 100644 index 000000000..0bdd52af5 --- /dev/null +++ b/apps/web-ui/packages/boot/plugin-manager/src/operations.ts @@ -0,0 +1,832 @@ +/** Shared profile package operations used by dsh plugin and the running manager. */ +import { once } from "node:events"; +import { existsSync, readFileSync } from "node:fs"; +import { mkdir, mkdtemp, open, rm } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { execa } from "execa"; +import type { EntryOptions } from "@deepseek-ai/cordis-plugin-loader"; +import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write"; +import { + DEFAULT_PROFILE_BUNDLES, + bundlePatchPaths, + initProfile, + PROFILE_TEMPLATES, + readProfileManifest, + resolveBundleDir, + resolveProfileDir, + loadOverlayPatches, + composeEntries, + readProfileVersionExemptions, + evaluatePluginCompatibility, + pluginCompatibilityWarning, + type ProfileManifest, +} from "@deepseek-ai/dsh-app-boot"; +import { scrubbedParentEnv } from "@deepseek-ai/dsh-subprocess"; +import { parseInstallSpec } from "./install-spec.ts"; +import { awaitTreeGone, leadsOwnGroup, treeAlive, type RunTree } from "./run-tree.ts"; +import { incompatiblePlugin } from "./failure.ts"; +import type { IncompatiblePlugin, PackageResult, Registry } from "./types.ts"; +export { + setProfileVersionExemption, + readProfileVersionExemptions, +} from "@deepseek-ai/dsh-app-boot"; + +/** Profile and invocation locations supplied by the launcher. */ +export interface PackageOperationContext { + profile: string; + /** Explicit directory for an application-owned profile; named CLI profiles resolve under home. */ + dir?: string; + installAnchor: string; + cwd: string; + home?: string; +} + +/** Output and cancellation policy for one pnpm operation. */ +export interface PackageOperationOptions { + /** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. Defaults to `pnpm`. */ + command?: string; + /** Prefix arguments for an application-owned executable. */ + args?: readonly string[]; + /** Application runtime environment, applied only to this package operation. */ + env?: Readonly>; + /** CLI inherits authentication and terminal descriptors; service scrubs secrets and captures output. */ + execution: "cli" | "service"; + signal?: AbortSignal; + outputBytes: number; + onOutput?: (text: string, stream: "stdout" | "stderr") => void; + activateNewBundles?: boolean; + lockWaitMs?: number; + /** + * Terminate the run once its captured output has been silent for this long, in + * milliseconds. A run stopped this way reports `timedOut`; without a bound, a + * child that stops progressing without exiting holds its caller forever. A run + * with inherited descriptors captures nothing and is never bound. + */ + idleTimeoutMs?: number; + /** Bound on the pre-install registry lookup, in milliseconds; without one a fixed bound applies. */ + lookupTimeoutMs?: number; +} + +/** Resolve relative package specs against the caller's directory. + * @param argument One pnpm argument. + * @param cwd Invocation directory, never the profile directory. + * @returns Anchored argument. + */ +export function anchorPathSpec(argument: string, cwd: string): string { + const match = /^(?(?:file|link):)?(?\.{1,2}(?:[/\\].*)?)$/.exec(argument); + if (match?.groups?.path === undefined) return argument; + return `${match.groups.prefix ?? ""}${resolve(cwd, match.groups.path)}`; +} + +/** Read bundle metadata without loading its JavaScript. + * @param name Installed dependency or installation-owned package name. + * @param dir Profile directory. + * @param anchor Installation manifest. + * @returns Resolved metadata, or undefined for packages without bundle metadata. + */ +export function bundleManifest( + name: string, + dir: string, + anchor: string, +): ProfileManifest | undefined { + const packageDir = resolveBundleDir("dsh", name, anchor, dir); + const manifest = readProfileManifest("dsh", packageDir); + return manifest.dsh?.bundle?.patch === undefined ? undefined : manifest; +} + +/** Atomically save a profile manifest while retaining unrelated fields. + * @param dir Profile directory. + * @param manifest Updated document. + */ +export async function saveManifest(dir: string, manifest: ProfileManifest): Promise { + await writeFileAtomic(join(dir, "package.json"), JSON.stringify(manifest, undefined, 2) + "\n", { + mode: 0o600, + }); +} + +/** Reconcile package removals and newly installed bundles without re-enabling retained dependencies. */ +async function reconcile( + before: ProfileManifest, + dir: string, + anchor: string, + options: PackageOperationOptions, +): Promise { + const after = readProfileManifest("dsh", dir); + const dependencies = Object.keys(after.dependencies ?? {}); + const beforeDeps = new Set(Object.keys(before.dependencies ?? {})); + const previous = after.dsh?.profile?.bundles ?? []; + const bundles = previous.filter((name) => { + if (!beforeDeps.has(name) && !dependencies.includes(name)) return true; + return dependencies.includes(name) && bundleManifest(name, dir, anchor) !== undefined; + }); + for (const name of dependencies) { + if (beforeDeps.has(name)) continue; + const metadata = bundleManifest(name, dir, anchor); + if (metadata?.dsh?.bundle === undefined) { + options.onOutput?.( + `dsh: warning: ${name} declares no dsh.bundle — installed as a plain dependency, not a profile layer\n`, + "stderr", + ); + continue; + } + for (const file of bundlePatchPaths( + resolveBundleDir("dsh", name, anchor, dir), + metadata.dsh.bundle, + )) + loadOverlayPatches("dsh", file); + if (!bundles.includes(name)) { + bundles.push(name); + } + } + if (JSON.stringify(previous) === JSON.stringify(bundles)) return; + after.dsh = { ...after.dsh, profile: { ...after.dsh?.profile, bundles } }; + await saveManifest(dir, after); +} + +/** + * How long the pipes keep draining after their process exited, as a fixed part of + * finishing a run rather than a deployment knob: a descendant that inherited them + * holds them open, and the tail a failure classification reads is written by then. + */ +const DRAIN_AFTER_EXIT_MS = 2_000; + +/** Whether every collector finished within `ms`. + * @param collectors The pipe readers racing the bound. + * @param ms The longest wait, in milliseconds. + * @returns True when all collectors settled in time. + */ +async function drainWithin(collectors: readonly Promise[], ms: number): Promise { + if (collectors.length === 0) return true; + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + Promise.allSettled(collectors).then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => { + resolve(false); + }, ms); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +/** Install commands that take the packages to install as positionals. */ +const INSTALL_COMMANDS = new Set(["add", "install", "i"]); + +/** Bound on a pre-install registry lookup when the caller names none. */ +const LOOKUP_TIMEOUT_MS = 20_000; + +/** Package specs an install command names explicitly, in order. */ +function namedSpecs(args: readonly string[]): string[] { + const index = args.findIndex((argument) => !argument.startsWith("-")); + const command = index < 0 ? undefined : args[index]; + if (command === undefined || !INSTALL_COMMANDS.has(command)) return []; + return args.slice(index + 1).filter((argument) => !argument.startsWith("-")); +} + +/** The manifest a named spec would install, read without installing it. + * A path spec is read from disk. A registry spec asks pnpm's own configuration for the version the + * range selects and its peer requirements. A git or tarball spec needs the fetch itself, so the + * check after installation is what judges it. + * @param dir Profile directory the lookup runs in. + * @param spec Anchored install spec. + * @param options Pnpm executable, prefix arguments, the caller's bound and signal. + * @param environment Environment of the caller's pnpm invocations. + * @param flags Flags of the run itself, so the lookup asks the registry that run will use. + * @returns The package manifest, or undefined when reading it would need the installation itself. + */ +async function namedSpecManifest( + dir: string, + spec: string, + options: PackageOperationOptions, + environment: Readonly>, + flags: readonly string[], +): Promise { + const parsed = parseInstallSpec(spec); + if (parsed.kind === "path") { + const filename = join(parsed.path, "package.json"); + return existsSync(filename) + ? (JSON.parse(readFileSync(filename, "utf8")) as object) + : undefined; + } + if (parsed.kind !== "registry") return undefined; + const viewed = await execa( + options.command ?? "pnpm", + [ + ...(options.args ?? []), + "view", + parsed.spec, + "name", + "version", + "peerDependencies", + "--json", + ...flags, + "--config.fetch-retries=0", + ], + { + cwd: dir, + env: environment, + extendEnv: false, + reject: false, + stdin: "ignore", + ...(options.signal === undefined ? {} : { cancelSignal: options.signal }), + timeout: options.lookupTimeoutMs ?? LOOKUP_TIMEOUT_MS, + }, + ); + if (viewed.exitCode !== 0) return undefined; + const value: unknown = JSON.parse(viewed.stdout); + return (Array.isArray(value) ? value.at(-1) : value) as object; +} + +/** Missing installed packages are repairable; their absence is part of the before/after comparison. */ +function optionalFile(path: string): string | undefined { + try { + return readFileSync(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } +} + +/** Where an operation records the pnpm run it started, for a successor when the operation's own process ends first. */ +function runRecordPath(dir: string): string { + return join(dir, ".plugin-manager", "run.json"); +} + +/** Record a started run; a successor that takes over the profile lock from an exited process waits for it. */ +async function recordRun(dir: string, tree: RunTree): Promise { + if (tree.pid === undefined) return; + await writeFileAtomic(runRecordPath(dir), `${JSON.stringify(tree)}\n`, { + mode: 0o600, + dirMode: 0o700, + }); +} + +/** + * Why this operation must not run: a record left by an operation whose process + * ended mid-run names a run that is still writing the profile. The profile + * lock is taken over once its holder exits, but its pnpm tree can outlive it. + * A recorded run that stopped, within a bounded wait, has its record removed. + * @param dir Profile directory. + * @returns The diagnostic, or undefined when no recorded run is active. + */ +async function activeRecordedRun(dir: string): Promise { + const path = runRecordPath(dir); + const text = optionalFile(path); + if (text === undefined) return undefined; + let tree: RunTree | undefined; + try { + const value: unknown = JSON.parse(text); + const { pid, grouped } = (typeof value === "object" && value !== null ? value : {}) as { + pid?: unknown; + grouped?: unknown; + }; + if (Number.isSafeInteger(pid) && (pid as number) > 0 && typeof grouped === "boolean") + tree = { pid: pid as number, grouped }; + } catch (error) { + // Records are replaced atomically, so an unparsable one was written by something else and is reported below. + void error; + } + if (tree === undefined) { + return `dsh: ${path} does not name a package run; delete it once no earlier package operation is still running in this profile\n`; + } + await awaitTreeGone(tree); + if (treeAlive(tree)) { + return ( + `dsh: process ${String(tree.pid)}, started by an earlier package operation whose own process ended, is still running in this profile; ` + + `wait for it or stop it, then retry. If process ${String(tree.pid)} is not that package run, delete ${path}.\n` + ); + } + await rm(path, { force: true }); + return undefined; +} + +/** pnpm can install plugins through any direct-dependency field. */ +function directDependencies(manifest: ProfileManifest): Record { + const extra = manifest as ProfileManifest & { + devDependencies?: Record; + optionalDependencies?: Record; + }; + return { ...extra.devDependencies, ...manifest.dependencies, ...extra.optionalDependencies }; +} + +/** Inspect only plugin rows contributed by the changed bundle, not its dependency closure. */ +function bundleComponentManifests( + manifest: ProfileManifest, + dir: string, + anchor: string, +): ProfileManifest[] { + const bundle = manifest.dsh?.bundle; + if (bundle === undefined) return []; + const patches = bundlePatchPaths(dir, bundle).flatMap((file) => loadOverlayPatches("dsh", file)); + const names = new Set(); + const visit = (rows: EntryOptions[]) => { + for (const row of rows) { + if (row.group && Array.isArray(row.config)) visit(row.config as EntryOptions[]); + if ( + typeof row.name !== "string" || + row.name.startsWith(".") || + row.name.startsWith("/") || + row.name.includes(":") + ) + continue; + const parts = row.name.split("/"); + names.add(parts.slice(0, row.name.startsWith("@") ? 2 : 1).join("/")); + } + }; + visit(composeEntries([patches.filter((patch) => patch.insert !== undefined)])); + return [...names].flatMap((name) => { + let packageDir: string; + try { + packageDir = resolveBundleDir("dsh", name, anchor, dir); + } catch (error) { + // Resolution errors for uninstalled or dynamic rows remain subject to the startup loader's checks. + void error; + return []; + } + return [readProfileManifest("dsh", packageDir)]; + }); +} + +/** Execute pnpm inside a profile whose caller already holds the profile write lock. + * Newly installed or updated direct dependencies are checked even when activation is disabled; + * an untouched dependency never blocks an unrelated operation and stays denied at startup. + * Compatibility denial restores the profile manifest and lockfile, but leaves downloaded modules on disk. + * @param context Launcher-owned profile and resolution locations. + * @param args Pnpm arguments, before relative path anchoring. + * @param options Output, activation and cancellation policy. + * @returns Exit status, whether the silence bound stopped the run, and the diagnostic path. + * A compatibility denial returns exit code 1. Service output is bounded; CLI output uses inherited descriptors. + */ +export async function runProfilePnpm( + context: PackageOperationContext, + args: readonly string[], + options: PackageOperationOptions, +): Promise { + const dir = context.dir ?? resolveProfileDir(context.profile, context.home); + // Before any profile file is read: a recorded run that is still active may be rewriting them. + const active = await activeRecordedRun(dir); + const logRoot = join(dir, ".plugin-manager", "logs"); + await mkdir(logRoot, { recursive: true, mode: 0o700 }); + const logDir = await mkdtemp(join(logRoot, "operation-")); + const logPath = join(logDir, "pnpm.log"); + const log = await open(logPath, "wx", 0o600); + if (active !== undefined) { + await log.write(active); + await log.close(); + options.onOutput?.(active, "stderr"); + const bytes = Buffer.from(active); + return { + exitCode: 1, + output: bytes.subarray(Math.max(0, bytes.length - options.outputBytes)).toString("utf8"), + truncated: bytes.length > options.outputBytes, + logPath, + }; + } + const before = readProfileManifest("dsh", dir); + const savedFiles = ["package.json", "pnpm-lock.yaml"].map((name) => ({ + path: join(dir, name), + text: optionalFile(join(dir, name)), + })); + const beforeDependencies = directDependencies(before); + const installedBefore = new Map( + Object.keys(beforeDependencies).map((name) => [ + name, + optionalFile(join(dir, "node_modules", name, "package.json")), + ]), + ); + let output = Buffer.alloc(0); + let truncated = false; + const append = (bytes: Buffer): void => { + output = Buffer.concat([output, bytes]); + if (output.length > options.outputBytes) { + truncated = true; + output = output.subarray(output.length - options.outputBytes); + } + }; + const environment = { + ...(options.execution === "cli" ? process.env : scrubbedParentEnv()), + ...options.env, + }; + const restore = async (): Promise => { + for (const file of savedFiles) { + if (file.text === undefined) await rm(file.path, { force: true }); + else await writeFileAtomic(file.path, file.text, { mode: 0o600 }); + } + }; + /** Packages a compatibility check refused; callers render them for their own surface. */ + const incompatible: IncompatiblePlugin[] = []; + const rejected = async ( + warnings: readonly string[], + restoration: string, + ): Promise => { + const diagnostic = `\ndsh: installation rejected: ${warnings.join("\n")}\ndsh: ${restoration}.\n`; + await log.write(diagnostic); + options.onOutput?.(diagnostic, "stderr"); + append(Buffer.from(diagnostic)); + await log.close(); + return { exitCode: 1, output: output.toString("utf8"), truncated, logPath, incompatible }; + }; + // An install command names the packages it adds, so their manifests are read and checked before + // pnpm runs: an incompatible version is never installed, and the one already in use keeps working. + const preflight: string[] = []; + const exemptions = readProfileVersionExemptions(dir); + // The run's own registry flags, so the lookup asks the registry the installation will use. + const registryFlags = args.filter((argument) => argument.startsWith("--registry=")); + for (const raw of namedSpecs(args)) { + // A spec whose manifest cannot be read or validated is left to the run itself and to the check + // after installation, which reports what it could not validate. + try { + const manifest = await namedSpecManifest( + dir, + anchorPathSpec(raw, context.cwd), + options, + environment, + registryFlags, + ); + if (manifest === undefined) continue; + const issue = evaluatePluginCompatibility(manifest, exemptions); + if (issue !== undefined && !issue.exempted) { + preflight.push(pluginCompatibilityWarning(issue)); + incompatible.push(incompatiblePlugin(issue)); + } + } catch (error) { + void error; + continue; + } + } + if (preflight.length > 0) return rejected(preflight, "nothing was installed"); + const cancellation = new AbortController(); + // A service run captures output, so execa terminates the tree it leads when the + // run is killed: a lifecycle script outlives the pnpm process that started it. + // The CLI keeps the caller's process group, so an interrupt still reaches it. + const grouped = leadsOwnGroup(options.execution); + const child = execa( + options.command ?? "pnpm", + [...(options.args ?? []), ...args.map((arg) => anchorPathSpec(arg, context.cwd))], + { + cwd: dir, + env: environment, + extendEnv: false, + reject: false, + stdout: options.execution === "cli" ? "inherit" : "pipe", + stderr: options.execution === "cli" ? "inherit" : "pipe", + killDescendants: options.execution === "service", + buffer: false, + stdin: options.execution === "cli" ? "inherit" : "ignore", + cancelSignal: + options.signal === undefined + ? cancellation.signal + : AbortSignal.any([cancellation.signal, options.signal]), + }, + ); + // Listened for before the record is written, so an exit during that write is not missed. + const exited = once(child.nodeChildProcess, "exit").catch(() => undefined); + let writes = Promise.resolve(); + /** `settled` records that the process outcome is known; `stalled` that the silence bound stopped the run. */ + const control = { settled: false, stalled: false }; + /** Set once this call cuts the reading short itself, so the close it causes is not read as a run failure. */ + let cut = false; + /** The first failure a reading hit before that cut, which the run still reports. */ + let failure: Error | undefined; + let idleTimer: NodeJS.Timeout | undefined; + /** The silence bound: a captured run that stops printing without exiting is terminated, never awaited. */ + const armIdle = (): void => { + if (control.settled || options.idleTimeoutMs === undefined) return; + clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + control.stalled = true; + // execa's kill reaches the whole tree of a service run, and escalates on its own. + child.kill(); + }, options.idleTimeoutMs); + }; + const collect = async (stream: AsyncIterable, kind: "stdout" | "stderr") => { + try { + for await (const chunk of stream) { + armIdle(); + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + writes = writes.then(async () => { + await log.write(bytes); + }); + await writes; + options.onOutput?.(bytes.toString("utf8"), kind); + append(bytes); + } + } catch (error) { + // A reading this call cut short is not a failure the run hit, and the run has + // already exited, so there is nothing left for the cancellation to stop. + if (!cut) { + failure ??= error instanceof Error ? error : new Error(String(error)); + cancellation.abort(); + } + throw error; + } + }; + const collectors = [ + ...(child.stdout === null ? [] : [collect(child.stdout, "stdout")]), + ...(child.stderr === null ? [] : [collect(child.stderr, "stderr")]), + ]; + // The drain decides whether a failure surfaces, so each reading is claimed now: + // an unclaimed rejection would be reported as unhandled while the pipes drain. + for (const collector of collectors) + void collector.catch(() => { + /* reported through `failure` and the settled readings */ + }); + // Inherited descriptors hand pnpm the terminal, so there is no captured output + // for a silence bound to observe: it would fire on a healthy run. + if (collectors.length > 0) armIdle(); + let exitCode: number; + try { + await recordRun(dir, { pid: child.pid, grouped }); + // execa resolves its promise only once the piped stdio has ended, so the + // process's own exit — the run's completion — is read from the raw child. A + // spawn failure settles without one. + const settled = Promise.allSettled([child]); + await Promise.race([exited, settled]); + control.settled = true; + clearTimeout(idleTimer); + // A stalled run stops its whole tree first, so the caller's rollback and lock + // release happen only after the scripts it started stopped writing. + if (control.stalled) await awaitTreeGone({ pid: child.pid, grouped }); + // A descendant that inherited the pipes can hold them open past the process; + // the tail drains under a bound instead of being awaited forever. + const drained = await drainWithin(collectors, DRAIN_AFTER_EXIT_MS); + if (drained) { + for (const stream of await Promise.allSettled(collectors)) + if (stream.status === "rejected") throw stream.reason; + } else { + // Cutting the tail short is this call's own end, not a failure the run hit; + // a failure from before the cut still surfaces, and the cut leaves a notice + // in the log because a classification may read an incomplete tail. + cut = true; + child.stdout?.destroy(); + child.stderr?.destroy(); + const notice = "dsh: pnpm output was cut short after its process exited\n"; + await log.write(notice); + // A failure from before the cut is the run's own and replaces the notice a + // caller would otherwise read; a rejection the cut itself causes is its end. + if (failure !== undefined) throw failure; + options.onOutput?.(notice, "stderr"); + append(Buffer.from(notice)); + } + const [completion] = await settled; + if (completion.status === "rejected") throw completion.reason; + const result = completion.value; + exitCode = result.exitCode ?? (result.code === "ENOENT" ? 127 : 1); + if (control.stalled) { + const notice = `dsh: pnpm printed nothing for ${String(options.idleTimeoutMs)}ms and was terminated\n`; + await log.write(notice); + options.onOutput?.(notice, "stderr"); + append(Buffer.from(notice)); + } + if (result.failed && output.length === 0) { + const diagnostic = result.shortMessage ?? "pnpm failed"; + await log.write(diagnostic); + truncated = Buffer.byteLength(diagnostic) > options.outputBytes; + output = Buffer.from(diagnostic).subarray(0, options.outputBytes); + } + // A terminated run's exit status says nothing about what it wrote, so it never reconciles the selection. + if (exitCode === 0 && !control.stalled) { + const after = readProfileManifest("dsh", dir); + const warnings: string[] = []; + for (const [name, spec] of Object.entries(directDependencies(after))) { + const packageDir = join(dir, "node_modules", name); + const installed = optionalFile(join(packageDir, "package.json")); + if (installed === undefined) continue; + // A dependency this run did not touch never blocks an unrelated operation; profile startup denies it. + const untouched = + beforeDependencies[name] === spec && installedBefore.get(name) === installed; + const found: string[] = []; + const issues: IncompatiblePlugin[] = []; + try { + const manifest = readProfileManifest("dsh", packageDir); + for (const candidate of [ + manifest, + ...bundleComponentManifests(manifest, packageDir, context.installAnchor), + ]) { + const issue = evaluatePluginCompatibility(candidate, readProfileVersionExemptions(dir)); + if (issue !== undefined && !issue.exempted) { + found.push(pluginCompatibilityWarning(issue)); + issues.push(incompatiblePlugin(issue)); + } + } + } catch (error) { + found.push(`Cannot validate installed package ${name}: ${String(error)}`); + } + if (found.length === 0) continue; + if (!untouched) { + warnings.push(...found); + incompatible.push(...issues); + } else { + const notice = `\ndsh: warning: ${found.join("\n")}\ndsh: it stays installed but profile startup denies it until you grant an exemption for those exact versions.\n`; + await log.write(notice); + options.onOutput?.(notice, "stderr"); + } + } + if (warnings.length > 0) { + // A bundle component's peers need installed contents, so this rejection lands after pnpm + // replaced the tree: restore the files, then reinstall the restored lockfile so the version + // that worked before this run keeps loading. A profile that had no lockfile is reinstalled + // from its restored manifest without creating one, which removes what this run added. + await restore(); + const hadLockfile = savedFiles.some( + (file) => file.path.endsWith("pnpm-lock.yaml") && file.text !== undefined, + ); + const repair = ["install", hadLockfile ? "--frozen-lockfile" : "--config.lockfile=false"]; + const repairing = execa(options.command ?? "pnpm", [...(options.args ?? []), ...repair], { + cwd: dir, + env: environment, + extendEnv: false, + reject: false, + stdin: "ignore", + ...(options.idleTimeoutMs === undefined ? {} : { timeout: options.idleTimeoutMs }), + }); + await recordRun(dir, { pid: repairing.pid, grouped: false }); + const repaired = await repairing; + exitCode = 1; + const restoration = + repaired.exitCode === 0 + ? "restored package.json, pnpm-lock.yaml, and node_modules" + : "restored package.json and pnpm-lock.yaml, but node_modules could not be reinstalled; run 'dsh plugin install'"; + const diagnostic = `\ndsh: installation rejected: ${warnings.join("\n")}\ndsh: ${restoration}.\n`; + await log.write(diagnostic); + options.onOutput?.(diagnostic, "stderr"); + append(Buffer.from(diagnostic)); + } else if (options.activateNewBundles !== false) { + await reconcile(before, dir, context.installAnchor, options); + } + } + } finally { + control.settled = true; + clearTimeout(idleTimer); + // This process saw the run end, so no successor has to wait for it. + await rm(runRecordPath(dir), { force: true }); + await log.close(); + } + return { + exitCode, + output: output.toString("utf8"), + truncated, + logPath, + ...(control.stalled ? { timedOut: true } : {}), + ...(incompatible.length > 0 ? { incompatible } : {}), + }; +} + +/** Initialize and run the dsh plugin command with the same write lock as the service. + * @param context Launcher-owned locations. + * @param args Pnpm arguments. + * @param options Output and cancellation policy. + * @returns Completed package-manager result. + */ +export async function runPluginCommand( + context: PackageOperationContext, + args: readonly string[], + options: PackageOperationOptions, +): Promise { + const dir = context.dir ?? resolveProfileDir(context.profile, context.home); + await mkdir(dir, { recursive: true }); + return withFileLock( + join(dir, "package.json"), + async () => { + if (!existsSync(join(dir, "package.json"))) { + const template = PROFILE_TEMPLATES[context.profile]; + initProfile(dir, template?.bundles ?? DEFAULT_PROFILE_BUNDLES); + options.onOutput?.(`dsh: initialized profile ${context.profile} at ${dir}\n`, "stderr"); + } + return runProfilePnpm(context, args, options); + }, + options.lockWaitMs === undefined ? undefined : { waitMs: options.lockWaitMs }, + ); +} + +/** What one registry lookup answered. */ +export interface PackageViewResult { + /** pnpm's exit code, null when it ended without one or never started. */ + exitCode: number | null; + stdout: string; + stderr: string; + /** The lookup ran past its bound and was killed. */ + timedOut: boolean; + /** The failure of starting pnpm at all, when that is what happened. */ + cause?: unknown; +} + +/** Bounds of one registry lookup. */ +export interface PackageViewOptions { + /** The pnpm executable name or path. Defaults to `pnpm`. */ + command?: string; + /** Prefix arguments for an application-owned executable. */ + args?: readonly string[]; + /** Application runtime environment, applied only to this package operation. */ + env?: Readonly>; + /** Ends the lookup early; the caller's signal, when it has one. */ + signal?: AbortSignal; + /** Bound on the lookup, in milliseconds. */ + timeoutMs: number; + /** The registry asked; null asks the one pnpm's own configuration names. */ + registry?: Registry; +} + +/** + * Read the registry pnpm's own configuration names in the profile: its `.npmrc` chain and workspace settings, + * as `pnpm config get registry` resolves them. + * @param dir Profile directory. + * @param options The pnpm executable and the time bound. + * @returns The registry URL as pnpm printed it, or null when pnpm did not answer with one. + */ +export async function readProfileRegistry( + dir: string, + options: { + command?: string; + args?: readonly string[]; + env?: Readonly>; + timeoutMs: number; + }, +): Promise { + const result = await execa( + options.command ?? "pnpm", + [...(options.args ?? []), "config", "get", "registry"], + { + cwd: dir, + env: { ...scrubbedParentEnv(), ...options.env }, + extendEnv: false, + reject: false, + stdin: "ignore", + timeout: options.timeoutMs, + }, + ); + // The registry is the last line: pnpm may print a notice before it. + const answer = + result.exitCode === 0 + ? result.stdout + .trim() + .replace(/^[\s\S]*\n/, "") + .trim() + : ""; + return /^https?:\/\/\S+$/.test(answer) ? answer : null; +} + +/** + * The argument that sends one pnpm command to a registry. + * @param registry - the registry, or null for the one pnpm's own configuration names. + * @returns `--registry=` for a URL; nothing for null. + */ +export function registryArguments(registry: Registry): string[] { + return registry === null ? [] : [`--registry=${registry}`]; +} + +/** + * Ask the registry what a spec names through `pnpm view`, run in the profile + * directory so the registry, proxy, and authentication settings of an install + * apply. The lookup makes one request without pnpm's own retries: a registry + * that does not answer is reported within `timeoutMs`, and the registries + * configured after it are the retry. + * @param dir Profile directory. + * @param spec One registry spec: a package name with an optional range. + * @param options The registry, cancellation, and the time bound. + * @returns pnpm's exit, output, and how the lookup ended. + */ +export async function viewProfilePackage( + dir: string, + spec: string, + options: PackageViewOptions, +): Promise { + const result = await execa( + options.command ?? "pnpm", + [ + ...(options.args ?? []), + "view", + spec, + "name", + "version", + "description", + "dsh", + "--json", + ...registryArguments(options.registry ?? null), + "--config.fetch-retries=0", + ], + { + cwd: dir, + env: { ...scrubbedParentEnv(), ...options.env }, + extendEnv: false, + reject: false, + stdin: "ignore", + timeout: options.timeoutMs, + ...(options.signal === undefined ? {} : { cancelSignal: options.signal }), + }, + ); + const cause = + result.exitCode === undefined && !result.timedOut && !result.isCanceled + ? Object.assign(new Error(result.shortMessage), { code: result.code }) + : undefined; + return { + exitCode: result.exitCode ?? null, + stdout: result.stdout, + stderr: result.stderr, + timedOut: result.timedOut, + ...(cause === undefined ? {} : { cause }), + }; +} diff --git a/apps/web-ui/packages/boot/plugin-manager/src/patch.ts b/apps/web-ui/packages/boot/plugin-manager/src/patch.ts new file mode 100644 index 000000000..37d3d04f5 --- /dev/null +++ b/apps/web-ui/packages/boot/plugin-manager/src/patch.ts @@ -0,0 +1,48 @@ +/** Comment-preserving profile plugin enablement edits. */ +import { readFile } from "node:fs/promises"; +import { isMap, isSeq, parseDocument } from "yaml"; +import { loadOptionalPatches } from "@deepseek-ai/dsh-app-boot"; +import { writeFileAtomic } from "@deepseek-ai/dsh-atomic-write"; + +/** Replace the last matching override or append one after existing insertions. + * @param filename Current profile patch file. + * @param id Unique composition entry id. + * @param name Module name used to match name-qualified overrides. + * @param enabled Desired entry enablement. + * @returns Whether the file changed. + */ +export async function writePluginEnabled( + filename: string, + id: string, + name: string, + enabled: boolean, +): Promise { + let text: string; + try { + text = await readFile(filename, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + text = "[]\n"; + } + const document = parseDocument(text, { + customTags: [{ tag: "tag:yaml.org,2002:js", resolve: (value: string) => value }], + }); + const error = document.errors[0]; + if (error !== undefined) throw error; + if (!isSeq(document.contents)) throw new Error("Profile patch must be a YAML sequence"); + loadOptionalPatches("dsh", filename); + const items = document.contents.items; + const target = items.findLast((item, index) => { + if (!isMap(item) || document.getIn([index, "id"]) !== id || item.has("insert")) return false; + const expectedName = document.getIn([index, "name"]); + return !expectedName || expectedName === name; + }); + if (isMap(target)) { + if (document.getIn([items.indexOf(target), "disabled"]) === !enabled) return false; + document.setIn([items.indexOf(target), "disabled"], !enabled); + } else { + document.add({ id, disabled: !enabled }); + } + await writeFileAtomic(filename, String(document), { mode: 0o600 }); + return true; +} diff --git a/apps/web-ui/packages/boot/plugin-manager/src/registry.ts b/apps/web-ui/packages/boot/plugin-manager/src/registry.ts new file mode 100644 index 000000000..6bea8a723 --- /dev/null +++ b/apps/web-ui/packages/boot/plugin-manager/src/registry.ts @@ -0,0 +1,118 @@ +/** + * Which registries one package operation asks, in order, and what a failed + * attempt could not reach. A registry is asked with pnpm's `--registry`; + * `null` is the one pnpm's own configuration names. This module runs in the + * browser too, through the package's `./registry` entry, so the dialog and + * the Host follow one rule. + * @module @deepseek-ai/dsh-plugin-manager/registry + */ + +import type { ParsedInstallSpec } from "./install-spec.ts"; +import type { PluginInstallFailureKind, PluginRegistries, Registry } from "./types.ts"; + +/** npm's own registry: what pnpm names without any configuration, and the one public registry a plan trusts as such. */ +export const OFFICIAL_NPM_REGISTRY = "https://registry.npmjs.org/"; + +/** Public npmmirror URL shared by the fallback configuration and public-registry comparison. */ +export const NPMMIRROR_REGISTRY = "https://registry.npmmirror.com/"; + +/** An http(s) URL, as pnpm's `--registry` takes it. */ +export const REGISTRY_URL = /^https?:\/\/\S+$/; + +/** + * Parse a registry URL into the form pnpm compares registries in: lower-case host, trailing slash. + * @param url - the registry as configured or requested. + * @returns the normalized URL. + * @throws {Error} for anything but an http(s) URL. + */ +export function normalizeRegistry(url: string): string { + let parsed: URL | undefined; + try { + parsed = new URL(url); + } catch { + /* named below: only an http(s) URL is a registry */ + } + if (parsed === undefined || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) { + throw new Error(`a registry must be an http(s) URL: ${url}`); + } + if (!parsed.pathname.endsWith("/")) parsed.pathname += "/"; + return parsed.href; +} + +/** + * The registries one operation asks, first to last. + * + * The configured set is the configured first registry and the fallbacks; a + * requested registry is asked first when it is one of them, alone otherwise, + * so a private registry never falls through to a public one. pnpm's own + * registry (`null`) belongs to the set only when what it names is known to + * be public: npm's own registry or one of the configured fallbacks. While it + * names anything else, or is unknown, it is asked alone, and a public + * registry asked instead never falls back into it. A registry pnpm's own + * configuration already names is asked once. + * @param requested - the caller's registry; undefined defers to the configured first one. + * @param configured - the configured first registry, the fallbacks after it, and what pnpm's own configuration names. + * @returns the registries to ask, in order; never empty. + */ +export function registryPlan( + requested: Registry | undefined, + configured: PluginRegistries, +): Registry[] { + const own = configured.resolved === null ? null : normalizeRegistry(configured.resolved); + const fallbacks = configured.fallbackRegistries.map(normalizeRegistry); + const ownIsPublic = own !== null && (own === OFFICIAL_NPM_REGISTRY || fallbacks.includes(own)); + // What a registry is compared as: pnpm's own registry stands for the URL it names, once that is known. + const keyOf = (registry: Registry): string | null => + registry === null ? own : normalizeRegistry(registry); + const known: Registry[] = []; + const keys: (string | null)[] = []; + for (const registry of [configured.registry, ...configured.fallbackRegistries]) { + if (registry === null && !ownIsPublic) continue; + const key = keyOf(registry); + if (keys.includes(key)) continue; + known.push(registry === null ? null : normalizeRegistry(registry)); + keys.push(key); + } + const first = requested === undefined ? configured.registry : requested; + const firstKey = keyOf(first); + const normalizedFirst = first === null ? null : normalizeRegistry(first); + // A private or unknown registry of pnpm's own, whichever way it was asked for, is asked alone. + if ((first === null || firstKey === own) && !ownIsPublic) return [normalizedFirst]; + if (!keys.includes(firstKey)) return [normalizedFirst]; + return [normalizedFirst, ...known.filter((_registry, index) => keys[index] !== firstKey)]; +} + +/** The failures after which another registry can answer differently: this one was unreachable, or its copy may be stale. */ +const NEXT_REGISTRY_KINDS: ReadonlySet = new Set([ + "network", + "timeout", + "not-found", + "no-matching-version", +]); + +/** A line of pnpm or git output that reports a failure, as opposed to a warning or progress line. */ +const ERROR_LINE = + /ERR_|ERROR|\berror\b|fatal:|Could not resolve|unable to access|ssh:|\bE[A-Z]{4,}\b/; + +/** + * What a failed attempt could not reach or get an answer from. + * @param kind - how the attempt failed. + * @param log - what the attempt printed. + * @param spec - the spec the attempt installed. + * @returns `registry` for a failure another registry can change; `spec-host` when an error line names the host a git + * or tarball spec is fetched from, which no registry stands in for; `other` for a failure neither explains. + */ +export function attributeFailure( + kind: PluginInstallFailureKind, + log: string, + spec: ParsedInstallSpec, +): "registry" | "spec-host" | "other" { + if (!NEXT_REGISTRY_KINDS.has(kind)) return "other"; + const host = + spec.kind === "git" || spec.kind === "tarball" ? spec.host?.toLowerCase() : undefined; + if (host === undefined) return "registry"; + const named = log + .split("\n") + .some((line) => ERROR_LINE.test(line) && line.toLowerCase().includes(host)); + return named ? "spec-host" : "registry"; +} diff --git a/apps/web-ui/packages/boot/plugin-manager/src/run-tree.ts b/apps/web-ui/packages/boot/plugin-manager/src/run-tree.ts new file mode 100644 index 000000000..74bd40aa4 --- /dev/null +++ b/apps/web-ui/packages/boot/plugin-manager/src/run-tree.ts @@ -0,0 +1,88 @@ +/** Waiting for one package run's process tree to disappear before its caller touches the profile. */ + +/** How long a terminated tree may take to disappear before the caller stops waiting. */ +const TREE_WAIT_MS = 5_000; + +/** Poll cadence while waiting for a terminated tree to disappear. */ +const TREE_POLL_MS = 15; + +/** The process (or process group) one run occupies. */ +export interface RunTree { + /** The run's process id, when its spawn published one. */ + pid: number | undefined; + /** Whether the run leads its own process group, which is what a POSIX group probe addresses. */ + grouped: boolean; +} + +/** Injectable process operations, so every platform's probing is testable on any host. */ +export interface RunTreeInternals { + /** Host platform override for target decisions. */ + platform?: NodeJS.Platform; + /** Liveness probe override, defaulting to `process.kill(target, 0)`. */ + alive?: (target: number) => boolean; + /** Wait bound override, defaulting to the tree wait constant. */ + waitMs?: number; +} + +/** + * Whether one run leads its own process group, which is the target a POSIX + * liveness probe addresses for the whole tree. A run that captures output is + * spawned as its own group leader, because its tree is terminated as a unit; a + * run that inherits the caller's descriptors keeps the caller's group, so an + * interrupt still reaches it. + * @param execution Whether the run captures output or inherits the caller's descriptors. + * @param platform Host platform deciding how a tree is addressed. + * @returns True when the run leads its own process group. + */ +export function leadsOwnGroup( + execution: "cli" | "service", + platform: NodeJS.Platform = process.platform, +): boolean { + return execution === "service" && platform !== "win32"; +} + +/** The target a tree probe addresses: a POSIX group when the run leads one, else the process itself. */ +function targetOf(pid: number, grouped: boolean, platform: NodeJS.Platform): number { + return platform !== "win32" && grouped ? -pid : pid; +} + +/** + * Whether any member of a run's tree is still alive. + * @param tree The run's process id and whether it leads its own group. + * @param internals Injectable process operations. + * @returns True while the probe finds the process or a group member. + */ +export function treeAlive(tree: RunTree, internals: RunTreeInternals = {}): boolean { + const pid = tree.pid; + if (pid === undefined) return false; + const platform = internals.platform ?? process.platform; + const alive = + internals.alive ?? + ((target: number) => { + process.kill(target, 0); + return true; + }); + try { + return alive(targetOf(pid, tree.grouped, platform)); + } catch { + return false; + } +} + +/** + * Wait until a terminated run's tree is gone, so the caller restores and + * unlocks the profile only after the scripts it started stopped writing. + * @param tree The run's process id and whether it leads its own group. + * @param internals Injectable process operations. + * @returns Fulfillment once no member remains, or the wait bound elapsed. + */ +export async function awaitTreeGone( + tree: RunTree, + internals: RunTreeInternals = {}, +): Promise { + const deadline = Date.now() + (internals.waitMs ?? TREE_WAIT_MS); + while (treeAlive(tree, internals)) { + if (Date.now() >= deadline) return; + await new Promise((resolve) => setTimeout(resolve, TREE_POLL_MS)); + } +} diff --git a/apps/web-ui/packages/boot/plugin-manager/src/tools.ts b/apps/web-ui/packages/boot/plugin-manager/src/tools.ts new file mode 100644 index 000000000..f03976ab3 --- /dev/null +++ b/apps/web-ui/packages/boot/plugin-manager/src/tools.ts @@ -0,0 +1,192 @@ +/** Agent-facing current-profile management using the same service as Web controls. */ +import { assertNever } from "@deepseek-ai/dsh-util-values"; +import type { Context } from "@deepseek-ai/cordis"; +import type {} from "./index.ts"; +import type {} from "@deepseek-ai/dsh-sandbox-policy"; +import type {} from "@deepseek-ai/dsh-user-approval"; +import { approveEscalation } from "@deepseek-ai/dsh-sandbox"; +import type { PluginEntryId } from "./types.ts"; +import { defineTool } from "@deepseek-ai/dsh-tools"; +import { getDshRuntimeVersion } from "@deepseek-ai/dsh-app-boot"; + +/** Required services for the management tool. */ +export const inject = ["tools", "pluginManager", "sandboxPolicy"]; + +/** Register one management tool for discovery and the four persistent actions. + * @param ctx Agent-scoped tool registration context. + */ +export function apply(ctx: Context): void { + ctx.tools.register( + defineTool({ + name: "plugin_manager", + description: + "List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately; startup profiles require restart. Incompatible DSH peer dependencies block installation and activation. Version exemptions risk crashes and data loss: warn the user and obtain explicit permission for the exact plugin and runtime versions before granting one.", + parameters: { + action: { + type: "string", + required: true, + enum: [ + "list_plugins", + "list_bundles", + "set_plugin", + "set_bundle", + "install_bundle", + "remove_bundle", + "list_version_exemptions", + "set_version_exemption", + ], + description: "Management operation.", + }, + target: { + type: "string", + description: + "Plugin entry id, bundle package name, or installation spec, according to action.", + }, + enabled: { + type: "boolean", + description: + "Required for set operations; defaults to true for installation. For set_version_exemption, true grants and false revokes.", + }, + runtimeVersion: { + type: "string", + description: + "For set_version_exemption: exact DSH version from list_version_exemptions. Target must be the manifest package-name@version, not an alias or version range.", + }, + acceptRisk: { + type: "boolean", + description: + "For granting an exemption: true only after warning the user about possible crashes and data loss and receiving explicit permission for this exact plugin/runtime pair. General installation permission is not enough.", + }, + approvedBuilds: { + type: "array", + items: { type: "string" }, + description: + "For install_bundle: pass names from pendingBuilds only after the user explicitly approves running their install scripts in the conversation. This grants persistent permission for this profile.", + }, + registry: { + type: "string", + description: + "For install_bundle: the npm registry URL asked first, when the user names one; otherwise the configured registry is asked, and its configured fallbacks while a registry is unreachable.", + }, + offset: { type: "number", description: "Zero-based list offset; defaults to 0." }, + limit: { type: "number", description: "List page size, from 1 to 100; defaults to 25." }, + }, + output: { + schema: { type: "string" }, + render: (_args, value) => [{ type: "text", text: value }], + }, + async execute(args, exec) { + const policy = ctx.sandboxPolicy.resolve( + exec.agent === undefined ? {} : { session: exec.agent.session }, + ); + await approveEscalation( + { + requestedMode: "danger-full-access", + effectiveMode: policy.mode, + subject: "plugin management operation", + justification: `plugin_manager ${JSON.stringify(args)}. Profile changes persist across sessions; installed Host code runs outside the workspace sandbox.`, + }, + { + approver: ctx.get("approval"), + agent: exec.agent, + callId: exec.callId, + toolName: "plugin_manager", + signal: exec.signal, + }, + ); + exec.signal.throwIfAborted(); + const manager = ctx.pluginManager; + switch (args.action) { + case "list_version_exemptions": + return JSON.stringify({ + runtimeVersion: getDshRuntimeVersion(), + ...manager.listVersionExemptions(), + }); + case "set_version_exemption": + if ( + args.target === undefined || + args.runtimeVersion === undefined || + args.enabled === undefined + ) { + throw new Error( + "target package-name@version, runtimeVersion, and enabled are required", + ); + } + return JSON.stringify( + await manager.setVersionExemption( + args.target, + args.runtimeVersion, + args.enabled, + args.acceptRisk, + ), + ); + case "list_plugins": + case "list_bundles": { + const offset = args.offset ?? 0; + const limit = args.limit ?? 25; + if ( + !Number.isInteger(offset) || + offset < 0 || + !Number.isInteger(limit) || + limit < 1 || + limit > 100 + ) { + throw new Error( + "offset must be a non-negative integer and limit must be an integer from 1 to 100", + ); + } + const rows = + args.action === "list_plugins" + ? await manager.listPlugins() + : await manager.listBundles(); + const entries = rows + .slice(offset, offset + limit) + .map(({ meta: _meta, ...row }) => + "rows" in row + ? { ...row, rows: row.rows.map(({ meta: _rowMeta, ...declared }) => declared) } + : row, + ); + return JSON.stringify({ + entries, + total: rows.length, + nextOffset: offset + entries.length < rows.length ? offset + entries.length : null, + }); + } + case "set_plugin": + case "set_bundle": { + if (args.target === undefined || args.enabled === undefined) + throw new Error("target and enabled are required"); + return JSON.stringify( + await (args.action === "set_plugin" + ? manager.setPluginEnabled(args.target as PluginEntryId, args.enabled) + : manager.setBundleEnabled(args.target, args.enabled)), + ); + } + case "install_bundle": + if (args.target === undefined) throw new Error("target package spec is required"); + return JSON.stringify( + await manager.installBundle(args.target, { + ...(args.enabled === undefined ? {} : { enabled: args.enabled }), + ...(args.approvedBuilds === undefined + ? {} + : { approvedBuilds: args.approvedBuilds }), + ...(args.registry === undefined ? {} : { registry: args.registry }), + }), + ); + case "remove_bundle": + if (args.target === undefined) throw new Error("target bundle name is required"); + return JSON.stringify(await manager.removeBundle(args.target)); + /* v8 ignore next -- tool JSON validation rejects actions outside the declared enum */ + default: + return assertNever(args.action); + } + }, + presentCall: (args) => ({ + card: "generic", + title: "Manage profile plugins", + kind: args.action.startsWith("list_") ? "read" : "other", + rawInput: args, + }), + }), + ); +} diff --git a/apps/web-ui/packages/boot/plugin-manager/src/types.ts b/apps/web-ui/packages/boot/plugin-manager/src/types.ts new file mode 100644 index 000000000..8513a9349 --- /dev/null +++ b/apps/web-ui/packages/boot/plugin-manager/src/types.ts @@ -0,0 +1,281 @@ +/** Public plugin management records shared with clients. */ +import type { Branded } from "@deepseek-ai/dsh-brand"; +import type { PluginLocalizedMeta } from "@deepseek-ai/dsh-package-manifest"; +import type { PluginInventoryEntry } from "@deepseek-ai/dsh-host-plugin-inventory/types"; +export type { PluginEntryId } from "@deepseek-ai/dsh-host-plugin-inventory/types"; +import type { PluginEntryId } from "@deepseek-ai/dsh-host-plugin-inventory/types"; + +/** Reasons a profile control cannot modify its target. */ +export type ReadOnlyReason = "management-required" | "unaddressable"; + +/** A package whose declared DSH peers reject the running DSH version, without an exemption for the exact pair. */ +export interface IncompatiblePlugin { + name: string; + version: string; + runtimeVersion: string; + /** Only the DSH peer ranges the running version does not satisfy. */ + peers: Record; +} + +/** Localizable management failure and optional external diagnostic. */ +export interface ManagementError { + code: + | ReadOnlyReason + | "unknown-plugin" + | "invalid-spec" + | "ambiguous-install" + | "not-bundle" + | "not-removable" + | "stop-profile" + | "bundle-in-use" + | "stale-approval" + | "incompatible-version" + | "operation-error"; + diagnostic?: string; + /** Present with `incompatible-version`: the packages the running DSH version rejects. */ + incompatible?: IncompatiblePlugin[]; +} + +/** One running-profile entry and its persistent control availability. */ +export type PluginInfo = PluginInventoryEntry & + ( + | { patchId: string; readOnlyReason?: never } + | { patchId?: never; readOnlyReason: ReadOnlyReason } + ); + +/** One row a bundle's patch declares, with its live entry while the bundle contributes it. */ +export interface BundleRowInfo { + /** The row id as the patch declares it. */ + rowId: string; + /** The module the row names. */ + moduleName: string; + /** Local package display metadata, including rows whose bundle is disabled. */ + meta?: PluginLocalizedMeta; + /** The Loader entry carrying this row, when exactly one live entry has its id. */ + entryId?: PluginEntryId; +} + +/** One installed or installation-provided bundle. */ +export interface BundleInfo { + name: string; + version?: string; + /** Local display text with available translations or literal fallbacks, or a metadata diagnostic. */ + meta?: PluginLocalizedMeta; + /** Untranslated `description` of this bundle's package manifest. */ + description?: string; + /** Selected in the profile manifest; a load error means its layer was skipped. */ + enabled: boolean; + /** Whether the profile's own dependencies hold the package; false for a bundle the dsh installation supplies. */ + installed: boolean; + /** + * Present for a profile dependency the installation does not also supply: the spec `pnpm add` accepts, with local + * paths made absolute and the user information of an http(s) URL removed. + */ + source?: string; + /** + * Whether the installation ships the bundle for the person to switch on: named by the launcher's `OPTIONAL_BUNDLES`, + * held by the installation's dependencies, selected by no shipped template, and never removable. + */ + optional: boolean; + removable: boolean; + readOnlyReason?: ReadOnlyReason; + error?: ManagementError; + /** The rows the bundle's patch inserts, in declaration order; empty when the patch cannot be read. */ + rows: BundleRowInfo[]; + /** Ids of rows the bundle's patch changes without declaring them: the built-in rows it configures or disables. */ + overrides: string[]; +} + +/** A registry to install from: an http(s) URL, or null for the one pnpm's own configuration names. */ +export type Registry = string | null; + +/** The registries the manager asks: the configured first one, its fallbacks in order, and what pnpm's own configuration names. */ +export interface PluginRegistries { + readonly registry: Registry; + readonly fallbackRegistries: readonly string[]; + /** The URL pnpm's own configuration names in the profile, read from pnpm; null when it could not be read. */ + readonly resolved: string | null; +} + +/** How a package operation failed, read off how it ended and what it printed. */ +export type PluginInstallFailureKind = + | "pnpm-missing" + | "timeout" + | "not-found" + | "no-matching-version" + | "network" + | "disk-full" + | "permission" + | "build-blocked" + | "integrity" + | "unknown"; + +/** Package operation completion, including Git checks and a retrieval path for unabridged diagnostics. */ +export interface PackageResult { + exitCode: number; + output: string; + truncated: boolean; + logPath: string; + /** Present when the run failed: what kind of failure its exit and output describe. */ + kind?: PluginInstallFailureKind; + /** The manager terminated the run after it printed nothing for its silence bound; `exitCode` still reports how it ended. */ + timedOut?: boolean; + /** Present when a compatibility check refused the run: the packages the running DSH version rejects. */ + incompatible?: IncompatiblePlugin[]; +} + +/** Persisted change and independently observed application outcome. */ +export interface ChangeResult { + changed: boolean; + /** `cancelled` is an installation the caller stopped, its files restored. */ + application: "applied" | "restart-required" | "overridden" | "failed" | "cancelled"; + /** Last attempted step; successful installation can proceed to enablement. */ + stage: "install" | "enable" | "remove"; + target: string; + enabled?: boolean; + error?: ManagementError; + /** Pre-existing inactive entries the operation left as they were. */ + warnings?: string[]; + packageResult?: PackageResult; + /** The bundle an installation added, once pnpm and the bundle check accepted it. */ + bundle?: string; + /** The installed bundle's manifest version, when declared; pnpm's `minimumReleaseAge` can make it older than the newest release. */ + version?: string; + /** Exact package names awaiting explicit script approval in the profile's pnpm settings, read after a failed run. */ + pendingBuilds?: string[]; + /** Package script permissions saved before this installation attempt. */ + approvedBuilds?: string[]; + /** The registries the installation asked, in order; `packageResult` is the last one's run. */ + registries?: Registry[]; + /** + * What the last failed run could not reach or get an answer from: the registry it asked, or the host a git or + * tarball spec is fetched from, which no registry stands in for; absent for a failure neither explains. + */ + failedAt?: "registry" | "spec-host"; +} + +/** Identifies one installation from its start to its settlement, including its log chunks and cancellation. */ +export type PluginInstallRequestId = Branded<"PluginInstallRequestId">; + +/** Bundle installation defaults to activation; callers that offer cancellation supply their request id. */ +export interface InstallBundleOptions { + enabled?: boolean; + requestId?: PluginInstallRequestId; + /** Explicitly allow these pending packages' scripts for this profile, then install; a name no longer pending refuses the call. */ + approvedBuilds?: string[]; + /** The registry asked first; absent, the configured one. The configured fallbacks follow while a registry is unreachable or stale. */ + registry?: Registry; +} + +/** Where an inspection asks. */ +export interface InspectOptions { + /** The registry asked first; absent, the configured one. */ + readonly registry?: Registry; +} + +/** The form one install spec takes, in pnpm's vocabulary. */ +export type InstallSpecKind = "registry" | "path" | "git" | "tarball"; + +/** Why a spec was refused before anything installed. */ +export type PluginInspectProblem = + | "invalid-spec" + | "already-installed" + | "not-found" + | "not-a-package" + | "not-a-bundle" + | "network" + | "unknown"; + +/** + * What a spec names, read before installing it: the registry's answer for a + * name, a directory's manifest for a path, and only the form for a git or + * tarball spec, whose package is known once pnpm has fetched it. + */ +export type PluginSpecInspection = + | { + readonly status: "accepted"; + readonly kind: InstallSpecKind; + readonly name?: string; + readonly version?: string; + /** `description` of the package manifest. */ + readonly description?: string; + /** Whether the package declares a bundle patch; null when the spec's form does not say. */ + readonly bundle: boolean | null; + /** The registry that answered for a name, and for the other forms the one an install of the spec asks first. */ + readonly registry: Registry; + /** The host a git spec or a tarball URL is fetched from, which no registry stands in for. */ + readonly host?: string; + } + | { + readonly status: "refused"; + readonly problem: PluginInspectProblem; + /** What pnpm, the registry, or the file system said. */ + readonly reason: string; + /** The registries asked, in order, when the refusal came from asking them; `reason` is the last one's. */ + readonly registries?: Registry[]; + }; + +/** The Host phase of one installation, before its install call settles; `installing` is announced once per registry asked. */ +export interface PluginInstallProgress { + readonly requestId: PluginInstallRequestId; + readonly phase: "installing" | "cancelling" | "applying"; + /** With `installing`: the registry this attempt asks, its one-based position, and how many the installation may ask. */ + readonly attempt?: { + readonly registry: Registry; + readonly index: number; + readonly total: number; + }; +} + +/** Cancellation is confirmed only after process exit and file restoration. */ +export interface PluginInstallCancellation { + readonly status: "cancelled" | "too-late" | "not-running"; +} + +/** One chunk of a pnpm run's output, as the run produces it. */ +export interface PluginInstallLogChunk { + /** The installation the run belongs to, when its caller supplied a request id. */ + readonly requestId?: PluginInstallRequestId; + /** The run the chunk belongs to. */ + readonly jobId: string; + /** The command line the run executes: pnpm's command name, then its arguments. */ + readonly argv: readonly string[]; + /** The directory the run executes in: the profile directory. */ + readonly cwd: string; + readonly stream: "stdout" | "stderr"; + readonly text: string; + /** Present on the run's last chunk: pnpm's exit code, null when it ended without one. */ + readonly exitCode?: number | null; +} + +/** What changed in the profile, for consumers that show it. */ +export interface PluginChange { + /** The operation that changed it. */ + readonly reason: "plugin" | "bundle" | "install" | "remove"; +} + +declare module "@deepseek-ai/cordis" { + interface Events { + /** + * The profile's plugins, bundles, or composition changed: a manager + * operation completed. A patch generation applied outside the manager, + * by HMR's watcher after a CLI or hand edit, announces nothing here. + * @mode emit + * @param change - what changed. + */ + "plugin-manager/changed"(change: PluginChange): void; + /** + * One chunk of a pnpm run's output, streamed as the run produces it. + * @mode emit + * @param chunk - the chunk and the run it belongs to. + */ + "plugin-manager/install-log"(chunk: PluginInstallLogChunk): void; + /** + * An installation moved between its Host phases. `installing` is announced once per registry the + * installation asks, with the attempt's registry and position; `cancelling` and `applying` once. + * @mode emit + * @param progress - the installation's request id and phase, with the attempt while installing. + */ + "plugin-manager/install-state"(progress: PluginInstallProgress): void; + } +} diff --git a/apps/web-ui/packages/client/connection/package.json b/apps/web-ui/packages/client/connection/package.json new file mode 100644 index 000000000..32709bb36 --- /dev/null +++ b/apps/web-ui/packages/client/connection/package.json @@ -0,0 +1,29 @@ +{ + "name": "@deepseek-ai/dsh-client-connection", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./client": { + "types": "./src/client/index.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dependencies": { + "@deepseek-ai/schemastery": "*" + }, + "dsh": { + "client": { + "inject": [], + "platform": "web", + "immediately": true + } + } +} diff --git a/apps/web-ui/packages/client/connection/src/api-path.ts b/apps/web-ui/packages/client/connection/src/api-path.ts new file mode 100644 index 000000000..466151666 --- /dev/null +++ b/apps/web-ui/packages/client/connection/src/api-path.ts @@ -0,0 +1,7 @@ +/** + * The /api URL prefix — single source for both halves of the web transport. + * The node half registers this prefix on the web server. + */ + +/** Route prefix owning every api request (`/api` and `/api/`). */ +export const API_PATH = "/api"; diff --git a/apps/web-ui/packages/client/connection/src/api-request-trust.ts b/apps/web-ui/packages/client/connection/src/api-request-trust.ts new file mode 100644 index 000000000..45d474c05 --- /dev/null +++ b/apps/web-ui/packages/client/connection/src/api-request-trust.ts @@ -0,0 +1,124 @@ +/** + * Browser-trust fence for every /api request. Defends the two confused-deputy + * paths a browser opens against a local HTTP API — DNS rebinding (Host names + * the attacker's domain while the socket reaches this server) and cross-site + * requests fired from a malicious page. The Host fence binds every request, + * browser-looking or not: over plain HTTP a browser attaches neither Origin + * nor Fetch-Metadata to reads (images and navigations — those + * headers go only to trustworthy destinations), so an unmarked request may + * still be a rebound browser read and Host is the one header rebinding cannot + * forge. Non-browser and remote clients pass the same fence via loopback, + * deployment-derived LAN IP literals, or a declared `trustedHosts` authority. + * Network reachability and authentication stay out of scope: binding policy + * belongs to the webserver config, and this fence is not an auth layer. + */ + +import { isLoopbackHostname } from "./loopback-hostname.ts"; +import type { ConnectionTrustRequest } from "./rpc.ts"; + +function header(headers: ConnectionTrustRequest["headers"], name: string): string | undefined { + if (headers instanceof Headers) return headers.get(name) ?? undefined; + const value = headers[name]; + return typeof value === "string" ? value : undefined; +} + +/** Normalized URL of a Host-header authority (hostname lowercased, default port stripped, IPv6 bracketed), or undefined when unparsable. */ +function parseAuthority(authority: string): URL | undefined { + try { + // http: is a WHATWG "special scheme": parsing yields a non-empty hostname or throws. + return new URL(`http://${authority}`); + } catch { + return undefined; + } +} + +/** + * Assert one configured `trustedHosts` entry is a bare authority (`host` or + * `host:port`) in canonical form: it must survive WHATWG parsing unchanged + * (case aside). Anything parsing would silently rewrite is refused as a typo + * that must fail the load loudly instead of being ignored until requests 403 + * or quietly changing the grant: URL parts beyond the authority + * (`harness.internal/path`, `user@harness.internal` — which would authorize + * the embedded hostname), stripped whitespace, a dangling colon or + * zero-padded port (which would broaden an intended exact-port grant to every + * port), and non-canonical host spellings (`0x7f.0.0.1`, percent-encoding, + * unbracketed IPv6; IDN hosts are declared in punycode, the form the wire + * carries). + * @param entry - the configured value, verbatim. + */ +export function assertTrustedAuthority(entry: string): void { + const entryUrl = parseAuthority(entry); + if (entryUrl !== undefined && canonicalAuthority(entry, entryUrl) === entry.toLowerCase()) return; + throw new Error( + `client-connection: trustedHosts entry ${JSON.stringify(entry)} is not a bare host[:port] authority`, + ); +} + +/** + * Canonical form of a parsed authority: `hostname` when no port was written, + * else `hostname:port`. The port is judged from URL parses under both special + * schemes (their default ports differ, so `:80` and `:443` still count as + * explicit), never from the raw string, where WHATWG trimming would misread + * shapes like `host:port ` as port-less. + */ +function canonicalAuthority(entry: string, entryUrl: URL): string { + // An authority that parsed under http cannot fail under https. + const port = entryUrl.port !== "" ? entryUrl.port : new URL(`https://${entry}`).port; + return port === "" ? entryUrl.hostname : `${entryUrl.hostname}:${port}`; +} + +/** + * Whether the request authority matches a `trustedHosts` entry. An entry with + * an explicit port matches that exact authority; a port-less entry matches the + * hostname on any port (the shape the CLI derives for IP-literal LAN serving, + * where the bound port may be OS-assigned). Both sides compare through WHATWG + * normalization, so case and a redundant `:80` never decide trust. + */ +function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): boolean { + return trustedHosts.some((entry) => { + const entryUrl = parseAuthority(entry); + if (entryUrl === undefined) return false; + return canonicalAuthority(entry, entryUrl) === entryUrl.hostname + ? entryUrl.hostname === hostUrl.hostname + : entryUrl.host === hostUrl.host; + }); +} + +/** + * Decide whether one /api request may reach the RPC bridge. + * @param request - Node HTTP or Fetch request facts (headers). + * @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port. + * @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin. + */ +export function isTrustedApiRequest( + request: ConnectionTrustRequest, + trustedHosts: readonly string[], +): boolean { + // Host fence (DNS-rebinding defense), applied to every request: the browser + // fills Host from the URL it believes it is talking to, so a rebound page + // carries the attacker's domain here even though the socket lands on this + // server. There is no marker shortcut — a browser read over plain HTTP + // (images and navigations) arrives with neither Origin nor + // Fetch-Metadata, indistinguishable from curl, and its response is readable + // by the rebound page. + const host = header(request.headers, "host"); + if (host === undefined) return false; + const hostUrl = parseAuthority(host); + if (hostUrl === undefined) return false; + if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) + return false; + // Cross-site fence: modern browsers label the initiator relationship on + // every fetch; an explicit cross-site marker is refused regardless of Origin. + if (header(request.headers, "sec-fetch-site") === "cross-site") return false; + // Origin fence: when a browser attaches an Origin it must be exactly this + // authority (compared through the same normalization as the Host). Absent + // Origin is fine — the Host fence above already bound the request. The + // literal "null" (sandboxed iframes, file: pages) is an opaque origin, refused. + const origin = header(request.headers, "origin"); + if (origin === undefined) return true; + try { + return new URL(origin).host === hostUrl.host; + } catch { + return false; + } +} diff --git a/apps/web-ui/packages/client/connection/src/browser-auth.ts b/apps/web-ui/packages/client/connection/src/browser-auth.ts new file mode 100644 index 000000000..6e4cbb854 --- /dev/null +++ b/apps/web-ui/packages/client/connection/src/browser-auth.ts @@ -0,0 +1,350 @@ +/** Browser-session authentication for the Host Connection carrier. */ + +import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import { credentialKey } from "@deepseek-ai/dsh-credentials"; +import type { CredentialProvider, CredentialRecord } from "@deepseek-ai/dsh-credentials"; +import type { + ConnectionIndexRequest, + ConnectionIndexResponse, + ConnectionTrustRequest, +} from "./rpc.ts"; + +const AUTH_RECORD_KEY = credentialKey("client-connection", "browser-session"); +const DAY_MILLISECONDS = 24 * 60 * 60 * 1000; +const SECRET_BYTES = 32; +const TOKEN_QUERY = "token"; +const COOKIE_PREFIX = "dsh-auth-"; +const COOKIE_PAYLOAD_VERSION = 1; +const STORED_SECRET_VERSION = 1; +const BASE64URL_PATTERN = /^[A-Za-z0-9_-]*$/; +const PROCESS_LAUNCH_TOKENS = new WeakMap(); + +interface StoredSecretPayload { + readonly version: typeof STORED_SECRET_VERSION; + readonly secret: string; +} + +interface BrowserCookiePayload { + readonly version: typeof COOKIE_PAYLOAD_VERSION; + readonly authority: string; + readonly issuedAt: number; + readonly expiresAt: number; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function encodeBase64Url(value: Uint8Array): string { + return Buffer.from(value) + .toString("base64") + .replaceAll("+", "-") + .replaceAll("/", "_") + .replace(/=+$/u, ""); +} + +function decodeBase64Url(value: string): Buffer | undefined { + if (!BASE64URL_PATTERN.test(value) || value.length % 4 === 1) return undefined; + const padding = "=".repeat((4 - (value.length % 4)) % 4); + const decoded = Buffer.from(value.replaceAll("-", "+").replaceAll("_", "/") + padding, "base64"); + return encodeBase64Url(decoded) === value ? decoded : undefined; +} + +function processLaunchToken(owner: object): string { + const existing = PROCESS_LAUNCH_TOKENS.get(owner); + if (existing !== undefined) return existing; + const created = encodeBase64Url(randomBytes(SECRET_BYTES)); + PROCESS_LAUNCH_TOKENS.set(owner, created); + return created; +} + +function header(headers: ConnectionTrustRequest["headers"], name: string): string | undefined { + if (headers instanceof Headers) return headers.get(name) ?? undefined; + const value = headers[name]; + return typeof value === "string" ? value : undefined; +} + +/** Canonical request authority used as the cookie name and signed audience. */ +function requestAuthority(headers: ConnectionTrustRequest["headers"]): string | undefined { + const host = header(headers, "host"); + if (host === undefined) return undefined; + try { + return new URL(`http://${host}`).host; + } catch { + return undefined; + } +} + +function canonicalSecret(value: unknown): Buffer | undefined { + if (typeof value !== "string") return undefined; + const decoded = decodeBase64Url(value); + if (decoded === undefined || decoded.byteLength !== SECRET_BYTES) return undefined; + return decoded; +} + +function storedSecret(record: CredentialRecord | undefined): Buffer | undefined { + if (record === undefined) return undefined; + if ( + record.kind !== "grant" || + !isRecord(record.payload) || + record.payload.version !== STORED_SECRET_VERSION + ) { + throw new Error( + "client-connection: browser-session credential record has an unsupported format", + ); + } + const secret = canonicalSecret(record.payload.secret); + if (secret === undefined) { + throw new Error("client-connection: browser-session credential record has an invalid secret"); + } + return secret; +} + +function tokenMatches(actual: string, expected: string): boolean { + const actualBytes = Buffer.from(actual, "utf8"); + const expectedBytes = Buffer.from(expected, "utf8"); + return ( + actualBytes.byteLength === expectedBytes.byteLength && + timingSafeEqual(actualBytes, expectedBytes) + ); +} + +function cookieName(authority: string): string { + return COOKIE_PREFIX + encodeBase64Url(createHash("sha256").update(authority).digest()); +} + +/** Read the exact generated cookie without implementing general Cookie decoding. */ +function cookieValue(headerValue: string, name: string): string | undefined { + for (const segment of headerValue.split(";")) { + const at = segment.indexOf("="); + if (at === -1 || segment.slice(0, at).trim() !== name) continue; + return segment.slice(at + 1).trim(); + } + return undefined; +} + +/** Serialize the fixed browser-session attributes; generated names and values are cookie-safe base64url. */ +function sessionCookie( + name: string, + value: string, + expiresAt: number, + maxAgeSeconds: number, +): string { + return `${name}=${value}; Max-Age=${String(maxAgeSeconds)}; Path=/; Expires=${new Date(expiresAt).toUTCString()}; HttpOnly; SameSite=Strict`; +} + +function signature(secret: Buffer, body: string): Buffer { + return createHmac("sha256", secret).update(body).digest(); +} + +function encodeCookie(payload: BrowserCookiePayload, secret: Buffer): string { + const body = encodeBase64Url(Buffer.from(JSON.stringify(payload), "utf8")); + return `v1.${body}.${encodeBase64Url(signature(secret, body))}`; +} + +function decodeCookie(value: string, secret: Buffer): BrowserCookiePayload | undefined { + const parts = value.split("."); + const [version, body, encodedSignature] = parts; + if ( + parts.length !== 3 || + version !== "v1" || + body === undefined || + encodedSignature === undefined + ) { + return undefined; + } + const actualSignature = decodeBase64Url(encodedSignature); + if (actualSignature === undefined) return undefined; + const expectedSignature = signature(secret, body); + if ( + actualSignature.byteLength !== expectedSignature.byteLength || + !timingSafeEqual(actualSignature, expectedSignature) + ) + return undefined; + let decoded: unknown; + try { + const bodyBytes = decodeBase64Url(body); + if (bodyBytes === undefined) return undefined; + decoded = JSON.parse(bodyBytes.toString("utf8")); + } catch { + return undefined; + } + if ( + !isRecord(decoded) || + decoded.version !== COOKIE_PAYLOAD_VERSION || + typeof decoded.authority !== "string" || + !Number.isSafeInteger(decoded.issuedAt) || + !Number.isSafeInteger(decoded.expiresAt) + ) + return undefined; + return decoded as unknown as BrowserCookiePayload; +} + +async function initializeSecret(credentials: CredentialProvider): Promise { + const generated: StoredSecretPayload = { + version: STORED_SECRET_VERSION, + secret: encodeBase64Url(randomBytes(SECRET_BYTES)), + }; + const record = await credentials.modifyRecord(AUTH_RECORD_KEY, (current) => { + if (current !== undefined) { + storedSecret(current); + return Promise.resolve(undefined); + } + return Promise.resolve({ kind: "grant", payload: generated }); + }); + const secret = storedSecret(record); + if (secret === undefined) { + throw new Error("client-connection: browser-session credential record was not created"); + } + return secret; +} + +/** + * Process launch-token exchange and persistent signed-cookie verification. + * Connection loads the credential provider's signing secret during activation + * and retains it for synchronous request authentication. + */ +export class BrowserAuth { + private readonly launchToken: string; + private readonly maxAgeMilliseconds: number; + + private constructor( + processOwner: object, + private readonly secret: Buffer, + maxAgeDays: number, + ) { + this.launchToken = processLaunchToken(processOwner); + this.maxAgeMilliseconds = maxAgeDays * DAY_MILLISECONDS; + if ( + !Number.isSafeInteger(this.maxAgeMilliseconds) || + !Number.isSafeInteger(Date.now() + this.maxAgeMilliseconds) + ) { + throw new Error("client-connection: cookieMaxAgeDays exceeds the safe timestamp range"); + } + } + + /** + * Initialize browser authentication and create its durable signing secret + * when this Harness home has none. + * @param processOwner - root application context retaining one token across Connection reloads. + * @param credentials - persistent credential provider for the Web profile. + * @param maxAgeDays - positive absolute browser-cookie lifetime in days. + * @returns initialized authentication owner with the process owner's launch token. + */ + static async create( + processOwner: object, + credentials: CredentialProvider, + maxAgeDays: number, + ): Promise { + return new BrowserAuth(processOwner, await initializeSecret(credentials), maxAgeDays); + } + + /** + * Add this process's launch token to the caller's application URL. + * @param baseUrl - clean browser URL whose authority and mount are preserved. + * @returns the same URL carrying the process token as its sole authentication input. + */ + authenticatedUrl(baseUrl: string): string { + const url = new URL(baseUrl); + url.searchParams.set(TOKEN_QUERY, this.launchToken); + return url.href; + } + + /** + * Authenticate an index request. A valid root query token mints the cookie + * and redirects to the directory-relative clean `./`; a valid cookie lets + * the caller serve the index; every other request receives the same minimal + * 401 response. + * @param req - incoming root or configured-index request. + * @param res - response owned when this method returns false. + * @returns true only when the caller may serve index.html. + */ + authorizeIndex(req: ConnectionIndexRequest, res: ConnectionIndexResponse): boolean { + /* v8 ignore next -- node:http always supplies url on server requests. */ + const url = new URL(req.url ?? "/", "http://dsh.invalid"); + const tokens = url.searchParams.getAll(TOKEN_QUERY); + if (tokens.length > 0) { + const authority = requestAuthority(req.headers); + if ( + req.method === "GET" && + url.pathname === "/" && + tokens.length === 1 && + authority !== undefined && + tokenMatches(tokens.join(""), this.launchToken) + ) { + const issuedAt = Date.now(); + const expiresAt = issuedAt + this.maxAgeMilliseconds; + const value = encodeCookie( + { + version: COOKIE_PAYLOAD_VERSION, + authority, + issuedAt, + expiresAt, + }, + this.secret, + ); + res.writeHead(303, { + "cache-control": "no-store", + location: "./", + "referrer-policy": "no-referrer", + "set-cookie": sessionCookie( + cookieName(authority), + value, + expiresAt, + Math.floor(this.maxAgeMilliseconds / 1000), + ), + }); + res.end(); + return false; + } + if (req.method === "GET" && url.pathname === "/" && this.isAuthenticated(req)) { + res.writeHead(303, { + "cache-control": "no-store", + location: "./", + "referrer-policy": "no-referrer", + }); + res.end(); + return false; + } + this.writeUnauthorized(req, res); + return false; + } + if (this.isAuthenticated(req)) return true; + this.writeUnauthorized(req, res); + return false; + } + + /** + * Verify the authority-bound browser cookie on a Host request. + * @param request - request headers carrying Host and Cookie. + * @returns true only for an unexpired cookie signed by this activation's loaded secret. + */ + isAuthenticated(request: ConnectionTrustRequest): boolean { + const authority = requestAuthority(request.headers); + const rawCookie = header(request.headers, "cookie"); + if (authority === undefined || rawCookie === undefined) return false; + const value = cookieValue(rawCookie, cookieName(authority)); + if (value === undefined) return false; + const payload = decodeCookie(value, this.secret); + if (payload === undefined || payload.authority !== authority) return false; + const now = Date.now(); + return ( + payload.issuedAt <= now && + payload.expiresAt > now && + payload.expiresAt > payload.issuedAt && + payload.expiresAt - payload.issuedAt <= this.maxAgeMilliseconds + ); + } + + private writeUnauthorized(req: ConnectionIndexRequest, res: ConnectionIndexResponse): void { + res.writeHead(401, { + "cache-control": "no-store", + "content-type": "text/plain; charset=utf-8", + }); + res.end( + req.method === "HEAD" + ? undefined + : "dsh web authentication required; reopen the URL printed by dsh web.\n", + ); + } +} diff --git a/apps/web-ui/packages/client/connection/src/client/api.ts b/apps/web-ui/packages/client/connection/src/client/api.ts new file mode 100644 index 000000000..a351c9518 --- /dev/null +++ b/apps/web-ui/packages/client/connection/src/client/api.ts @@ -0,0 +1,25 @@ +/** Browser-safe Connection protocol and shared application value exports. */ + +export type { + ClientRequest, + RpcMessage, + RpcRequest, + RpcResponse, + RpcResult, + ServerResponse, +} from "../rpc.ts"; +export { RpcId, transportError } from "../rpc.ts"; +export type { SessionId, SessionEvent } from "@deepseek-ai/dsh-session/types"; +export type { MessageId } from "@deepseek-ai/dsh-llm/brand"; +export type { ContentBlock, StreamChunk } from "@deepseek-ai/dsh-llm/types"; + +import type { RpcResponse, RpcResult } from "../rpc.ts"; + +/** + * Return the business result carried by an RPC response. + * @param response - RPC response to unwrap. + * @returns the response's business result. + */ +export function resultOf(response: RpcResponse): RpcResult { + return response.result; +} diff --git a/apps/web-ui/packages/client/connection/src/client/connection.ts b/apps/web-ui/packages/client/connection/src/client/connection.ts new file mode 100644 index 000000000..0e67920c3 --- /dev/null +++ b/apps/web-ui/packages/client/connection/src/client/connection.ts @@ -0,0 +1,334 @@ +/** Connection generation readiness, cancellation, and continuous recovery. */ +import { resolveConnectionConfig, type ConnectionRecoveryConfig } from "../recovery-config.ts"; + +export type { ConnectionRecoveryConfig } from "../recovery-config.ts"; + +/** Stable Host facts delivered by one established Remote event generation. */ +export interface ConnectionHostInfo { + /** Host account home used only to abbreviate displayed filesystem paths. */ + readonly home: string; +} + +/** One successfully established Host generation. */ +export interface ConnectionGeneration { + /** Monotone generation number within this Client runtime. */ + readonly id: number; + /** Host facts carried by this generation's opening frame. */ + readonly host: ConnectionHostInfo; +} + +const MANUAL_RECONNECT = new Error("connection: manual reconnect requested"); +const NETWORK_STATE_CHANGED = new Error("connection: browser network state changed"); + +function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + const t = setTimeout(done, ms); + signal.addEventListener("abort", done, { once: true }); + function done(): void { + clearTimeout(t); + signal.removeEventListener("abort", done); + resolve(); + } + }); +} + +function waitForAbort(signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve(); + return new Promise((resolve) => { + signal.addEventListener( + "abort", + () => { + resolve(); + }, + { once: true }, + ); + }); +} + +/** Connection lifecycle state published after the first attempt has an outcome. */ +export type ConnectionState = "connected" | "disconnected" | "connecting"; + +/** Connection-generation callbacks owned by API Gateway. */ +export interface ConnectionSinks { + /** After the generation source reports ready, first connect included. */ + onConnected?: (host: ConnectionHostInfo) => void; + /** State transitions after the initial attempt has an outcome. Equivalent states are deduplicated. */ + onStateChange?: (state: ConnectionState) => void; + /** Start one fresh physical-carrier attempt before each logical retry. */ + onReconnectRequested?: () => void; +} + +/** + * One long-lived source defining a Connection generation. The source must + * attach its incremental listeners before calling `ready`, then remain pending + * until the generation is lost or `signal` aborts. On abort it must stop + * delivery, release its resources, and settle before a replacement can start. + * @param signal - cancellation for the current generation. + * @param ready - one-shot report that incremental delivery is attached. + * @returns a promise settling only when this generation ends or fails. + */ +export type ConnectionGenerationSource = ( + signal: AbortSignal, + ready: (host: ConnectionHostInfo) => void, +) => Promise; + +/** + * Opens the registered generation source, reconnecting with exponential backoff on loss. + * State (generation/attempt) is instance-private, never in the store. + * Sink exceptions do not kill the generation loop. + */ +export class ConnectionController { + private generation = 0; + private attempt = 0; + private current: AbortController | null = null; + private retryDelay: AbortController | null = null; + private running = false; + private immediateRetry = false; + private networkAvailable = true; + private lastState: ConnectionState | undefined; + private readonly config: Required; + + constructor( + private readonly source: ConnectionGenerationSource, + private readonly sinks: ConnectionSinks = {}, + config: ConnectionRecoveryConfig = {}, + ) { + this.config = resolveConnectionConfig(config); + } + + /** Idempotent: begin the connect/pump/reconnect loop. */ + start(): void { + if (this.running) return; + this.running = true; + void this.loop(); + } + + /** Stop the loop and abort the current generation source. */ + stop(): void { + this.running = false; + this.current?.abort(); + this.current = null; + this.retryDelay?.abort(); + this.retryDelay = null; + } + + /** Reset the retry sequence and replace the current generation or retry delay immediately. */ + reconnect(): void { + if (!this.running) return; + this.attempt = 0; + this.immediateRetry = true; + this.emitState("connecting"); + if (!this.isRunning()) return; + this.current?.abort(MANUAL_RECONNECT); + this.retryDelay?.abort(MANUAL_RECONNECT); + } + + /** + * Suspend automatic retries while offline and restart backoff when the network returns. + * @param available - whether the browser reports network access. + */ + setNetworkAvailable(available: boolean): void { + if (this.networkAvailable === available) return; + this.networkAvailable = available; + this.attempt = 0; + this.immediateRetry = false; + if (!this.running) return; + this.emitState(available ? "connecting" : "disconnected"); + if (!this.isRunning()) return; + this.current?.abort(NETWORK_STATE_CHANGED); + this.retryDelay?.abort(NETWORK_STATE_CHANGED); + } + + private backoffCap(attempt: number): number { + const { backoffBaseMs, backoffFactor, backoffMaxMs } = this.config; + return Math.min(backoffMaxMs, backoffBaseMs * backoffFactor ** Math.max(0, attempt - 1)); + } + + private backoffDelay(attempt: number): number { + const cap = this.backoffCap(attempt); + return cap / 2 + Math.random() * (cap / 2); + } + + /** Re-read retry inputs after a potentially reentrant state sink. */ + private isRetryInterrupted(immediate: boolean): boolean { + return this.immediateRetry || (!this.networkAvailable && !immediate); + } + + /** Read through a method: stop() flips the flag across awaits, so narrowing from the loop condition must not stick. */ + private isRunning(): boolean { + return this.running; + } + + /** Re-read both mutable liveness guards after a potentially reentrant sink. */ + private isGenerationActive(controller: AbortController): boolean { + return this.isRunning() && !controller.signal.aborted; + } + + private async loop(): Promise { + let retry = false; + while (this.running) { + if (!this.networkAvailable && !this.immediateRetry) { + const retryDelay = new AbortController(); + this.retryDelay = retryDelay; + this.emitState("disconnected"); + await waitForAbort(retryDelay.signal); + if (this.retryDelay === retryDelay) this.retryDelay = null; + if (!this.isRunning()) return; + retry = true; + continue; + } + + let manualAttempt = false; + if (retry) { + const immediate = this.immediateRetry; + this.immediateRetry = false; + if (immediate) this.attempt = 0; + manualAttempt = immediate; + const attempt = ++this.attempt; + this.emitState("connecting"); + if (!this.isRunning()) return; + if (this.isRetryInterrupted(immediate)) continue; + if (!immediate) { + const retryDelay = new AbortController(); + this.retryDelay = retryDelay; + await sleep(this.backoffDelay(attempt), retryDelay.signal); + if (this.retryDelay === retryDelay) this.retryDelay = null; + if (!this.isRunning()) return; + if (retryDelay.signal.aborted) continue; + } + console.warn(`[connection] connection lost, retry #${String(attempt)}`); + this.callSink(() => { + this.sinks.onReconnectRequested?.(); + }); + if (!this.isRunning()) return; + } + + const gen = ++this.generation; + const ac = new AbortController(); + this.current = ac; + + let sourceReady = false; + let resolveReady!: (host: ConnectionHostInfo) => void; + let rejectReady!: (error: Error) => void; + let rejectSourceLost!: (error: Error) => void; + const ready = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + }); + const sourceLost = new Promise((_resolve, reject) => { + rejectSourceLost = reject; + }); + const reportReady = (host: ConnectionHostInfo): void => { + if (sourceReady || gen !== this.generation || !this.isGenerationActive(ac)) return; + sourceReady = true; + resolveReady(host); + }; + + const failed = new Promise((resolve) => { + const settle = (): void => { + if (gen === this.generation && !ac.signal.aborted) ac.abort(); + resolve(); + }; + void Promise.resolve() + .then(() => this.source(ac.signal, reportReady)) + .then( + () => { + const error = new Error("connection generation ended"); + if (!sourceReady) rejectReady(error); + rejectSourceLost(error); + settle(); + }, + (error: unknown) => { + const failure = + error instanceof Error + ? error + : new Error("connection generation failed", { cause: error }); + if (!sourceReady) rejectReady(failure); + rejectSourceLost(failure); + settle(); + }, + ); + }); + + try { + const host = await Promise.race([waitForReady(ready, this.config, ac.signal), sourceLost]); + if (ac.signal.aborted) throw new Error("generation aborted during readiness handshake"); + this.attempt = 0; + this.emitState("connected"); + // A state sink may synchronously stop this controller. + if (this.isGenerationActive(ac)) { + this.callSink(() => { + this.sinks.onConnected?.(host); + }); + } + } catch (error) { + if (!ac.signal.aborted) ac.abort(error); + } + + await failed; + if (!this.isRunning()) return; + if (manualAttempt) this.attempt = 0; + retry = true; + } + } + + /** Deduplicated state emission (sink isolation applies). */ + private emitState(state: ConnectionState): void { + if (this.lastState === state) return; + this.lastState = state; + this.callSink(() => this.sinks.onStateChange?.(state)); + } + + /** Sink exception isolation: a business-layer throw is logged only, never affecting pump or reconnect semantics. */ + private callSink(fn: () => void): void { + try { + fn(); + } catch (error) { + console.error("[connection] connection sink threw:", error); + } + } +} + +/** Report a slow handshake before the hard deadline ends its generation. */ +function waitForReady( + ready: Promise, + config: Required, + signal: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const warning = setTimeout(() => { + console.warn( + `[connection] generation is still not ready after ${String(config.generationReadyWarnMs)}ms`, + ); + }, config.generationReadyWarnMs); + const timeout = setTimeout(() => { + const error = new Error( + `connection generation was not ready within ${String(config.generationReadyTimeoutMs)}ms`, + ); + console.warn(`[connection] ${error.message}; cancelling generation`); + finish({ error }); + }, config.generationReadyTimeoutMs); + const aborted = (): void => { + finish({ error: new Error("connection generation aborted", { cause: signal.reason }) }); + }; + const finish = (outcome: { readonly value: T } | { readonly error: Error }): void => { + if (settled) return; + settled = true; + clearTimeout(warning); + clearTimeout(timeout); + signal.removeEventListener("abort", aborted); + if ("error" in outcome) reject(outcome.error); + else resolve(outcome.value); + }; + signal.addEventListener("abort", aborted, { once: true }); + void ready.then( + (value) => { + finish({ value }); + }, + (error: unknown) => { + finish({ error: error as Error }); + }, + ); + }); +} diff --git a/apps/web-ui/packages/client/connection/src/client/index.ts b/apps/web-ui/packages/client/connection/src/client/index.ts new file mode 100644 index 000000000..96c9f193b --- /dev/null +++ b/apps/web-ui/packages/client/connection/src/client/index.ts @@ -0,0 +1,351 @@ +/** Browser wire client: Remote transport and connection generations. */ +import type { Context } from "@deepseek-ai/cordis"; +import { + ConnectionController, + type ConnectionRecoveryConfig, + type ConnectionGeneration, + type ConnectionGenerationSource, + type ConnectionSinks, + type ConnectionState, +} from "./connection.ts"; +import { createWebConnectionRpc, type RpcFetch, type RpcStreamOpen } from "./rpc.ts"; +import { isLoopbackHostname } from "../loopback-hostname.ts"; +import type { ClientConnectionRpc } from "../rpc.ts"; +import { resolveConnectionConfig } from "../recovery-config.ts"; + +declare module "@deepseek-ai/cordis" { + interface Events { + /** + * A connection generation was established. Wire-derived caches must + * repull; long-lived streams own their own resume and baseline lifecycle. + * @mode emit + */ + "connection/reset"(): void; + } +} + +// ---- Browser-safe protocol and shared value re-exports ---- +export type { + MessageId, + RpcRequest, + RpcResponse, + RpcResult, + ClientRequest, + ServerResponse, + RpcMessage, + SessionId, + SessionEvent, + ContentBlock, + StreamChunk, +} from "./api.ts"; +export { RpcId, transportError } from "./api.ts"; + +// Connection loop types are public through ConnectionHandle.start; the +// controller remains package-internal. +export type { + ConnectionRecoveryConfig, + ConnectionGeneration, + ConnectionGenerationSource, + ConnectionHostInfo, + ConnectionSinks, + ConnectionState, +} from "./connection.ts"; +export type { ClientConnectionRpc, ConnectionRpcFailure, ConnectionRpcResult } from "../rpc.ts"; +export type { RpcFetch } from "./rpc.ts"; + +/** Observable identity and Host facts for the active connection generation. */ +export interface ConnectionGenerationState { + /** Active generation, or undefined before readiness and while reconnecting. */ + getSnapshot(): ConnectionGeneration | undefined; + /** Subscribe to generation establishment, replacement, and loss. */ + subscribe(listener: () => void): () => void; +} + +/** Observable recovery lifecycle of the owned Connection loop. */ +export interface ConnectionStateSource { + /** Current state, or undefined before the first connection outcome. */ + getSnapshot(): ConnectionState | undefined; + /** Subscribe to state changes. */ + subscribe(listener: () => void): () => void; +} + +/** Required services (none — this is the wire root). */ +export const inject: string[] = []; + +/** + * Physical carrier selected when the Connection service is installed. The + * served web app omits it and gets HTTP + WebSocket; a shell that owns a + * different transport (the worker preview's postMessage tunnel) provides both + * halves instead of forking this plugin. + */ +export interface ClientTransportHooks { + /** + * Already decoded logical RPC carrier. When present it replaces the HTTP + * caller outright: no envelopes, no `fetch`, no `openStream` (an in-process + * Host such as a test mock plugs in here). + */ + rpc?: ClientConnectionRpc; + /** Transport for generic unary RPC channels (the Typert gateway); unused when `rpc` is present. */ + fetch?: RpcFetch; + /** Worker-local Gateway stream carrier; absent when the page uses the Gateway WebSocket or `rpc` is present. */ + openStream?: RpcStreamOpen; + /** + * Bundle transport for the module system, present when the carrier also owns + * bundle bytes (the worker tunnel). Absent in the served web app, whose + * bundles load over HTTP. + */ + loadBundle?(url: string): Promise; + /** + * The transport owner declares the page owns the Host outright: the Host + * runs inside a worker this page spawned, so no other party can reach it and + * the loopback stand-in for "the operator's own machine" is vacuous. + * `ctx.connection.isLoopback` then reports the privileged surface reachable + * regardless of the page authority. Only a shell that assembles its own + * transport can set this; served pages never carry the global at all. + */ + ownsHost?: boolean; + /** HTTP origin of a shell-owned Host when its WebSocket uses a different page origin. */ + streamBaseUrl?: string; +} + +/** Page global carrying {@link ClientTransportHooks}; absent in the served web app. */ +interface ClientTransportGlobal { + __DSH_TRANSPORT__?: ClientTransportHooks; + __DSH_CONNECTION_RECOVERY__?: unknown; +} + +/** Browser location fields used to classify loopback authority. */ +export interface ConnectionLocation { + readonly hostname: string; +} + +/** Instance-local inputs for installing a Connection service. */ +export interface ConnectionInstallOptions { + /** Explicit physical carrier; omit for the browser HTTP + WebSocket carrier. */ + readonly transport?: ClientTransportHooks; + /** Reconnect timing overrides; omitted fields use controller defaults. */ + readonly recovery?: ConnectionRecoveryConfig; + /** Page location; omit for a non-browser composition. */ + readonly location?: ConnectionLocation; +} + +/** + * The ctx.connection service API. API Gateway supplies generation readiness + * and reset callbacks; Connection stays independent of downstream domain state. + */ +export interface ConnectionHandle { + /** + * Whether the privileged surface is reachable: the page authority is + * loopback, the transport declares the page owns the Host + * ({@link ClientTransportHooks.ownsHost}), or the context is not a browser. + */ + readonly isLoopback: boolean; + /** Current Remote event generation and the Host facts carried by its opening frame. */ + readonly generation: ConnectionGenerationState; + /** Current recovery lifecycle for connection-specific consumers. */ + readonly state: ConnectionStateSource; + /** Generic logical RPC channels over the same Connection transport. */ + readonly rpc: ClientConnectionRpc; + /** Reset retry progression and replace the current attempt immediately. */ + reconnect(): void; + /** + * Register the sole source defining Host generations. The source reports + * ready only after its incremental listeners are attached. + * @param source - long-lived generation source owned by the push carrier. + * @returns disposer withdrawing the source and stopping an active loop. + */ + registerGenerationSource(source: ConnectionGenerationSource): () => void; + /** + * Start the connect/reconnect loop with the consumer's state callbacks. + * API Gateway owns the loop; a second call throws. + * @param sinks - connection-state callbacks. + * @param config - explicit timing overrides; omitted fields use Host bootstrap timing. + * @returns lifecycle controls for the loop. + */ + start(sinks: ConnectionSinks, config?: ConnectionRecoveryConfig): ConnectionLoop; +} + +/** Controls retained by the sole owner of a running connection loop. */ +export interface ConnectionLoop { + /** Stop the loop and withdraw its active generation. */ + stop(): void; +} + +interface ConnectionOwner { + readonly token: object; + readonly source: ConnectionGenerationSource; + readonly controller: ConnectionController; + readonly stopNetworkWatch: () => void; +} + +interface BrowserNetworkTarget { + readonly navigator?: { readonly onLine?: boolean }; + addEventListener(type: "online" | "offline", listener: () => void): void; + removeEventListener(type: "online" | "offline", listener: () => void): void; +} + +function watchBrowserNetwork(controller: ConnectionController): () => void { + const browser = (globalThis as { readonly window?: BrowserNetworkTarget }).window; + const initiallyAvailable = browser?.navigator?.onLine; + if (browser === undefined || initiallyAvailable === undefined) return () => {}; + const online = (): void => { + controller.setNetworkAvailable(true); + }; + const offline = (): void => { + controller.setNetworkAvailable(false); + }; + controller.setNetworkAvailable(initiallyAvailable); + browser.addEventListener("online", online); + browser.addEventListener("offline", offline); + return () => { + browser.removeEventListener("online", online); + browser.removeEventListener("offline", offline); + }; +} + +/** + * Install one Context-owned Connection service from explicit composition inputs. + * @param ctx - client Cordis context. + * @param options - physical carrier, reconnect timing, and page location. + */ +export function installConnection(ctx: Context, options: ConnectionInstallOptions = {}): void { + const pageLocation = options.location; + const transport = options.transport; + const recovery = options.recovery ?? {}; + const rpc = transport?.rpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream); + let generationSource: ConnectionGenerationSource | undefined; + let owner: ConnectionOwner | undefined; + let generationId = 0; + let generation: ConnectionGeneration | undefined; + let state: ConnectionState | undefined; + const generationListeners = new Set<() => void>(); + const stateListeners = new Set<() => void>(); + const publishGeneration = (next: ConnectionGeneration | undefined): void => { + if (Object.is(generation, next)) return; + generation = next; + for (const listener of [...generationListeners]) { + try { + listener(); + } catch (error) { + console.error("[connection] generation listener threw:", error); + } + } + }; + const publishState = (next: ConnectionState | undefined): void => { + if (state === next) return; + state = next; + for (const listener of [...stateListeners]) { + try { + listener(); + } catch (error) { + console.error("[connection] state listener threw:", error); + } + } + }; + const releaseOwner = (current: ConnectionOwner): void => { + if (owner !== current) return; + owner = undefined; + current.stopNetworkWatch(); + current.controller.stop(); + publishGeneration(undefined); + publishState(undefined); + }; + const handle: ConnectionHandle = { + isLoopback: + transport?.ownsHost === true || + pageLocation === undefined || + isLoopbackHostname(pageLocation.hostname), + generation: { + getSnapshot: () => generation, + subscribe: (listener) => { + generationListeners.add(listener); + return () => { + generationListeners.delete(listener); + }; + }, + }, + state: { + getSnapshot: () => state, + subscribe: (listener) => { + stateListeners.add(listener); + return () => { + stateListeners.delete(listener); + }; + }, + }, + rpc, + reconnect() { + owner?.controller.reconnect(); + }, + registerGenerationSource(source) { + if (generationSource !== undefined) { + throw new Error("connection: a generation source is already registered"); + } + generationSource = source; + return () => { + if (generationSource !== source) return; + generationSource = undefined; + const current = owner; + if (current?.source === source) releaseOwner(current); + }; + }, + start(sinks, config) { + if (owner !== undefined) + throw new Error("connection: the stream loop is already owned by another consumer"); + const source = generationSource; + if (source === undefined) throw new Error("connection: no generation source is registered"); + const token = {}; + const ownsGeneration = (): boolean => owner?.token === token; + const controller = new ConnectionController( + source, + { + ...sinks, + onConnected: (host) => { + const nextGeneration = { id: ++generationId, host }; + publishGeneration(nextGeneration); + if (!ownsGeneration() || !Object.is(generation, nextGeneration)) return; + sinks.onConnected?.(host); + }, + onStateChange: (state) => { + if (state !== "connected") { + publishGeneration(undefined); + } + if (!ownsGeneration()) return; + publishState(state); + sinks.onStateChange?.(state); + }, + }, + { ...recovery, ...config }, + ); + const current = { + token, + source, + controller, + stopNetworkWatch: watchBrowserNetwork(controller), + }; + owner = current; + controller.start(); + return { + stop: () => { + releaseOwner(current); + }, + }; + }, + }; + ctx.provide("connection", handle); +} + +/** + * Client plugin body: read the page composition and install its Connection service. + * @param ctx - client Cordis context. + */ +export function apply(ctx: Context): void { + const globals = globalThis as ClientTransportGlobal; + const pageLocation = typeof location === "undefined" ? undefined : location; + const transport = globals.__DSH_TRANSPORT__; + installConnection(ctx, { + ...(transport === undefined ? {} : { transport }), + recovery: resolveConnectionConfig(globals.__DSH_CONNECTION_RECOVERY__), + ...(pageLocation === undefined ? {} : { location: pageLocation }), + }); +} diff --git a/apps/web-ui/packages/client/connection/src/client/random-uuid.ts b/apps/web-ui/packages/client/connection/src/client/random-uuid.ts new file mode 100644 index 000000000..fb83e87cb --- /dev/null +++ b/apps/web-ui/packages/client/connection/src/client/random-uuid.ts @@ -0,0 +1,14 @@ +/** Browser-safe UUID generation for client-side wire correlation. */ + +/** + * Generate an RFC 4122 version 4 UUID without requiring a secure context. + * @returns a UUID backed by `crypto.getRandomValues()`, which browsers expose on insecure origins. + */ +export function randomUuid(): string { + const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16)); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + view.setUint8(6, (view.getUint8(6) & 0x0f) | 0x40); + view.setUint8(8, (view.getUint8(8) & 0x3f) | 0x80); + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} diff --git a/apps/web-ui/packages/client/connection/src/client/rpc.ts b/apps/web-ui/packages/client/connection/src/client/rpc.ts new file mode 100644 index 000000000..d0b5f7fdf --- /dev/null +++ b/apps/web-ui/packages/client/connection/src/client/rpc.ts @@ -0,0 +1,220 @@ +/** Browser caller for generic Connection unary RPC channels. */ + +import { RpcId, type ClientRequest, type RpcId as RpcIdType } from "../rpc.ts"; +import type { ClientConnectionRpc, ConnectionRpcResult } from "../rpc.ts"; +import { randomUuid } from "./random-uuid.ts"; + +const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/; +const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/; + +/** + * Transport this caller posts through; same signature as the global `fetch`. + * Receives the document-relative route so a carrier resolves it against its own base. + */ +export type RpcFetch = (input: string | URL, init: RequestInit) => Promise; + +/** Worker-local opener for decoded Gateway Remote streams; `uplink` carries the Client's items for the stream. */ +export type RpcStreamOpen = ( + endpoint: string, + payload: unknown, + signal: AbortSignal, + uplink?: AsyncIterable, +) => AsyncIterable; + +/** + * Create the browser-backed generic RPC caller. + * @param doFetch - transport override; defaults to the page's global fetch. + * @param openStream - optional worker-local Gateway stream carrier. + * @returns caller that owns request correlation and response-envelope validation. + */ +export function createWebConnectionRpc( + doFetch?: RpcFetch, + openStream?: RpcStreamOpen, +): ClientConnectionRpc { + const send: RpcFetch = doFetch ?? ((input, init) => globalThis.fetch(input, init)); + return { + async call(channel, endpoint, payload, signal) { + assertTarget(channel, endpoint); + const rpcId = RpcId(randomUuid()); + const message: ClientRequest = { + type: "client-request", + rpcId, + method: endpoint, + payload, + }; + // The channel key is absolute; a page posts the document-relative form, and + // a carrier that resolves against the Host root accepts the same form. + const route = `${channel}/${endpoint}`.slice(1); + const response = await send(route, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(message), + ...(signal === undefined ? {} : { signal }), + }); + if (!response.ok) { + throw new Error(`transport failure for ${channel}/${endpoint}: HTTP ${response.status}`); + } + const mediaType = response.headers + .get("content-type") + ?.split(";", 1)[0] + ?.trim() + .toLowerCase(); + const full = + mediaType === "multipart/form-data" + ? await parseBinaryResponse(response) + : parseConnectionResponse(await response.json()); + signal?.throwIfAborted(); + if (full.rpcId !== rpcId) { + throw new Error(`rpcId mismatch for ${endpoint}: sent ${rpcId}, got ${full.rpcId}`); + } + return full.result; + }, + ...(openStream === undefined + ? {} + : { + open(channel, endpoint, payload, signal, uplink) { + assertTarget(channel, endpoint); + if (channel !== "/api") { + throw new Error( + `connection: worker-local streams require the /api channel, got ${JSON.stringify(channel)}`, + ); + } + return openStream(endpoint, payload, signal, uplink); + }, + }), + }; +} + +async function parseBinaryResponse( + response: Response, +): Promise> { + const body = await response.formData(); + const fields = new Map(); + for (const [name, value] of body) { + if (fields.has(name)) throw new TypeError("connection: invalid binary response fields"); + fields.set(name, value); + } + const metadata = fields.get("metadata"); + fields.delete("metadata"); + if (typeof metadata !== "string") { + throw new TypeError("connection: invalid binary response fields"); + } + const envelope: unknown = JSON.parse(metadata); + const full = parseConnectionResponse(envelope); + if ( + !full.result.ok || + !isRecord(envelope) || + !Array.isArray(envelope.attachments) || + envelope.attachments.length === 0 + ) { + throw new TypeError("connection: invalid binary response result"); + } + const root = { value: full.result.value }; + for (const attachment of envelope.attachments) { + if ( + !isRecord(attachment) || + attachment.codec !== "bytes" || + typeof attachment.part !== "string" || + !Array.isArray(attachment.path) + ) { + throw new TypeError("connection: invalid binary response attachment"); + } + const data = fields.get(attachment.part); + fields.delete(attachment.part); + if (!(data instanceof Blob)) { + throw new TypeError("connection: invalid binary response fields"); + } + let parent: object = root; + let key: string | number = "value"; + for (const segment of attachment.path) { + const value: unknown = Reflect.get(parent, key); + if (typeof value !== "object" || value === null) { + throw new TypeError("connection: invalid binary response path"); + } + if (Array.isArray(value)) { + if ( + typeof segment !== "number" || + !Number.isSafeInteger(segment) || + segment < 0 || + segment >= value.length + ) { + throw new TypeError("connection: invalid binary response path"); + } + } else if (typeof segment !== "string") { + throw new TypeError("connection: invalid binary response path"); + } + if (!Object.hasOwn(value, segment)) + throw new TypeError("connection: invalid binary response path"); + parent = value; + key = segment; + } + if (Reflect.get(parent, key) !== null) + throw new TypeError("connection: invalid binary response placeholder"); + Object.defineProperty(parent, key, { + value: new Uint8Array(await data.arrayBuffer()), + enumerable: true, + writable: true, + configurable: true, + }); + } + if (fields.size !== 0) throw new TypeError("connection: invalid binary response fields"); + return { + rpcId: full.rpcId, + result: { ok: true, value: root.value }, + }; +} + +function parseConnectionResponse(value: unknown): { + readonly rpcId: RpcIdType; + readonly result: ConnectionRpcResult; +} { + if (!isRecord(value) || value.type !== "server-response" || typeof value.rpcId !== "string") { + throw new TypeError("connection: invalid server-response envelope"); + } + const result = value.result; + if (!isRecord(result)) throw new TypeError("connection: invalid server-response result"); + if (result.ok === true) { + return { + rpcId: RpcId(value.rpcId), + result: { ok: true, value: result.value }, + }; + } + if (result.ok !== false || !isRecord(result.error)) { + throw new TypeError("connection: invalid server-response result"); + } + const error = result.error; + if ( + typeof error.code !== "string" || + typeof error.message !== "string" || + !isRecord(error.details) + ) { + throw new TypeError("connection: invalid server-response failure"); + } + return { + rpcId: RpcId(value.rpcId), + result: { + ok: false, + error: { code: error.code, message: error.message, details: error.details }, + }, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function assertTarget(channel: string, endpoint: string): void { + const segments = endpoint.split("/"); + if ( + !CHANNEL_PATTERN.test(channel) || + segments.some( + (segment) => + segment === "" || + segment === "." || + segment === ".." || + !ENDPOINT_SEGMENT_PATTERN.test(segment), + ) + ) { + throw new Error(`connection: invalid RPC target ${JSON.stringify(`${channel}/${endpoint}`)}`); + } +} diff --git a/apps/web-ui/packages/client/connection/src/http-bridge.ts b/apps/web-ui/packages/client/connection/src/http-bridge.ts new file mode 100644 index 000000000..ca5cf5221 --- /dev/null +++ b/apps/web-ui/packages/client/connection/src/http-bridge.ts @@ -0,0 +1,130 @@ +/** + * node:http ↔ WHATWG fetch bridge for the /api transport (host side of the + * web carrier; the fetch-shaped handler itself is transport-agnostic). + */ + +import type { IncomingMessage } from "node:http"; +import { Readable } from "node:stream"; +import type { ConnectionFetchHandler } from "./rpc.ts"; + +/** Default carrier cap for all HTTP RPC bodies: sized for the default + * aggregate image limit (200 MiB) after base64 expansion plus envelope + * headroom (~267.7 MiB required), rounded up for slack. The bridge buffers + * each body in memory, so this cap is also the per-request resident bound. */ +export const DEFAULT_MAX_REQUEST_BODY_BYTES = 300 * 1024 * 1024; + +interface BridgeServerResponse { + readonly destroyed: boolean; + readonly writableEnded: boolean; + on(event: "close", listener: () => void): this; + off(event: "close" | "drain", listener: () => void): this; + once(event: "close" | "drain", listener: () => void): this; + writeHead(statusCode: number, headers?: Record): unknown; + write(chunk: Uint8Array): boolean; + end(): unknown; +} + +/** + * Bridge one node:http request to the fetch-shaped handler (client close + * aborts; response writes respect backpressure and stop on disconnect). + * @param req - incoming node:http request. + * @param res - node:http response the bridge writes and owns to completion. + * @param apiHandler - fetch-shaped API carrier the request is dispatched to. + * @param maxRequestBodyBytes - maximum bytes buffered for a buffered route. + */ +export async function bridge( + req: IncomingMessage, + res: BridgeServerResponse, + apiHandler: ConnectionFetchHandler, + maxRequestBodyBytes = DEFAULT_MAX_REQUEST_BODY_BYTES, +): Promise { + const abort = new AbortController(); + // Client-disconnect detection MUST hang off the response, not the request: + // since Node 16, IncomingMessage 'close' fires as soon as the request body is + // fully consumed (immediately for a bodyless GET), which would abort a + // streaming response right after open. ServerResponse 'close' fires on connection teardown; + // writableEnded distinguishes a normal end() from the client going away. + res.on("close", () => { + if (!res.writableEnded) abort.abort(); + }); + /* v8 ignore next 2 -- node:http always sets url/method on server requests. */ + const url = new URL(req.url ?? "/", "http://dsh.internal"); + const method = req.method ?? "GET"; + const headers = Object.fromEntries( + Object.entries(req.headers).filter(([, value]) => typeof value === "string") as [ + string, + string, + ][], + ); + const bodyMode = apiHandler.requestBodyMode({ method, url }); + let request: Request; + if (bodyMode === "buffered") { + const declaredLength = req.headers["content-length"]; + if (declaredLength !== undefined && Number(declaredLength) > maxRequestBodyBytes) { + res.writeHead(413, { connection: "close" }); + res.end(); + req.destroy(); + return; + } + const chunks: Buffer[] = []; + let received = 0; + for await (const chunk of req) { + const buffer = chunk as Buffer; + received += buffer.byteLength; + if (received > maxRequestBodyBytes) { + res.writeHead(413, { connection: "close" }); + res.end(); + req.destroy(); + return; + } + chunks.push(buffer); + } + request = new Request(url, { + method, + headers, + ...(chunks.length > 0 ? { body: Buffer.concat(chunks) } : {}), + signal: abort.signal, + }); + } else { + request = new Request(url, { + method, + headers, + body: Readable.toWeb(req) as ReadableStream, + signal: abort.signal, + duplex: "half", + } as RequestInit & { duplex: "half" }); + } + const response = await apiHandler.fetch(request); + const requestUnread = bodyMode === "streaming" && !req.readableEnded; + const responseHeaders = Object.fromEntries(response.headers.entries()); + res.writeHead( + response.status, + requestUnread ? { ...responseHeaders, connection: "close" } : responseHeaders, + ); + if (response.body === null) { + res.end(); + if (requestUnread) req.destroy(); + return; + } + for await (const chunk of response.body) { + // Drain without writing after disconnect: cancelling Node multipart bodies + // can race their producer and reject with ERR_INVALID_STATE. + if (abort.signal.aborted) continue; + // Backpressure: a false return means the socket buffer is full — wait for drain + // instead of buffering unboundedly (slow or suspended consumers). 'close' also + // resolves so a mid-wait disconnect cannot park this loop forever. + if (!res.write(chunk) && !res.destroyed) { + await new Promise((resolve) => { + const done = (): void => { + res.off("drain", done); + res.off("close", done); + resolve(); + }; + res.once("drain", done); + res.once("close", done); + }); + } + } + res.end(); + if (requestUnread) req.destroy(); +} diff --git a/apps/web-ui/packages/client/connection/src/index.ts b/apps/web-ui/packages/client/connection/src/index.ts new file mode 100644 index 000000000..c9476b4c7 --- /dev/null +++ b/apps/web-ui/packages/client/connection/src/index.ts @@ -0,0 +1,173 @@ +/** Host HTTP bridge for browser-client RPC. */ +import type { Context } from "@deepseek-ai/cordis"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import z from "@deepseek-ai/schemastery"; +import type {} from "@deepseek-ai/dsh-attachment"; +import type {} from "@deepseek-ai/dsh-credentials"; +// Activates the webServer Context merge used below. +import type { WebRoute } from "@deepseek-ai/dsh-host-webserver"; +import { API_PATH } from "./api-path.ts"; +import { bridge, DEFAULT_MAX_REQUEST_BODY_BYTES } from "./http-bridge.ts"; +import { assertTrustedAuthority } from "./api-request-trust.ts"; +import { BrowserAuth } from "./browser-auth.ts"; +import { HostConnectionService } from "./rpc-host.ts"; +import { + ConnectionRecoveryConfigSchema, + resolveConnectionConfig, + type ConnectionRecoveryConfig, +} from "./recovery-config.ts"; + +export type { + PeerAdmission, + ConnectionFetchMethod, + ConnectionFetchHandler, + ConnectionFetchRoute, + ConnectionIndexRequest, + ConnectionIndexResponse, + ConnectionRpcEndpointMatcher, + ConnectionRpcAttachment, + ConnectionRpcFailure, + ConnectionRpcHandler, + ConnectionRpcHandlerResult, + ConnectionRequestRejection, + ConnectionRpcResult, + ConnectionRequestBodyMode, + ConnectionTrustRequest, + ClientRequest, + HostConnectionHandle, + HostConnectionFetch, + HostConnectionRpc, + RpcMessage, + ServerResponse, +} from "./rpc.ts"; +export type { PeerId, PeerScope, RemoteInvocation } from "@deepseek-ai/dsh-typert-protocol"; +export { RpcId, transportError } from "./rpc.ts"; +export { OperatorPeer } from "./operator-peer.ts"; +export { + clientRequestSchema, + rpcErrorSchema, + rpcIdSchema, + rpcMessageSchema, + rpcResultSchema, + serverResponseSchema, +} from "./rpc-schema.ts"; +export { HostConnectionService } from "./rpc-host.ts"; + +export { API_PATH } from "./api-path.ts"; + +/** Stable Cordis plugin name. */ +export const name = "client-connection"; + +declare module "@deepseek-ai/cordis" { + interface Events { + /** + * Admit or wrap an authenticated shared API request, including body transfer. + * Existing requests continue when a listener refuses subsequent requests. + * @param request - Authenticated incoming HTTP request. + * @param response - Response owned until the delegated bridge settles. + * @param next - Delegate to the next listener or the shared API bridge. + * @mode waterfall + */ + "connection/request"( + request: IncomingMessage, + response: ServerResponse, + next: () => Promise, + ): Promise; + } +} + +/** Headroom for RPC JSON fields around aggregate base64 image payloads. */ +const REQUEST_ENVELOPE_HEADROOM_BYTES = 1024 * 1024; + +function assertImageBodyCapacity(ctx: Context, maxRequestBodyBytes: number): void { + const attachments = ctx.get("attachments"); + if (attachments === undefined) return; + const requiredImageBodyBytes = + Math.ceil((attachments.imageLimits.maxMessageImageBytes * 4) / 3) + + REQUEST_ENVELOPE_HEADROOM_BYTES; + if (maxRequestBodyBytes < requiredImageBodyBytes) { + throw new Error( + `client-connection maxRequestBodyBytes (${String(maxRequestBodyBytes)}) must be at least ` + + `${String(requiredImageBodyBytes)} for the configured aggregate image limit`, + ); + } +} + +/** Services required before providing Connection. */ +export const inject = ["credentials"]; + +/** Browser authentication, request limits, and connection recovery configuration. */ +export interface ConnectionConfig { + /** Browser recovery timing, injected into each served page. */ + recovery?: ConnectionRecoveryConfig; + /** + * Authorities this deployment serves beyond loopback: exact `host:port`, or + * port-less `host` matching any port. The /api trust fence refuses any + * request whose Host is neither loopback nor listed here, so a + * non-loopback (`0.0.0.0`) deployment must declare the names it is reached + * by; the Web runtime derives LAN IP literals from an active all-interface + * bind. An entry that is not a bare, canonical authority fails plugin load. + */ + trustedHosts?: string[]; + /** Absolute browser-session lifetime in days. Default: 30. */ + cookieMaxAgeDays?: number; + /** Maximum buffered JSON body for every `/api` request. Default: 300 MiB. */ + maxRequestBodyBytes?: number; +} + +export const Config: z = z.object({ + recovery: ConnectionRecoveryConfigSchema.default({}), + trustedHosts: z.array(String).default([]), + cookieMaxAgeDays: z.natural().min(1).default(30), + maxRequestBodyBytes: z.natural().min(1).default(DEFAULT_MAX_REQUEST_BODY_BYTES), +}); + +/** + * Provides carrier-neutral RPC and Fetch registries. When `webServer` is + * present, the plugin also mounts the `/api` browser transport with Host/Origin + * checks and persistent browser authentication. + * @param ctx - Host plugin context. + * @param config - resolved plugin config (schema defaults applied). + */ +export async function apply(ctx: Context, config?: ConnectionConfig): Promise { + const recovery = resolveConnectionConfig(config?.recovery); + // The Loader resolves schema defaults; hand-built test contexts may pass none. + const trustedHosts = config?.trustedHosts ?? []; + const cookieMaxAgeDays = config?.cookieMaxAgeDays ?? 30; + const maxRequestBodyBytes = config?.maxRequestBodyBytes ?? DEFAULT_MAX_REQUEST_BODY_BYTES; + // Config boundary: a malformed entry fails the load loudly here rather than + // silently authorizing its hostname prefix at request time. + for (const entry of trustedHosts) assertTrustedAuthority(entry); + assertImageBodyCapacity(ctx, maxRequestBodyBytes); + const connection = new HostConnectionService( + ctx, + trustedHosts, + await BrowserAuth.create(ctx.root, ctx.credentials, cookieMaxAgeDays), + ); + ctx.inject(["webServer"], (webCtx) => { + assertImageBodyCapacity(webCtx, maxRequestBodyBytes); + webCtx.on("webserver/index-inject", (table) => { + table.push({ kind: "global", name: "__DSH_CONNECTION_RECOVERY__", value: recovery }); + }); + const fetchHandler = connection.createSharedFetchHandler(API_PATH); + const route: WebRoute = { + kind: "prefix", + path: API_PATH, + handler: async (req, res) => { + const admission = connection.admit(req); + if ("rejection" in admission) { + res.writeHead(admission.rejection); + res.end(admission.rejection === 401 ? "unauthorized" : "forbidden"); + return; + } + await webCtx.waterfall("connection/request", req, res, () => + bridge(req, res, fetchHandler, maxRequestBodyBytes), + ); + }, + }; + webCtx.effect(() => webCtx.webServer.register(route), "client-connection: /api route"); + }); + ctx.inject(["attachments"], (attachmentCtx) => { + assertImageBodyCapacity(attachmentCtx, maxRequestBodyBytes); + }); +} diff --git a/apps/web-ui/packages/client/connection/src/loopback-hostname.ts b/apps/web-ui/packages/client/connection/src/loopback-hostname.ts new file mode 100644 index 000000000..da0c90760 --- /dev/null +++ b/apps/web-ui/packages/client/connection/src/loopback-hostname.ts @@ -0,0 +1,20 @@ +/** + * Browser-safe, zero-dependency loopback classification shared by the `/api` + * Host fence and the package's `ctx.connection` state. The predicate stays + * package-internal; client plugins consume the derived state through Cordis. + */ + +/** + * Whether a normalized URL hostname names the local loopback authority. + * @param hostname - WHATWG URL hostname (IPv6 literals retain brackets). + * @returns true for localhost, IPv6 loopback, or any IPv4 address in 127/8. + */ +export function isLoopbackHostname(hostname: string): boolean { + if (hostname === "localhost" || hostname === "[::1]") return true; + const parts = hostname.split("."); + return ( + parts.length === 4 && + parts[0] === "127" && + parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255) + ); +} diff --git a/apps/web-ui/packages/client/connection/src/operator-peer.ts b/apps/web-ui/packages/client/connection/src/operator-peer.ts new file mode 100644 index 000000000..d931ddf26 --- /dev/null +++ b/apps/web-ui/packages/client/connection/src/operator-peer.ts @@ -0,0 +1,33 @@ +/** + * The operator Peer: the one party this Host answers to. Connection owns it + * for its own lifetime, admits every request as it, and hands it to each + * Remote call as `invocation.peer`. + * @module @deepseek-ai/dsh-client-connection/src/operator-peer + */ + +import { randomUUID } from "node:crypto"; +import type { Context } from "@deepseek-ai/cordis"; +import { createScope, type Scope } from "@deepseek-ai/dsh-scope"; +import type { PeerId, PeerScope } from "@deepseek-ai/dsh-typert-protocol"; + +/** + * The operator's scope. The instance is its own scope key, so `scopeOf(peer.ctx)` + * returns it and events dispatched with `scopeTarget(subject, peer)` reach + * listeners registered through `peer.ctx` and nobody else. + */ +export class OperatorPeer implements PeerScope { + readonly id: PeerId = randomUUID() as PeerId; + readonly ctx: Context; + private readonly scope: Scope; + + /** @param owner - Connection plugin context the scope fiber hangs under. */ + constructor(owner: Context) { + this.scope = createScope(owner, this); + this.ctx = this.scope.ctx; + } + + /** Tear down every connection-lifetime registration; racing calls share one completion. */ + dispose(): Promise { + return this.scope.dispose(); + } +} diff --git a/apps/web-ui/packages/client/connection/src/recovery-config.ts b/apps/web-ui/packages/client/connection/src/recovery-config.ts new file mode 100644 index 000000000..b22a8762c --- /dev/null +++ b/apps/web-ui/packages/client/connection/src/recovery-config.ts @@ -0,0 +1,46 @@ +/** Shared validation for Host-configured and browser-local connection recovery. */ +import z from "@deepseek-ai/schemastery"; + +/** Timing for generation readiness and automatic reconnection. */ +export interface ConnectionRecoveryConfig { + /** First-retry delay cap in ms; actual delay is 50–100% of the cap. Default: 500. */ + backoffBaseMs?: number; + /** Finite growth factor of at least 1 per failed attempt; 1 keeps a fixed cap. Default: 2. */ + backoffFactor?: number; + /** Maximum retry delay cap in ms; retries continue at this cap. Default: 10000. */ + backoffMaxMs?: number; + /** + * Delay before reporting a slow handshake, without cancelling it. Default: 3000. + * Omitted when readiness, failure, cancellation, or the hard deadline occurs first. + */ + generationReadyWarnMs?: number; + /** Deadline in ms for readiness, including physical connection setup. Default: 15000. */ + generationReadyTimeoutMs?: number; +} + +// Browsers and Node share this maximum signed 32-bit timer delay. +const MAX_TIMER_MS = 2_147_483_647; + +/** Schema shared by the Host plugin and the Client's recovery input parser. */ +export const ConnectionRecoveryConfigSchema: z = z.object({ + backoffBaseMs: z.natural().min(1).max(MAX_TIMER_MS).default(500), + backoffFactor: z.number().min(1).max(Number.MAX_VALUE).default(2), + backoffMaxMs: z.natural().min(1).max(MAX_TIMER_MS).default(10_000), + generationReadyWarnMs: z.natural().min(1).max(MAX_TIMER_MS).default(3_000), + generationReadyTimeoutMs: z.natural().min(1).max(MAX_TIMER_MS).default(15_000), +}); + +/** + * Validate recovery input and supply every timing default before starting work. + * @param config - Host configuration, page bootstrap data, or direct loop options. + * @returns validated, complete recovery timing. + */ +export function resolveConnectionConfig(config: unknown = {}): Required { + const resolved = ConnectionRecoveryConfigSchema( + config as ConnectionRecoveryConfig, + ) as Required; + if (!Number.isFinite(resolved.backoffFactor)) { + throw new RangeError("connection recovery backoffFactor must be finite"); + } + return resolved; +} diff --git a/apps/web-ui/packages/client/connection/src/rpc-host.ts b/apps/web-ui/packages/client/connection/src/rpc-host.ts new file mode 100644 index 000000000..2b68a7dac --- /dev/null +++ b/apps/web-ui/packages/client/connection/src/rpc-host.ts @@ -0,0 +1,340 @@ +/** Host registry and HTTP adapter for generic Connection RPC channels. */ + +import { Context, Service } from "@deepseek-ai/cordis"; +import type { WebRoute } from "@deepseek-ai/dsh-host-webserver"; +import type { PeerScope } from "@deepseek-ai/dsh-typert-protocol"; +import { RpcId, type ClientRequest, type RpcId as RpcIdType } from "./rpc.ts"; +import { clientRequestSchema } from "./rpc-schema.ts"; +import { bridge } from "./http-bridge.ts"; +import { isTrustedApiRequest } from "./api-request-trust.ts"; +import { API_PATH } from "./api-path.ts"; +import type { BrowserAuth } from "./browser-auth.ts"; +import { OperatorPeer } from "./operator-peer.ts"; +import type { + PeerAdmission, + ConnectionIndexRequest, + ConnectionIndexResponse, + ConnectionFetchRoute, + ConnectionFetchHandler, + HostConnectionFetch, + ConnectionRpcEndpointMatcher, + ConnectionRpcFailure, + ConnectionRpcHandler, + ConnectionRpcResult, + ConnectionRequestRejection, + ConnectionTrustRequest, + HostConnectionHandle, + HostConnectionRpc, +} from "./rpc.ts"; + +const INVALID_REQUEST_RPC_ID = RpcId("invalid-request"); +const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/; +const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/; + +interface ConnectionRpcInterceptor { + readonly matches: ConnectionRpcEndpointMatcher; + readonly fetchHandler: ConnectionFetchHandler; +} + +interface RegisteredFetchRoute { + readonly methods: ReadonlySet; + readonly requestBody: ConnectionFetchRoute["requestBody"]; + readonly fetch: ConnectionFetchRoute["fetch"]; +} + +interface ConnectionServerResponse { + readonly type: "server-response"; + readonly rpcId: RpcIdType; + readonly result: ConnectionRpcResult; +} + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Host Connection transport and RPC registrations. */ + connection: HostConnectionHandle; + } +} + +/** Host Connection service whose channel registrations belong to the caller fiber. */ +export class HostConnectionService extends Service implements HostConnectionHandle { + /** The operator Peer every admitted request speaks for. */ + readonly operator: PeerScope; + private readonly interceptors = new Map(); + private readonly fetchRoutes = new Map(); + + /** + * Provide the Host half over the active HTTP server. + * @param ctx - owning Connection plugin context. + * @param trustedHosts - deployment authorities accepted by the Host/Origin fence. + * @param browserAuth - process token and persistent browser-session owner. + */ + constructor( + ctx: Context, + private readonly trustedHosts: readonly string[], + private readonly browserAuth: BrowserAuth, + ) { + super(ctx, "connection"); + this.operator = new OperatorPeer(ctx); + ctx.effect(() => () => this.operator.dispose(), "client-connection: operator Peer"); + } + + /** Generic channel registry scoped to the Context reading this service. */ + get rpc(): HostConnectionRpc { + const owner = this.ctx; + return { + handle: (channel, handler) => this.register(owner, channel, handler), + intercept: (channel, matches, handler) => + this.registerInterceptor(owner, channel, matches, handler), + }; + } + + /** Exact Fetch-route registry scoped to the Context reading this service. */ + get fetch(): HostConnectionFetch { + const owner = this.ctx; + return { + register: (route) => this.registerFetchRoute(owner, route), + }; + } + + /** Apply the configured Host/Origin fence, then browser authentication. */ + requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection { + if (!isTrustedApiRequest(request, this.trustedHosts)) return 403; + return this.browserAuth.isAuthenticated(request) ? undefined : 401; + } + + /** A request that passes the fence and authentication speaks for the operator. */ + admit(request: ConnectionTrustRequest): PeerAdmission { + const rejection = this.requestRejection(request); + return rejection === undefined ? { peer: this.operator } : { rejection }; + } + + /** Authenticate an index request through the process-token exchange or cookie. */ + authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): boolean { + return this.browserAuth.authorizeIndex(request, response); + } + + /** Add this process's launch token to the clean application URL. */ + authenticatedUrl(baseUrl: string): string { + return this.browserAuth.authenticatedUrl(baseUrl); + } + + /** + * Compose one shared-channel Fetch handler from exact routes and its interceptor. + * @param channel - shared channel mounted by Connection. + * @returns Fetch handler that selects one owner or returns 404. + */ + createSharedFetchHandler(channel: "/api"): ConnectionFetchHandler { + return { + requestBodyMode: ({ method, url }) => { + const route = this.fetchRoutes.get(url.pathname); + return route?.methods.has(method) === true ? route.requestBody : "buffered"; + }, + fetch: (request) => { + const pathname = new URL(request.url).pathname; + const route = this.fetchRoutes.get(pathname); + if (route?.methods.has(request.method) === true) return route.fetch(request); + const endpoint = endpointFromPath(channel, pathname); + const interceptor = this.interceptors.get(channel); + if (endpoint === undefined || interceptor === undefined || !interceptor.matches(endpoint)) { + return Promise.resolve(new Response("not found", { status: 404 })); + } + return interceptor.fetchHandler.fetch(request); + }, + }; + } + + private registerFetchRoute(owner: Context, route: ConnectionFetchRoute): () => Promise { + assertFetchRoute(route); + const registered: RegisteredFetchRoute = { + methods: new Set(route.methods), + requestBody: route.requestBody, + fetch: route.fetch, + }; + return owner.effect(() => { + if (this.fetchRoutes.has(route.path)) { + throw new Error( + `connection: exact Fetch route ${JSON.stringify(route.path)} is already registered`, + ); + } + this.fetchRoutes.set(route.path, registered); + return () => { + this.fetchRoutes.delete(route.path); + }; + }, `client-connection: ${route.path} Fetch route`); + } + + private register( + owner: Context, + channel: string, + handler: ConnectionRpcHandler, + ): () => Promise { + assertChannel(channel); + const fetchHandler = rpcFetchHandler(channel, handler, this.operator); + const route: WebRoute = { + kind: "prefix", + path: channel, + handler: async (req, res) => { + const admission = this.admit(req); + if ("rejection" in admission) { + res.writeHead(admission.rejection); + res.end(admission.rejection === 401 ? "unauthorized" : "forbidden"); + return; + } + await bridge(req, res, fetchHandler); + }, + }; + return owner.effect( + () => owner.webServer.register(route), + `client-connection: ${channel} rpc channel`, + ); + } + + private registerInterceptor( + owner: Context, + channel: string, + matches: ConnectionRpcEndpointMatcher, + handler: ConnectionRpcHandler, + ): () => Promise { + if (channel !== API_PATH) { + throw new Error(`connection: invalid shared RPC channel ${JSON.stringify(channel)}`); + } + const interceptor: ConnectionRpcInterceptor = { + matches, + fetchHandler: rpcFetchHandler(channel, handler, this.operator), + }; + return owner.effect(() => { + if (this.interceptors.has(channel)) { + throw new Error( + `connection: shared RPC channel ${JSON.stringify(channel)} already has an interceptor`, + ); + } + this.interceptors.set(channel, interceptor); + return () => { + this.interceptors.delete(channel); + }; + }, `client-connection: ${channel} rpc interceptor`); + } +} + +function rpcFetchHandler( + channel: string, + handler: ConnectionRpcHandler, + peer: PeerScope, +): ConnectionFetchHandler { + return { + requestBodyMode: () => "buffered", + async fetch(request: Request): Promise { + const endpoint = endpointFromPath(channel, new URL(request.url).pathname); + if (request.method !== "POST" || endpoint === undefined) { + return new Response("not found", { status: 404 }); + } + + const mediaType = request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase(); + if (mediaType !== "application/json") { + return new Response("content type must be application/json", { status: 415 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return new Response("body is not JSON", { status: 400 }); + } + + const envelope = clientRequestSchema.safeParse(body); + if (!envelope.success) { + return invalidEnvelopeResponse(body, envelope.error.issues); + } + const message: ClientRequest = envelope.data; + if (message.method !== endpoint) { + return errorResponse(message.rpcId, { + code: "gateway/bad-request", + message: `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(endpoint)}`, + details: { issues: [] }, + }); + } + + try { + const result = await handler(endpoint, message.payload, request.signal, peer); + return fullResponse(message.rpcId, result); + } catch (error) { + return new Response(`handler failure: ${String(error)}`, { status: 500 }); + } + }, + }; +} + +function invalidEnvelopeResponse(body: unknown, issues: readonly object[]): Response { + const rawId = (body as { rpcId?: unknown } | null)?.rpcId; + const rpcId = typeof rawId === "string" ? RpcId(rawId) : INVALID_REQUEST_RPC_ID; + return errorResponse(rpcId, { + code: "gateway/bad-request", + message: "invalid client-request message", + details: { issues }, + }); +} + +function endpointFromPath(channel: string, pathname: string): string | undefined { + if (!pathname.startsWith(`${channel}/`)) return undefined; + const endpoint = pathname.slice(channel.length + 1); + const segments = endpoint.split("/"); + if ( + segments.some( + (segment) => + segment === "" || + segment === "." || + segment === ".." || + !ENDPOINT_SEGMENT_PATTERN.test(segment), + ) + ) { + return undefined; + } + return endpoint; +} + +function errorResponse(rpcId: RpcIdType, error: ConnectionRpcFailure): Response { + return fullResponse(rpcId, { ok: false, error }); +} + +function fullResponse( + rpcId: RpcIdType, + result: Awaited>, +): Response { + if (!result.ok) { + const body: ConnectionServerResponse = { type: "server-response", rpcId, result }; + return Response.json(body); + } + const { attachments, ...success } = result; + const body: ConnectionServerResponse = { type: "server-response", rpcId, result: success }; + if (attachments === undefined || attachments.length === 0) return Response.json(body); + const parts = new FormData(); + const attachmentMetadata = attachments.map((attachment, index) => { + const part = `bytes-${index}`; + // FileSystem bytes may have SharedArrayBuffer backing, which BlobPart excludes. + parts.set(part, new Blob([new Uint8Array(attachment.bytes)])); + return { path: [...attachment.path], codec: "bytes" as const, part }; + }); + parts.set("metadata", JSON.stringify({ ...body, attachments: attachmentMetadata })); + return new Response(parts); +} + +function assertChannel(channel: string): void { + if (!CHANNEL_PATTERN.test(channel) || channel === "/api") { + throw new Error(`connection: invalid or reserved RPC channel ${JSON.stringify(channel)}`); + } +} + +function assertFetchRoute(route: ConnectionFetchRoute): void { + if (endpointFromPath(API_PATH, route.path) === undefined) { + throw new Error(`connection: invalid exact Fetch route ${JSON.stringify(route.path)}`); + } + if (route.methods.length === 0) { + throw new Error( + `connection: exact Fetch route ${JSON.stringify(route.path)} declares no methods`, + ); + } + const methods = new Set(route.methods); + if (methods.size !== route.methods.length) { + throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} repeats a method`); + } +} diff --git a/apps/web-ui/packages/client/connection/src/rpc-schema.ts b/apps/web-ui/packages/client/connection/src/rpc-schema.ts new file mode 100644 index 000000000..d513ba687 --- /dev/null +++ b/apps/web-ui/packages/client/connection/src/rpc-schema.ts @@ -0,0 +1,56 @@ +/** Runtime validation for Connection RPC envelopes. */ + +import { z } from "zod"; +import type { ClientRequest, RpcId, RpcMessage, ServerResponse } from "./rpc.ts"; + +/** Correlation id after wire validation. */ +export const rpcIdSchema = z.string() as unknown as z.ZodType; + +/** Generic endpoint failure carried in a response envelope. */ +export const rpcErrorSchema = z.object({ + code: z.string(), + message: z.string(), + details: z.record(z.string(), z.unknown()), +}); + +/** + * Build the result parser for one endpoint value parser. + * @param value - endpoint-owned success-value parser. + * @returns parser for either a success value or generic failure. + */ +export function rpcResultSchema(value: z.ZodType): z.ZodType< + | { + readonly ok: true; + readonly value: T; + } + | { + readonly ok: false; + readonly error: z.infer; + } +> { + return z.union([ + z.object({ ok: z.literal(true), value }), + z.object({ ok: z.literal(false), error: rpcErrorSchema }), + ]); +} + +/** Client request envelope; endpoint payload validation belongs to its owner. */ +export const clientRequestSchema = z.object({ + type: z.literal("client-request"), + rpcId: rpcIdSchema, + method: z.string(), + payload: z.unknown(), +}) as z.ZodType; + +/** Server response envelope; endpoint value validation belongs to its caller. */ +export const serverResponseSchema = z.object({ + type: z.literal("server-response"), + rpcId: rpcIdSchema, + result: rpcResultSchema(z.unknown().optional()), +}) as z.ZodType; + +/** Either Connection RPC envelope direction. */ +export const rpcMessageSchema = z.discriminatedUnion("type", [ + clientRequestSchema as unknown as z.ZodObject, + serverResponseSchema as unknown as z.ZodObject, +]) as unknown as z.ZodType; diff --git a/apps/web-ui/packages/client/connection/src/rpc.ts b/apps/web-ui/packages/client/connection/src/rpc.ts new file mode 100644 index 000000000..ff0e6884d --- /dev/null +++ b/apps/web-ui/packages/client/connection/src/rpc.ts @@ -0,0 +1,289 @@ +/** Generic unary RPC contracts shared by the Host and Client Connection halves. */ + +import type { Branded } from "@deepseek-ai/dsh-brand"; +import type { PeerScope } from "@deepseek-ai/dsh-typert-protocol"; + +/** Correlation id minted by a caller and echoed by the Connection response. */ +export type RpcId = Branded<"rpc-id">; + +/** + * Brand one validated string as a Connection correlation id. + * @param id - validated wire identity. + * @returns the same string with the correlation-id brand. + */ +export function RpcId(id: string): RpcId { + return id as RpcId; +} + +/** Carrier-neutral failure returned by one logical RPC endpoint. */ +export interface ConnectionRpcFailure { + readonly code: string; + readonly message: string; + readonly details: object; +} + +/** Carrier-neutral result returned by one logical RPC endpoint. */ +export type ConnectionRpcResult = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: ConnectionRpcFailure }; + +/** One binary value separated from a successful RPC result before transport framing. */ +export interface ConnectionRpcAttachment { + /** Result-relative path occupied by the attachment's `null` placeholder. */ + readonly path: readonly (string | number)[]; + /** Byte view carried outside the JSON response metadata. */ + readonly bytes: Uint8Array; +} + +/** Successful or failed handler result ready for Connection transport framing. */ +export type ConnectionRpcHandlerResult = + | { + readonly ok: true; + readonly value: unknown; + /** Binary fields already projected by the handler that owns the result protocol. */ + readonly attachments?: readonly ConnectionRpcAttachment[]; + } + | { readonly ok: false; readonly error: ConnectionRpcFailure }; + +/** Historical short name for a generic Connection result. */ +export type RpcResult = ConnectionRpcResult; + +/** + * Convert a rejected transport operation into a generic failure result. + * @param error - rejected transport value. + * @returns an `internal` failure preserving the available message. + */ +export function transportError(error: unknown): RpcResult { + return { + ok: false, + error: { + code: "gateway/internal", + message: error instanceof Error ? error.message : String(error), + details: {}, + }, + }; +} + +/** Narrow request form used by direct fixture adapters. */ +export interface RpcRequest

{ + readonly rpcId: RpcId; + readonly payload: P; +} + +/** Narrow response form used by direct fixture adapters. */ +export interface RpcResponse { + readonly rpcId: RpcId; + readonly result: RpcResult; +} + +/** Full request envelope carried by Connection RPC transports. */ +export interface ClientRequest { + readonly type: "client-request"; + readonly rpcId: RpcId; + readonly method: string; + readonly payload: unknown; +} + +/** Full response envelope carried by Connection RPC transports. */ +export interface ServerResponse { + readonly type: "server-response"; + readonly rpcId: RpcId; + readonly result: ConnectionRpcResult; +} + +/** Complete Connection RPC envelope union. */ +export type RpcMessage = ClientRequest | ServerResponse; + +/** HTTP request facts consumed by browser trust and authentication. */ +export interface ConnectionTrustRequest { + /** Request headers supplied by either the Fetch or node:http representation. */ + readonly headers: Headers | Readonly>; +} + +/** HTTP status returned before dispatch, or undefined when the request may proceed. */ +export type ConnectionRequestRejection = 401 | 403 | undefined; + +/** Root/index request facts used by the browser-token exchange. */ +export interface ConnectionIndexRequest extends ConnectionTrustRequest { + readonly method?: string | undefined; + readonly url?: string | undefined; +} + +/** Root/index response operations owned by the browser-token exchange. */ +export interface ConnectionIndexResponse { + writeHead(status: number, headers?: Readonly>): unknown; + end(body?: string): unknown; +} + +/** Outcome of admitting one request: the operator Peer it speaks for, or the status refusing it. */ +export type PeerAdmission = { readonly peer: PeerScope } | { readonly rejection: 401 | 403 }; + +/** + * Handler invoked after Connection has decoded the transport envelope. + * `peer` is the Peer the request was admitted as: the operator. + */ +export type ConnectionRpcHandler = ( + endpoint: string, + payload: unknown, + signal: AbortSignal, + peer: PeerScope, +) => Promise; + +/** Synchronous ownership test for one endpoint on a shared RPC channel. */ +export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean; + +/** HTTP methods supported by exact Fetch routes on the shared API channel. */ +export type ConnectionFetchMethod = "GET" | "HEAD" | "POST"; + +/** How the node:http bridge presents one request body to its Fetch route. */ +export type ConnectionRequestBodyMode = "buffered" | "streaming"; + +/** One exact, transport-independent Fetch route owned by a Host feature. */ +export interface ConnectionFetchRoute { + /** Absolute path below `/api`; query parameters remain available on the request URL. */ + readonly path: string; + /** Methods this route owns. Other methods continue through normal shared-channel dispatch. */ + readonly methods: readonly ConnectionFetchMethod[]; + /** Buffered requests obey the configured JSON cap; streaming requests arrive with backpressure and no aggregate cap. */ + readonly requestBody: ConnectionRequestBodyMode; + /** Handle one request after the physical carrier has applied its trust and authentication policy. */ + readonly fetch: (request: Request) => Promise; +} + +/** Host registry for exact Fetch routes that cannot use JSON Remote invocation. */ +export interface HostConnectionFetch { + /** + * Register one exact route on the shared API channel. + * @param route - path, methods, and Fetch-shaped implementation. + * @returns asynchronous disposer removing this exact contribution. + */ + register(route: ConnectionFetchRoute): () => Promise; +} + +/** Host registry for logical RPC channels carried by the current transport. */ +export interface HostConnectionRpc { + /** + * Register one authenticated absolute channel prefix. + * @param channel - absolute logical channel such as `/rpc`. + * @param handler - decoded endpoint handler returning the existing RPC result shape. + * @returns asynchronous disposer removing the channel and its physical route. + */ + handle(channel: string, handler: ConnectionRpcHandler): () => Promise; + + /** + * Intercept owned endpoints on the shared `/api` channel before its fallback. + * @param channel - reserved shared channel; currently `/api`. + * @param matches - synchronous endpoint ownership test. + * @param handler - decoded endpoint handler returning the existing RPC result shape. + * @returns asynchronous disposer removing the interceptor. + */ + intercept( + channel: "/api", + matches: ConnectionRpcEndpointMatcher, + handler: ConnectionRpcHandler, + ): () => Promise; +} + +/** Host `ctx.connection` members consumed by transport-independent adapters. */ +export interface HostConnectionHandle { + /** Generic RPC channel registry. */ + readonly rpc: HostConnectionRpc; + /** Exact Fetch routes for streaming or browser-native responses. */ + readonly fetch: HostConnectionFetch; + /** The operator Peer every admitted request speaks for; its scope lives as long as Connection. */ + readonly operator: PeerScope; + + /** + * Compose exact Fetch routes and the shared-channel RPC interceptor. + * @param channel - shared channel mounted by Connection. + * @returns Fetch handler for trusted, authenticated requests. + */ + createSharedFetchHandler(channel: "/api"): ConnectionFetchHandler; + + /** + * Apply Connection's Host/Origin checks and browser authentication to + * another Web route. + * @param request - request headers from the HTTP or upgrade request. + * @returns rejection status, or undefined when the route may accept the request. + */ + requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection; + + /** + * Admit one request: it passes {@link requestRejection} and speaks for the + * operator, or it is refused with that status. + * @param request - request headers from the HTTP or upgrade request. + * @returns the operator Peer, or the rejection status. + */ + admit(request: ConnectionTrustRequest): PeerAdmission; + + /** + * Authenticate one frontend index request, owning a token redirect or 401. + * @param request - root or configured-index HTTP request. + * @param response - response owned when the result is false. + * @returns true only when the frontend may serve index.html. + */ + authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): boolean; + + /** + * Add the fresh process token to an ordinary Web application URL. + * @param baseUrl - clean application URL whose authority and mount are preserved. + * @returns tokenized URL for initial login; a mount proxy strips its prefix before {@link authorizeIndex}. + */ + authenticatedUrl(baseUrl: string): string; +} + +/** Transport-independent Fetch handler used by HTTP and worker carriers. */ +export interface ConnectionFetchHandler { + /** + * Resolve body handling before the bridge reads any request bytes. + * @param request - request method and URL available from node:http headers. + * @returns the registered route's body handling mode. + */ + requestBodyMode(request: { + readonly method: string; + readonly url: URL; + }): ConnectionRequestBodyMode; + + /** + * Dispatch one already-authenticated request. + * @param request - Fetch request below the shared channel. + * @returns the registered response or a 404 response. + */ + fetch(request: Request): Promise; +} + +/** Client caller for logical RPC channels carried by the current transport. */ +export interface ClientConnectionRpc { + /** + * Call one endpoint through an already registered logical channel. + * @param channel - absolute logical channel such as `/api`. + * @param endpoint - channel-relative endpoint such as `goals/create`. + * @param payload - channel-owned request payload. + * @param signal - optional caller cancellation. + * @returns the endpoint-owned success/error result; correlation stays inside Connection. + */ + call( + channel: string, + endpoint: string, + payload: unknown, + signal?: AbortSignal, + ): Promise>; + + /** + * Open an in-process logical stream when the selected carrier supplies one. + * Browser transports omit this method; API Gateway owns their WebSocket mux. + * @param channel - absolute logical channel such as `/api`. + * @param endpoint - channel-relative endpoint such as `session/follow`. + * @param payload - channel-owned request payload. + * @param signal - caller cancellation for this logical stream. + * @param uplink - Client uplink items the Host method reads through `invocation.uplink()`. + * @returns decoded stream values from the in-process carrier. + */ + readonly open?: ( + channel: string, + endpoint: string, + payload: unknown, + signal: AbortSignal, + uplink?: AsyncIterable, + ) => AsyncIterable; +} diff --git a/apps/web-ui/packages/client/connection/tsdown.config.ts b/apps/web-ui/packages/client/connection/tsdown.config.ts new file mode 100644 index 000000000..8ad2f6680 --- /dev/null +++ b/apps/web-ui/packages/client/connection/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from "../tsdown.client.ts"; + +export default clientBundle("@deepseek-ai/dsh-client-connection", ["lib/types/index.js"]); diff --git a/apps/web-ui/packages/client/file-upload/generated/remote.d.ts b/apps/web-ui/packages/client/file-upload/generated/remote.d.ts new file mode 100644 index 000000000..347caa7b6 --- /dev/null +++ b/apps/web-ui/packages/client/file-upload/generated/remote.d.ts @@ -0,0 +1,26 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import type { + RemoteResult, + TypertRemoteContribution, +} from '@deepseek-ai/dsh-typert-protocol' +import type { EncodedFileUploadRequest, FileUploadValue } from '@deepseek-ai/dsh-client-file-upload/types' +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertRemoteNamespace$66696c6555706c6f616473 { + upload: (agentId: SessionId, request: EncodedFileUploadRequest, signal?: AbortSignal) => Promise> + } + interface TypertRemoteMap { + 'fileUploads/upload': (agentId: SessionId, request: EncodedFileUploadRequest, signal?: AbortSignal) => Promise> + } + interface TypertRemoteNamespaceMap { + 'fileUploads': TypertRemoteNamespace$66696c6555706c6f616473 + } + interface TypertRemoteScopeMap { + 'agent:fileUploads/upload': (request: EncodedFileUploadRequest, signal?: AbortSignal) => Promise> + } +} + +export declare const TYPERT_REMOTE: TypertRemoteContribution +export default TYPERT_REMOTE +//# sourceMappingURL=typert.remote-client.d.ts.map diff --git a/apps/web-ui/packages/client/file-upload/generated/remote.js b/apps/web-ui/packages/client/file-upload/generated/remote.js new file mode 100644 index 000000000..e4aac0171 --- /dev/null +++ b/apps/web-ui/packages/client/file-upload/generated/remote.js @@ -0,0 +1,68 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import { z } from 'zod' + +let _deepseek_ai_dsh_client_file_upload_fileUploads_upload_parameter_0$schema$value +const _deepseek_ai_dsh_client_file_upload_fileUploads_upload_parameter_0$schema = () => (_deepseek_ai_dsh_client_file_upload_fileUploads_upload_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_client_file_upload_fileUploads_upload_parameter_1$schema$value +const _deepseek_ai_dsh_client_file_upload_fileUploads_upload_parameter_1$schema = () => (_deepseek_ai_dsh_client_file_upload_fileUploads_upload_parameter_1$schema$value ??= z.object({ + 'data': z.string().readonly(), + 'name': z.string().readonly().optional(), +})) +let _deepseek_ai_dsh_client_file_upload_fileUploads_upload_result$schema$value +const _deepseek_ai_dsh_client_file_upload_fileUploads_upload_result$schema = () => (_deepseek_ai_dsh_client_file_upload_fileUploads_upload_result$schema$value ??= z.object({ + 'receiptId': z.intersection(z.string(), z.unknown()).readonly(), + 'file': z.object({ + 'attachmentId': z.intersection(z.string(), z.unknown()), + 'name': z.string(), + 'bytes': z.number(), +}).readonly(), +})) + +export const TYPERT_REMOTE = { + package: '@deepseek-ai/dsh-client-file-upload', + descriptors: [ + { + id: '@deepseek-ai/dsh-client-file-upload#fileUploads/upload', + service: 'fileUploads', + namespace: 'fileUploads', + method: 'upload', + invocation: { kind: 'direct' }, + scope: { + context: 'agent', + wire: 'agentId', + }, + parameters: [ + { + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'agent', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + create: _deepseek_ai_dsh_client_file_upload_fileUploads_upload_parameter_0$schema, + }, + }, + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-client-file-upload/types#EncodedFileUploadRequest', + create: _deepseek_ai_dsh_client_file_upload_fileUploads_upload_parameter_1$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-client-file-upload/types#FileUploadValue', + create: _deepseek_ai_dsh_client_file_upload_fileUploads_upload_result$schema, + }, + sourceLocation: {"file":"packages/client/file-upload/src/index.ts","line":105,"column":3}, + }, + ], +} + +export default TYPERT_REMOTE diff --git a/apps/web-ui/packages/client/file-upload/package.json b/apps/web-ui/packages/client/file-upload/package.json new file mode 100644 index 000000000..369da3452 --- /dev/null +++ b/apps/web-ui/packages/client/file-upload/package.json @@ -0,0 +1,46 @@ +{ + "name": "@deepseek-ai/dsh-client-file-upload", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./client": { + "types": "./src/client/index.ts", + "default": "./lib/client.js" + }, + "./types": { + "types": "./src/types.ts", + "default": "./src/types.ts" + }, + "./typert": { + "types": "./src/typert.host.ts", + "default": "./src/typert.host.ts" + }, + "./remote": { + "types": "./generated/remote.d.ts", + "default": "./generated/remote.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dependencies": { + "zod": "^4.4.3", + "@deepseek-ai/cordis": "*", + "@deepseek-ai/dsh-util-crypto": "*", + "@deepseek-ai/dsh-typert-protocol": "*" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-remotes" + ], + "platform": "web", + "immediately": true + } + } +} diff --git a/apps/web-ui/packages/client/file-upload/src/client/contract.ts b/apps/web-ui/packages/client/file-upload/src/client/contract.ts new file mode 100644 index 000000000..86c257ae6 --- /dev/null +++ b/apps/web-ui/packages/client/file-upload/src/client/contract.ts @@ -0,0 +1,33 @@ +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { RemoteResult } from "@deepseek-ai/dsh-typert-protocol"; +import type { FileUploadValue } from "../types.ts"; + +/** Browser request body accepted by the background file-upload service. */ +export type FileUploadBody = Blob | ReadableStream; + +/** Monotone byte progress reported while a browser body is consumed. */ +export interface FileUploadProgress { + readonly loaded: number; + readonly total?: number; +} + +/** Browser upload service addressed by one Session identity. */ +export interface FileUploadService { + /** + * Store one file for a Session. Blob and stream bodies use + * the background carrier; exact bytes use Remote. + * @param sessionId - Session that owns the staged receipt. + * @param data - browser Blob, exact bytes, or a one-shot byte stream. + * @param name - optional display name. + * @param signal - optional cancellation for the active upload. + * @param onProgress - optional byte-progress observer for background bodies. + * @returns the staged receipt and durable file reference, or a business error. + */ + upload( + sessionId: SessionId, + data: Blob | Uint8Array | ReadableStream, + name?: string, + signal?: AbortSignal, + onProgress?: (progress: FileUploadProgress) => void, + ): Promise>; +} diff --git a/apps/web-ui/packages/client/file-upload/src/client/index.ts b/apps/web-ui/packages/client/file-upload/src/client/index.ts new file mode 100644 index 000000000..c01166346 --- /dev/null +++ b/apps/web-ui/packages/client/file-upload/src/client/index.ts @@ -0,0 +1,26 @@ +/** Browser background-upload Cordis service. */ + +import type { Context } from "@deepseek-ai/cordis"; +import { FileUploadRuntime } from "./runtime.ts"; +import type { FileUploadService } from "./contract.ts"; + +export type { FileUploadProgress, FileUploadService } from "./contract.ts"; +export type { FileUploadReceiptId, FileUploadValue } from "../types.ts"; + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Session-addressed browser service for staged file uploads. */ + fileUpload: FileUploadService; + } +} + +/** Keep existing Remote plugin wiring; browser file bodies use raw-byte intake. */ +export const inject = ["remote"]; + +/** + * Provide the browser background-upload service. + * @param ctx - Client plugin context. + */ +export function apply(ctx: Context): void { + ctx.plugin(FileUploadRuntime); +} diff --git a/apps/web-ui/packages/client/file-upload/src/client/runtime.ts b/apps/web-ui/packages/client/file-upload/src/client/runtime.ts new file mode 100644 index 000000000..2a465a30d --- /dev/null +++ b/apps/web-ui/packages/client/file-upload/src/client/runtime.ts @@ -0,0 +1,343 @@ +/** Background browser upload implementation for Blob and byte-stream bodies. */ + +import { Service, type Context } from "@deepseek-ai/cordis"; +import { RemoteError } from "@deepseek-ai/dsh-typert-protocol"; +import type { RemoteResult } from "@deepseek-ai/dsh-typert-protocol"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import { FILE_UPLOAD_ROUTE } from "../protocol.ts"; +import type { ClientFileUploadHooks, FileUploadFetch, FileUploadValue } from "../types.ts"; +import type { FileUploadBody, FileUploadService } from "./contract.ts"; + +interface FileUploadRequest { + /** Document-relative app-owned upload route, query string included. */ + readonly path: string; + readonly body: FileUploadBody; + readonly headers?: Readonly>; + readonly signal?: AbortSignal; + readonly onProgress?: (progress: { readonly loaded: number; readonly total?: number }) => void; +} + +interface FileUploadResponse { + readonly status: number; + readonly body: string; +} + +interface UploadWorkerStart { + readonly url: string; + readonly body: FileUploadBody; + readonly headers: Readonly>; +} + +type UploadWorkerOutput = + | { readonly kind: "progress"; readonly loaded: number; readonly total?: number } + | { readonly kind: "complete"; readonly status: number; readonly body: string } + | { readonly kind: "error"; readonly message: string }; + +interface UploadWorkerScope { + onmessage: ((event: MessageEvent) => void) | null; + postMessage(message: UploadWorkerOutput): void; +} + +interface UploadXhr { + readonly upload: { onprogress: ((event: ProgressEvent) => void) | null }; + status: number; + responseText: string; + withCredentials: boolean; + onload: ((event: ProgressEvent) => void) | null; + onerror: ((event: ProgressEvent) => void) | null; + open(method: string, url: string): void; + setRequestHeader(name: string, value: string): void; + send(body: Blob): void; +} + +type UploadWorkerFetch = ( + input: string, + init: RequestInit & { readonly duplex: "half" }, +) => Promise; + +/** + * Self-contained Worker body; its string form becomes the Blob Worker source. + * @param scope - Worker global used for requests and progress messages. + * @param createXhr - XMLHttpRequest factory used for Blob progress. + * @param doFetch - Fetch carrier used for one-shot ReadableStream bodies. + */ +export function fileUploadWorker( + scope: UploadWorkerScope = self, + createXhr: () => UploadXhr = () => new XMLHttpRequest(), + doFetch: UploadWorkerFetch = (input, init) => fetch(input, init), +): void { + scope.onmessage = (event: MessageEvent) => { + const request = event.data; + if (request.body instanceof Blob) { + const xhr = createXhr(); + xhr.open("POST", request.url); + xhr.withCredentials = true; + for (const [name, value] of Object.entries(request.headers)) + xhr.setRequestHeader(name, value); + xhr.upload.onprogress = (progress) => { + scope.postMessage({ + kind: "progress", + loaded: progress.loaded, + ...(progress.lengthComputable ? { total: progress.total } : {}), + } satisfies UploadWorkerOutput); + }; + xhr.onload = () => { + scope.postMessage({ + kind: "complete", + status: xhr.status, + body: xhr.responseText, + } satisfies UploadWorkerOutput); + }; + xhr.onerror = () => { + scope.postMessage({ + kind: "error", + message: "background upload transport failed", + } satisfies UploadWorkerOutput); + }; + xhr.send(request.body); + return; + } + if (!(request.body instanceof ReadableStream)) { + scope.postMessage({ + kind: "error", + message: "background upload worker received an invalid body", + }); + return; + } + const source = request.body; + void (async () => { + const reader = source.getReader(); + let loaded = 0; + const body = new ReadableStream({ + async pull(controller) { + const item = await reader.read(); + if (item.done) { + controller.close(); + return; + } + if (!(item.value instanceof Uint8Array)) { + throw new TypeError("background upload stream produced a non-Uint8Array chunk"); + } + loaded += item.value.byteLength; + scope.postMessage({ kind: "progress", loaded }); + controller.enqueue(item.value); + }, + async cancel(reason) { + await reader.cancel(reason); + }, + }); + const response = await doFetch(request.url, { + method: "POST", + headers: request.headers, + credentials: "include", + body, + duplex: "half", + }); + scope.postMessage({ + kind: "complete", + status: response.status, + body: await response.text(), + }); + })().catch((error: unknown) => { + scope.postMessage({ + kind: "error", + message: error instanceof Error ? error.message : String(error), + }); + }); + }; +} + +interface ClientFileUploadGlobal { + __DSH_FILE_UPLOAD__?: ClientFileUploadHooks; +} + +interface FileUploadTransport { + post(request: FileUploadRequest): Promise; +} + +/** Cordis service that owns one background carrier per upload operation. */ +export class FileUploadRuntime extends Service implements FileUploadService { + private readonly transport: FileUploadTransport; + + /** @param ctx - providing Client context. */ + constructor(ctx: Context) { + super(ctx, "fileUpload"); + const hook = (globalThis as ClientFileUploadGlobal).__DSH_FILE_UPLOAD__; + this.transport = hook === undefined ? workerTransport() : customTransport(hook.fetch); + } + + /** + * Post one body with the carrier selected before Cordis boot. + * @param request - target, body, cancellation, and progress observer. + * @returns the response status and text body. + */ + post(request: FileUploadRequest): Promise { + return this.transport.post(request); + } + + /** + * Store one file for a Session. + * @param sessionId - Session that owns the staged receipt. + * @param data - browser Blob, exact bytes, or a one-shot byte stream. + * @param name - optional display name. + * @param signal - optional cancellation for the active upload. + * @param onProgress - optional byte-progress observer for background bodies. + * @returns the staged receipt and durable file reference, or a business error. + */ + async upload( + sessionId: SessionId, + data: Blob | Uint8Array | ReadableStream, + name?: string, + signal?: AbortSignal, + onProgress?: (progress: { readonly loaded: number; readonly total?: number }) => void, + ): Promise> { + const query = new URLSearchParams({ sessionId }); + if (name !== undefined) query.set("name", name); + // All browser carriers use raw-byte intake, never the bounded JSON RPC route. + const body = data instanceof Uint8Array ? new Blob([Uint8Array.from(data).buffer]) : data; + const response = await this.post({ + path: `${FILE_UPLOAD_ROUTE}?${query.toString()}`, + body, + headers: { "content-type": "application/octet-stream" }, + ...(signal === undefined ? {} : { signal }), + ...(onProgress === undefined ? {} : { onProgress }), + }); + if (response.status !== 200) { + throw new Error(`file upload transport failed with HTTP ${String(response.status)}`); + } + return parseFileUploadResult(response.body); + } +} + +function customTransport(customFetch: FileUploadFetch): FileUploadTransport { + return { + async post(request) { + const init: RequestInit & { duplex?: "half" } = { + method: "POST", + ...(request.headers === undefined ? {} : { headers: request.headers }), + body: request.body, + ...(request.body instanceof ReadableStream ? { duplex: "half" as const } : {}), + ...(request.signal === undefined ? {} : { signal: request.signal }), + }; + const response = await customFetch(request.path, init); + return { status: response.status, body: await response.text() }; + }, + }; +} + +function workerTransport(): FileUploadTransport { + return { + post(request) { + if (typeof Worker !== "function") { + return Promise.reject(new Error("background upload requires Web Worker support")); + } + const workerUrl = URL.createObjectURL( + new Blob([`(${fileUploadWorker.toString()})()`], { type: "text/javascript" }), + ); + const worker = new Worker(workerUrl, { name: "dsh-file-upload" }); + URL.revokeObjectURL(workerUrl); + return new Promise((resolve, reject) => { + let settled = false; + const abort = (): void => { + settled = true; + worker.terminate(); + request.signal?.removeEventListener("abort", abort); + reject(new DOMException("The operation was aborted.", "AbortError")); + }; + const finish = (settle: () => void): void => { + if (settled) return; + settled = true; + request.signal?.removeEventListener("abort", abort); + worker.terminate(); + settle(); + }; + worker.onmessage = (event: MessageEvent) => { + const output = event.data; + if (output.kind === "progress") { + request.onProgress?.({ + loaded: output.loaded, + ...(output.total === undefined ? {} : { total: output.total }), + }); + } else if (output.kind === "complete") { + finish(() => { + resolve({ status: output.status, body: output.body }); + }); + } else { + finish(() => { + reject(new Error(output.message)); + }); + } + }; + worker.onerror = (event) => { + finish(() => { + reject(new Error(event.message || "background upload worker failed")); + }); + }; + if (request.signal?.aborted === true) { + abort(); + return; + } + request.signal?.addEventListener("abort", abort, { once: true }); + const message: UploadWorkerStart = { + // The Worker's own base is `blob:`, so its request URL must be absolute. + url: new URL(request.path, document.baseURI).href, + body: request.body, + headers: request.headers ?? {}, + }; + if (request.body instanceof ReadableStream) worker.postMessage(message, [request.body]); + else worker.postMessage(message); + }); + }, + }; +} + +function parseFileUploadResult(body: string): RemoteResult { + const value: unknown = JSON.parse(body); + if (!isRecord(value) || typeof value.ok !== "boolean") { + throw new TypeError("file upload transport returned an invalid result"); + } + if (!value.ok) { + const error = value.error; + if ( + !isRecord(error) || + typeof error.code !== "string" || + typeof error.message !== "string" || + !isRecord(error.details) + ) { + throw new TypeError("file upload transport returned an invalid failure"); + } + return { + ok: false, + error: new RemoteError(error.code as never, error.message, error.details as never), + }; + } + const result = value.value; + const file = isRecord(result) ? result.file : undefined; + if ( + !isRecord(result) || + typeof result.receiptId !== "string" || + !isRecord(file) || + typeof file.attachmentId !== "string" || + typeof file.name !== "string" || + typeof file.bytes !== "number" || + !Number.isSafeInteger(file.bytes) || + file.bytes < 0 + ) { + throw new TypeError("file upload transport returned an invalid receipt"); + } + return { + ok: true, + value: { + receiptId: result.receiptId as FileUploadValue["receiptId"], + file: { + attachmentId: file.attachmentId as FileUploadValue["file"]["attachmentId"], + name: file.name, + bytes: file.bytes, + }, + }, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/apps/web-ui/packages/client/file-upload/src/http-route.ts b/apps/web-ui/packages/client/file-upload/src/http-route.ts new file mode 100644 index 000000000..cf672088e --- /dev/null +++ b/apps/web-ui/packages/client/file-upload/src/http-route.ts @@ -0,0 +1,87 @@ +/** Authenticated raw-byte upload route registered on the Connection fetch registry. */ + +import { brandString } from "@deepseek-ai/dsh-brand"; +import type { SessionId } from "@deepseek-ai/dsh-session"; +import { remoteErrorOf } from "@deepseek-ai/dsh-typert-protocol"; +import type { FileUploads } from "./index.ts"; +import type { FileUploadValue } from "./types.ts"; + +type FileUploadHttpResult = + | { readonly ok: true; readonly value: FileUploadValue } + | { + readonly ok: false; + readonly error: { readonly code: string; readonly message: string; readonly details: object }; + }; + +/** + * Handle one authenticated raw-byte upload. + * @param service - Host upload service receiving streamed bytes. + * @param request - authenticated HTTP request from Connection. + * @returns JSON result using HTTP status 200 after request validation. + */ +export async function handleFileUploadHttp( + service: FileUploads, + request: Request, +): Promise { + if (request.method !== "POST") { + return new Response(null, { status: 405, headers: { allow: "POST" } }); + } + const mediaType = request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase(); + if (mediaType !== "application/octet-stream") { + return new Response("content type must be application/octet-stream", { status: 415 }); + } + const url = new URL(request.url); + const sessionId = url.searchParams.get("sessionId"); + if (sessionId === null || sessionId === "") { + return new Response("sessionId is required", { status: 400 }); + } + const name = url.searchParams.get("name") ?? undefined; + let result: FileUploadHttpResult; + try { + result = { + ok: true, + value: await service.uploadStream({ + sessionId: brandString(sessionId), + data: requestBodyChunks(request.body), + signal: request.signal, + ...(name === undefined ? {} : { name }), + }), + }; + } catch (error) { + const failure = remoteErrorOf(error); + result = { + ok: false, + error: + failure !== undefined + ? { code: failure.code, message: failure.message, details: failure.details } + : { + code: "gateway/internal", + message: error instanceof Error ? error.message : String(error), + details: {}, + }, + }; + } + return new Response(JSON.stringify(result), { + status: 200, + headers: { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + }, + }); +} + +async function* requestBodyChunks( + body: ReadableStream | null, +): AsyncIterable { + if (body === null) return; + const reader = body.getReader(); + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) return; + yield chunk.value; + } + } finally { + reader.releaseLock(); + } +} diff --git a/apps/web-ui/packages/client/file-upload/src/index.ts b/apps/web-ui/packages/client/file-upload/src/index.ts new file mode 100644 index 000000000..29f029f41 --- /dev/null +++ b/apps/web-ui/packages/client/file-upload/src/index.ts @@ -0,0 +1,281 @@ +/** Host file-upload service: streamed intake and Session-owned staged receipts. */ + +import { randomUUID } from "node:crypto"; +import type { Context } from "@deepseek-ai/cordis"; +import type { Agent } from "@deepseek-ai/dsh-agent"; +import type { FileAttachmentRef } from "@deepseek-ai/dsh-attachment"; +import type {} from "@deepseek-ai/dsh-client-connection"; +import type { CommandFileReceiptResolver } from "@deepseek-ai/dsh-commands"; +import type { Session, SessionEvent, SessionId } from "@deepseek-ai/dsh-session"; +import { Remote, RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol"; +import { handleFileUploadHttp } from "./http-route.ts"; +import { FILE_UPLOAD_PATH } from "./protocol.ts"; +import type { EncodedFileUploadRequest, FileUploadReceiptId, FileUploadValue } from "./types.ts"; + +export type * from "./types.ts"; + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Host storage and staged-receipt service for browser file uploads. */ + fileUploads: FileUploads; + } +} + +interface StagedFileUpload { + readonly file: FileAttachmentRef; + /** Prompt that accepted this receipt; absent until successful admission. */ + requestId?: string; +} + +/** Resolve or resume the ordinary Agent that owns one Session identity. */ +export type AgentResolver = (sessionId: SessionId) => Promise; + +/** Prompt receipt binding that restores its previous owners unless delivery commits it. */ +export interface PromptFileBinding extends Disposable { + /** Keep the receipt bindings until queue or history observation retires them. */ + commit(): void; +} + +class PromptFileBindingGuard implements PromptFileBinding { + private settled = false; + + constructor(private readonly rollback: () => void) {} + + commit(): void { + this.settled = true; + } + + [Symbol.dispose](): void { + if (this.settled) return; + this.settled = true; + this.rollback(); + } +} + +/** Host service owning upload storage and receipts keyed by each receiving Agent's exact Session. */ +export class FileUploads extends TypertRemoteService { + static inject = ["agents", "attachments", "commands", "connection"]; + + private readonly stagedFiles = new WeakMap>(); + private agentResolver: AgentResolver | undefined; + + /** @param ctx - Host context carrying Agent, attachment, command, and Connection services. */ + constructor(ctx: Context) { + super(ctx, "fileUploads"); + const resolve: CommandFileReceiptResolver = (agent, receiptId) => + this.resolve(agent, receiptId as FileUploadReceiptId); + ctx.effect( + () => ctx.commands.registerFileReceiptResolver(resolve), + "file-upload: command file receipt resolver", + ); + ctx.effect( + () => + ctx.connection.fetch.register({ + path: FILE_UPLOAD_PATH, + methods: ["POST"], + requestBody: "streaming", + fetch: (request) => handleFileUploadHttp(this, request), + }), + "file-upload: streaming route", + ); + ctx.on("session/event", (session, event) => { + this.observeSessionEvent(session, event); + }); + ctx.on("session/disposed", (session) => { + this.stagedFiles.delete(session); + }); + } + + /** + * Register the ordinary-Session resolver used when a raw upload addresses a cold Session. + * @param resolve - resolver that returns the exact live Agent or throws a Remote error. + * @returns disposer removing this resolver. + */ + registerAgentResolver(resolve: AgentResolver): () => void { + if (this.agentResolver !== undefined) + throw new Error("file-upload: Agent resolver is already registered"); + this.agentResolver = resolve; + return () => { + if (this.agentResolver === resolve) this.agentResolver = undefined; + }; + } + + /** + * Persist one encoded upload and stage it under the Agent receiver selected by Typert. + * @param agent - receiving Agent resolved from the Remote Agent scope. + * @param request - canonical base64 bytes and optional display name. + * @param signal - caller cancellation before storage begins. + * @returns the staged receipt and durable file reference. + */ + @Remote("upload") + upload( + agent: Agent, + request: EncodedFileUploadRequest, + signal: AbortSignal, + ): Promise { + signal.throwIfAborted(); + return this.commit(agent, async () => + this.ctx.attachments.admitEncodedFile({ + data: request.data, + ...(request.name === undefined ? {} : { name: request.name }), + }), + ); + } + + /** + * Persist raw chunks for one Session without aggregating the upload. + * @param request - Session identity, ordered bytes, cancellation, and optional display name. + * @returns the staged receipt and durable file reference. + */ + async uploadStream(request: { + readonly sessionId: SessionId; + readonly data: AsyncIterable; + readonly signal?: AbortSignal; + readonly name?: string; + }): Promise { + const agent = await this.resolveAgent(request.sessionId); + return this.commit(agent, async () => + this.ctx.attachments.saveFileStream({ + data: request.data, + ...(request.signal === undefined ? {} : { signal: request.signal }), + ...(request.name === undefined ? {} : { name: request.name }), + }), + ); + } + + /** + * Resolve one staged receipt for the receiving Agent's exact Session. + * @param agent - receiving Agent. + * @param receiptId - opaque receipt minted for one completed upload. + * @returns durable file reference, or `undefined` for an unknown or foreign receipt. + */ + resolve(agent: Agent, receiptId: FileUploadReceiptId): FileAttachmentRef | undefined { + return this.stagedFiles.get(agent.session)?.get(receiptId)?.file; + } + + /** + * Bind receipts while one prompt enters an Agent inbox. + * Disposal restores every prior binding unless the caller commits successful delivery. + * @param agent - receiving Agent. + * @param receiptIds - distinct staged receipts referenced by the prompt. + * @param requestId - prompt identity later observed in queue or history. + * @returns binding kept after commit until queue or history observation retires its receipts. + */ + bindPrompt( + agent: Agent, + receiptIds: readonly FileUploadReceiptId[], + requestId: string, + ): PromptFileBinding { + const staged = this.stagedFiles.get(agent.session); + const bound = receiptIds.map((receiptId) => { + const upload = staged?.get(receiptId); + if (upload === undefined) throw fileNotStaged(); + return { upload, previous: upload.requestId }; + }); + for (const { upload } of bound) upload.requestId = requestId; + return new PromptFileBindingGuard(() => { + for (const { upload, previous } of bound) { + if (previous === undefined) delete upload.requestId; + else upload.requestId = previous; + } + }); + } + + /** + * Retire every receipt accepted by one removed queue occurrence. + * @param agent - receiving Agent. + * @param requestId - prompt identity carried by the queue occurrence. + */ + retirePrompt(agent: Agent, requestId: string): void { + this.retire(agent.session, requestId); + } + + private async commit( + agent: Agent, + save: () => Promise, + ): Promise { + this.assertOrdinaryAgent(agent); + let file: FileAttachmentRef; + try { + file = await save(); + } catch (error) { + if (this.ctx.attachments.isAttachmentError(error)) { + throw new RemoteError("session/attachment-invalid" as never, error.message, { + reason: error.code, + } as never); + } + throw new RemoteError( + "gateway/internal", + `failed to store file upload: ${String(error)}`, + {}, + { cause: error }, + ); + } + if (this.ctx.agents.get(agent.id) !== agent) { + throw new RemoteError( + "session/not-found", + `session "${agent.id}" was disposed before its file upload completed`, + { sessionId: agent.id }, + ); + } + let staged = this.stagedFiles.get(agent.session); + if (staged === undefined) { + staged = new Map(); + this.stagedFiles.set(agent.session, staged); + } + const receiptId = randomUUID() as FileUploadReceiptId; + staged.set(receiptId, { file }); + return { receiptId, file }; + } + + private async resolveAgent(sessionId: SessionId): Promise { + const live = this.ctx.agents.get(sessionId); + if (live !== undefined) return live; + const resolver = this.agentResolver; + if (resolver === undefined) { + throw new RemoteError("session/not-found", `session "${sessionId}" is not attached`, { + sessionId, + }); + } + return resolver(sessionId); + } + + private assertOrdinaryAgent(agent: Agent): void { + if (agent.session.header.origin === "subagent") { + throw new RemoteError( + "subagent/attachment-invalid" as never, + "subagent conversations do not accept file uploads", + { reason: "SUBAGENT_FILE_UNSUPPORTED" } as never, + ); + } + } + + private observeSessionEvent(session: Session, event: SessionEvent): void { + if ( + event.type !== "user/message" || + event.data.source.kind !== "user" || + !("rpcId" in event.data.source) + ) + return; + if (typeof event.data.source.rpcId === "string") this.retire(session, event.data.source.rpcId); + } + + private retire(session: Session, requestId: string): void { + const staged = this.stagedFiles.get(session); + if (staged === undefined) return; + for (const [receiptId, upload] of staged) { + if (upload.requestId === requestId) staged.delete(receiptId); + } + if (staged.size === 0) this.stagedFiles.delete(session); + } +} + +function fileNotStaged(): RemoteError { + return new RemoteError( + "session/attachment-invalid" as never, + "File was not uploaded for this session.", + { reason: "FILE_NOT_STAGED" } as never, + ); +} + +export default FileUploads; diff --git a/apps/web-ui/packages/client/file-upload/src/protocol.ts b/apps/web-ui/packages/client/file-upload/src/protocol.ts new file mode 100644 index 000000000..c84eebdc5 --- /dev/null +++ b/apps/web-ui/packages/client/file-upload/src/protocol.ts @@ -0,0 +1,8 @@ +/** Authenticated raw-byte route owned by the file-upload service. */ +export const FILE_UPLOAD_PATH = "/api/session/uploadFileBinary"; + +/** + * Browser-relative form of {@link FILE_UPLOAD_PATH}; see + * .agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md. + */ +export const FILE_UPLOAD_ROUTE = FILE_UPLOAD_PATH.slice(1); diff --git a/apps/web-ui/packages/client/file-upload/src/types.ts b/apps/web-ui/packages/client/file-upload/src/types.ts new file mode 100644 index 000000000..573fa54a0 --- /dev/null +++ b/apps/web-ui/packages/client/file-upload/src/types.ts @@ -0,0 +1,36 @@ +/** Browser-safe request and receipt types for staged file uploads. */ + +import type { FileAttachmentRef } from "@deepseek-ai/dsh-attachment/types"; +import type { Branded } from "@deepseek-ai/dsh-brand"; + +/** Canonical encoded upload accepted by the Remote fallback. */ +export interface EncodedFileUploadRequest { + /** Canonical base64 encoding of the exact file bytes. */ + readonly data: string; + /** Optional display name; the Host sanitizes it into the stored leaf name. */ + readonly name?: string; +} + +/** Durable receipt for one staged file upload. */ +export interface FileUploadValue { + /** Per-upload authority accepted only inside the receiving Agent scope. */ + readonly receiptId: FileUploadReceiptId; + readonly file: FileAttachmentRef; +} + +/** Host-minted authority for one staged file upload in one Agent scope. */ +export type FileUploadReceiptId = Branded<"file-upload-receipt-id">; + +/** + * Fetch-shaped carrier installed by a page that owns its Host transport. + * @param input - document-relative upload route; a carrier that needs an + * absolute URL resolves it against its own base. + * @param init - raw request body, headers, and cancellation signal. + * @returns the Host response. + */ +export type FileUploadFetch = (input: string | URL, init: RequestInit) => Promise; + +/** Pre-Cordis hook supplied by a page whose Host runs in another execution context. */ +export interface ClientFileUploadHooks { + readonly fetch: FileUploadFetch; +} diff --git a/apps/web-ui/packages/client/file-upload/test/runtime.test.ts b/apps/web-ui/packages/client/file-upload/test/runtime.test.ts new file mode 100644 index 000000000..5c87c4c83 --- /dev/null +++ b/apps/web-ui/packages/client/file-upload/test/runtime.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { it } from "node:test"; +import { Context } from "@deepseek-ai/cordis"; +import { FileUploadRuntime } from "../src/client/runtime.ts"; + +it("Blob, Uint8Array and stream carriers all bypass the JSON RPC file-size ceiling", async (t) => { + const scope = globalThis as typeof globalThis & { __DSH_FILE_UPLOAD__?: { fetch: typeof fetch } }; + const previous = scope.__DSH_FILE_UPLOAD__; + t.after(() => { + if (previous) scope.__DSH_FILE_UPLOAD__ = previous; + else delete scope.__DSH_FILE_UPLOAD__; + }); + const bodies: Uint8Array[] = []; + scope.__DSH_FILE_UPLOAD__ = { + fetch: async (url, init) => { + assert.match(String(url), /^api\/session\/uploadFileBinary\?/u); + assert.equal( + init?.headers && (init.headers as Record)["content-type"], + "application/octet-stream", + ); + const bytes = new Uint8Array(await new Response(init?.body).arrayBuffer()); + bodies.push(bytes); + return new Response( + JSON.stringify({ + ok: true, + value: { + receiptId: "receipt", + file: { attachmentId: "file", name: "data.bin", bytes: bytes.length }, + }, + }), + ); + }, + }; + // No Remote service is supplied; the old Uint8Array fallback would fail here. + const runtime = new FileUploadRuntime(new Context()); + const bytes = Uint8Array.from([0, 255, 1, 42]); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }); + for (const body of [new Blob([bytes]), bytes, stream]) { + const result = await runtime.upload("thread" as never, body, "data.bin"); + assert.ok(result.ok); + } + assert.equal(bodies.length, 3); + for (const body of bodies) assert.deepEqual(body, bytes); +}); diff --git a/apps/web-ui/packages/client/file-upload/tsdown.config.ts b/apps/web-ui/packages/client/file-upload/tsdown.config.ts new file mode 100644 index 000000000..733ecb264 --- /dev/null +++ b/apps/web-ui/packages/client/file-upload/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from "../tsdown.client.ts"; + +export default clientBundle("@deepseek-ai/dsh-client-file-upload", ["lib/types/index.js"]); diff --git a/apps/web-ui/packages/client/hmr/package.json b/apps/web-ui/packages/client/hmr/package.json new file mode 100644 index 000000000..5afa25170 --- /dev/null +++ b/apps/web-ui/packages/client/hmr/package.json @@ -0,0 +1,29 @@ +{ + "name": "@deepseek-ai/dsh-client-hmr", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/events.ts", + "default": "./src/events.ts" + }, + "./client": { + "types": "./src/client/index.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dependencies": {}, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-modules" + ], + "platform": "web", + "immediately": true + } + } +} diff --git a/apps/web-ui/packages/client/hmr/src/client/index.ts b/apps/web-ui/packages/client/hmr/src/client/index.ts new file mode 100644 index 000000000..e4e06e246 --- /dev/null +++ b/apps/web-ui/packages/client/hmr/src/client/index.ts @@ -0,0 +1,56 @@ +/** Web SSE transport for page-owned client entry reconciliation and rebuilt code replacement. */ +import type { Context } from "@deepseek-ai/cordis"; +import type { PluginsEventParseResult } from "../events.ts"; +import { EVENTS_ROUTE, parsePluginsEventFrame } from "../events.ts"; + +export type { PluginsEventFrame } from "../events.ts"; + +/** Cordis plugin name. */ +export const name = "client-hmr"; + +/** Required service: the client module system whose entry controller handles received frames. */ +export const inject = ["modules"]; + +/** + * Forward graph snapshots and rebuilds to the page's shared serial controller. + * @param ctx - Plugin context with the client module system. + */ +export function apply(ctx: Context): void { + const entries = ctx.modules.entries; + const handle = (frame: Extract["frame"]): void => { + if (frame.type === "shell") { + location.reload(); + return; + } + const run = + frame.type === "graph" + ? Promise.resolve().then(() => entries.sync(frame.graph)) + : entries.reload(frame.id, frame.rev); + void run.catch((error: unknown) => { + ctx.logger.error(error); + }); + }; + + ctx.effect(() => { + const source = new EventSource(EVENTS_ROUTE); + source.addEventListener("message", (event: MessageEvent) => { + let value: unknown; + try { + value = JSON.parse(event.data) as unknown; + } catch { + // Wire boundary: a malformed transport frame is dropped loudly. + ctx.logger.warn(`client-hmr: unparseable event frame: ${event.data}`); + return; + } + const parsed = parsePluginsEventFrame(value); + if (parsed.kind === "invalid") { + ctx.logger.warn(`client-hmr: invalid event frame: ${event.data}`); + } else if (parsed.kind === "frame") { + handle(parsed.frame); + } + }); + return () => { + source.close(); + }; + }, "client-hmr: event source"); +} diff --git a/apps/web-ui/packages/client/hmr/src/events.ts b/apps/web-ui/packages/client/hmr/src/events.ts new file mode 100644 index 000000000..5aa5e35ca --- /dev/null +++ b/apps/web-ui/packages/client/hmr/src/events.ts @@ -0,0 +1,56 @@ +/** + * Wire protocol of the `/plugins/events` SSE channel — single source for + * both halves of this package. Frames still cross a wire boundary: the + * browser half validates them at its JSON parse point; sharing the type keeps + * the two ends from drifting, not from parsing. + */ + +import type { WebBootGraph } from "@deepseek-ai/dsh-client-modules"; + +/** + * One SSE frame: the settled graph on connection or change, one rebuilt bundle notice, or a + * rebuilt shell (CodexHost: the statically linked shell cannot be swapped, so the page reloads). + */ +export type PluginsEventFrame = + | { type: "graph"; graph: WebBootGraph } + | { type: "rebuilt"; id: string; rev: string } + | { type: "shell" }; + +/** Browser wire-parse result: known frame, forward-compatible unknown type, or malformed payload. */ +export type PluginsEventParseResult = + | { + kind: "frame"; + frame: { type: "graph"; graph: unknown } | Exclude; + } + | { kind: "unknown" } + | { kind: "invalid" }; + +/** + * Validate the frame envelope; the module controller parses the complete graph before updating its index. + * @param value - Parsed JSON value from the EventSource message. + * @returns the known frame, an unknown-type marker, or an invalid marker. + */ +export function parsePluginsEventFrame(value: unknown): PluginsEventParseResult { + if (typeof value !== "object" || value === null) return { kind: "invalid" }; + const record = value as Record; + switch (record.type) { + case "rebuilt": + return typeof record.id === "string" && typeof record.rev === "string" + ? { kind: "frame", frame: { type: "rebuilt", id: record.id, rev: record.rev } } + : { kind: "invalid" }; + case "shell": + return { kind: "frame", frame: { type: "shell" } }; + case "graph": + return typeof record.graph === "object" && record.graph !== null + ? { kind: "frame", frame: { type: "graph", graph: record.graph } } + : { kind: "invalid" }; + default: + return typeof record.type === "string" ? { kind: "unknown" } : { kind: "invalid" }; + } +} + +/** System SSE endpoint pushing graph/rebuilt frames (wire protocol constant). */ +export const EVENTS_ENDPOINT = "/plugins/events"; + +/** Document-relative form of {@link EVENTS_ENDPOINT} used by the browser half. */ +export const EVENTS_ROUTE = EVENTS_ENDPOINT.slice(1); diff --git a/apps/web-ui/packages/client/hmr/tsdown.config.ts b/apps/web-ui/packages/client/hmr/tsdown.config.ts new file mode 100644 index 000000000..31704a576 --- /dev/null +++ b/apps/web-ui/packages/client/hmr/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from "../tsdown.client.ts"; + +export default clientBundle("@deepseek-ai/dsh-client-hmr", ["lib/types/index.js"]); diff --git a/apps/web-ui/packages/client/locale/package.json b/apps/web-ui/packages/client/locale/package.json new file mode 100644 index 000000000..6f7546cd0 --- /dev/null +++ b/apps/web-ui/packages/client/locale/package.json @@ -0,0 +1,37 @@ +{ + "name": "@deepseek-ai/dsh-client-locale", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./client": { + "types": "./src/client/index.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dependencies": { + "@deepseek-ai/dsh-client-store": "*", + "react": "^18.2.0", + "@deepseek-ai/dsh-client-ui-primitives": "*", + "@deepseek-ai/schemastery": "*" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-api-remotes" + ], + "platform": "web", + "immediately": true + } + } +} diff --git a/apps/web-ui/packages/client/locale/src/client/LanguageRow.module.css b/apps/web-ui/packages/client/locale/src/client/LanguageRow.module.css new file mode 100644 index 000000000..af02724b4 --- /dev/null +++ b/apps/web-ui/packages/client/locale/src/client/LanguageRow.module.css @@ -0,0 +1,51 @@ +/* Language row (figma 'Setting-Cell': gap 8, pad 16/0, hairline separator; + * the section column removes the separator on its last child). */ + +.row { + display: flex; + align-items: center; + gap: 8px; + padding: 16px 0; + border-bottom: 0.5px solid var(--dsw-alias-border-l2); +} + +.rowText { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + padding-right: 48px; +} + +.title { + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +/* Language selector uses the standard control size. */ +.selector { + display: inline-flex; + align-items: center; + gap: 12px; + height: 36px; + padding: 0 14px; + border: none; + border-radius: var(--dsw-radius-md); + background: var(--dsw-alias-bg-module-platform); + font: inherit; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +.selector:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.chevron { + flex: none; +} diff --git a/apps/web-ui/packages/client/locale/src/client/LanguageRow.tsx b/apps/web-ui/packages/client/locale/src/client/LanguageRow.tsx new file mode 100644 index 000000000..dd194c260 --- /dev/null +++ b/apps/web-ui/packages/client/locale/src/client/LanguageRow.tsx @@ -0,0 +1,72 @@ +/** + * Language preference row registered into the General section item slot + * (figma 501:30011 'Setting-Cell'): title + selector pill opening the locale + * menu. Registered by this package — the locale feature owns its own + * settings surface. + */ +import { useState } from "react"; +import type { PropsLocale, PropsRuntime, PropsStore } from "@deepseek-ai/dsh-client-ui-slots"; +import { IconChevronDownOutlineRegular, Menu } from "@deepseek-ai/dsh-client-ui-primitives"; +import type {} from "@deepseek-ai/dsh-client-ui-settings/client"; +import type { createLanguageRowStore } from "./settings-store.ts"; +import css from "./LanguageRow.module.css"; + +/** Injected business face: the preference write (t rides the standard locale seat). */ +export interface LanguageRowInjected { + /** Switch the active locale (a registered locale id). */ + setLocale: (id: string) => void; +} + +/** Full component props: runtime share + store share + locale seat + injected face. */ +export type LanguageRowComponentProps = PropsRuntime<"settings.general.item"> & + PropsStore> & + PropsLocale<"settings.locale"> & + LanguageRowInjected; + +/** + * Render the Language row. + * @param props - composed slot props. + * @returns the row element tree. + */ +export function LanguageRow({ t, setLocale, useStore }: LanguageRowComponentProps) { + const active = useStore((s) => s.active); + const options = useStore((s) => s.options); + const [open, setOpen] = useState(false); + const activeLabel = options.find((o) => o.id === active)?.label ?? active; + + return ( +

+
+
{t("language.title")}
+
+ { + setOpen(false); + }} + items={options.map((o) => ({ id: o.id, label: o.label }))} + selectedId={active} + onSelect={(id) => { + setLocale(id); + setOpen(false); + }} + align="end" + portal + anchor={ + + } + /> +
+ ); +} diff --git a/apps/web-ui/packages/client/locale/src/client/bootstrap.ts b/apps/web-ui/packages/client/locale/src/client/bootstrap.ts new file mode 100644 index 000000000..279ae75f0 --- /dev/null +++ b/apps/web-ui/packages/client/locale/src/client/bootstrap.ts @@ -0,0 +1,37 @@ +/** Locale initialization supplied by a native shell before its Client tree mounts. */ + +/** System languages and the existing Host preference for one page load. */ +export interface LocaleBootstrap { + /** Operating-system language tags in preference order. */ + readonly languages: readonly string[]; + /** Stored locale.preference; null preserves automatic language selection. */ + readonly preference: string | null; +} + +/** Optional page-global bridge named __DSH_LOCALE__; ordinary browsers omit it. */ +export interface LocaleBridge { + /** Read current initialization data through the shell's isolated preload. @returns unvalidated IPC data. */ + read(): Promise; + /** Publish the resolved Client locale without writing another preference. @param locale - active registered locale id. */ + onChange(locale: string): void; +} + +/** + * Validate initialization data returned over the preload IPC bridge. + * @param value - untrusted IPC response. + * @returns language initialization with no persistence side effects. + */ +export function parseLocaleBootstrap(value: unknown): LocaleBootstrap { + if ( + typeof value !== "object" || + value === null || + !("languages" in value) || + !Array.isArray(value.languages) || + !value.languages.every((language: unknown) => typeof language === "string") || + !("preference" in value) || + (value.preference !== null && typeof value.preference !== "string") + ) { + throw new TypeError("locale: invalid native initialization data"); + } + return { languages: value.languages, preference: value.preference }; +} diff --git a/apps/web-ui/packages/client/locale/src/client/index.ts b/apps/web-ui/packages/client/locale/src/client/index.ts new file mode 100644 index 000000000..aeff42d83 --- /dev/null +++ b/apps/web-ui/packages/client/locale/src/client/index.ts @@ -0,0 +1,668 @@ +/** + * Browser-side locale registry. Bound translation functions retain stable + * identity for injected consumers. The plugin also registers the Language + * preference row into the settings General section — the locale feature owns + * its own settings surface. + */ +import type { Context as ClientContext } from "@deepseek-ai/cordis"; +import type { LocalizedText } from "@deepseek-ai/dsh-package-manifest"; +import { + type BoundActions, + type LocaleDictOf, + type LocaleNamespaceMap, + type Translate, + type TranslateNS, +} from "@deepseek-ai/dsh-client-ui-slots"; +// Type-only: the ctx.configForms Context merge and the settings slot types. +// Cross-plugin collaboration goes through the service, never a value import +// (client bundle purity gate). +import type { ConfigForm } from "@deepseek-ai/dsh-client-ui-settings/client"; +import { parseLocaleBootstrap, type LocaleBootstrap, type LocaleBridge } from "./bootstrap.ts"; +// Type-only: pulls the SlotRegistry service merge (ctx.slots). +import type {} from "@deepseek-ai/dsh-client-ui-renderer/client"; +import { + LOCALE_ID_PATTERN, + LOCALE_IDS, + LOCALE_PREFERENCE_FIELD, + LOCALE_SETTINGS_NAMESPACE, + type BuiltInLocaleId, + type LocaleId, + type LocaleSettings, +} from "../locale-settings.ts"; +import { en, zh, type CommonKey } from "../locales/index.ts"; +import { en as settingsEn, zh as settingsZh, type SettingsLocaleKey } from "../locales/settings.ts"; +import type { LanguageRowInjected } from "./LanguageRow.tsx"; +import { LanguageRow } from "./LanguageRow.tsx"; +import { createLanguageRowStore } from "./settings-store.ts"; + +export type { LanguageRowComponentProps, LanguageRowInjected } from "./LanguageRow.tsx"; +export type { LanguageOptionRow, LanguageRowState } from "./settings-store.ts"; +export type { CommonKey } from "../locales/index.ts"; +export type { BuiltInLocaleId, LocaleId, LocaleSettings } from "../locale-settings.ts"; + +// The translate currency lives in ui-slots (the render machinery synthesizes +// the seat); re-exported here so dictionary owners import one package. +// TranslateNS<'model'> is the namespace-addressed developer-facing form. +export type { Translate, TranslateNS } from "@deepseek-ai/dsh-client-ui-slots"; + +declare module "@deepseek-ai/dsh-client-ui-slots" { + interface LocaleNamespaceMap { + /** Shared cross-feature vocabulary, consulted by the lookup chain after the entry's own namespace misses. */ + common: CommonKey; + /** This feature's own settings-row copy (the Language row). */ + "settings.locale": SettingsLocaleKey; + } +} + +/** Locale dictionary: flat key to template string ({name} placeholders). */ +export type LocaleDict = Record; + +/** Input accepted when a language-pack plugin adds a selectable language. */ +export interface LanguageRegistration { + /** Stable BCP 47-style id stored as the locale preference. */ + id: LocaleId; + /** Display name written in the represented language. */ + label: string; + /** Registered language consulted when this language lacks a dictionary key. */ + fallback: LocaleId; +} + +/** One normalized selectable locale published in snapshots. */ +export interface LocaleDefinition { + /** Stable id persisted by {@link LocaleRuntime.setLocale}. */ + readonly id: LocaleId; + /** Display name written in the represented language. */ + readonly label: string; + /** Next language in the per-key fallback chain; absent only for English. */ + readonly fallback?: LocaleId; +} + +/** Immutable locale state published on every change. */ +export interface LocaleSnapshot { + /** Active locale id. */ + active: LocaleId; + /** Selectable locales in display order. */ + locales: readonly LocaleDefinition[]; + /** Monotonic change counter (registry or active changes). */ + revision: number; +} + +declare module "@deepseek-ai/cordis" { + interface Context { + locale: LocaleRuntime; + } + interface Events { + /** + * The active locale switched. Dictionary registrations do NOT emit this + * event (listeners may re-register slots in response, and boot registers + * one namespace per package); continuous render refresh rides the + * LocaleFace revision instead. + * @param snapshot - Current immutable locale snapshot. + * @mode emit + */ + "locale/change"(snapshot: LocaleSnapshot): void; + } +} + +/** + * English is both the locale the UI opens in when the browser names no registered + * language (and for non-browser runs), and the dictionary consulted after the + * active locale misses a key. One constant serves both because the shipped + * `zh`/`en` dictionaries carry identical key sets, so neither direction can + * leave a key unresolved; the residual case points at English rather than + * zh because a browser naming no registered language is the reader least + * likely to read Chinese. + */ +export const FALLBACK_LOCALE: BuiltInLocaleId = "en"; + +/** Shared namespace for shell-level texts. */ +export const COMMON_NS = "common"; + +/** Namespace owning this feature's settings-row copy. */ +export const SETTINGS_NS = "settings.locale"; + +/** The two locales and dictionaries shipped by this package. */ +const BUILT_IN_LOCALE_METADATA = { + zh: { label: "中文", fallback: "en" }, + en: { label: "English" }, +} as const satisfies Record>; +const BUILT_IN_LOCALES: readonly LocaleDefinition[] = Object.freeze( + LOCALE_IDS.map((id) => Object.freeze({ id, ...BUILT_IN_LOCALE_METADATA[id] })), +); + +/** Case-insensitive key for BCP 47-style ids. */ +function localeKey(value: string): string { + return value.toLowerCase(); +} + +/** Validate and detach a language-pack contribution from its mutable input. */ +function normalizeLanguage(input: LanguageRegistration): Readonly { + if (!LOCALE_ID_PATTERN.test(input.id)) { + throw new Error(`locale id "${input.id}" is not a BCP 47-style tag`); + } + if (input.label.trim() === "") throw new Error("locale label must not be empty"); + if (!LOCALE_ID_PATTERN.test(input.fallback)) { + throw new Error(`locale fallback "${input.fallback}" is not a BCP 47-style tag`); + } + return Object.freeze({ id: input.id, label: input.label, fallback: input.fallback }); +} + +/** + * Point `` at the active locale, keeping the served document in + * sync with locale snapshot changes. + * @param snapshot - current locale state, including the active definition. + */ +function syncDocumentLanguage(snapshot: LocaleSnapshot): void { + // Non-browser runs (node boots of the client tree) have no document. + if (typeof document === "undefined") return; + const language = snapshot.active === "zh" ? "zh-CN" : snapshot.active; + if (document.documentElement.lang !== language) document.documentElement.lang = language; +} + +/** + * Dictionary registry plus locale preference. Lookup walks the active + * language's declared fallback chain in the entry namespace, then repeats it + * in the shared common namespace before showing the key itself. Reads go + * through {@link getLocale}; preferences change only through + * {@link setLocale}, while language packs extend the catalog through + * {@link addLanguage}. Continuous sync uses the `locale/change` event or + * the LocaleFace getSnapshot/subscribe pair installed through + * `ctx.slots.installLocale`. + */ +export class LocaleRuntime { + private dicts = new Map>(); + private bound = new Map(); + private catalog = new Map(); + private fallbackChains = new Map(); + private snapshot: LocaleSnapshot; + private listeners = new Set<() => void>(); + private readonly ctx: ClientContext; + private readonly host: ConfigForm | undefined; + /** Browser-derived locale standing wherever no explicit Host selection does. */ + private provisional: LocaleId; + /** Last explicit selection, including one awaiting an external registration. */ + private preference: LocaleId | undefined; + + /** + * @param ctx - owning context (change events are emitted on it; the scope + * listener is released through ctx.effect on dispose). + * @param host - durable preference scope owned by the providing plugin; + * absent compositions (standalone dictionary registries) stay process-local. + * @param bootstrap - native initialization; absent in ordinary browsers. + */ + constructor( + ctx: ClientContext, + host?: ConfigForm, + private readonly bootstrap?: LocaleBootstrap, + ) { + this.ctx = ctx; + this.host = host; + for (const locale of BUILT_IN_LOCALES) this.catalog.set(localeKey(locale.id), locale); + const locales = this.localeList(); + this.provisional = resolveInitialLocale(locales, bootstrap?.languages); + this.preference = bootstrap?.preference ?? undefined; + this.snapshot = Object.freeze({ active: this.resolveActive(), locales, revision: 0 }); + if (host !== undefined) { + ctx.effect( + () => + host.subscribe(() => { + this.adopt(host); + }), + "locale: settings scope adoption", + ); + this.adopt(host); + } + } + + /** + * Read the current immutable locale snapshot. + * @returns the current snapshot (stable reference until the next change). + */ + getLocale(): LocaleSnapshot { + return this.snapshot; + } + + /** + * Resolve package text through the active language's declared fallback chain. + * Plain strings stay verbatim; maps do not consult registered dictionaries. + * @param text - package text whose locale keys are lowercase and include English. + * @returns the first available translation, including an empty string. + */ + resolveText(text: LocalizedText): string { + if (typeof text === "string") return text; + return this.fallbackChain(this.snapshot.active).reduceRight( + (resolved, locale) => text[localeKey(locale)] ?? resolved, + text.en, + ); + } + + /** + * LocaleFace getSnapshot: the current snapshot (carries `revision`; stable + * reference between changes, uSES-safe). + * @returns the current snapshot. + */ + getSnapshot(): LocaleSnapshot { + return this.snapshot; + } + + /** + * LocaleFace subscribe: notified on every snapshot change (locale switch + * or dictionary registration — registrations bump the revision so already + * rendered outlets pick up late-arriving dictionaries and locale definitions). + * @param fn - change callback. + * @returns unsubscribe. + */ + subscribe(fn: () => void): () => void { + this.listeners.add(fn); + return () => { + this.listeners.delete(fn); + }; + } + + /** + * Switch the active locale — the only user preference write entry. + * + * The durable write happens even when the id already matches the active + * locale, because the active value may be a provisional browser-derived or + * fallback resolution that nothing has stored yet. Picking the language + * already on screen is still an explicit choice, and it must survive a + * different browser sharing the same DSH home. Only the render notification + * is conditional: republishing an unchanged locale would churn every + * subscriber for nothing. + * @param id - a registered locale id; unknown ids throw. + */ + setLocale(id: string): void { + const match = this.catalog.get(localeKey(id)); + if (match === undefined) throw new Error(`locale "${id}" is not registered`); + this.preference = match.id; + if (this.snapshot.active !== match.id) this.publish(match.id, true); + void this.host?.set(LOCALE_PREFERENCE_FIELD, match.id); + } + + /** + * Add one selectable language to the shared catalog. Its fallback must + * already be registered, and following fallback definitions must terminate + * at English. Dictionaries may register before or after this definition. + * Registration rechecks an unresolved Host preference and the browser's + * ordered language list. The caller owns the returned disposer; removing an + * active language falls back without clearing the stored id. + * @param input - stable id, self-described label, and fallback language id. + * @returns idempotent disposer removing this exact definition. + * @throws when fields are malformed, the id is occupied, or the fallback + * target is unknown or creates a cycle. + */ + addLanguage(input: LanguageRegistration): () => void { + const candidate = normalizeLanguage(input); + const key = localeKey(candidate.id); + if (this.catalog.has(key)) throw new Error(`locale "${candidate.id}" is already registered`); + const fallback = this.catalog.get(localeKey(candidate.fallback)); + if (fallback === undefined) { + throw new Error(`locale fallback "${candidate.fallback}" is not registered`); + } + const language = Object.freeze({ ...candidate, fallback: fallback.id }); + this.catalog.set(key, language); + try { + this.assertFallbackChain(language.id); + } catch (error) { + this.catalog.delete(key); + throw error; + } + this.publishCatalog(); + return () => { + if (this.catalog.get(key) !== language) return; + this.catalog.delete(key); + this.publishCatalog(); + }; + } + + /** + * Adopt the scope's accepted durable selection without writing it back; an + * absent selection returns to the browser-derived locale. + * @param host - the constructor-narrowed scope driving this adoption. + */ + private adopt(host: ConfigForm): void { + const section = host.getSnapshot().value; + if (section === undefined) return; + this.preference = section.preference; + const target = this.resolveActive(); + if (this.snapshot.active === target) return; + this.publish(target, true); + } + + /** Recompute browser fallback and publish the current catalog. */ + private publishCatalog(): void { + this.fallbackChains.clear(); + const locales = this.localeList(); + this.provisional = resolveInitialLocale(locales, this.bootstrap?.languages); + const active = this.resolveActive(); + this.publish(active, active !== this.snapshot.active, locales); + } + + /** Resolve an explicit preference only while its definition is available. */ + private resolveActive(): LocaleId { + if (this.preference === undefined) return this.provisional; + return this.catalog.get(localeKey(this.preference))?.id ?? this.provisional; + } + + /** Snapshot the catalog in registration order. */ + private localeList(): readonly LocaleDefinition[] { + return Object.freeze([...this.catalog.values()]); + } + + /** Fail a new definition whose complete fallback path does not reach English. */ + private assertFallbackChain(start: LocaleId): void { + const seen = new Set(); + let current = this.catalog.get(localeKey(start)); + while (current !== undefined) { + const key = localeKey(current.id); + if (seen.has(key)) throw new Error(`locale fallback cycle includes "${current.id}"`); + seen.add(key); + if (key === localeKey(FALLBACK_LOCALE)) return; + /* v8 ignore next -- English is the only built-in terminal and every + * language accepted by addLanguage has a required fallback. */ + if (current.fallback === undefined) { + throw new Error( + `locale "${current.id}" fallback chain does not reach "${FALLBACK_LOCALE}"`, + ); + } + const next = this.catalog.get(localeKey(current.fallback)); + if (next === undefined) { + throw new Error(`locale fallback "${current.fallback}" is not registered`); + } + current = next; + } + } + + /** Resolve a lookup chain, falling directly to English across an unload gap. */ + private fallbackChain(start: LocaleId): readonly LocaleId[] { + const startKey = localeKey(start); + const cached = this.fallbackChains.get(startKey); + if (cached !== undefined) return cached; + const chain: LocaleId[] = []; + const seen = new Set(); + let current = this.catalog.get(startKey); + while (current !== undefined && !seen.has(localeKey(current.id))) { + const key = localeKey(current.id); + seen.add(key); + chain.push(current.id); + current = + current.fallback === undefined ? undefined : this.catalog.get(localeKey(current.fallback)); + } + if (!seen.has(localeKey(FALLBACK_LOCALE))) chain.push(FALLBACK_LOCALE); + const resolved = Object.freeze(chain); + this.fallbackChains.set(startKey, resolved); + return resolved; + } + + /** + * Register a declared namespace's dictionaries, all locales in one call — + * the typed form: each dictionary is checked against the namespace's + * {@link LocaleNamespaceMap} key union (a missing or extra key is a + * compile error), and every shipped locale is required (bilingual balance + * enforced at registration). Duplicate (ns, locale) throws (single occupant; a + * namespace's texts have one owner). Registration bumps the revision so + * mounted outlets pick up late-arriving dictionaries. + * @param ns - a namespace merged into LocaleNamespaceMap. + * @param dicts - complete dictionaries keyed by built-in locale id. + * @returns disposer removing every locale registered by this call (idempotent). + */ + register>( + ns: N, + dicts: Record>, + ): () => void; + /** + * Single-locale untyped form for language-pack contributions and namespaces + * outside the merge table. + * @param ns - namespace. + * @param locale - locale tag. + * @param dict - dictionary. + * @returns disposer (idempotent). + * @throws when locale is not a BCP 47-style tag. + */ + register(ns: string, locale: string, dict: LocaleDict): () => void; + register( + ns: string, + localeOrDicts: string | Record, + dict?: LocaleDict, + ): () => void { + const pairs: [string, LocaleDict][] = + typeof localeOrDicts === "string" + ? // Overload guarantees dict on the single-locale arm. + [[localeOrDicts, dict as LocaleDict]] + : Object.entries(localeOrDicts); + for (const [locale] of pairs) { + if (!LOCALE_ID_PATTERN.test(locale)) { + throw new Error(`locale id "${locale}" is not a BCP 47-style tag`); + } + } + let locales = this.dicts.get(ns); + if (!locales) { + locales = new Map(); + this.dicts.set(ns, locales); + } + for (const [locale] of pairs) { + if (locales.has(localeKey(locale))) { + throw new Error(`locale namespace "${ns}" already has locale "${locale}"`); + } + } + for (const [locale, entries] of pairs) locales.set(localeKey(locale), entries); + this.publish(this.snapshot.active, false); + return () => { + const owner = this.dicts.get(ns); + /* v8 ignore next -- defensive: a namespace's locales map is created on + * first register and never removed, so the disposer always finds it. */ + if (!owner) return; + let removed = false; + for (const [locale, entries] of pairs) { + const key = localeKey(locale); + if (owner.get(key) === entries) { + owner.delete(key); + removed = true; + } + } + if (removed) this.publish(this.snapshot.active, false); + }; + } + + /** + * Bind a declared namespace to a translate function typed to its + * dictionary key union (plus the shared common vocabulary) — the same key + * domain the framework-injected `t` seat carries. The returned reference + * is stable per namespace (repeat binds return the same function), so it + * can ride inject surfaces without breaking memoization. + * @param ns - a namespace merged into LocaleNamespaceMap. + * @returns the typed translate function (reads the active locale at call time). + */ + bind>(ns: N): TranslateNS; + /** + * Untyped form for namespaces outside the merge table (dynamic + * composition, tests). + * @param ns - namespace. + * @returns the translate function. + */ + bind(ns: string): Translate; + bind(ns: string): Translate { + let t = this.bound.get(ns); + if (!t) { + t = (key, params) => this.translate(ns, key, params); + this.bound.set(ns, t); + return t; + } + return t; + } + + private translate(ns: string, key: string, params?: Record): string { + const chain = this.fallbackChain(this.snapshot.active); + const template = + this.lookup(ns, key, chain) ?? + (ns !== COMMON_NS ? this.lookup(COMMON_NS, key, chain) : undefined) ?? + key; + if (!params) return template; + return template.replace(/\{(\w+)\}/g, (match, name: string) => + name in params ? String(params[name]) : match, + ); + } + + private lookup(ns: string, key: string, chain: readonly LocaleId[]): string | undefined { + const locales = this.dicts.get(ns); + for (const locale of chain) { + const value = locales?.get(localeKey(locale))?.[key]; + if (value !== undefined) return value; + } + return undefined; + } + + /** + * Advance the snapshot revision and notify LocaleFace subscribers (render + * refresh). Only an active-locale switch additionally emits + * `locale/change` — dictionary registrations stay off the event so + * registration-heavy boot cannot storm event listeners (which may + * re-register slots in response). + */ + private publish( + active: LocaleId, + localeChanged: boolean, + locales: readonly LocaleDefinition[] = this.snapshot.locales, + ): void { + this.snapshot = Object.freeze({ + active, + locales, + revision: this.snapshot.revision + 1, + }); + if (localeChanged) this.ctx.emit("locale/change", this.snapshot); + for (const fn of [...this.listeners]) { + try { + fn(); + } catch (error) { + // One throwing subscriber must not strand the rest on a stale + // revision (outlets would keep the previous language). + console.error("locale subscriber crashed:", error); + } + } + } +} + +/** + * The browser's own language wins over {@link FALLBACK_LOCALE}; an explicit + * Host preference may replace this provisional value after plugin activation. + */ +function resolveInitialLocale( + locales: readonly LocaleDefinition[], + languages?: readonly string[], +): LocaleId { + return detectBrowserLocale(locales, languages) ?? FALLBACK_LOCALE; +} + +/** + * The first registered locale the browser asks for. Each browser tag first + * matches a locale id exactly, then its primary subtag, so an exact regional + * registration wins before a language-wide fallback. + * `window` is the browser test, not `navigator`: Node exposes a global + * `navigator` reporting the machine's own language, which must not decide the + * locale for non-browser runs. `navigator.language` trails the ordered + * `languages` list and covers hosts exposing only the single tag. + * @param locales - definitions currently available to the browser. + * @param languages - native system language order, when supplied by a shell. + * @returns the first matching locale id, or undefined. + */ +function detectBrowserLocale( + locales: readonly LocaleDefinition[], + languages?: readonly string[], +): LocaleId | undefined { + if (languages === undefined) { + if (typeof window === "undefined") return undefined; + // Embedders and older WebViews may omit the DOM-typed `languages` property. + const browserLanguages = (navigator as { readonly languages?: readonly string[] }).languages; + languages = [...(browserLanguages ?? []), navigator.language]; + } + for (const tag of languages) { + const requested = localeKey(tag); + const exact = locales.find((locale) => localeKey(locale.id) === requested); + if (exact !== undefined) return exact.id; + const primary = requested.split("-")[0]; + const match = locales.find((locale) => localeKey(locale.id).split("-")[0] === primary); + if (match !== undefined) return match.id; + } + return undefined; +} + +/** Required services: slot registration plus the settings transport. */ +export const inject = ["slots", "remote", "configForms"]; + +/** + * Client plugin body: provide the locale service with base dictionaries and + * register the feature-owned Language preference row into the General + * section's item slot (a feature owns its settings surface). + * @param ctx - client cordis context. + * @returns resolves after native language initialization and plugin registration. + */ +export async function apply(ctx: ClientContext): Promise { + const bridge = (globalThis as { __DSH_LOCALE__?: LocaleBridge }).__DSH_LOCALE__; + let bootstrap: LocaleBootstrap | undefined; + if (bridge !== undefined) { + let value: unknown; + try { + value = await bridge.read(); + } catch (error) { + if (ctx.fiber.uid === null) return; + throw error; + } + if (ctx.fiber.uid === null) return; + bootstrap = parseLocaleBootstrap(value); + } + const host = ctx.configForms.get(LOCALE_SETTINGS_NAMESPACE); + const locale = new LocaleRuntime(ctx, host, bootstrap); + locale.register(COMMON_NS, { zh, en }); + locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn }); + ctx.provide("locale", locale); + if (bridge !== undefined) { + ctx.on("locale/change", (snapshot) => { + bridge.onChange(snapshot.active); + }); + bridge.onChange(locale.getSnapshot().active); + } + // The service IS the LocaleFace (bind + getSnapshot/subscribe): install it + // so the render machinery can synthesize the `t` standard seat. + ctx.slots.installLocale(locale); + + const store = createLanguageRowStore(); + let bound: BoundActions | undefined; + const sync = (): void => { + const snapshot = locale.getSnapshot(); + syncDocumentLanguage(snapshot); + bound?.sync( + snapshot.active, + snapshot.locales.map((l) => ({ id: l.id, label: l.label })), + snapshot.revision, + ); + }; + ctx.effect(() => locale.subscribe(sync), "locale: language row and document synchronization"); + // The served markup declares one language; the resolved locale may differ + // (browser detection, or a stored preference adopted after activation), so + // state it once at activation rather than waiting for the first change. + sync(); + const injected = (actions: BoundActions): LanguageRowInjected => { + bound = actions; + // Re-sync from the getter so no event is lost between registration and + // first render (the store's revision guard drops stale duplicates). + sync(); + return { + setLocale: (id) => { + locale.setLocale(id); + }, + }; + }; + ctx.slots.inject("settings.general.item", () => + ctx.slots.register( + { + name: "settings.general.item", + id: "language", + order: 0, + store, + locale: SETTINGS_NS, + inject: injected, + }, + LanguageRow, + ), + ); +} diff --git a/apps/web-ui/packages/client/locale/src/client/settings-store.ts b/apps/web-ui/packages/client/locale/src/client/settings-store.ts new file mode 100644 index 000000000..49579cafd --- /dev/null +++ b/apps/web-ui/packages/client/locale/src/client/settings-store.ts @@ -0,0 +1,52 @@ +/** + * Language row slot store: a mirror of the locale service snapshot. The + * plugin's apply-world change listener is the only writer; the row component + * reads via props.useStore. + */ +import { defineStore, type EngineStoreHandle } from "@deepseek-ai/dsh-client-store"; + +/** One selectable locale row (id + self-described label). */ +export interface LanguageOptionRow { + /** Locale id (the setLocale argument). */ + id: string; + /** Display name in its own language (中文 / English). */ + label: string; +} + +/** Store state mirrored from the locale snapshot. */ +export interface LanguageRowState { + /** Active locale id. */ + active: string; + /** Selectable locales in display order. */ + options: LanguageOptionRow[]; + /** Service revision; -1 until first sync so revision 0 lands as a change. */ + revision: number; +} + +/** Declared action shape giving the exported factory a stable return type. */ +type LanguageRowActions = { + sync: ( + draft: LanguageRowState, + active: string, + options: LanguageOptionRow[], + revision: number, + ) => void; +}; + +/** + * Declares the Language row state and write surface. + * @returns the store handle. + */ +export function createLanguageRowStore(): EngineStoreHandle { + return defineStore({ + init: (): LanguageRowState => ({ active: "", options: [], revision: -1 }), + actions: { + sync: (d, active: string, options: LanguageOptionRow[], revision: number) => { + if (revision <= d.revision) return; + d.active = active; + d.options = options; + d.revision = revision; + }, + }, + }); +} diff --git a/apps/web-ui/packages/client/locale/src/css-modules.d.ts b/apps/web-ui/packages/client/locale/src/css-modules.d.ts new file mode 100644 index 000000000..b284bf80b --- /dev/null +++ b/apps/web-ui/packages/client/locale/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module "*.module.css" { + const classes: Record; + export default classes; +} + +declare module "*.css"; diff --git a/apps/web-ui/packages/client/locale/src/index.ts b/apps/web-ui/packages/client/locale/src/index.ts new file mode 100644 index 000000000..325c96b17 --- /dev/null +++ b/apps/web-ui/packages/client/locale/src/index.ts @@ -0,0 +1,38 @@ +/** Host registration for the browser locale preference. */ +import type {} from "@deepseek-ai/dsh-settings"; + +import type { Volatile, Context } from "@deepseek-ai/cordis"; + +import z from "@deepseek-ai/schemastery"; +import { LOCALE_PREFERENCE_FIELD } from "./locale-settings.ts"; + +import { LocaleSettingsFields } from "./locale-settings.ts"; + +export { + LOCALE_IDS, + LOCALE_PREFERENCE_FIELD, + LOCALE_SETTINGS_NAMESPACE, + type BuiltInLocaleId, + type LocaleId, + type LocaleSettings, +} from "./locale-settings.ts"; + +/** Runtime preferences projected to the browser. */ +export interface Config { + /** Explicit locale; omission follows the browser. */ + preference: Volatile; +} + +/** Live preferences projected to the browser. */ +export const Config = z.object({ + [LOCALE_PREFERENCE_FIELD]: LocaleSettingsFields[LOCALE_PREFERENCE_FIELD].volatile(), +}); + +/** Host preferences are consumed through the configuration form projection. + * @param ctx Plugin context used for optional settings presentation. + */ +export function apply(ctx: Context): void { + ctx.inject(["settings"], (child) => { + child.effect(() => child.settings.configure({ auto: false }, ctx.fiber)); + }); +} diff --git a/apps/web-ui/packages/client/locale/src/locale-settings.ts b/apps/web-ui/packages/client/locale/src/locale-settings.ts new file mode 100644 index 000000000..6fd5e662c --- /dev/null +++ b/apps/web-ui/packages/client/locale/src/locale-settings.ts @@ -0,0 +1,35 @@ +/** Locale preference stored in the Host user-settings document. */ + +import z from "@deepseek-ai/schemastery"; + +/** Settings namespace owned by the locale plugin. */ +export const LOCALE_SETTINGS_NAMESPACE = "locale"; + +/** Field carrying an explicit locale selection; absence delegates to the browser. */ +export const LOCALE_PREFERENCE_FIELD = "preference"; + +/** Accepted BCP 47-style language ids. */ +export const LOCALE_ID_PATTERN = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/u; + +/** Locale identifiers shipped by the browser client. */ +export const LOCALE_IDS = ["zh", "en"] as const; + +/** Locale identifier shipped by the browser client. */ +export type BuiltInLocaleId = (typeof LOCALE_IDS)[number]; + +/** Open locale identifier accepted from language-pack plugins. */ +export type LocaleId = string; + +/** Durable locale section shared by the Host schema and the browser scope. */ +export interface LocaleSettings { + /** Explicit locale selection; absence delegates to the browser. */ + preference?: LocaleId; +} + +/** Durable locale schema; also the wire envelope the browser scope validates against. */ +export const LocaleSettingsFields = { + [LOCALE_PREFERENCE_FIELD]: z.string().pattern(LOCALE_ID_PATTERN).required(false), +}; + +/** Schema for the shared locale preference. */ +export const LocaleSettingsSchema = z.object(LocaleSettingsFields); diff --git a/apps/web-ui/packages/client/locale/src/locales/en.ts b/apps/web-ui/packages/client/locale/src/locales/en.ts new file mode 100644 index 000000000..d9ff0844c --- /dev/null +++ b/apps/web-ui/packages/client/locale/src/locales/en.ts @@ -0,0 +1,46 @@ +import type { CommonKey } from "./zh.ts"; + +/** en base dictionary for the common namespace, checked complete against the zh key set. */ +export const en = { + ok: "OK", + cancel: "Cancel", + close: "Close", + copy: "Copy", + copied: "Copied", + "codeBlock.title": "Code block", + "codeBlock.wrap": "Wrap lines", + "codeBlock.unwrap": "Do not wrap lines", + "copy.failed": "Copy failed", + "copy.value": "Copy value", + "copy.json": "Copy JSON", + "copy.path": "Copy property path", + "copy.prettyJson": "Copy pretty JSON", + "copy.compactJson": "Copy compact JSON", + "copy.optionsHint": "{action}; right-click for copy options", + retry: "Retry", + loading: "Loading…", + "load.failed": "Failed to load", + submit: "Submit", + submitting: "Submitting…", + next: "Next", + previous: "Previous", + skip: "Skip", + delete: "Delete", + edit: "Edit", + save: "Save", + search: "Search", + more: "More", + collapse: "Collapse", + expand: "Expand", + back: "Back", + "brand.localBuild": "DSH Local Build", + "workspace.defaultName": "Default workspace", + unknown: "Unknown", + none: "None", + truncated: "Truncated", + "json.label": "JSON", + "markdown.footnotes": "Footnotes", + "markdown.truncatedCharacters": "… truncated at {total} characters", + "number.thousand": "{value}K", + "number.million": "{value}M", +} satisfies Record; diff --git a/apps/web-ui/packages/client/locale/src/locales/index.ts b/apps/web-ui/packages/client/locale/src/locales/index.ts new file mode 100644 index 000000000..78a1b846f --- /dev/null +++ b/apps/web-ui/packages/client/locale/src/locales/index.ts @@ -0,0 +1,8 @@ +/** + * The common-namespace dictionary pair. zh is the source of truth for the + * key set (Chinese-first repo convention); en is checked complete against it + * — a missing or extra en key is a compile error. + */ +export { zh } from "./zh.ts"; +export { en } from "./en.ts"; +export type { CommonKey } from "./zh.ts"; diff --git a/apps/web-ui/packages/client/locale/src/locales/settings.ts b/apps/web-ui/packages/client/locale/src/locales/settings.ts new file mode 100644 index 000000000..790d4c48f --- /dev/null +++ b/apps/web-ui/packages/client/locale/src/locales/settings.ts @@ -0,0 +1,14 @@ +/** `settings.locale` namespace dictionaries (the Language row's copy). */ + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + "language.title": "语言", +} satisfies Record; + +/** The settings.locale namespace key union. */ +export type SettingsLocaleKey = keyof typeof zh; + +/** English dictionary, checked complete against the zh key set. */ +export const en = { + "language.title": "Language", +} satisfies Record; diff --git a/apps/web-ui/packages/client/locale/src/locales/zh.ts b/apps/web-ui/packages/client/locale/src/locales/zh.ts new file mode 100644 index 000000000..0a3d0289c --- /dev/null +++ b/apps/web-ui/packages/client/locale/src/locales/zh.ts @@ -0,0 +1,47 @@ +/** zh base dictionary for the common namespace: cross-feature standard words. */ +export const zh = { + ok: "确定", + cancel: "取消", + close: "关闭", + copy: "复制", + copied: "复制成功", + "codeBlock.title": "代码块", + "codeBlock.wrap": "自动换行", + "codeBlock.unwrap": "取消自动换行", + "copy.failed": "复制失败", + "copy.value": "复制值", + "copy.json": "复制 JSON", + "copy.path": "复制属性路径", + "copy.prettyJson": "复制格式化 JSON", + "copy.compactJson": "复制紧凑 JSON", + "copy.optionsHint": "{action};右键点击可选择复制方式", + retry: "重试", + loading: "加载中…", + "load.failed": "加载失败", + submit: "提交", + submitting: "正在提交…", + next: "下一步", + previous: "上一步", + skip: "跳过", + delete: "删除", + edit: "编辑", + save: "保存", + search: "搜索", + more: "更多", + collapse: "收起", + expand: "展开", + back: "返回", + "brand.localBuild": "DSH 本地构建", + "workspace.defaultName": "默认工作区", + unknown: "未知", + none: "无", + truncated: "已截断", + "json.label": "JSON", + "markdown.footnotes": "脚注", + "markdown.truncatedCharacters": "… 已截断,共 {total} 字符", + "number.thousand": "{value}K", + "number.million": "{value}M", +} satisfies Record; + +/** The common vocabulary key union (zh is the key-set source of truth). */ +export type CommonKey = keyof typeof zh; diff --git a/apps/web-ui/packages/client/locale/tsdown.config.ts b/apps/web-ui/packages/client/locale/tsdown.config.ts new file mode 100644 index 000000000..e5ae79cd9 --- /dev/null +++ b/apps/web-ui/packages/client/locale/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from "../tsdown.client.ts"; + +export default clientBundle("@deepseek-ai/dsh-client-locale", ["lib/types/index.js"]); diff --git a/apps/web-ui/packages/client/modules/package.json b/apps/web-ui/packages/client/modules/package.json new file mode 100644 index 000000000..c448257cb --- /dev/null +++ b/apps/web-ui/packages/client/modules/package.json @@ -0,0 +1,27 @@ +{ + "name": "@deepseek-ai/dsh-client-modules", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./client": { + "types": "./src/client/index.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dependencies": {}, + "dsh": { + "client": { + "platform": "web", + "inject": [], + "immediately": true + } + } +} diff --git a/apps/web-ui/packages/client/modules/src/client/entries.ts b/apps/web-ui/packages/client/modules/src/client/entries.ts new file mode 100644 index 000000000..1fb46f2fb --- /dev/null +++ b/apps/web-ui/packages/client/modules/src/client/entries.ts @@ -0,0 +1,268 @@ +/** Page-owned Loader entries; transport-independent reconciliation, retries and code replacement. */ +import type { FiberState } from "@deepseek-ai/cordis"; +import type { Entry, Loader } from "@deepseek-ai/cordis-plugin-loader"; +import type { ObservableSnapshot } from "@deepseek-ai/dsh-client-store"; +import { parseBootManifest } from "./manifest.ts"; +import type { BootManifest, ClientModuleLoader } from "./manifest.ts"; +import { removeOwnedStyles, tearDownEntryFiber } from "./entry-lifecycle.ts"; + +/** Page-local failures do not change the Host's bundle enablement. */ +export interface ClientEntryState { + /** True while a snapshot, retry or code replacement is being applied. */ + readonly syncing: boolean; + /** Package ids and errors from the latest reconciliation. */ + readonly failures: readonly { readonly id: string; readonly message: string }[]; +} + +/** Module-table capabilities used within serialized entry operations. */ +interface ModuleIndex { + update(manifest: BootManifest, managed: Iterable): void; + invalidateForReplacement(id: string, rev: string): void; + prune(roots: Iterable): void; +} + +/** Numeric values mirror Cordis's const enum, which bundle loaders cannot import as a runtime object. */ +const ACTIVE = 2 as FiberState.ACTIVE; +const FAILED = 3 as FiberState.FAILED; + +/** Revisions and requests identify desired code; URLs only select its immutable delivery resource. */ +function entryTargets(manifest: BootManifest): string { + return JSON.stringify(manifest.modules.map((row) => [row.id, row.rev, row.inject, row.external])); +} + +/** Manages only entries created from the Host manifest; other Loader contributors retain ownership. */ +export class ClientEntries { + /** Stable observable consumed by page diagnostics through the renderer's injected hook. */ + readonly state: ObservableSnapshot = { + getSnapshot: () => this.snapshot, + subscribe: (listener) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }, + }; + // The modules bootstrap factory cannot request platform libraries before the shell supplies its seed. + private snapshot: ClientEntryState = { syncing: false, failures: [] }; + private readonly listeners = new Set<() => void>(); + private readonly managed = new Map(); + private readonly revisions = new Map(); + private loader: Loader | undefined; + private queue: Promise = Promise.resolve(); + private desired: BootManifest; + private generation = 0; + private stopped = false; + + /** + * Construct the page controller before Cordis boot. + * @param modules - Module arrival and materialization owner. + * @param index - Private descriptor replacement and unused-module cleanup. + */ + constructor( + private readonly modules: ClientModuleLoader, + private readonly index: ModuleIndex, + ) { + this.desired = modules.manifest; + } + + /** + * Create the initial roster and retain its entry identities for subsequent reconciliation. + * @param loader - Page Loader, already configured with the module system. + * @param manifest - Initial roster audited by the boot caller. + * @returns after initial entries and their activation settle; boot owns its activation audit. + */ + start(loader: Loader, manifest: BootManifest): Promise { + if (this.loader !== undefined) throw new Error("client-modules: entries already started"); + this.loader = loader; + this.desired = manifest; + loader.ctx.effect( + () => () => { + this.stopped = true; + this.generation++; + return this.queue; + }, + "client-modules: entry reconciliation", + ); + return this.enqueue(async () => { + await Promise.all( + this.desired.plugins.map(async ({ id }) => { + await this.create(loader, id); + }), + ); + await loader.await(); + for (const row of this.modules.manifest.modules) this.revisions.set(row.id, row.rev); + }); + } + + /** + * Validate and apply the latest full Host graph. Changed targets cancel obsolete mounts; identical targets share pending loads. + * @param graph - JSON-decoded graph received from the Host. + * @returns after the queued reconciliation; per-package failures remain available in {@link state}. + */ + sync(graph: unknown): Promise { + const manifest = parseBootManifest(graph); + if (entryTargets(manifest) !== entryTargets(this.desired)) this.generation++; + this.desired = manifest; + const generation = this.generation; + return this.enqueue(() => this.reconcile(generation)); + } + + /** + * Retry failed entries against the latest graph, including an unchanged revision. + * @returns after retry settlement, with remaining errors in {@link state}. + */ + retry(): Promise { + const generation = ++this.generation; + return this.enqueue(() => this.reconcile(generation)); + } + + /** + * Replace one entry's code in the same queue as graph updates; duplicate revisions are ignored. + * Entries missing after a failed import are reconciled; bootstrap replacement fails before teardown. + * @param id - Package id from a rebuilt frame. + * @param rev - Opaque revision selecting the rebuilt artifact. + * @returns after queued work; replacement errors reject, while per-package reconciliation errors remain in {@link state}. + */ + reload(id: string, rev: string): Promise { + this.desired = { + ...this.desired, + modules: this.desired.modules.map((row) => (row.id === id ? { ...row, rev } : row)), + }; + return this.enqueue(async () => { + const desired = this.desired.modules.find((row) => row.id === id); + if (this.stopped || desired === undefined) return; + const entry = this.managed.get(id); + if (entry === undefined) { + this.modules.invalidate(id, desired.rev); + removeOwnedStyles(id); + await this.reconcile(this.generation); + return; + } + if (this.revisions.get(id) === rev) return; + this.publish({ + syncing: true, + failures: this.snapshot.failures.filter((failure) => failure.id !== id), + }); + await this.replace(entry, id, rev, this.generation); + this.publish({ syncing: false, failures: this.snapshot.failures }); + }, id); + } + + private publish(snapshot: ClientEntryState): void { + this.snapshot = snapshot; + for (const listener of [...this.listeners]) { + try { + listener(); + } catch (error) { + // The page controller has no owning plugin Context for a scoped logger. + console.error("client-modules: synchronization subscriber failed", error); + } + } + } + + private enqueue(task: () => Promise, subject = "graph"): Promise { + const run = this.queue.then(task); + // Each operation reports its own failure; later operations must still run. + this.queue = run.then( + () => undefined, + (error: unknown) => { + this.publish({ + syncing: false, + failures: [ + ...this.snapshot.failures.filter((failure) => failure.id !== subject), + { id: subject, message: String(error) }, + ], + }); + }, + ); + return run; + } + + private current(generation: number): boolean { + return !this.stopped && generation === this.generation; + } + + /** Keep ownership even when Loader rejects a module's plugin exports after inserting its entry. */ + private async create(loader: Loader, id: string): Promise { + const options = { name: id }; + const entryId = loader.ensureId(options); + try { + await loader.create(options); + } finally { + this.managed.set(id, loader.resolve(entryId)); + } + } + + private async replace(entry: Entry, id: string, rev: string, generation: number): Promise { + this.index.invalidateForReplacement(id, rev); + await this.modules.prefetch(id); + if (!this.current(generation)) return; + await tearDownEntryFiber(entry); + removeOwnedStyles(id); + if (!this.current(generation)) return; + await this.modules.import(id, "", {}); + if (!this.current(generation)) return; + await entry.refresh(); + await entry.fiber?.await(); + if (entry.fiber === undefined) + throw new Error(`client-modules: ${id} import failed (see console)`); + this.revisions.set(id, rev); + } + + private async reconcile(generation: number): Promise { + if (!this.current(generation)) return; + const loader = this.loader; + if (loader === undefined) throw new Error("client-modules: entries have not started"); + const manifest = this.desired; + this.publish({ syncing: true, failures: [] }); + const failures: { id: string; message: string }[] = []; + this.index.update(manifest, this.managed.keys()); + const wanted = new Set(manifest.plugins.map((row) => row.id)); + for (const [id, entry] of this.managed) { + if (wanted.has(id)) continue; + const fiber = entry.fiber; + loader.remove(entry.id); + this.managed.delete(id); + this.revisions.delete(id); + // Removed fibers no longer appear in Loader.getTasks(). + while (fiber?.inertia !== undefined) await fiber.inertia; + } + for (const row of manifest.modules) { + if (!this.current(generation)) break; + try { + const entry = this.managed.get(row.id); + if (entry === undefined) { + await this.modules.prefetch(row.id); + if (!this.current(generation)) break; + await this.modules.import(row.id, "", {}); + if (!this.current(generation)) break; + await this.create(loader, row.id); + this.revisions.set(row.id, row.rev); + } else if (this.revisions.get(row.id) !== row.rev) { + await this.replace(entry, row.id, row.rev, generation); + } else if (entry.fiber === undefined) { + await this.replace(entry, row.id, row.rev, generation); + } else if (entry.fiber.state === FAILED) { + entry.fiber.update(entry.options.config); + } + } catch (error) { + failures.push({ id: row.id, message: String(error) }); + } + } + await loader.await(); + for (const [id, entry] of this.managed) { + if (failures.some((failure) => failure.id === id)) continue; + if (entry.fiber?.state === ACTIVE) continue; + try { + if (entry.fiber === undefined) + throw new Error(`client-modules: ${id} import failed (see console)`); + await entry.fiber.await(); + failures.push({ id, message: `client-modules: ${id} is waiting for activation` }); + } catch (error) { + failures.push({ id, message: String(error) }); + } + } + this.index.prune([...loader.entries()].map((entry) => entry.options.name)); + if (this.current(generation)) this.publish({ syncing: false, failures }); + } +} diff --git a/apps/web-ui/packages/client/modules/src/client/entry-lifecycle.ts b/apps/web-ui/packages/client/modules/src/client/entry-lifecycle.ts new file mode 100644 index 000000000..43d216320 --- /dev/null +++ b/apps/web-ui/packages/client/modules/src/client/entry-lifecycle.ts @@ -0,0 +1,28 @@ +/** Loader lifecycle operations shared by live graph reconciliation and code replacement. */ +import type { Entry } from "@deepseek-ai/cordis-plugin-loader"; + +/** + * Release a runtime before clearing its entry fiber so Loader refresh can import new code. + * Registry deletion prevents Loader from treating replacement as a user disable. + * @param entry - Entry retained for code replacement. + */ +export async function tearDownEntryFiber(entry: Entry): Promise { + const fiber = entry.fiber; + if (fiber === undefined) return; + const runtime = fiber.runtime; + /* v8 ignore next -- Loader entries own plugin fibers; only the root context has a null runtime. */ + if (runtime !== null) entry.ctx.registry.delete(runtime.callback); + while (fiber.inertia !== undefined) await fiber.inertia; + delete entry.fiber; +} + +/** + * Remove styles after their plugin's effect cleanup has settled. + * @param id - Package whose factory owns the style tags. + */ +export function removeOwnedStyles(id: string): void { + if (typeof document === "undefined") return; + for (const el of document.querySelectorAll("style[data-plugin]")) { + if (el.getAttribute("data-plugin") === id) el.remove(); + } +} diff --git a/apps/web-ui/packages/client/modules/src/client/index.ts b/apps/web-ui/packages/client/modules/src/client/index.ts new file mode 100644 index 000000000..2fdf5cb4d --- /dev/null +++ b/apps/web-ui/packages/client/modules/src/client/index.ts @@ -0,0 +1,84 @@ +/** + * Browser half (the standard `./client` export): the module-system class and + * wire contract, plus the enrollment plugin face. The module system itself is + * built by the shell kernel BEFORE cordis exists (the bootstrap exception — + * the mechanism that loads plugins cannot arrive through itself). The host + * parser-preloads this ordinary client bundle into the pending registration + * queue. The HTML-installed loader facade materializes this bundle and calls + * its bootstrap export, which constructs the system and retains the same + * exports for this package's graph row. The plugin face enrolls the module + * system attached to its own Loader as `ctx.modules`. + * @module @deepseek-ai/dsh-client-modules/client + */ +import type { Context } from "@deepseek-ai/cordis"; +import type { Loader } from "@deepseek-ai/cordis-plugin-loader"; +import { ClientModuleSystem } from "./system.ts"; +import { parseBootManifest } from "./manifest.ts"; +import type { + ClientBootstrapModule, + ClientModuleCreateOptions, + ClientModuleLoader, + ClientModuleLoaderTarget, +} from "./manifest.ts"; + +export { ClientModuleSystem }; +export { tearDownEntryFiber } from "./entry-lifecycle.ts"; +export type { ClientEntries, ClientEntryState } from "./entries.ts"; +export { + exactPackageSpecifier, + parseBootManifest, + parseDshClient, + stripClientSuffix, +} from "./manifest.ts"; +export type { + BootManifest, + BootModuleRow, + BootPluginRow, + ClientBootstrapModule, + ClientBundleRegistration, + ClientModuleCreateOptions, + ClientModuleLoader, + ClientModuleLoaderTarget, + ClientModuleRecord, + ClientModuleSystemOptions, + DshWindow, + WebBootEntry, + WebBootGraph, +} from "./manifest.ts"; + +/** + * Build the live module system from the HTML facade's materialized modules bundle. + * @param target - Stable registration facade whose pending queue becomes the live sink. + * @param bootstrapModule - This bundle's id and already-materialized exports. + * @param options - Raw boot graph, platform seed, and optional bundle transport. + * @returns The created module system. + */ +export function createClientModuleSystem( + target: ClientModuleLoaderTarget, + bootstrapModule: ClientBootstrapModule, + options: ClientModuleCreateOptions, +): ClientModuleSystem { + return new ClientModuleSystem({ + manifest: parseBootManifest(options.boot), + staticModules: options.staticModules, + registrationTarget: target, + bootstrapModule, + ...(options.loadBundle === undefined ? {} : { loadBundle: options.loadBundle }), + }); +} + +/** Required service: the Loader whose internal module system this plugin publishes. */ +export const inject = ["loader"]; + +/** + * Enroll the kernel-built module system as `ctx.modules`. + * @param ctx - client root context. + */ +export function apply(ctx: Context): void { + const loader: Loader = ctx.loader; + const modules = loader.internal as unknown as ClientModuleLoader | undefined; + if (modules?.version !== "client") { + throw new Error("client-modules: the Loader has no client module system"); + } + ctx.reflect.provide("modules", modules); +} diff --git a/apps/web-ui/packages/client/modules/src/client/manifest.ts b/apps/web-ui/packages/client/modules/src/client/manifest.ts new file mode 100644 index 000000000..9fa5bc7f8 --- /dev/null +++ b/apps/web-ui/packages/client/modules/src/client/manifest.ts @@ -0,0 +1,465 @@ +/** + * Client module system: the browser peer of Node's internal ESM loader, built + * as a lazy CJS table. The vendored cordis Loader consumes this object + * through its `internal` contract (the only call site is `EntryTree.import` → + * `internal.import`), which keeps entry governance (fiber lifecycle, inject + * waiting, update/refresh) entirely on the vendored side while this package + * owns code arrival. + * + * Lazy CJS model: executing a plugin bundle only REGISTERS its + * factory (`window.__ModuleLoader__.load({id, factory})`); every module body + * side effect — including CSS injection — lives inside the factory closure + * and runs at materialization, not at script execution. Materialization + * (factory(require) → exports) happens on first import/require and is + * memoized in {@link ClientModuleLoader.loadCache}; a factory that requires + * another registered-but-unmaterialized module materializes it recursively, + * so load order needs no external sequencing. + * + * Resolution branch order (import): seed word → shell instance; memoized + * record → exports; graph row → register its dependency factories and own + * factory; registered factory → materialize; anything else → throw (loud — + * the runtime mirror of the build-time bundle purity gate). + * The synchronous `require` handed to factories walks the same order minus + * the load branch. Loading is async, so a requested dynamic package must have + * registered its factory before a consumer materializes. + * + * This file is the browser-safe contract face (zero node imports): the + * `__DSH_BOOT__` wire types, the boot-manifest parser, and the boundaries around + * {@link ClientModuleSystem}. The package root is the host-side service that + * composes the wire. + */ + +import type {} from "@deepseek-ai/cordis"; +import type { DshClientManifest } from "@deepseek-ai/dsh-package-manifest"; +import type { ClientEntries } from "./entries.ts"; +import type { ClientModuleSystem } from "./system.ts"; + +declare module "@deepseek-ai/cordis" { + interface Context { + /** The client module system the web shell builds at boot (provided by the `./client` wrapper plugin). */ + modules: ClientModuleLoader; + } +} + +/** + * One composed client entry pushed by the host (a graph row). Wire + * single source: the host node half (package root) produces this same shape. + * `immediately` marks stage-one prefetch. `inject` names package rows whose + * factories must arrive before this row materializes, while Cordis separately + * uses the same package edges to compose entries. `external` carries exact + * non-inject module requests (see {@link WebBootGraph.entries}). + */ +export interface WebBootEntry { + /** Entry name == package name. */ + id: string; + /** + * Revisioned single-resource combo reference used by HMR. It is relative to + * the document, so the browser resolves it under whatever mount served the page. + */ + url: string; + /** Opaque plugin-artifact revision used for HMR cache busting. */ + rev: string; + /** Package-name dependency edges used for factory arrival and plugin composition. */ + inject?: string[]; + /** Stage-one prefetch mark: load the script for factory registration during module-face boot. */ + immediately?: boolean; + /** Non-baseline module specifiers this row requests; omitted when it requests none. */ + external?: string[]; +} + +/** Initial scheduling phase for one revisioned combo script. */ +export type WebBootBatchPhase = "bootstrap" | "application"; + +/** One initial combo script; a scheduling phase may span several descriptors. */ +export interface WebBootBatch { + /** Parser-blocking bootstrap or preloaded application scheduling. */ + phase: WebBootBatchPhase; + /** Content-addressed combo script reference, document-relative like {@link WebBootEntry.url}. */ + url: string; + /** Revision derived from the ordered entry revisions. */ + rev: string; + /** Graph entry ids whose factories the script registers, in execution order. */ + entries: string[]; +} + +/** The composed client entry graph the host injects as `window.__DSH_BOOT__`. */ +export interface WebBootGraph { + /** Consistency anchor over the current entry and batch descriptors. */ + rev: string; + /** + * Composed entries in module-graph order — a dynamic package row precedes + * rows whose `external` requests that package. Cordis activation order is + * unrelated and remains owned by fiber service waiting. + */ + entries: WebBootEntry[]; + /** Initial combo descriptors; every entry belongs to exactly one descriptor. */ + batches: WebBootBatch[]; +} + +/** The npm-package view of one boot row: what the module table needs to fetch the bundle. */ +export interface BootModuleRow { + /** Entry name == package name (module-table key). */ + id: string; + /** Revisioned single-resource combo reference: the fallback when the row's batch fails and the reload target after HMR invalidation. */ + url: string; + /** Content-addressed combo reference used before the first HMR invalidation. */ + initialUrl: string; + /** Opaque plugin-artifact revision used after HMR invalidation. */ + rev: string; + /** Injected package rows whose factories arrive before this row materializes. */ + inject: string[]; + /** Module specifiers this row requests from the module table ([] when the wire omits them). */ + external: string[]; +} + +/** The cordis-plugin view of one boot row: what entry composition needs (optional wire fields normalized). */ +export interface BootPluginRow { + /** Entry name == package name. */ + id: string; + /** Package-name dependency edges ([] when the wire omits them). */ + inject: string[]; + /** Stage-one prefetch tier (false when the wire omits it). */ + immediately: boolean; +} + +/** The parsed boot manifest: one wire, two consumer views. */ +export interface BootManifest { + /** Consistency anchor over the whole graph. */ + rev: string; + /** Rows as the module table consumes them. */ + modules: BootModuleRow[]; + /** Rows as entry composition consumes them. */ + plugins: BootPluginRow[]; +} + +/** + * Validate an optional string-array field read from a `dsh.client` declaration + * or from the boot wire. + * @param subject - diagnostic prefix naming the package or the wire row. + * @param field - field name as it appears in the diagnostic. + * @param value - the raw field value. + * @returns the validated array, or undefined when the field is absent. + * @throws {Error} when the value is present but is not an array of strings. + */ +export function optionalStringArray( + subject: string, + field: string, + value: unknown, +): string[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) { + throw new Error(`client-modules: ${subject} ${field} must be a string array`); + } + return value as string[]; +} + +/** + * Narrow an unknown parsed JSON value to the `dsh.client` declaration. Shared + * by the node half's Loader scan and the roster generator, so both read a + * package's browser declaration through one validator. + * @param pkgName - package name used as the diagnostic prefix. + * @param value - the raw `dsh.client` field of the package manifest. + * @returns the validated declaration, or undefined when the field is absent. + * @throws {Error} when the field is present but any member is malformed. + */ +export function parseDshClient(pkgName: string, value: unknown): DshClientManifest | undefined { + if (value === undefined) return undefined; + if (typeof value !== "object" || value === null) { + throw new Error(`client-modules: ${pkgName} has a non-object dsh.client declaration`); + } + const decl = value as Record; + if (typeof decl.platform !== "string") { + throw new Error(`client-modules: ${pkgName} dsh.client.platform must be a string`); + } + const inject = optionalStringArray(pkgName, "dsh.client.inject", decl.inject); + const external = optionalStringArray(pkgName, "dsh.client.external", decl.external); + if (decl.immediately !== undefined && typeof decl.immediately !== "boolean") { + throw new Error(`client-modules: ${pkgName} dsh.client.immediately must be a boolean`); + } + return { + platform: decl.platform, + ...(inject !== undefined ? { inject } : {}), + ...(external !== undefined ? { external } : {}), + ...(decl.immediately !== undefined ? { immediately: decl.immediately } : {}), + }; +} + +/** + * The bare package-root specifier `specifier` names, or undefined for a subpath, a path, or any scheme-qualified + * specifier (`cordis:` builtins, `node:` modules, URLs). + * @param specifier - Loader row name. + * @returns the package name, or undefined. + */ +export function exactPackageSpecifier(specifier: string): string | undefined { + if (specifier.startsWith("@")) { + const parts = specifier.split("/"); + return parts.length === 2 && parts.every(Boolean) ? specifier : undefined; + } + return specifier.length > 0 && !specifier.includes("/") && !specifier.includes(":") + ? specifier + : undefined; +} + +/** + * Normalize a module specifier onto the graph row that owns it: a plugin bundle + * IS its package's client half, so `/client` (the exports subpath external + * bundles emit) and the bare package name resolve to the same exports. Both the + * require path and graph composition normalize here, which is what lets each + * importing package request the subpath its own code imports. + * @param spec - module specifier as a bundle requires it or a declaration spells it. + * @returns the specifier with a trailing `/client` removed. + */ +export function stripClientSuffix(spec: string): string { + return spec.endsWith("/client") ? spec.slice(0, -"/client".length) : spec; +} + +/** + * Parse `window.__DSH_BOOT__` into the two consumer views. Wire boundary: + * a missing or malformed graph throws (the shell shows the loud failure — + * a page without a valid manifest cannot boot anything). + * @param wire - the raw `window.__DSH_BOOT__` value. + * @returns the manifest with optional plugin-view fields normalized. + */ +export function parseBootManifest(wire: unknown): BootManifest { + if (typeof wire !== "object" || wire === null) { + throw new Error("client-modules: window.__DSH_BOOT__ is missing or not an object"); + } + const graph = wire as Record; + if (typeof graph.rev !== "string") { + throw new Error("client-modules: boot manifest rev must be a string"); + } + if (!Array.isArray(graph.entries)) { + throw new Error("client-modules: boot manifest entries must be an array"); + } + if (!Array.isArray(graph.batches)) { + throw new Error("client-modules: boot manifest batches must be an array"); + } + const moduleFields: Omit[] = []; + const plugins: BootPluginRow[] = []; + const seenEntryIds = new Set(); + for (const value of graph.entries as unknown[]) { + if (typeof value !== "object" || value === null) { + throw new Error("client-modules: boot manifest entry is not an object"); + } + const row = value as Record; + const where = typeof row.id === "string" ? `"${row.id}"` : JSON.stringify(row); + if (typeof row.id !== "string" || typeof row.url !== "string" || typeof row.rev !== "string") { + throw new Error(`client-modules: boot manifest entry ${where} must carry string id/url/rev`); + } + if (seenEntryIds.has(row.id)) + throw new Error(`client-modules: duplicate graph entry "${row.id}"`); + seenEntryIds.add(row.id); + const subject = `boot manifest entry ${where}`; + const inject = optionalStringArray(subject, "inject", row.inject); + const external = optionalStringArray(subject, "external", row.external); + if (row.immediately !== undefined && typeof row.immediately !== "boolean") { + throw new Error(`client-modules: boot manifest entry ${where} immediately must be a boolean`); + } + moduleFields.push({ + id: row.id, + url: row.url, + rev: row.rev, + inject: inject === undefined ? [] : [...inject], + external: external === undefined ? [] : [...external], + }); + plugins.push({ + id: row.id, + inject: inject === undefined ? [] : [...inject], + immediately: row.immediately === true, + }); + } + + const entryIds = new Set(moduleFields.map((row) => row.id)); + const initialUrls = new Map(); + const batchUrls = new Set(); + for (const value of graph.batches as unknown[]) { + if (typeof value !== "object" || value === null) { + throw new Error("client-modules: boot manifest batch is not an object"); + } + const batch = value as Record; + const phase = batch.phase; + if (phase !== "bootstrap" && phase !== "application") { + throw new Error( + `client-modules: boot manifest batch phase must be "bootstrap" or "application", received ${JSON.stringify(phase)}`, + ); + } + if (typeof batch.url !== "string" || typeof batch.rev !== "string") { + throw new Error(`client-modules: boot manifest ${phase} batch must carry string url/rev`); + } + if (batchUrls.has(batch.url)) { + throw new Error( + `client-modules: boot manifest carries duplicate batch URL ${JSON.stringify(batch.url)}`, + ); + } + batchUrls.add(batch.url); + const entries = optionalStringArray(`boot manifest ${phase} batch`, "entries", batch.entries); + if (entries === undefined || entries.length === 0) { + throw new Error( + `client-modules: boot manifest ${phase} batch entries must be a non-empty string array`, + ); + } + for (const id of entries) { + if (!entryIds.has(id)) { + throw new Error(`client-modules: boot manifest ${phase} batch names unknown entry "${id}"`); + } + if (initialUrls.has(id)) { + throw new Error( + `client-modules: boot manifest entry "${id}" belongs to more than one batch`, + ); + } + initialUrls.set(id, batch.url); + } + } + const modules = moduleFields.map((row): BootModuleRow => { + const initialUrl = initialUrls.get(row.id); + if (initialUrl === undefined) { + throw new Error( + `client-modules: boot manifest entry "${row.id}" belongs to no initial-load batch`, + ); + } + return { ...row, initialUrl }; + }); + return { rev: graph.rev, modules, plugins }; +} + +/** Module resolver passed into a registered Client bundle factory. */ +export interface ClientBundleRequire { + /** Resolve a module-table dependency synchronously. */ + (specifier: string): unknown; + /** Load and resolve a package-local dynamic chunk asynchronously. */ + async(specifier: string): Promise; +} + +/** One client bundle's factory registration submitted through `window.__ModuleLoader__.load`. */ +export interface ClientBundleRegistration { + /** Plugin id (package name) — the registration key; must match the graph row being executed. */ + id: string; + /** Package-local chunk filename; absent for the package's `client.js` entry. */ + chunk?: string; + /** + * Closure factory holding the whole bundle body: receives the module-table + * require whose `async` operation loads generated chunks, and returns the + * bundle's exports. The factory runs once, at materialization. + */ + factory: (require: ClientBundleRequire) => Record; +} + +/** Inputs passed by the web entry when it creates the client module system. */ +export interface ClientModuleCreateOptions { + /** Raw Host-injected boot graph; the modules bundle owns validation and projection. */ + boot: unknown; + /** Module-table seed: platform-singleton specifier → shell instance. */ + staticModules: Record; + /** Bundle-load hook. Defaults to a same-origin classic ``; +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/html/bytes.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/html/bytes.ts new file mode 100644 index 000000000..237bb6fec --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/html/bytes.ts @@ -0,0 +1,26 @@ +/** UTF-8 decoding for file bytes and encoding only for the iframe's script payload. */ + +const BASE64_CHUNK_BYTES = 0x8000; + +/** + * Decode complete UTF-8 text, rejecting invalid byte sequences. + * @param data - complete UTF-8 bytes. + * @returns decoded text; invalid UTF-8 throws. + */ +export function decodeText(data: Uint8Array): string { + return new TextDecoder("utf-8", { fatal: true }).decode(data); +} + +/** + * Encode Unicode text for the iframe's base64 payload. + * @param text - Unicode text. + * @returns base64 of its UTF-8 bytes. + */ +export function encodeText(text: string): string { + const bytes = new TextEncoder().encode(text); + const chunks: string[] = []; + for (let offset = 0; offset < bytes.length; offset += BASE64_CHUNK_BYTES) { + chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + BASE64_CHUNK_BYTES))); + } + return btoa(chunks.join("")); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/html/index.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/html/index.ts new file mode 100644 index 000000000..1e9572e26 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/html/index.ts @@ -0,0 +1,62 @@ +/** Builtin HTML metadata and keyed body registration; assembly belongs to the package entry. */ +import type { Context } from "@deepseek-ai/cordis"; +import type {} from "../index.ts"; +import type { DocumentPreviewDefinition } from "../document/registry.ts"; +import { hostFileOf } from "../rpc.ts"; +import { HtmlBody } from "./HtmlBody.tsx"; +import type { HtmlBodyInjected } from "./HtmlBody.tsx"; +import type {} from "@deepseek-ai/dsh-client-ui-settings/client"; +import { en, zh } from "./locales.ts"; + +/** HTML implementation identity, shared by metadata and the keyed slot. */ +export const HTML_BODY_ID = "@deepseek-ai/dsh-client-ui-sidebar-documentpreview/html"; + +/** + * Describe the builtin HTML renderer's file types and loading mode. + * @param title - locale-owned implementation name. + * @returns metadata for complete HTML documents. + */ +export function htmlBodyDefinition(title: () => string): DocumentPreviewDefinition { + return { + id: HTML_BODY_ID, + extensions: ["html", "htm"], + priority: "builtin", + title, + loading: "bytes-complete", + wrap: false, + }; +} + +/** + * Register the HTML dictionary, metadata and body with reversible effects. + * @param ctx - owning plugin context. + */ +export function apply(ctx: Context): void { + const t = ctx.locale.bind("documentHtml"); + ctx.effect(() => ctx.locale.register("documentHtml", { zh, en })); + ctx.effect(() => ctx.documentPreviews.register(htmlBodyDefinition(() => t("title")))); + ctx.effect(() => + ctx.slots.inject("sidebar.right.tab.document", () => + ctx.slots.register( + { + name: "sidebar.right.tab.document", + key: HTML_BODY_ID, + locale: "documentHtml", + inject: (): HtmlBodyInjected => ({ + hooks: { interactivePreview: ctx.configForms.developerTools.enabled }, + readRelated: (address, relativePath, signal) => { + const file = hostFileOf(address); + return ctx.remote.workspaceFiles.readBytes( + file.sessionId, + relativePath, + { baseFile: file.path }, + signal, + ); + }, + }), + }, + HtmlBody, + ), + ), + ); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/html/locales.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/html/locales.ts new file mode 100644 index 000000000..5972391a0 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/html/locales.ts @@ -0,0 +1,25 @@ +/** Locale-owned HTML implementation name and iframe status text. */ +export const zh = { + title: "HTML", + frame: "HTML 文档预览", + loading: "文档渲染中...", + failed: "无法预览这份 HTML 文档", +} satisfies Record; + +/** HTML renderer dictionary keys. */ +export type HtmlPreviewKey = keyof typeof zh; + +/** English dictionary with the same keys as the Chinese dictionary. */ +export const en = { + title: "HTML", + frame: "HTML document preview", + loading: "Rendering document...", + failed: "This HTML document could not be previewed.", +} satisfies Record; + +declare module "@deepseek-ai/dsh-client-ui-slots" { + interface LocaleNamespaceMap { + /** HTML preview selection and status text. */ + documentHtml: HtmlPreviewKey; + } +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/html/pack.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/html/pack.ts new file mode 100644 index 000000000..720ed6efb --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/html/pack.ts @@ -0,0 +1,83 @@ +/** Finite HTML-declared classic scripts and stylesheets; no module, CSS dependency or runtime fetch traversal. */ +import type { HtmlAsset, HtmlBundle } from "./bootstrap.ts"; +import type { DocumentFileBytes } from "../rpc.ts"; +import { decodeText } from "./bytes.ts"; + +/** + * A read bound to the original document's session and directory, using ordinary file operations. + * @param reference - HTML-decoded relative URL, including any query or fragment; the reader resolves its file path. + * @param signal - cancellation of this packing operation. + * @returns complete file bytes; permission and read failures reject. + */ +export type ReadHtmlRelative = ( + reference: string, + signal: AbortSignal, +) => Promise; + +const MAX_ASSET_BYTES = 4 * 1024 * 1024; +const MAX_TOTAL_BYTES = 32 * 1024 * 1024; +const MAX_ASSETS = 64; + +/** Whether this reference can be read relative to the original document, never the parent application URL. */ +function relative(reference: string): boolean { + return ( + reference.length > 0 && + !/^(?:[a-z][a-z\d+.-]*:|[/\\#?])/iu.test(reference) && + !reference.includes("\0") + ); +} + +/** + * Collect static dependencies without executing or mounting document elements in the parent page. + * A base element leaves URL resolution to the browser. Only direct .js classic scripts and .css + * links are packed; local CSS url/import, modules and dynamically constructed URLs are unsupported. + * @param data - complete UTF-8 HTML bytes. + * @param readRelative - original-document-scoped read, never exposed to the iframe. + * @param signal - stops reads and prevents publication after cancellation. + * @returns complete HTML and its finite static asset set; decoding, limits and read failures reject. + */ +export async function packHtml( + data: Uint8Array, + readRelative: ReadHtmlRelative, + signal: AbortSignal, +): Promise { + signal.throwIfAborted(); + let total = data.byteLength; + if (total > MAX_TOTAL_BYTES) throw new Error("HTML package exceeds its total byte limit"); + const template = document.createElement("template"); + template.innerHTML = decodeText(data); + const assets: HtmlAsset[] = []; + if (template.content.querySelector("base[href]") !== null) return { data, assets }; + + const seen = new Set(); + for (const element of template.content.querySelectorAll("script[src],link[href]")) { + const script = element.localName === "script"; + const type = element.getAttribute("type")?.trim().toLowerCase() ?? ""; + if (script && !["", "text/javascript", "application/javascript"].includes(type)) continue; + if ( + !script && + !(element.getAttribute("rel") ?? "").toLowerCase().split(/\s+/u).includes("stylesheet") + ) + continue; + // The selector requires the corresponding URL attribute. + const reference = element.getAttribute(script ? "src" : "href") as string; + const suffix = reference.search(/[?#]/u); + const path = suffix === -1 ? reference : reference.slice(0, suffix); + if (!relative(reference) || !(script ? /\.js$/iu : /\.css$/iu).test(path)) continue; + const kind = script ? "script" : "stylesheet"; + const key = `${kind}:${reference}`; + if (seen.has(key)) continue; + if (assets.length >= MAX_ASSETS) throw new Error("HTML package exceeds its asset count limit"); + signal.throwIfAborted(); + const asset = await readRelative(reference, signal); + signal.throwIfAborted(); + const size = asset.data.byteLength; + if (size > MAX_ASSET_BYTES) throw new Error("HTML asset exceeds its byte limit"); + total += size; + if (total > MAX_TOTAL_BYTES) throw new Error("HTML package exceeds its total byte limit"); + decodeText(asset.data); + assets.push({ kind, reference, data: asset.data }); + seen.add(key); + } + return { data, assets }; +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/html/read-relative.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/html/read-relative.ts new file mode 100644 index 000000000..189426ad5 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/html/read-relative.ts @@ -0,0 +1,60 @@ +/** Adapt a relative workspace read without changing its Session or Host path authority. */ +import type { RemoteResult } from "@deepseek-ai/dsh-api-remotes/client"; +import type { DocumentFileBytes } from "../rpc.ts"; +import type { ReadHtmlRelative } from "./pack.ts"; +import { sessionFileAddress } from "@deepseek-ai/dsh-util-workspace-path"; +import { hostFileOf } from "../rpc.ts"; + +/** + * Read one dependency relative to the addressed HTML file through the Host. + * @param address - root HTML file address. + * @param relativePath - decoded dependency path, resolved only by the Host. + * @param signal - cancellation shared by the tab and packing operation. + * @returns the complete-byte result, including declared failures. + */ +export type ReadHtmlRelated = ( + address: string, + relativePath: string, + signal: AbortSignal, +) => Promise>; + +/** + * Bind a package reader to the original HTML file's address. + * @param readRelated - workspace reader using the Session in the root HTML address. + * @param address - root HTML file address. + * @param lifetime - tab lifetime. + * @param addResource - subscribes to the dependency path reported by the Host. + * @returns a reader that strips URL query/fragment, decodes one path, and preserves Host failures. + */ +export function createReadHtmlRelative( + readRelated: ReadHtmlRelated, + address: string, + lifetime: AbortSignal, + addResource: (address: string) => void, +): ReadHtmlRelative { + return async (reference, signal) => { + const suffix = reference.search(/[?#]/u); + const path = decodeURIComponent(suffix === -1 ? reference : reference.slice(0, suffix)); + if ( + path.length === 0 || + /^(?:[a-z][a-z\d+.-]*:|[/\\])/iu.test(path) || + path.includes("\0") || + path.includes("\\") + ) { + throw new Error("HTML dependency must use a relative file path"); + } + const combined = AbortSignal.any([lifetime, signal]); + combined.throwIfAborted(); + const file = hostFileOf(address); + const result = await readRelated(address, path, combined); + combined.throwIfAborted(); + if (!result.ok) { + if ("path" in result.error.details && typeof result.error.details.path === "string") { + addResource(sessionFileAddress(file.sessionId, result.error.details.path)); + } + throw new Error(result.error.message); + } + addResource(sessionFileAddress(file.sessionId, result.value.absolutePath)); + return result.value; + }; +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.module.css b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.module.css new file mode 100644 index 000000000..fd83de6e2 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.module.css @@ -0,0 +1,56 @@ +/* The frame follows the scroller's width, so the image's percentage maximum + resolves against the pane and never scrolls horizontally; a taller image + grows the frame and scrolls vertically. */ +.frame { + display: flex; + box-sizing: border-box; + width: 100%; + min-width: 100%; + height: max-content; + min-height: 100%; + padding: 12px; + font-family: var(--dsw-font, sans-serif); + white-space: normal; +} + +.frame:has([data-document-loading]) { + height: 100%; +} + +/* A wider image shrinks to the pane's width at its aspect ratio; a smaller + one keeps its intrinsic size. The auto margins centre it either way. */ +.surface { + display: block; + flex: none; + max-width: none; + margin: auto; +} + +.surface[hidden] { + display: none; +} + +.image { + display: block; + width: 100%; + max-width: none; + height: auto; + max-height: none; + border-radius: var(--dsw-radius-md); + user-select: none; +} + +.status { + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: 100%; + min-height: 100%; + margin: 0; + padding: 10px; + color: var(--dsw-alias-label-secondary); + font-size: 13px; + line-height: 1.5; + white-space: normal; +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.tsx b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.tsx new file mode 100644 index 000000000..4ab9aef86 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.tsx @@ -0,0 +1,207 @@ +/** Complete image bytes rendered in a shared zoom viewport. */ +import { + useCallback, + useEffect, + useMemo, + useState, + type CSSProperties, + type ReactNode, +} from "react"; +import type { PropsLocale, PropsStore } from "@deepseek-ai/dsh-client-ui-slots"; +import { pathPartsOf } from "@deepseek-ai/dsh-util-workspace-path"; +import type { DocumentPreviewProps } from "../document/contract.ts"; +import { LoadingIndicator } from "../LoadingIndicator.tsx"; +import { hostFileOf } from "../rpc.ts"; +import { ZoomViewport, zoomSurfaceClass } from "../zoom/ZoomViewport.tsx"; +import { DEFAULT_ZOOM, type ZoomInjected, type ZoomStore } from "../zoom/store.ts"; +import type { ZoomLabels, ZoomPreference } from "../zoom/types.ts"; +import type {} from "./locales.ts"; +import css from "./ImageBody.module.css"; + +const IMAGE_MEDIA_TYPES = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + bmp: "image/bmp", + ico: "image/x-icon", + svg: "image/svg+xml", +} as const; + +type ImageMediaType = (typeof IMAGE_MEDIA_TYPES)[keyof typeof IMAGE_MEDIA_TYPES]; + +/** Standard document props plus the image renderer's dictionary. */ +export type ImageBodyProps = DocumentPreviewProps & + PropsLocale<"sidebarImage"> & + PropsStore & + ZoomInjected; + +type ImageSource = + | { + readonly kind: "ready"; + readonly data: Uint8Array; + readonly mediaType: ImageMediaType; + readonly url: string; + } + | { + readonly kind: "failed"; + readonly data: Uint8Array; + readonly mediaType: ImageMediaType; + }; + +/** + * Resolve a supported filename to the media type assigned to its Blob. + * @param path - decoded workspace file path. + * @returns the image media type, or undefined for an unregistered suffix. + */ +export function imageMediaType(path: string): ImageMediaType | undefined { + const normalized = path.replaceAll("\\", "/"); + const name = normalized.slice(normalized.lastIndexOf("/") + 1).toLowerCase(); + const extension = name.slice(name.lastIndexOf(".") + 1) as keyof typeof IMAGE_MEDIA_TYPES; + return IMAGE_MEDIA_TYPES[extension]; +} + +/** + * Present complete image bytes with fit-width and fixed-scale viewing. + * @param props - document bytes, resource identity, and locale. + * @returns a rounded image fitted or scaled at its intrinsic aspect ratio. + */ +export function ImageBody(props: ImageBodyProps): ReactNode { + const { content, resourceAddress, t } = props; + const { tab } = props.useTabInfo(); + const preference = props.useStore((state) => state.byTab[tab.id] ?? DEFAULT_ZOOM); + const path = useMemo(() => hostFileOf(resourceAddress).path, [resourceAddress]); + const mediaType = imageMediaType(path); + const data = content.kind === "bytes" ? content.data : undefined; + const [source, setSource] = useState(); + const setPreference = useCallback( + (value: ZoomPreference): void => { + props.actions.zoom(tab.id, value); + }, + [props.actions, tab.id], + ); + + useEffect(() => { + props.retainTab(tab.id, tab.signal); + }, [props.retainTab, tab.id, tab.signal]); + + useEffect(() => { + if (data === undefined || mediaType === undefined) return; + let url: string | undefined; + try { + url = URL.createObjectURL(new Blob([data], { type: mediaType })); + setSource({ kind: "ready", data, mediaType, url }); + } catch { + setSource({ kind: "failed", data, mediaType }); + } + return () => { + if (url !== undefined) URL.revokeObjectURL(url); + }; + }, [data, mediaType]); + + if (data === undefined || mediaType === undefined) { + return ( +

+ {t("unsupported")} +

+ ); + } + if (source?.data !== data || source.mediaType !== mediaType) { + return ; + } + if (source.kind === "failed") + return ( +

+ {t("failed")} +

+ ); + const { name } = pathPartsOf(path); + const labels: ZoomLabels = { + controls: t("zoomControls"), + menu: t("zoomMenu"), + out: t("zoomOut"), + into: t("zoomIn"), + fitWidth: t("zoomFitWidth"), + value: (percent) => t("zoomValue", { percent }), + }; + return ( + + ); +} + +/** SVG stays in the browser's static image mode because its bytes only reach an img Blob URL. */ +function LoadedImage({ + url, + name, + preference, + onPreference, + labels, + signal, + scrollportRef, + t, +}: { + readonly url: string; + readonly name: string; + readonly preference: ZoomPreference; + readonly onPreference: (preference: ZoomPreference) => void; + readonly labels: ZoomLabels; + readonly signal: AbortSignal; + readonly scrollportRef: ImageBodyProps["scrollportRef"]; + readonly t: ImageBodyProps["t"]; +}): ReactNode { + const [state, setState] = useState<"loading" | "ready" | "failed">("loading"); + const [width, setWidth] = useState(); + return ( + +
+ {state === "loading" && } + {state === "failed" && ( +

+ {t("failed")} +

+ )} + +
+
+ ); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/image/index.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/image/index.ts new file mode 100644 index 000000000..a9f3b7eec --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/image/index.ts @@ -0,0 +1,71 @@ +/** Builtin image metadata and keyed document-body registration. */ +import type { Context } from "@deepseek-ai/cordis"; +import type { BoundActions } from "@deepseek-ai/dsh-client-store"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type {} from "../index.ts"; +import type { DocumentPreviewDefinition } from "../document/registry.ts"; +import { retainDocumentTabs } from "../document/tab-lifetime.ts"; +import { createZoomStore, type ZoomInjected, type ZoomStore } from "../zoom/store.ts"; +import { ImageBody } from "./ImageBody.tsx"; +import { en, zh } from "./locales.ts"; + +/** Image implementation identity, shared by metadata and the keyed slot. */ +export const IMAGE_BODY_ID = "@deepseek-ai/dsh-client-ui-sidebar-documentpreview/image"; + +/** File suffixes rendered by the builtin image body. */ +export const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "svg"] as const; + +/** Bitmap suffixes whose bytes are unreadable as text; SVG stays out because its XML source is worth reading. */ +export const BINARY_IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico"] as const; + +/** + * Describe the builtin image renderer independently from its keyed body slot. + * @param title - locale-owned implementation name. + * @returns metadata for complete image files. + */ +export function imageBodyDefinition(title: () => string): DocumentPreviewDefinition { + return { + id: IMAGE_BODY_ID, + extensions: IMAGE_EXTENSIONS, + binaryExtensions: BINARY_IMAGE_EXTENSIONS, + priority: "builtin", + title, + loading: "bytes-complete", + wrap: false, + }; +} + +/** + * Register the image dictionary, metadata, and body with reversible effects. + * @param ctx - owning plugin context. + */ +export function apply(ctx: Context): void { + const t = ctx.locale.bind("sidebarImage"); + ctx.effect(() => ctx.locale.register("sidebarImage", { zh, en }), "document-image: dictionaries"); + ctx.effect( + () => ctx.documentPreviews.register(imageBodyDefinition(() => t("title"))), + "document-image: metadata", + ); + const store = createZoomStore(); + const retainTab = retainDocumentTabs(ctx); + ctx.effect( + () => + ctx.slots.inject("sidebar.right.tab.document", () => + ctx.slots.register( + { + name: "sidebar.right.tab.document", + key: IMAGE_BODY_ID, + locale: "sidebarImage", + store, + inject: (_sessionId: SessionId, actions: BoundActions): ZoomInjected => ({ + retainTab: (tabId, signal) => { + retainTab(tabId, signal, actions.forget); + }, + }), + }, + ImageBody, + ), + ), + "document-image: body", + ); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/image/locales.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/image/locales.ts new file mode 100644 index 000000000..5387678fa --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/image/locales.ts @@ -0,0 +1,31 @@ +import { zoomEn, zoomZh } from "../zoom/locales.ts"; + +/** Locale-owned image renderer labels and status text. */ +export const zh = { + ...zoomZh, + title: "图片", + preview: "图片预览:{name}", + loading: "文档渲染中...", + failed: "无法显示这张图片", + unsupported: "图片预览需要完整文件内容", +} satisfies Record; + +/** Image renderer dictionary keys. */ +export type ImagePreviewKey = keyof typeof zh; + +/** English dictionary with the same keys as the Chinese dictionary. */ +export const en = { + ...zoomEn, + title: "Image", + preview: "Image preview: {name}", + loading: "Rendering document...", + failed: "This image could not be displayed.", + unsupported: "Image preview requires the complete file contents.", +} satisfies Record; + +declare module "@deepseek-ai/dsh-client-ui-slots" { + interface LocaleNamespaceMap { + /** Image preview selection, accessible name, and status text. */ + sidebarImage: ImagePreviewKey; + } +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/index.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/index.ts new file mode 100644 index 000000000..1f638eb51 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/index.ts @@ -0,0 +1,178 @@ +/** + * Browser half: register `text` as a right-Sidebar tab type. + * + * The type reaches the Sidebar through its public path only: the definition into + * `ctx.sidebarRightTabs`, the body into the keyed `sidebar.right.pane.tab` + * seat, and the chip title into `sidebar.right.pane.tab.title`, both under the + * definition's `id`. Nothing here reaches into the Sidebar's store, its + * panes, or its sequence. The file's metadata comes from the standard + * `useResource`, served by the `file` provider; the content is this type's own + * business, read through its face. Every import from another + * client plugin is a type. + */ +import type { Context as ClientContext } from "@deepseek-ai/cordis"; +import type {} from "@deepseek-ai/dsh-api-remotes/client"; +import type {} from "@deepseek-ai/dsh-client-locale/client"; +import type {} from "@deepseek-ai/dsh-client-resources/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-sidebar-right/client"; +import type {} from "@deepseek-ai/dsh-api-gateway/client"; +import type {} from "@deepseek-ai/dsh-api-workspace-files/remote"; +import type { WorkspaceFileParams } from "@deepseek-ai/dsh-api-workspace-files/client"; +import { TextPreview } from "./TextPreview.tsx"; +import type { TextPreviewInjected } from "./TextPreview.tsx"; +import { TextTitle } from "./TextTitle.tsx"; +import { TEXTPREVIEW_ID, textDefinition } from "./definition.ts"; +import { textFace } from "./face.ts"; +import { createReadPage } from "./rpc.ts"; +import { createTextStore } from "./store.ts"; +import { en, zh } from "./locales.ts"; +import { DocumentPreviewRegistry } from "./document/registry.ts"; +import { documentTabInfoFactory } from "./document/contract.ts"; +import { apply as registerText } from "./text/index.ts"; +import { apply as registerMarkdown } from "./markdown/index.ts"; +import { apply as registerHtml } from "./html/index.ts"; +import { apply as registerImage } from "./image/index.ts"; +import { apply as registerPdf } from "./pdf/index.ts"; +import { apply as registerCode } from "./code/index.ts"; +import { apply as registerOffice } from "./office/index.ts"; +import { apply as registerExcel } from "./excel/index.ts"; +import { Config } from "../config.ts"; + +// Values stay package-private unless another package needs them; the plugin +// surface is `apply`, `inject`, and the store factory another registration may +// share, plus the types a consumer of the seat or the store names. +export type { SidebarDocumentPreviewKey } from "./locales.ts"; +export type { TextPreviewProps } from "./TextPreview.tsx"; +export type { TextInjected } from "./face.ts"; +export type { + ReadDocumentBytes, + DocumentFileBytes, + ReadWorkspaceFilePage, + SessionFile, + WorkspaceFilesReadRemote, +} from "./rpc.ts"; +export type { TextPage, TextState, TextStore, TextTabState } from "./store.ts"; +export type { + DocumentContent, + DocumentPreviewProps, + DocumentTextPage, +} from "./document/contract.ts"; +export type { DocumentLoadMode, DocumentPreviewDefinition } from "./document/registry.ts"; + +declare module "@deepseek-ai/cordis" { + interface Context { + /** File-extension renderer registrations, independent from their keyed document bodies. */ + documentPreviews: DocumentPreviewRegistry; + } +} + +/** This package's copy namespace. */ +const NS = "sidebarDocumentPreview"; + +declare module "@deepseek-ai/dsh-client-ui-sidebar-right/client" { + interface SidebarRightResourceParamsMap { + /** File line navigation supported by the text preview. */ + file: WorkspaceFileParams; + } +} + +declare module "@deepseek-ai/dsh-client-ui-slots" { + interface LocaleNamespaceMap { + /** Text-preview progress, paging, change, control, and failure lines. */ + sidebarDocumentPreview: import("./locales.ts").SidebarDocumentPreviewKey; + } +} + +/** + * Required browser services: the tab registry, the slot registry, copy, and the + * workspace Remote for bytes and paged text reads. + */ +export const inject = [ + "slots", + "locale", + "sidebarRightTabs", + "remote", + "remote.workspaceFiles", + "configForms", + "resources", +]; + +/** + * Client plugin body: register the type, its dictionaries, its body, and its chip title. + * @param ctx - client root context carrying the registry, slots, copy, and file readers. + */ +export function apply(ctx: ClientContext): void { + const config = Config( + (globalThis as { __DSH_DOCUMENT_PREVIEW_CONFIG__?: unknown }).__DSH_DOCUMENT_PREVIEW_CONFIG__ ?? + {}, + ); + const previews = new DocumentPreviewRegistry(); + const disposePreviews = ctx.reflect.provide("documentPreviews", previews); + ctx.effect(() => disposePreviews); + ctx.effect( + () => ctx.sidebarRightTabs.register(textDefinition()), + "ui-sidebar-documentpreview: text type", + ); + ctx.effect(() => ctx.locale.register(NS, { zh, en }), "ui-sidebar-documentpreview: dictionaries"); + + const store = createTextStore(); + const face = textFace( + createReadPage(ctx.remote), + (file, signal) => ctx.remote.workspaceFiles.readBytes(file.sessionId, file.path, {}, signal), + ctx.resources, + ); + const source = { getSnapshot: previews.getSnapshot, subscribe: previews.subscribe }; + ctx.effect( + () => + ctx.slots.inject("sidebar.right.pane.tab", () => + ctx.slots.register( + { + name: "sidebar.right.pane.tab", + key: TEXTPREVIEW_ID, + locale: NS, + store, + children: { + "sidebar.right.tab.document": { + kind: "keyed", + scope: "session", + inject: { hooks: { tabInfo: documentTabInfoFactory } }, + }, + "sidebar.right.tab.document.actions": { kind: "list", scope: "session" }, + "sidebar.right.tab.document.unpreviewable": { kind: "list", scope: "session" }, + "sidebar.right.tab.document.action": { + kind: "keyed", + scope: "session", + inject: { hooks: { tabInfo: documentTabInfoFactory } }, + }, + }, + inject: (sessionId, actions): TextPreviewInjected => ({ + ...face(sessionId, actions), + hooks: { documentPreviews: source }, + }), + }, + TextPreview, + ), + ), + "ui-sidebar-documentpreview: text body", + ); + ctx.effect( + () => + ctx.slots.inject("sidebar.right.pane.tab.title", () => + ctx.slots.register( + { name: "sidebar.right.pane.tab.title", key: TEXTPREVIEW_ID }, + TextTitle, + ), + ), + "ui-sidebar-documentpreview: text title", + ); + registerText(ctx); + registerMarkdown(ctx); + registerHtml(ctx); + registerImage(ctx); + registerPdf(ctx); + registerOffice(ctx, config.office); + registerExcel(ctx, config.excel); + registerCode(ctx); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/locales.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/locales.ts new file mode 100644 index 000000000..ed9b9f075 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/locales.ts @@ -0,0 +1,62 @@ +/** + * `sidebarDocumentPreview` namespace dictionaries. + * + * The failure lines are the point of this file: a preview that cannot show a + * page has to say which of several different things went wrong, and each one + * suggests a different next step for the reader. + */ + +/** Simplified Chinese dictionary and key-set source of truth. */ +export const zh = { + loading: "文档渲染中...", + loadMore: "加载更多", + changed: "文件已更新,当前显示为旧内容", + reloadNow: "重新载入", + reload: "重新读取文件", + autoRefresh: "自动刷新", + "autoRefresh.enable": "开启自动刷新", + "autoRefresh.disable": "关闭自动刷新", + "wrap.enable": "自动换行", + "wrap.disable": "取消换行", + "wrap.aria": "自动换行", + openWith: "打开方式", + "viewer.text": "纯文本", + resourceUnavailable: "文件资源服务不可用", + rendererUnavailable: "预览器 {name} 不可用", + unsupportedFile: "该格式文件暂时无法预览", + "error.notFound": "文件不存在,可能已被移动或删除", + "error.tooLarge": "单页内容超过 {limit} 上限,无法读取", + "error.notText": "该格式文件暂时无法预览", + "error.notRegularFile": "该路径不是普通文件,没有可显示的内容", + "error.unavailable": "读取失败:{message}", + retry: "重试", +} satisfies Record; + +/** Text-preview dictionary key union. */ +export type SidebarDocumentPreviewKey = keyof typeof zh; + +/** English dictionary, checked against the Chinese key set. */ +export const en = { + loading: "Rendering document...", + loadMore: "Load more", + changed: "The file has changed, showing the previous content.", + reloadNow: "Reload", + reload: "Read the file again", + autoRefresh: "Auto refresh", + "autoRefresh.enable": "Enable auto refresh", + "autoRefresh.disable": "Disable auto refresh", + "wrap.enable": "Turn on line wrap", + "wrap.disable": "Turn off line wrap", + "wrap.aria": "Line wrap", + openWith: "Open with", + "viewer.text": "Plain text", + resourceUnavailable: "The file resource service is unavailable.", + rendererUnavailable: "The {name} preview is unavailable.", + unsupportedFile: "Preview is not available for this file type yet.", + "error.notFound": "File not found. It may have been moved or deleted.", + "error.tooLarge": "This page exceeds the {limit} limit and cannot be read.", + "error.notText": "Preview is not available for this file type yet.", + "error.notRegularFile": "Not a regular file, nothing to display.", + "error.unavailable": "Read failed: {message}", + retry: "Retry", +} satisfies Record; diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/markdown/MarkdownBody.module.css b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/markdown/MarkdownBody.module.css new file mode 100644 index 000000000..7966be001 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/markdown/MarkdownBody.module.css @@ -0,0 +1,31 @@ +/* The owner supplies scrolling; Markdown uses prose whitespace inside its source-oriented container. */ +.document { + min-width: 0; + padding: 10px 12px; + font-family: var(--dsw-font, inherit); + white-space: normal; +} + +/* Leading YAML frontmatter: a bordered metadata box above the body, never heading text. */ +.frontmatter { + margin: 0 0 16px; + padding: 10px 12px; + border: 0.5px solid var(--dsw-alias-border-l2); + border-radius: var(--dsw-radius-md); + background: var(--dsw-alias-bg-layer-1); + font: var(--dsw-font-markdown-small); + color: var(--dsw-alias-label-primary); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +/* A document without frontmatter content leaves the box empty. */ +.frontmatter:empty { + display: none; +} + +.frontmatterSource { + margin: 0; + font-family: var(--ds-font-family-code); + white-space: inherit; +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/markdown/MarkdownBody.tsx b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/markdown/MarkdownBody.tsx new file mode 100644 index 000000000..2a0669943 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/markdown/MarkdownBody.tsx @@ -0,0 +1,96 @@ +/** One retained Markdown renderer over the document owner's accumulated text. */ +import { lazy, Suspense, useMemo } from "react"; +import type { ReactNode } from "react"; +import { + MarkdownText, + type MarkdownLabels, + type MarkdownPathImages, +} from "@deepseek-ai/dsh-client-ui-primitives"; +import type { PropsLocale } from "@deepseek-ai/dsh-client-ui-slots"; +import type { DocumentPreviewProps } from "../document/contract.ts"; +import { splitFrontmatter } from "./frontmatter.ts"; +import type { FrontmatterFieldsProps } from "./frontmatter-fields.tsx"; +import { markdownImageUrl } from "./path-images.ts"; +import type {} from "./locales.ts"; +import css from "./MarkdownBody.module.css"; + +/** Show the verbatim frontmatter when the field-list chunk cannot load. */ +function FrontmatterFallback({ fallback }: FrontmatterFieldsProps): ReactNode { + return fallback; +} + +const FrontmatterFields = lazy(() => + import("./frontmatter-fields.tsx").then( + (module) => ({ default: module.FrontmatterFields }), + // The failed chunk only removes the field layout; the body and verbatim metadata stay usable. + () => ({ default: FrontmatterFallback }), + ), +); + +/** Standard document inputs and this implementation's locale. */ +export type MarkdownBodyProps = DocumentPreviewProps & PropsLocale<"documentMarkdown">; + +/** + * Render one accumulated document; EOF completes the primitive's full parse. + * A leading YAML frontmatter block renders as a field list instead of Markdown; its + * verbatim source stands in while the field-list chunk loads or when it fails. + * @param props - owner-loaded contents and localized primitive labels. + * @returns Markdown content, or nothing for a non-text delivery. + */ +export function MarkdownBody({ + content, + resourceAddress, + useResource, + t, +}: MarkdownBodyProps): ReactNode { + const absolutePath = useResource<"file">(resourceAddress).value?.absolutePath; + const pathImages = useMemo( + () => ({ + resolve: (value) => markdownImageUrl(document.baseURI, absolutePath, value), + }), + [absolutePath], + ); + const copyLabel = t("code.copy"); + const copiedLabel = t("code.copied"); + const footnotes = t("footnotes"); + const codeLabel = t("codeBlock.title"); + const wrapLabel = t("codeBlock.wrap"); + const unwrapLabel = t("codeBlock.unwrap"); + const labels = useMemo( + () => ({ + code: { copyLabel, copiedLabel, toolbarLabels: { codeLabel, wrapLabel, unwrapLabel } }, + footnotes, + }), + [copyLabel, copiedLabel, footnotes, codeLabel, wrapLabel, unwrapLabel], + ); + const text = content.kind === "text" ? content.text : ""; + const split = useMemo(() => splitFrontmatter(text), [text]); + if (content.kind !== "text") return null; + return ( +
+ {split === undefined ? null : } + +
+ ); +} + +/** + * Frame one frontmatter block and choose its field list or verbatim source. + * @param props - YAML between the frontmatter delimiters. + * @returns the metadata box; it collapses when the document has no content. + */ +function FrontmatterBox({ source }: { source: string }): ReactNode { + const verbatim =
{source}
; + return ( +
+ + + +
+ ); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/markdown/frontmatter-fields.module.css b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/markdown/frontmatter-fields.module.css new file mode 100644 index 000000000..6230529ff --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/markdown/frontmatter-fields.module.css @@ -0,0 +1,25 @@ +/* Key/value grid inside the Markdown body's frontmatter box. */ +.fields { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 4px 16px; + margin: 0; +} + +.field { + display: contents; +} + +.key { + color: var(--dsw-alias-label-secondary); + font-family: var(--ds-font-family-code); +} + +.value, +.block { + margin: 0; +} + +.block { + font-family: var(--ds-font-family-code); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/markdown/frontmatter-fields.tsx b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/markdown/frontmatter-fields.tsx new file mode 100644 index 000000000..1252183db --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/markdown/frontmatter-fields.tsx @@ -0,0 +1,75 @@ +/** + * Field list for a Markdown document's leading YAML frontmatter. The Markdown + * body loads this module lazily so the YAML parser stays out of the startup + * chunk. + */ +import { useMemo, type ReactNode } from "react"; +import { isMap, isNode, isScalar, parseDocument } from "yaml"; +import css from "./frontmatter-fields.module.css"; + +/** One top-level entry; `block` marks the authored YAML of a collection or alias value. */ +interface FrontmatterField { + key: string; + value: string; + block: boolean; +} + +/** Owner inputs for the lazily loaded field list. */ +export interface FrontmatterFieldsProps { + /** YAML between the frontmatter delimiters. */ + source: string; + /** Verbatim block shown for invalid YAML and non-mapping documents. */ + fallback: ReactNode; +} + +/** + * Resolve top-level mapping fields from the YAML syntax tree. + * @param source - YAML between the frontmatter delimiters. + * @returns the fields, `[]` for a document without content, or `undefined` for invalid YAML and non-mapping documents. + */ +function parseFields(source: string): readonly FrontmatterField[] | undefined { + const parsed = parseDocument(source); + if (parsed.errors.length > 0) return undefined; + if (parsed.contents === null) return []; + if (!isMap(parsed.contents)) return undefined; + return parsed.contents.items.map(({ key, value }) => ({ + key: nodeText(source, key), + value: nodeText(source, value), + block: isNode(value) && !isScalar(value), + })); +} + +// Strings show their resolved value, so folded and multiline text reads as prose; other +// nodes keep their authored text, so `1.0` stays `1.0` and aliases stay unexpanded. +function nodeText(source: string, node: unknown): string { + if (isScalar(node) && typeof node.value === "string") return node.value.trimEnd(); + if (!isNode(node)) return ""; + /* v8 ignore next -- parsed nodes always carry their source range. */ + const [start, end] = node.range ?? [0, 0]; + const indent = start - source.lastIndexOf("\n", start - 1) - 1; + return source + .slice(start, end) + .replace(new RegExp(`\\n {0,${indent}}`, "gu"), "\n") + .trimEnd(); +} + +/** + * Render frontmatter fields, or the owner's verbatim block when the YAML is not a mapping. + * @param props - frontmatter source and its verbatim fallback. + * @returns the field list, the fallback, or nothing for a document without content. + */ +export function FrontmatterFields({ source, fallback }: FrontmatterFieldsProps): ReactNode { + const fields = useMemo(() => parseFields(source), [source]); + if (fields === undefined) return fallback; + if (fields.length === 0) return null; + return ( +
+ {fields.map((field, index) => ( +
+
{field.key}
+
{field.value}
+
+ ))} +
+ ); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/markdown/frontmatter.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/markdown/frontmatter.ts new file mode 100644 index 000000000..209dd2d96 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/markdown/frontmatter.ts @@ -0,0 +1,22 @@ +/** Leading YAML frontmatter split from a Markdown document before Markdown parsing. */ + +/** Frontmatter source between its delimiters and the Markdown body after the closing delimiter. */ +export interface FrontmatterSplit { + source: string; + body: string; +} + +// Only an opening `---` on the first line (after an optional BOM) counts; the block ends at the +// first closing `---` or `...` line, and `??` lets an empty block close on the next line. +const FRONTMATTER = /^\uFEFF?---[ \t]*\r?\n(?:([\s\S]*?)\r?\n)??(?:---|\.\.\.)[ \t]*(?:\r?\n|$)/u; + +/** + * Split a leading YAML frontmatter block from Markdown text without parsing its YAML. + * @param text - Markdown source; an unterminated block is not frontmatter. + * @returns frontmatter source and the remaining body, or `undefined` when the text has none. + */ +export function splitFrontmatter(text: string): FrontmatterSplit | undefined { + const match = FRONTMATTER.exec(text); + if (match === null) return undefined; + return { source: match[1] ?? "", body: text.slice(match[0].length) }; +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/markdown/index.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/markdown/index.ts new file mode 100644 index 000000000..ab4922815 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/markdown/index.ts @@ -0,0 +1,51 @@ +/** Builtin Markdown metadata and keyed document-body registration. */ +import type { Context } from "@deepseek-ai/cordis"; +import type {} from "../index.ts"; +import type { DocumentPreviewDefinition } from "../document/registry.ts"; +import { MarkdownBody } from "./MarkdownBody.tsx"; +import { en, zh } from "./locales.ts"; + +/** Implementation identity shared by metadata and the document slot. */ +export const MARKDOWN_BODY_ID = "@deepseek-ai/dsh-client-ui-sidebar-documentpreview/markdown"; + +/** + * Describe the Markdown implementation without taking ownership of loading. + * @param title - locale-owned implementation name. + * @returns builtin Markdown registration metadata. + */ +export function markdownDefinition(title: () => string): DocumentPreviewDefinition { + return { + id: MARKDOWN_BODY_ID, + extensions: ["md", "markdown"], + priority: "builtin", + title, + loading: "text-pages", + wrap: false, + }; +} + +/** + * Register locale, metadata, and the document body for the owning plugin lifetime. + * @param ctx - plugin context carrying locale, document registry, and slots. + */ +export function apply(ctx: Context): void { + const t = ctx.locale.bind("documentMarkdown"); + ctx.effect( + () => ctx.locale.register("documentMarkdown", { zh, en }), + "document-markdown: dictionaries", + ); + ctx.effect( + () => ctx.documentPreviews.register(markdownDefinition(() => t("viewer.label"))), + "document-markdown: metadata", + ); + ctx.effect( + () => + ctx.slots.inject("sidebar.right.tab.document", () => + ctx.slots.register( + { name: "sidebar.right.tab.document", key: MARKDOWN_BODY_ID, locale: "documentMarkdown" }, + MarkdownBody, + ), + ), + "document-markdown: body", + ); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/markdown/locales.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/markdown/locales.ts new file mode 100644 index 000000000..4240a81cf --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/markdown/locales.ts @@ -0,0 +1,25 @@ +/** Markdown implementation labels and primitive chrome. */ +export const zh = { + "viewer.label": "Markdown", + "code.copy": "复制", + "code.copied": "已复制", + footnotes: "脚注", +} satisfies Record; + +/** Markdown namespace keys. */ +export type MarkdownPreviewKey = keyof typeof zh; + +/** English labels, paired with the Chinese key set. */ +export const en = { + "viewer.label": "Markdown", + "code.copy": "Copy", + "code.copied": "Copied", + footnotes: "Footnotes", +} satisfies Record; + +declare module "@deepseek-ai/dsh-client-ui-slots" { + interface LocaleNamespaceMap { + /** Markdown document renderer and its code/footnote controls. */ + documentMarkdown: MarkdownPreviewKey; + } +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/markdown/path-images.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/markdown/path-images.ts new file mode 100644 index 000000000..5187d1ced --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/markdown/path-images.ts @@ -0,0 +1,36 @@ +/** Local Markdown image destinations served by the authenticated file route. */ +import { + fileMediaUrl, + isAbsoluteWorkspacePath, + pathPartsOf, +} from "@deepseek-ai/dsh-util-workspace-path"; + +/** + * Build a file URL, resolving relative destinations beside the previewed file. + * @param base - document base URI, including any deployment prefix. + * @param documentPath - absolute source path reported by the Host, when available. + * @param destination - authored Markdown image URL; query and fragment are not filename components. + * @returns a Web or Desktop file URL, or undefined for unsupported or malformed destinations. + */ +export function markdownImageUrl( + base: string, + documentPath: string | undefined, + destination: string, +): string | undefined { + const suffix = destination.search(/[?#]/u); + let path: string; + try { + path = decodeURIComponent(suffix === -1 ? destination : destination.slice(0, suffix)); + } catch (_error) { + // Malformed percent escapes do not identify a local file. + return undefined; + } + if (path.length === 0) return undefined; + const windowsDrive = /^[a-z]:[/\\]/iu.test(path); + if (!windowsDrive && /^[a-z][a-z\d+.-]*:/iu.test(path)) return undefined; + if (!isAbsoluteWorkspacePath(path)) { + if (documentPath === undefined) return undefined; + path = pathPartsOf(documentPath).directory + path; + } + return fileMediaUrl(base, path); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.module.css b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.module.css new file mode 100644 index 000000000..a21364f2a --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.module.css @@ -0,0 +1,69 @@ +.anchor { + display: inline-flex; + flex: none; +} + +.anchor .warning, +.anchor .warning:hover { + color: var(--dsw-alias-state-warn-label); +} + +.anchor .warning:hover, +.anchor .warning[aria-expanded="true"] { + background: var(--dsw-alias-state-warn-tertiary); +} + +.panel { + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); + position: fixed; + z-index: 100; + box-sizing: border-box; + width: min(340px, calc(100vw - 24px)); + max-height: calc(100dvh - 24px); + padding: 16px; + overflow: auto; + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-bg-layer-2); + border: 0; + border-radius: var(--dsw-radius-lg); + box-shadow: var(--dsw-elevation-prominent); + font-size: 13px; + line-height: 1.6; +} + +.panelHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.panelHeader h3 { + margin: 0; + font-size: 14px; + font-weight: 600; +} + +.description { + margin: 8px 0 16px; + color: var(--dsw-alias-label-secondary); +} + +.count { + margin: 0 0 6px; + font-size: 12px; + color: var(--dsw-alias-label-tertiary); +} + +.fonts { + margin: 0; + padding: 0; + list-style: none; + overflow-wrap: anywhere; +} + +.fonts li { + padding: 8px 0; + border-top: 0.5px solid var(--dsw-alias-border-l2); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.tsx b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.tsx new file mode 100644 index 000000000..8ce25ea96 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.tsx @@ -0,0 +1,128 @@ +/** Toolbar warning and non-modal details for the current preview’s missing fonts. */ +import { useId, useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import clsx from "clsx"; +import type { PropsLocale } from "@deepseek-ai/dsh-client-ui-slots"; +import { + Button, + IconCloseOutlineRegular, + IconWarningTriangleOutlineRegular, + Tooltip, + useAnchoredPosition, + useDismissOnOutsidePointer, +} from "@deepseek-ai/dsh-client-ui-primitives"; +import common from "../TextPreview.module.css"; +import css from "./FontNotice.module.css"; + +/** Notice inputs supplied by the document owner and Office locale registration. */ +export type FontNoticeProps = PropsLocale<"sidebarOffice"> & { + readonly fonts: readonly string[]; +}; + +/** + * Show a warning while fonts are unavailable for the current preview. + * @param props - missing font families and localized copy. + * @returns a warning button and its anchored details, or nothing when fonts are available. + */ +export function FontNotice({ fonts, t }: FontNoticeProps): ReactNode { + const [expanded, setExpanded] = useState(false); + const open = fonts.length > 0 && expanded; + const anchor = useRef(null); + const panel = useRef(null); + const id = useId(); + const position = useAnchoredPosition({ + open, + anchorRef: anchor, + panelRef: panel, + gap: 8, + margin: 12, + }); + useDismissOnOutsidePointer( + anchor, + open, + () => { + setExpanded(false); + }, + panel, + ); + const closeDetails = (): void => { + setExpanded(false); + anchor.current?.querySelector("button")?.focus(); + }; + const positioned = position !== null; + useLayoutEffect(() => { + if (!open || !positioned) return; + panel.current?.focus(); + }, [open, positioned]); + if (fonts.length === 0) return null; + const label = t("viewMissingFonts", { count: fonts.length }); + return ( + <> + + + + + + {open && + createPortal( + , + document.body, + )} + + ); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.module.css b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.module.css new file mode 100644 index 000000000..c39c4cac2 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.module.css @@ -0,0 +1,6 @@ +.body { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.tsx b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.tsx new file mode 100644 index 000000000..307cc4e26 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.tsx @@ -0,0 +1,116 @@ +/** Office presents retained conversion results and font notices around the shared PDF view. */ +import { useEffect, type ReactNode } from "react"; +import type { + PropsLocale, + PropsRenderSlots, + PropsStore, + SlotHookFactory, +} from "@deepseek-ai/dsh-client-ui-slots"; +import type { TabId } from "@deepseek-ai/dsh-client-ui-dockkit"; +import { Button, FileTypeIcon, classifyFileType } from "@deepseek-ai/dsh-client-ui-primitives"; +import { pathPartsOf } from "@deepseek-ai/dsh-util-workspace-path"; +import type { UseSidebarRightTabInfo } from "@deepseek-ai/dsh-client-ui-sidebar-right/client"; +import type { DocumentBodyOwner, DocumentPreviewProps } from "../document/contract.ts"; +import { hostFileOf } from "../rpc.ts"; +import { LoadingIndicator } from "../LoadingIndicator.tsx"; +import type { OfficeLoadInjected } from "./face.ts"; +import type { OfficeStore } from "./store.ts"; +import common from "../TextPreview.module.css"; +import css from "./OfficeBody.module.css"; + +declare module "@deepseek-ai/dsh-client-ui-slots" { + interface SlotMap { + /** PDF presentation supplied with Office-owned converted bytes. */ + "sidebar.right.tab.document.office.pdf": { + kind: "keyed"; + scope: "session"; + owner: DocumentBodyOwner; + hookContext: UseSidebarRightTabInfo; + inject: { + hooks: { tabInfo: SlotHookFactory<"sidebar.right.tab.document", UseSidebarRightTabInfo> }; + }; + }; + } +} + +/** Office loading callbacks supplied by the registration's services. */ +export interface OfficeBodyInjected extends OfficeLoadInjected { + /** @param tab - owning tab. @param signal - tab lifetime, including hidden bodies. */ + readonly retainTab: (tab: TabId, signal: AbortSignal) => void; +} + +/** Office body inputs and its private PDF child. */ +export type OfficeBodyProps = DocumentPreviewProps & + PropsStore & + OfficeBodyInjected & + PropsLocale<"sidebarOffice"> & + PropsRenderSlots<"sidebar.right.tab.document.office.pdf">; + +/** + * Load one Office revision and preserve its result while its tab remains open. + * @param props - renderer loading request, tab state, conversion callbacks, and PDF slot. + * @returns conversion status or the PDF scrollport. + */ +export function OfficeBody(props: OfficeBodyProps): ReactNode { + const { tab } = props.useTabInfo(); + const { load, retainTab, resourceAddress, t } = props; + const request = props.content.kind === "renderer" ? props.content : undefined; + const revision = request?.revision; + const held = props.useStore((state) => state.byTab[tab.id]); + const view = held?.revision === revision ? held : undefined; + const settled = view?.file !== undefined || view?.failure !== undefined; + useEffect(() => { + retainTab(tab.id, tab.signal); + }, [retainTab, tab.id, tab.signal]); + // Metadata may replace callbacks without advancing the content revision. + useEffect(() => { + if (request === undefined || settled || tab.signal.aborted) return; + const controller = new AbortController(); + const signal = AbortSignal.any([controller.signal, tab.signal]); + load( + tab.id, + request.revision, + hostFileOf(resourceAddress), + signal, + request.loaded, + request.failed, + ); + return () => { + controller.abort(); + }; + }, [revision, resourceAddress, tab.id, tab.signal, load, settled]); + const file = view?.file; + if (request === undefined) return null; + if (view?.failure !== undefined) { + const { name } = pathPartsOf(resourceAddress); + return ( +
+ +

{view.failure.message}

+ +
+ ); + } + if (file === undefined) return ; + return ( +
+ {props.renderSlot( + "sidebar.right.tab.document.office.pdf", + { + resourceAddress, + content: { kind: "bytes", data: file.data }, + wrap: props.wrap, + scrollportRef: props.scrollportRef, + addResource: props.addResource, + setResources: props.setResources, + }, + { + entryKey: "@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office", + hookContext: props.useTabInfo, + }, + )} +
+ ); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/office/OfficeFontAction.tsx b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/office/OfficeFontAction.tsx new file mode 100644 index 000000000..0e155050b --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/office/OfficeFontAction.tsx @@ -0,0 +1,28 @@ +/** Font warning for the Office revision displayed by the document toolbar. */ +import type { ReactNode } from "react"; +import type { PropsLocale, PropsRuntime, PropsStore } from "@deepseek-ai/dsh-client-ui-slots"; +import type { OfficeStore } from "./store.ts"; +import { FontNotice } from "./FontNotice.tsx"; + +/** Office toolbar inputs share the body’s conversion result. */ +export type OfficeFontActionProps = PropsRuntime<"sidebar.right.tab.document.action"> & + PropsStore & + PropsLocale<"sidebarOffice">; + +/** + * Expose font details only for the currently loaded Office revision. + * @param props - toolbar revision, tab reader, Office store, and localized copy. + * @returns the font warning, or nothing before conversion completes. + */ +export function OfficeFontAction({ + content, + useTabInfo, + useStore, + t, +}: OfficeFontActionProps): ReactNode { + const { tab } = useTabInfo(); + const held = useStore((state) => state.byTab[tab.id]); + if (content.kind !== "renderer" || held?.revision !== content.revision || held.file === undefined) + return null; + return ; +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/office/cache.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/office/cache.ts new file mode 100644 index 000000000..0c8039af5 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/office/cache.ts @@ -0,0 +1,258 @@ +/** Session-authorized, version-checked Office bytes shared by concurrent preview reads. */ +import type { + OfficeToPdfPriority, + OfficeToPdfGeneration, +} from "@deepseek-ai/dsh-office-to-pdf/types"; +import type { RemoteResult } from "@deepseek-ai/dsh-api-remotes/client"; +import type { WorkspaceFileStat } from "@deepseek-ai/dsh-api-workspace-files/types"; +import type { DocumentFileBytes, SessionFile } from "../rpc.ts"; + +/** PDF bytes and conversion metadata owned by Office preview. */ +export type OfficeFileBytes = DocumentFileBytes & { + readonly missingFonts: readonly string[]; + readonly generation: OfficeToPdfGeneration; +}; + +/** + * Load authorized PDF contents for one Office source. + * @param file - Session and source path. + * @param signal - this reader's lifetime. + * @returns converted PDF bytes or a declared source-access failure. + */ +export type ReadOfficeDocument = ( + file: SessionFile, + signal: AbortSignal, +) => Promise>; + +/** Authorized cached reads carry explicit scheduling intent to the Host. */ +export type ReadOfficeBytes = ( + file: SessionFile, + signal: AbortSignal, + priority: OfficeToPdfPriority, +) => ReturnType; + +type Result = Awaited>; +type Success = Extract; +type Stat = (file: SessionFile, signal: AbortSignal) => Promise>; +interface Pending { + readonly controller: AbortController; + readonly promise: Promise; + users: number; +} + +/** Bounded successful results; each caller reauthorizes and checks source freshness before reuse. */ +export class OfficePreviewCache { + private readonly ready = new Map(); + private readonly pending = new Map(); + private bytes = 0; + private readers = 0; + private generation: OfficeToPdfGeneration | undefined; + private generationQuery = 0; + private acceptedQuery = 0; + private readonly superseded = new Error(); + private readonly lifetime = new AbortController(); + private readonly tasks = new Set>(); + private readonly reads = new Set>(); + + /** + * @param stat - authorized source metadata lookup. + * @param convert - Host render Remote returning binary PDF bytes borrowed read-only by callers. + * @param maxEntries - maximum completed results retained. + * @param maxBytes - maximum retained PDF byteLength. + * @param maxPending - maximum unsettled Host conversion requests, including cancellation teardown. + * @param maxReaders - maximum readers, including metadata lookups. + * @param generation - current Host renderer generation, checked before cached reuse. + * @param busy - localized capacity failure. + */ + constructor( + private readonly stat: Stat, + private readonly convert: ReadOfficeBytes, + private readonly maxEntries: number, + private readonly maxBytes: number, + private readonly maxPending: number, + private readonly maxReaders: number, + private readonly currentGeneration: ( + signal: AbortSignal, + ) => Promise>, + private readonly busy: () => Error, + ) {} + + /** + * Share a conversion without letting one caller cancel another caller's work. + * Renderer replacement retries authorization once; repeated replacement reports localized capacity failure. + * @param file - Session authorization scope and source path. + * @param signal - this caller's lifetime. + * @param priority - foreground preview or speculative read. + * @returns current PDF bytes borrowed read-only, or a declared source-read failure; cancellation rejects. + */ + async read( + file: SessionFile, + signal: AbortSignal, + priority: OfficeToPdfPriority = "foreground", + ): Promise { + signal.throwIfAborted(); + this.lifetime.signal.throwIfAborted(); + const readerLimit = priority === "background" ? this.maxReaders - 1 : this.maxReaders; + if (this.readers >= readerLimit || (priority === "background" && this.maxPending === 1)) + throw this.busy(); + this.readers++; + const operation = this.lookup(file, signal, priority); + this.reads.add(operation); + try { + return await operation; + } finally { + this.readers--; + this.reads.delete(operation); + } + } + + private async lookup( + file: SessionFile, + signal: AbortSignal, + priority: OfficeToPdfPriority, + ): Promise { + for (let attempt = 0; attempt < 2; attempt++) { + try { + return await this.lookupGeneration(file, signal, priority); + } catch (error) { + if (error !== this.superseded) throw error; + } + } + throw this.busy(); + } + + private async lookupGeneration( + file: SessionFile, + signal: AbortSignal, + priority: OfficeToPdfPriority, + ): Promise { + signal = AbortSignal.any([signal, this.lifetime.signal]); + signal.throwIfAborted(); + const query = ++this.generationQuery; + const generation = await this.currentGeneration(signal); + signal.throwIfAborted(); + if (!generation.ok) return generation; + if (query < this.acceptedQuery && generation.value !== this.generation) throw this.superseded; + this.acceptedQuery = Math.max(query, this.acceptedQuery); + if (generation.value !== this.generation) { + this.generation = generation.value; + this.ready.clear(); + this.bytes = 0; + for (const pending of this.pending.values()) pending.controller.abort(this.superseded); + this.pending.clear(); + } + const metadata = await this.stat(file, signal); + signal.throwIfAborted(); + if (!metadata.ok) return metadata; + if (generation.value !== this.generation) throw this.superseded; + const key = JSON.stringify([ + generation.value, + file.sessionId, + metadata.value.absolutePath, + metadata.value.version, + ]); + const cached = this.ready.get(key); + if (cached !== undefined) { + this.ready.delete(key); + this.ready.set(key, cached); + return cached; + } + // A foreground RPC joins and promotes background work at the shared Host queue. + const pendingKey = JSON.stringify([key, priority]); + let entry = this.pending.get(pendingKey); + if (entry === undefined) { + const pendingLimit = priority === "background" ? this.maxPending - 1 : this.maxPending; + if (this.tasks.size >= pendingLimit) throw this.busy(); + const controller = new AbortController(); + const promise = Promise.resolve() + .then(() => { + controller.signal.throwIfAborted(); + return this.convert(file, controller.signal, priority); + }) + .then((result) => { + controller.signal.throwIfAborted(); + if ( + generation.value === this.generation && + result.ok && + result.value.generation === generation.value && + result.value.version === metadata.value.version && + result.value.absolutePath === metadata.value.absolutePath + ) { + this.retain(key, result); + } + return result; + }) + .finally(() => { + this.tasks.delete(promise); + if (this.pending.get(pendingKey)?.controller === controller) + this.pending.delete(pendingKey); + }); + this.tasks.add(promise); + entry = { controller, promise, users: 0 }; + this.pending.set(pendingKey, entry); + } + const shared = entry; + shared.users += 1; + return new Promise((resolve, reject) => { + let settled = false; + const finish = (): boolean => { + if (settled) return false; + settled = true; + signal.removeEventListener("abort", abort); + shared.users -= 1; + if (shared.users === 0 && this.pending.get(pendingKey) === shared) { + this.pending.delete(pendingKey); + shared.controller.abort(); + } + return true; + }; + const abort = (): void => { + const reason: unknown = signal.reason; + finish(); + reject( + reason instanceof Error + ? reason + : new Error("Office preview cancelled", { cause: reason }), + ); + }; + signal.addEventListener("abort", abort, { once: true }); + shared.promise.then( + (result) => { + if (finish()) resolve(result); + }, + (error: unknown) => { + if (finish()) + reject( + error instanceof Error ? error : new Error("Office preview failed", { cause: error }), + ); + }, + ); + }); + } + + /** Clear retained bytes, cancel outstanding conversions, and await their completion. */ + async dispose(): Promise { + this.lifetime.abort(); + this.pending.clear(); + this.ready.clear(); + this.bytes = 0; + await Promise.allSettled([...this.reads, ...this.tasks]); + } + + private retain(key: string, result: Success): void { + const previous = this.ready.get(key); + if (previous !== undefined) { + this.ready.delete(key); + this.bytes -= previous.value.data.byteLength; + } + const size = result.value.data.byteLength; + if (size > this.maxBytes) return; + while (this.ready.size >= this.maxEntries || this.bytes + size > this.maxBytes) { + const oldest = this.ready.entries().next().value as [string, Success]; + this.ready.delete(oldest[0]); + this.bytes -= oldest[1].value.data.byteLength; + } + this.ready.set(key, result); + this.bytes += size; + } +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/office/face.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/office/face.ts new file mode 100644 index 000000000..a22c2f495 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/office/face.ts @@ -0,0 +1,73 @@ +/** Office conversion reads and settlements bound to the renderer's declared store. */ +import type { BoundActions } from "@deepseek-ai/dsh-client-store"; +import type { TabId } from "@deepseek-ai/dsh-client-ui-dockkit"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { RemoteFailure } from "@deepseek-ai/dsh-api-remotes/client"; +import type { SessionFile } from "../rpc.ts"; +import type { ReadOfficeDocument } from "./cache.ts"; +import type { OfficeStore } from "./store.ts"; + +/** Conversion loading callback supplied to the Office body through Slot injection. */ +export interface OfficeLoadInjected { + /** + * Read one conversion revision; cancellation suppresses its store writes and version report. + * @param tabId - owning tab. + * @param revision - requested content revision. + * @param file - addressed Session and source path. + * @param signal - request lifetime, cancelled on replacement, unmount, or tab closure. + * @param loaded - report the displayed source version to the document owner. + * @param failed - report a failed load to the document owner. + */ + readonly load: ( + tabId: TabId, + revision: number, + file: SessionFile, + signal: AbortSignal, + loaded: (version: string) => void, + failed: () => void, + ) => void; +} + +/** + * Bind Office reads to store actions without exposing Remote work to the component. + * @param read - authorized conversion reader, including Client cache reuse. + * @param describeFailure - localized conversion or source-access failure text. + * @returns Slot inject factory; the file address supplies the read's Session authority. + */ +export function officeFace( + read: ReadOfficeDocument, + describeFailure: (failure: RemoteFailure | { readonly message: string }) => string, +): (sessionId: SessionId, actions: BoundActions) => OfficeLoadInjected { + return (_sessionId, actions) => ({ + load: (tabId, revision, file, signal, loaded, failed) => { + if (signal.aborted) return; + actions.loading(tabId, revision); + void read(file, signal).then( + (result) => { + if (signal.aborted) return; + if (result.ok) { + actions.complete(tabId, revision, result.value); + loaded(result.value.version); + } else { + actions.failed(tabId, revision, { + code: result.error.code, + message: describeFailure(result.error), + }); + failed(); + } + }, + (error: unknown) => { + if (!signal.aborted) { + actions.failed(tabId, revision, { + code: "gateway/internal", + message: describeFailure({ + message: error instanceof Error ? error.message : String(error), + }), + }); + failed(); + } + }, + ); + }, + }); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/office/index.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/office/index.ts new file mode 100644 index 000000000..33d25ea48 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/office/index.ts @@ -0,0 +1,217 @@ +/** Office preview registration backed by authorized Host rendering and the existing PDF body. */ +import type { Context } from "@deepseek-ai/cordis"; +import { retainDocumentTabs } from "../document/tab-lifetime.ts"; +import type {} from "@deepseek-ai/dsh-client-ui-renderer/client"; +import type {} from "@deepseek-ai/dsh-office-to-pdf/remote"; +import type {} from "@deepseek-ai/dsh-client-locale/client"; +import type {} from "@deepseek-ai/dsh-api-workspace-files/remote"; +import { failureLine } from "../failure-line.ts"; +import { documentTabInfoFactory } from "../document/contract.ts"; +import { en, zh, type OfficePreviewKey } from "./locales.ts"; +import { OfficePreviewCache, type ReadOfficeBytes, type ReadOfficeDocument } from "./cache.ts"; +import { pdfBodyRegistration } from "../pdf/index.ts"; +import { LazyPdfBody } from "../pdf/LazyPdfBody.tsx"; +import { OfficeBody, type OfficeBodyInjected } from "./OfficeBody.tsx"; +import { OfficeFontAction } from "./OfficeFontAction.tsx"; +import { officeFace } from "./face.ts"; +import { createOfficeStore } from "./store.ts"; +import type { Config } from "../../config.ts"; + +declare module "@deepseek-ai/dsh-client-ui-slots" { + interface LocaleNamespaceMap { + sidebarOffice: OfficePreviewKey; + } +} + +/** + * Register Office previews with versioned PDF reuse and missing-font notices. + * @param ctx - Client renderer registry, localized copy, and optional Host Remotes. + * @param config - Resolved Office preview cache limits. + */ +export function apply(ctx: Context, config: Config["office"]): void { + const id = "@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office"; + const extensions = ["doc", "docx", "ppt", "pptx"]; + ctx.effect(() => ctx.locale.register("sidebarOffice", { zh, en })); + const t = ctx.locale.bind("sidebarOffice"); + const unavailable: ReadOfficeDocument = (_file, signal) => { + signal.throwIfAborted(); + return Promise.reject(new Error(t("unavailable"))); + }; + let read = unavailable; + ctx.effect(() => + ctx.documentPreviews.register({ + id, + extensions, + binaryExtensions: extensions, + priority: "builtin", + title: () => t("title"), + loading: "renderer", + wrap: false, + }), + ); + const store = createOfficeStore(); + ctx.effect(() => + ctx.slots.inject("sidebar.right.tab.document.action", () => + ctx.slots.register( + { + name: "sidebar.right.tab.document.action", + key: id, + locale: "sidebarOffice", + store, + }, + OfficeFontAction, + ), + ), + ); + const retainTab = retainDocumentTabs(ctx); + const documentT = ctx.locale.bind("sidebarDocumentPreview"); + const face = officeFace( + (file, signal) => read(file, signal), + (failure) => + "code" in failure + ? failureLine(documentT, failure) + : documentT("error.unavailable", { message: failure.message }), + ); + ctx.effect(() => + ctx.slots.inject("sidebar.right.tab.document", () => + ctx.slots.register( + { + name: "sidebar.right.tab.document", + key: id, + locale: "sidebarOffice", + store, + children: { + "sidebar.right.tab.document.office.pdf": { + kind: "keyed", + scope: "session", + inject: { hooks: { tabInfo: documentTabInfoFactory } }, + }, + }, + inject: (sessionId, actions): OfficeBodyInjected => ({ + ...face(sessionId, actions), + retainTab: (tabId, signal) => { + retainTab(tabId, signal, actions.forget); + }, + }), + }, + OfficeBody, + ), + ), + ); + const pdfPresentation = pdfBodyRegistration(ctx); + ctx.effect(() => + ctx.slots.inject("sidebar.right.tab.document.office.pdf", () => + ctx.slots.register( + { + name: "sidebar.right.tab.document.office.pdf", + key: id, + locale: "sidebarPdf", + ...pdfPresentation, + }, + LazyPdfBody, + ), + ), + ); + ctx.inject(["remote", "remote.officeToPdf", "remote.workspaceFiles"], (scope) => { + const convert: ReadOfficeBytes = async (file, signal, priority) => { + signal.throwIfAborted(); + const result = await scope.remote.officeToPdf.render( + file.sessionId, + file.path, + priority, + signal, + ); + signal.throwIfAborted(); + if (!result.ok && result.error.code === "document-render/failed") { + throw new Error(t(conversionErrorKey(result.error.details.reason)), { + cause: result.error, + }); + } + return result; + }; + const createCache = () => + new OfficePreviewCache( + async (file, signal) => { + const authorized = await scope.remote.workspaceFiles.readBytes( + file.sessionId, + file.path, + { range: { offset: 0, length: 1 } }, + signal, + ); + signal.throwIfAborted(); + if (!authorized.ok) return authorized; + const metadata = await scope.remote.workspaceFiles.stat( + file.sessionId, + file.path, + signal, + ); + if ( + metadata.ok && + (authorized.value.absolutePath !== metadata.value.absolutePath || + authorized.value.version !== metadata.value.version) + ) { + throw new Error(t("changed")); + } + return metadata; + }, + convert, + config.maxCachedEntries, + config.maxCachedBytes, + config.maxPending, + config.maxReaders, + async (signal) => { + const result = await scope.remote.officeToPdf.generation(signal); + signal.throwIfAborted(); + if (!result.ok) throw new Error(t("unavailable"), { cause: result.error }); + return result; + }, + () => new Error(t("busy")), + ); + let cache = createCache(); + const retired = new Set>(); + read = async (file, signal) => { + const result = await cache.read(file, signal); + if ( + !result.ok && + (result.error.code === "gateway/invocation-unavailable" || + result.error.code === "gateway/service-unavailable") + ) { + throw new Error(t("unavailable"), { cause: result.error }); + } + return result; + }; + scope.on("connection/reset", () => { + const previous = cache; + cache = createCache(); + const closing = previous.dispose().finally(() => { + retired.delete(closing); + }); + retired.add(closing); + }); + scope.effect(() => async () => { + read = unavailable; + await Promise.all([...retired, cache.dispose()]); + }); + }); +} + +function conversionErrorKey(code: string): OfficePreviewKey { + switch (code) { + case "input-too-large": + case "output-too-large": + return "tooLarge"; + case "invalid-document": + case "unsupported-format": + return "invalid"; + case "timeout": + return "timeout"; + case "unavailable": + return "unavailable"; + case "busy": + return "busy"; + case "source-changed": + return "changed"; + default: + return "failed"; + } +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/office/locales.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/office/locales.ts new file mode 100644 index 000000000..acc03f990 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/office/locales.ts @@ -0,0 +1,44 @@ +/** Office preview copy and Host render configuration guidance. */ +export const zh = { + title: "Office 文档", + loading: "文档渲染中...", + retry: "重试", + viewMissingFonts: "缺失 {count} 种字体,点击查看", + missingFontsTitle: "缺失的字体", + missingFontsDescription: "本次预览无法使用以下字体,预览中的文字和排版可能与原文档不同。", + missingFontsCount: "{count} 种字体", + closeDetails: "关闭字体详情", + unavailable: "Office 预览不可用。请在运行 DeepSeek Harness 的主机上启用文档预览服务。", + invalid: "无法预览此 Office 文件。文件可能已损坏、受密码保护,或与扩展名不符。", + tooLarge: "Office 文件或转换后的 PDF 超过预览大小上限,请缩小文件或调整预览配置。", + failed: "Office 转换失败,未生成可用的 PDF。请检查该文件后重试。", + timeout: "Office 转换超时,请重试。", + busy: "Office 预览任务较多,请稍后重试。", + changed: "文件在读取时已更改,请重新打开预览。", +} satisfies Record; + +/** Office preview locale keys. */ +export type OfficePreviewKey = keyof typeof zh; + +/** English translations checked against the Chinese key set. */ +export const en = { + title: "Office document", + loading: "Rendering document...", + retry: "Retry", + viewMissingFonts: "Missing fonts: {count}. Click to view.", + missingFontsTitle: "Missing fonts", + missingFontsDescription: + "These fonts are unavailable for this preview. Text and layout may differ from the original document.", + missingFontsCount: "Fonts: {count}", + closeDetails: "Close font details", + unavailable: + "Office previews are unavailable. Enable the document preview service on the computer running DeepSeek Harness.", + invalid: + "This Office file cannot be previewed. It may be damaged, password protected, or have the wrong extension.", + tooLarge: + "The Office file or converted PDF exceeds the preview size limit. Reduce the file size or adjust the preview configuration.", + failed: "Office conversion did not produce a usable PDF. Check the file and try again.", + timeout: "Office conversion timed out. Try again.", + busy: "Office preview is busy. Try again shortly.", + changed: "The file changed while being read. Reopen the preview.", +} satisfies Record; diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/office/store.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/office/store.ts new file mode 100644 index 000000000..319fda32d --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/office/store.ts @@ -0,0 +1,60 @@ +/** Loaded Office previews survive body remounts until reload or tab closure. */ +import { defineStore, type EngineStoreHandle } from "@deepseek-ai/dsh-client-store"; +import type { TabId } from "@deepseek-ai/dsh-client-ui-dockkit"; +import type { OfficeFileBytes } from "./cache.ts"; + +/** One requested source revision and its settled preview. */ +export interface OfficeView { + readonly revision: number; + readonly file?: OfficeFileBytes; + readonly failure?: { readonly code: string; readonly message: string }; +} + +/** Office-owned content, isolated by tab identity. */ +export interface OfficeState { + byTab: Record; +} + +type OfficeActions = { + loading: (state: OfficeState, tab: TabId, revision: number) => void; + complete: (state: OfficeState, tab: TabId, revision: number, file: OfficeFileBytes) => void; + failed: ( + state: OfficeState, + tab: TabId, + revision: number, + failure: NonNullable, + ) => void; + forget: (state: OfficeState, tab: TabId) => void; +}; + +/** + * Retain Office contents across body remounts within a Session. + * @returns the tab-content store declaration. + */ +export function createOfficeStore(): EngineStoreHandle { + return defineStore({ + init: (): OfficeState => ({ byTab: {} }), + actions: { + /** @param state - draft. @param tab - owning tab. @param revision - new content revision. */ + loading(state, tab: TabId, revision: number) { + state.byTab[tab] = { revision }; + }, + /** @param state - draft. @param tab - owning tab. @param revision - completed revision. @param file - borrowed PDF bytes. */ + complete(state, tab: TabId, revision: number, file: OfficeFileBytes) { + state.byTab[tab] = { revision, file }; + }, + /** @param state - draft. @param tab - owning tab. @param revision - failed revision. @param failure - displayable failure. */ + failed(state, tab: TabId, revision: number, failure: NonNullable) { + state.byTab[tab] = { revision, failure }; + }, + /** @param state - draft. @param tab - closed tab. */ + forget(state, tab: TabId) { + const { [tab]: _closed, ...remaining } = state.byTab; + state.byTab = remaining; + }, + }, + }); +} + +/** Store declaration used by the Office body. */ +export type OfficeStore = ReturnType; diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/LazyPdfBody.tsx b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/LazyPdfBody.tsx new file mode 100644 index 000000000..1bc551534 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/LazyPdfBody.tsx @@ -0,0 +1,20 @@ +/** Load the PDF renderer only after a PDF body is mounted. */ +import { lazy, Suspense, type ReactNode } from "react"; +import { LoadingIndicator } from "../LoadingIndicator.tsx"; +import type { PdfBodyProps } from "./pdf.tsx"; + +const LoadedPdfBody = lazy(async () => ({ default: (await import("./pdf.tsx")).PdfBody })); + +/** + * Suspend while the package-local PDF chunk arrives. + * @param props - PDF body props supplied by the document slot. + * @returns the deferred PDF renderer. + */ +export function LazyPdfBody(props: PdfBodyProps): ReactNode { + const loading = ; + return ( + + + + ); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/PdfBody.module.css b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/PdfBody.module.css new file mode 100644 index 000000000..5caa32ed0 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/PdfBody.module.css @@ -0,0 +1,147 @@ +.body { + display: flex; + flex-direction: column; + gap: 12px; + box-sizing: border-box; + width: 100%; + min-height: 100%; + min-width: 100%; + padding: 12px; + overflow: visible; + background: var(--dsw-alias-bg-document-preview); + font-family: var(--dsw-font, sans-serif); + white-space: normal; +} + +:global([data-document-zoom-mode="fixed"]) .body { + width: max-content; +} + +.page { + box-sizing: border-box; + width: 100%; + min-width: 0; + overflow-x: visible; +} + +/* An unrendered page holds its place as a quiet block; the block itself is + the loading feedback, so no spinner competes with the centred open wait. */ +.placeholder { + width: 100%; + aspect-ratio: 3 / 4; + background: var(--dsw-alias-bg-skeleton); +} + +.canvas { + display: block; + width: 100%; + max-width: none; + height: auto; + margin-inline: auto; +} + +.surface { + position: relative; + max-width: none; + margin-inline: auto; + box-shadow: var(--dsw-elevation-prominent); +} + +.surface[hidden] { + display: none; +} + +.text { + position: absolute; + inset: 0; + overflow: clip; +} + +/* TextLayerBuilder selection styles follow pdfjs-dist/web/pdf_viewer.css. */ +.text :global(.textLayer) { + position: absolute; + inset: 0; + text-align: initial; + overflow: clip; + line-height: 1; + letter-spacing: normal; + word-spacing: normal; + text-size-adjust: none; + forced-color-adjust: none; + transform-origin: 0 0; + caret-color: CanvasText; + z-index: 0; + --text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size)); + --min-font-size-inv: calc(1 / var(--min-font-size)); +} + +.text :global(.textLayer)[data-main-rotation="90"] { + transform: rotate(90deg) translateY(-100%); +} + +.text :global(.textLayer)[data-main-rotation="180"] { + transform: rotate(180deg) translate(-100%, -100%); +} + +.text :global(.textLayer)[data-main-rotation="270"] { + transform: rotate(270deg) translateX(-100%); +} + +.text :is(span, br) { + position: absolute; + color: transparent; + white-space: pre; + cursor: text; + transform-origin: 0 0; + user-select: text; +} + +.text :global(.textLayer) > :not(:global(.markedContent)), +.text :global(.markedContent) span:not(:global(.markedContent)) { + z-index: 1; + --font-height: 0; + --scale-x: 1; + --rotate: 0deg; + font-size: calc(var(--text-scale-factor) * var(--font-height)); + transform: rotate(var(--rotate)) scaleX(var(--scale-x)) scale(var(--min-font-size-inv)); +} + +.text :global(.markedContent) { + display: contents; +} + +.text ::selection { + background: var(--dsw-alias-bg-document-selection); + color: transparent; +} + +.text br::selection { + background: transparent; +} + +.text :global(.textLayer) :global(.endOfContent) { + display: block; + position: absolute; + inset: 100% 0 0; + z-index: 0; + cursor: default; + user-select: none; +} + +.text :global(.selecting) :global(.endOfContent) { + top: 0; +} + +.status { + display: flex; + gap: 8px; + align-items: center; + padding: 10px; + color: var(--dsw-alias-label-secondary); + font-size: 13px; + line-height: 1.5; +} + +.body .status { + color: var(--dsw-alias-label-document-preview); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/asset-imports.d.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/asset-imports.d.ts new file mode 100644 index 000000000..45487df7f --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/asset-imports.d.ts @@ -0,0 +1,5 @@ +/** Type declaration for the build-owned PDF.js worker source. */ +declare module "pdfjs-dist/build/pdf.worker.min.mjs?raw" { + const source: string; + export default source; +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/assets.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/assets.ts new file mode 100644 index 000000000..5c1d32aae --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/assets.ts @@ -0,0 +1,47 @@ +/** Build-owned, same-version PDF.js resources; all binary assets are decoded locally. */ +import workerSource from "pdfjs-dist/build/pdf.worker.min.mjs?raw"; + +export { workerSource }; + +/** Resource kinds used by PDF.js 6's BinaryDataFactory requests. */ +export type PdfAssetKind = "cMapUrl" | "standardFontDataUrl" | "wasmUrl"; + +/** Original filenames mapped to base64, in the same PDF.js version as the worker. */ +export type PdfAssetMap = Readonly>>>; + +declare global { + /** Inline artifact data supplied by the package-local build configuration. */ + const __DSH_PDFJS_ASSETS__: PdfAssetMap; +} + +/** Public methods required by PDF.js's BinaryDataFactory option. */ +export interface PdfBinaryDataFactory { + /** @param request - PDF.js resource kind and exact filename. @returns independent transferable resource bytes. */ + fetch(request: { readonly kind: PdfAssetKind; readonly filename: string }): Promise; +} + +/** + * Capture this build's binary assets without network fallbacks. + * @param assets - build-inlined base64 resources, read only when a PDF is opened. + * @returns the constructor passed to PDF.js getDocument. + */ +export function createPdfBinaryDataFactory( + assets: PdfAssetMap = __DSH_PDFJS_ASSETS__, +): new () => PdfBinaryDataFactory { + return class implements PdfBinaryDataFactory { + fetch({ + kind, + filename, + }: { + readonly kind: PdfAssetKind; + readonly filename: string; + }): Promise { + return Promise.resolve().then(() => { + const files = assets[kind]; + const data = Object.hasOwn(files, filename) ? files[filename] : undefined; + if (data === undefined) throw new Error(`PDF.js asset is not bundled: ${kind}/${filename}`); + return Uint8Array.from(atob(data), (character) => character.charCodeAt(0)); + }); + } + }; +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/document.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/document.ts new file mode 100644 index 000000000..0ef211993 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/document.ts @@ -0,0 +1,94 @@ +/** Canvas rendering with cancellation and page cleanup, shared by the PDF body and real-library smoke. */ +import type { PDFDocumentProxy, PDFPageProxy, PageViewport } from "pdfjs-dist"; + +/** The document operations used by one mounted PDF body. */ +export type PdfDocument = Pick; + +/** One in-flight PDF load and its complete cleanup operation. */ +export interface PdfSession { + readonly document: Promise; + /** + * Cancel loading and rendering, destroy the document, and release its worker. + * Library teardown failures are logged after native resources are released. + * @returns cleanup completion without rejection. + */ + dispose(): Promise; +} + +/** Page geometry expressed in CSS pixels. */ +export interface PdfPageSize { + readonly width: number; + readonly height: number; +} + +/** Optional DOM text rendering that shares the canvas page's cleanup barrier. */ +export type RenderPdfText = ( + page: PDFPageProxy, + viewport: PageViewport, +) => { + readonly promise: Promise; + cancel(): void; +}; + +/** + * Render one page into an exclusively owned canvas. Cancellation cannot write + * dimensions after a delayed getPage; active render tasks are cancelled and + * awaited before the page is cleaned up. + * @param document - loaded pdfjs document. + * @param pageNumber - 1-based selected page. + * @param canvas - canvas owned by this render only. + * @param signal - render lifetime. + * @param pixelRatio - device pixel ratio multiplied by the settled document zoom. + * @param renderText - optional selectable text layer sharing this page and viewport. + * @returns the page's CSS dimensions after rendering completes. + */ +export async function renderPdfPage( + document: PdfDocument, + pageNumber: number, + canvas: HTMLCanvasElement, + signal: AbortSignal, + pixelRatio: number, + renderText?: RenderPdfText, +): Promise { + signal.throwIfAborted(); + const page: PDFPageProxy = await document.getPage(pageNumber); + try { + signal.throwIfAborted(); + const viewport = page.getViewport({ scale: 96 / 72 }); + // Limit raster allocation without changing the document's display dimensions. + const ratio = Math.min(pixelRatio, Math.sqrt(16_777_216 / (viewport.width * viewport.height))); + canvas.width = Math.max(1, Math.floor(viewport.width * ratio)); + canvas.height = Math.max(1, Math.floor(viewport.height * ratio)); + canvas.style.setProperty("--pdf-page-width", `${viewport.width}px`); + canvas.style.setProperty("--pdf-page-height", `${viewport.height}px`); + const task = page.render({ + canvas, + viewport, + transform: ratio === 1 ? undefined : [ratio, 0, 0, ratio, 0, 0], + }); + let text: ReturnType | undefined; + let cancelled = false; + const cancel = (): void => { + if (cancelled) return; + cancelled = true; + task.cancel(); + text?.cancel(); + }; + signal.addEventListener("abort", cancel, { once: true }); + try { + text = renderText?.(page, viewport); + if (signal.aborted) cancel(); + await Promise.all([task.promise, text?.promise]); + signal.throwIfAborted(); + return { width: viewport.width, height: viewport.height }; + } catch (error) { + cancel(); + await Promise.allSettled([task.promise, text?.promise]); + throw error; + } finally { + signal.removeEventListener("abort", cancel); + } + } finally { + page.cleanup(); + } +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/errors.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/errors.ts new file mode 100644 index 000000000..479070e1d --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/errors.ts @@ -0,0 +1,11 @@ +/** Structured PDF worker failures; the renderer's locale owns visible explanations. */ +export class PdfWorkerFailure extends Error { + /** Distinguishes Worker startup/transport failures from document parsing errors. */ + readonly kind = "worker"; + + /** @param cause - native error or messageerror event, retained for diagnostics. */ + constructor(cause: Event) { + super(undefined, { cause }); + this.name = "PdfWorkerFailure"; + } +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/index.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/index.ts new file mode 100644 index 000000000..0e141ab84 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/index.ts @@ -0,0 +1,76 @@ +/** Builtin PDF registration through document metadata and the keyed body slot. */ +import type { Context } from "@deepseek-ai/cordis"; +import { retainDocumentTabs } from "../document/tab-lifetime.ts"; +import type {} from "../index.ts"; +import type { DocumentPreviewDefinition } from "../document/registry.ts"; +import type { PdfBodyInjected } from "./pdf.tsx"; +import { LazyPdfBody } from "./LazyPdfBody.tsx"; +import type { BoundActions } from "@deepseek-ai/dsh-client-store"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import { createPdfStore, type PdfStore } from "./store.ts"; +import { ZoomViewport, zoomSurfaceClass } from "../zoom/ZoomViewport.tsx"; +import { en, zh } from "./locales.ts"; + +/** PDF metadata and keyed body share this package-local implementation identity. */ +export const PDF_BODY_ID = "@deepseek-ai/dsh-client-ui-sidebar-documentpreview/pdf"; + +/** + * Describe the builtin PDF renderer independently from its keyed body slot. + * @param title - locale-owned implementation name. + * @returns the complete-file PDF registration. + */ +export function pdfBodyDefinition(title: () => string): DocumentPreviewDefinition { + return { + id: PDF_BODY_ID, + extensions: ["pdf"], + binaryExtensions: ["pdf"], + priority: "builtin", + title, + loading: "bytes-complete", + wrap: false, + }; +} + +/** @param ctx - context carrying the locale, document registry, and slot registry. */ +export function apply(ctx: Context): void { + ctx.effect(() => ctx.locale.register("sidebarPdf", { zh, en })); + const t = ctx.locale.bind("sidebarPdf"); + ctx.effect(() => ctx.documentPreviews.register(pdfBodyDefinition(() => t("title")))); + const presentation = pdfBodyRegistration(ctx); + ctx.effect(() => + ctx.slots.inject("sidebar.right.tab.document", () => + ctx.slots.register( + { + name: "sidebar.right.tab.document", + key: PDF_BODY_ID, + locale: "sidebarPdf", + ...presentation, + }, + LazyPdfBody, + ), + ), + ); +} + +/** + * Retain PDF viewing state for a document entry's tab lifetime. + * @param ctx - owning registration context. + * @returns the store and injection shared by ordinary and Office PDF registrations. + */ +export function pdfBodyRegistration(ctx: Context): { + store: PdfStore; + inject: (sessionId: SessionId, actions: BoundActions) => PdfBodyInjected; +} { + const store = createPdfStore(); + const retainTab = retainDocumentTabs(ctx); + return { + store, + inject: (_sessionId, actions): PdfBodyInjected => ({ + retainTab: (tabId, signal) => { + retainTab(tabId, signal, actions.forget); + }, + ZoomViewport, + zoomSurfaceClass, + }), + }; +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/locales.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/locales.ts new file mode 100644 index 000000000..84f872d90 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/locales.ts @@ -0,0 +1,39 @@ +import { zoomEn, zoomZh } from "../zoom/locales.ts"; + +/** Copy owned by the PDF renderer. */ +export const zh = { + ...zoomZh, + title: "PDF", + pageImage: "PDF 第 {page} 页", + loading: "文档渲染中...", + rendering: "正在绘制页面…", + failed: "无法显示 PDF:{message}", + password: "此 PDF 需要密码,暂不支持预览", + workerFailed: "PDF 渲染进程无法继续,请重试", + unsupported: "PDF 预览需要完整文件内容", + retry: "重试", +} satisfies Record; + +/** PDF translation keys shared by both dictionaries. */ +export type PdfLocaleKey = keyof typeof zh; + +/** English PDF-renderer dictionary. */ +export const en = { + ...zoomEn, + title: "PDF", + pageImage: "PDF page {page}", + loading: "Rendering document...", + rendering: "Rendering page…", + failed: "Cannot display PDF: {message}", + password: "This PDF requires a password; password-protected previews are not supported.", + workerFailed: "The PDF rendering process could not continue. Please retry.", + unsupported: "PDF preview requires the complete file contents.", + retry: "Retry", +} satisfies Record; + +declare module "@deepseek-ai/dsh-client-ui-slots" { + interface LocaleNamespaceMap { + /** PDF page, loading, and failure messages. */ + sidebarPdf: PdfLocaleKey; + } +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/pdf.tsx b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/pdf.tsx new file mode 100644 index 000000000..841fe4592 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/pdf.tsx @@ -0,0 +1,336 @@ +/** PDF page presentation; binary content and tab information come from the document owner. */ +import { + useCallback, + useEffect, + useRef, + useState, + type ComponentType, + type CSSProperties, + type ReactNode, +} from "react"; +import { Button } from "@deepseek-ai/dsh-client-ui-primitives"; +import type { PropsLocale, PropsStore } from "@deepseek-ai/dsh-client-ui-slots"; +import type { TabId } from "@deepseek-ai/dsh-client-ui-dockkit"; +import type { DocumentPreviewProps } from "../document/contract.ts"; +import type { PdfStore, PdfView } from "./store.ts"; +import { renderPdfPage, type PdfDocument } from "./document.ts"; +import { openPdf } from "./runtime.ts"; +import { PdfWorkerFailure } from "./errors.ts"; +import { pdfTextRenderer } from "./text.ts"; +import type { ZoomViewportProps } from "../zoom/ZoomViewport.tsx"; +import type { ZoomLabels, ZoomPreference } from "../zoom/types.ts"; +import type {} from "./locales.ts"; +import css from "./PdfBody.module.css"; + +/** A record's viewing preferences survive body unmounts and leave with the tab. */ +export interface PdfBodyInjected { + /** + * Retain viewing preferences until the tab record ends. + * @param tabId - owning tab. + * @param signal - tab-record lifetime, not body visibility. + */ + readonly retainTab: (tabId: TabId, signal: AbortSignal) => void; + /** Main-bundle viewport reused by the lazy PDF body. */ + readonly ZoomViewport: ComponentType; + /** Main-bundle class implementing fit-width and fixed-size surfaces. */ + readonly zoomSurfaceClass: string; +} + +/** Standard document props plus the PDF entry's locale, viewing store, and lifetime callback. */ +export type PdfBodyProps = DocumentPreviewProps & + PropsLocale<"sidebarPdf"> & + PropsStore & + PdfBodyInjected; + +type LoadState = + | { + readonly kind: "loaded"; + readonly data: Uint8Array; + readonly document: PdfDocument; + } + | { readonly kind: "failed"; readonly data: Uint8Array; readonly error: unknown }; + +const DEFAULT_PDF_VIEW: PdfView = { page: 1 }; +const FIT_WIDTH: ZoomPreference = { kind: "fit-width" }; +/** + * Present a PDF with tab-local viewing preferences and component-owned rendering resources. + * @param props - complete bytes, framework-owned tab/store/locale seats, and main-bundle loading content. + * @returns the PDF reader. + */ +export function PdfBody(props: PdfBodyProps & { readonly loading: ReactNode }): ReactNode { + const { tab } = props.useTabInfo(); + const view = props.useStore((state) => state.byTab[tab.id] ?? DEFAULT_PDF_VIEW); + const data = props.content.kind === "bytes" ? props.content.data : undefined; + const [load, setLoad] = useState(); + const [attempt, setAttempt] = useState(0); + const [pageWidth, setPageWidth] = useState(); + const [renderZoom, setRenderZoom] = useState(1); + const { retainTab, actions, t, ZoomViewport, zoomSurfaceClass } = props; + const preference = view.zoom ?? FIT_WIDTH; + const labels: ZoomLabels = { + controls: t("zoomControls"), + menu: t("zoomMenu"), + out: t("zoomOut"), + into: t("zoomIn"), + fitWidth: t("zoomFitWidth"), + value: (percent) => t("zoomValue", { percent }), + }; + const pageVisible = useCallback( + (page: number): void => { + actions.page(tab.id, page); + }, + [actions, tab.id], + ); + const setPreference = useCallback( + (value: ZoomPreference): void => { + actions.zoom(tab.id, value); + }, + [actions, tab.id], + ); + + useEffect(() => { + retainTab(tab.id, tab.signal); + }, [retainTab, tab.id, tab.signal]); + useEffect(() => { + if (data === undefined || tab.signal.aborted) return; + const lifetime = new AbortController(); + const signal = AbortSignal.any([lifetime.signal, tab.signal]); + setLoad(undefined); + const session = openPdf(data, signal, (error) => { + if (!signal.aborted) setLoad({ kind: "failed", data, error }); + }); + void session.document.then( + (document) => { + if (!signal.aborted) setLoad({ kind: "loaded", data, document }); + }, + (error: unknown) => { + if (!signal.aborted) setLoad({ kind: "failed", data, error }); + }, + ); + return () => { + lifetime.abort(); + void session.dispose(); + }; + }, [data, tab.signal, attempt]); + if (data === undefined) + return ( +

+ {t("unsupported")} +

+ ); + if (load?.data !== data) return props.loading; + if (load.kind === "failed") { + return ( +
+ {failureText(load.error, t)} + +
+ ); + } + const pages = ( +
+ {Array.from({ length: load.document.numPages }, (_, index) => ( + + ))} +
+ ); + return ( + + {pages} + + ); +} + +function PdfPage({ + document, + page, + requested: initiallyRequested, + onVisible, + signal, + onWidth, + zoom, + zoomSurfaceClass, + t, +}: { + readonly document: PdfDocument; + readonly page: number; + readonly requested: boolean; + readonly onVisible: (page: number) => void; + readonly signal: AbortSignal; + readonly onWidth: ((width: number) => void) | undefined; + readonly zoom: number; + readonly zoomSurfaceClass: string; +} & PropsLocale<"sidebarPdf">): ReactNode { + const host = useRef(null); + const canvas = useRef(null); + const text = useRef(null); + const [width, setWidth] = useState(); + const [requested, setRequested] = useState(initiallyRequested); + const [visible, setVisible] = useState(initiallyRequested); + const renderedZoom = useRef(); + const pendingRender = useRef(Promise.resolve()); + const textTask = useRef>>(); + const [state, setState] = useState<"loading" | "ready">("loading"); + const [failure, setFailure] = useState<{ readonly error: unknown }>(); + const [attempt, setAttempt] = useState(0); + useEffect(() => { + // A zoomed canvas can remain visible after its narrower page wrapper has + // scrolled out horizontally. Observe the displayed page once it is ready. + const node = + state === "ready" ? (canvas.current as HTMLCanvasElement) : (host.current as HTMLDivElement); + if (typeof IntersectionObserver === "undefined") { + setRequested(true); + setVisible(true); + return; + } + let disposed = false; + const observer = new IntersectionObserver( + (entries) => { + if (disposed) return; + const intersects = entries.some((entry) => entry.isIntersecting); + setVisible(intersects); + if (!intersects) return; + setRequested(true); + onVisible(page); + }, + { rootMargin: "100% 0px" }, + ); + observer.observe(node); + return () => { + disposed = true; + observer.disconnect(); + }; + }, [page, onVisible, state]); + useEffect( + () => () => { + textTask.current?.cancel(); + }, + [], + ); + useEffect(() => { + if (!requested) return; + setFailure(undefined); + if (renderedZoom.current !== undefined && (!visible || renderedZoom.current === zoom)) return; + const node = canvas.current as HTMLCanvasElement; + const lifetime = new AbortController(); + const renderSignal = AbortSignal.any([lifetime.signal, signal]); + let newText: typeof textTask.current; + // PDF.js shares a page proxy across renders; its cancelled task must finish + // cleanup before another render starts. The displayed bitmap stays intact. + pendingRender.current = pendingRender.current + .then(async () => { + renderSignal.throwIfAborted(); + const buffer = node.ownerDocument.createElement("canvas"); + try { + const size = await renderPdfPage( + document, + page, + buffer, + renderSignal, + window.devicePixelRatio * zoom, + textTask.current === undefined + ? (pdfPage, viewport) => { + newText = pdfTextRenderer(text.current as HTMLDivElement)(pdfPage, viewport); + return newText; + } + : undefined, + ); + renderSignal.throwIfAborted(); + const context = node.getContext("2d"); + if (context === null) throw new Error("PDF canvas has no 2D context"); + node.width = buffer.width; + node.height = buffer.height; + node.style.setProperty("--pdf-page-width", `${size.width}px`); + node.style.setProperty("--pdf-page-height", `${size.height}px`); + context.drawImage(buffer, 0, 0); + textTask.current ??= newText; + newText = undefined; + renderedZoom.current = zoom; + setWidth(size.width); + onWidth?.(size.width); + setState("ready"); + } finally { + buffer.width = buffer.height = 0; + newText?.cancel(); + } + }) + .catch((error: unknown) => { + if (!renderSignal.aborted) setFailure({ error }); + }); + return () => { + lifetime.abort(); + newText?.cancel(); + }; + }, [document, page, requested, visible, zoom, signal, attempt, onWidth]); + return ( +
+ {failure === undefined && + state !== "ready" && + (requested ? ( +
+ ) : ( +
+ ))} + {failure !== undefined && ( +
+ {failureText(failure.error, t)} + +
+ )} + + ); +} + +function failureText(error: unknown, t: PropsLocale<"sidebarPdf">["t"]): string { + if (error instanceof PdfWorkerFailure) return t("workerFailed"); + if (error instanceof Error && error.name === "PasswordException") return t("password"); + return t("failed", { message: error instanceof Error ? error.message : String(error) }); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/runtime.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/runtime.ts new file mode 100644 index 000000000..58269127b --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/runtime.ts @@ -0,0 +1,128 @@ +/** One real module Worker and PDF.js loading task per mounted binary document. */ +import { getDocument, PDFWorker } from "pdfjs-dist"; +import { createPdfBinaryDataFactory, workerSource } from "./assets.ts"; +import type { PdfSession } from "./document.ts"; +import { PdfWorkerFailure } from "./errors.ts"; + +const WORKER_READY = "dsh-pdf-worker-ready"; + +/** + * Open complete PDF bytes with an explicitly owned Worker. Startup failure + * never falls back to main-thread PDF parsing. Disposal keeps the Worker alive + * for PDF.js teardown, except after a fatal worker error, then terminates the + * native Worker and revokes its source URL. + * @param data - complete PDF bytes retained by the preview; the worker receives a copy. + * @param signal - document/body lifetime. + * @param reportFailure - reports a fatal Worker failure, including failures after document loading. + * @returns the pending document and idempotent asynchronous cleanup. + */ +export function openPdf( + data: Uint8Array, + signal: AbortSignal, + reportFailure: (error: PdfWorkerFailure) => void, +): PdfSession { + const lifetime = new AbortController(); + const stopped = AbortSignal.any([signal, lifetime.signal]); + const failed = Promise.withResolvers(); + const broken = Promise.withResolvers(); + let url: string | undefined; + let worker: Worker | undefined; + let bridge: PDFWorker | undefined; + let loading: ReturnType | undefined; + let closing: Promise | undefined; + let removeReady: (() => void) | undefined; + + const workerFailed = (event: Event): void => { + const error = new PdfWorkerFailure(event); + failed.reject(error); + broken.resolve(undefined); + try { + if (!stopped.aborted) reportFailure(error); + } catch (failure) { + console.error("[pdf] failure callback threw", failure); + } finally { + void dispose(); + } + }; + const aborted = (): void => { + failed.reject(stopped.reason); + void dispose(); + }; + stopped.addEventListener("abort", aborted, { once: true }); + + const dispose = (): Promise => { + if (closing !== undefined) return closing; + closing = Promise.resolve() + .then(async () => { + try { + if (loading !== undefined) await Promise.race([loading.destroy(), broken.promise]); + } finally { + removeReady?.(); + stopped.removeEventListener("abort", aborted); + bridge?.destroy(); + worker?.removeEventListener("error", workerFailed); + worker?.removeEventListener("messageerror", workerFailed); + worker?.terminate(); + if (url !== undefined) URL.revokeObjectURL(url); + } + }) + .catch((error: unknown) => { + console.error("[pdf] cleanup failed", error); + }); + lifetime.abort(); + return closing; + }; + + const initialize = async () => { + stopped.throwIfAborted(); + const BinaryDataFactory = createPdfBinaryDataFactory(); + const bytes = data.slice(); + url = URL.createObjectURL( + new Blob([workerSource, `\nself.postMessage({type:${JSON.stringify(WORKER_READY)}});\n`], { + type: "text/javascript", + }), + ); + worker = new Worker(url, { type: "module", name: "dsh-pdf" }); + worker.addEventListener("error", workerFailed); + worker.addEventListener("messageerror", workerFailed); + const started = Promise.withResolvers(); + const ready = (event: MessageEvent): void => { + if ( + typeof event.data !== "object" || + event.data === null || + !("type" in event.data) || + event.data.type !== WORKER_READY + ) + return; + started.resolve(undefined); + }; + worker.addEventListener("message", ready); + const port = worker; + removeReady = () => { + port.removeEventListener("message", ready); + }; + await Promise.race([started.promise, failed.promise]); + removeReady(); + stopped.throwIfAborted(); + bridge = PDFWorker.create({ port }); + const options = { + data: bytes, + worker: bridge, + BinaryDataFactory, + cMapPacked: true, + useWorkerFetch: false, + enableXfa: false, + stopAtErrors: true, + // Compatibility field; PDF.js 6 does not expose or read this option. + isEvalSupported: false, + }; + loading = getDocument(options); + return loading.promise; + }; + + const document = Promise.race([initialize(), failed.promise]).catch(async (error: unknown) => { + await dispose(); + throw error; + }); + return { document, dispose }; +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/store.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/store.ts new file mode 100644 index 000000000..1f3e2a065 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/store.ts @@ -0,0 +1,52 @@ +/** Restorable PDF viewing preferences; document objects and canvases remain component-local. */ +import { defineStore, type EngineStoreHandle } from "@deepseek-ai/dsh-client-store"; +import type { TabId } from "@deepseek-ai/dsh-client-ui-dockkit"; +import type { ZoomPreference } from "../zoom/types.ts"; + +/** One tab's last visible page and optional PDF zoom preference. */ +export interface PdfView { + readonly page: number; + readonly zoom?: ZoomPreference; +} + +/** Page state isolated by the owning tab record. */ +export interface PdfState { + byTab: Record; +} + +type PdfActions = { + page: (draft: PdfState, tabId: TabId, page: number) => void; + zoom: (draft: PdfState, tabId: TabId, zoom: ZoomPreference) => void; + forget: (draft: PdfState, tabId: TabId) => void; +}; + +/** + * Declare the last visible page and zoom preference isolated by tab identity. + * @returns a store declaration instantiated by the document slot for each Session. + */ +export function createPdfStore(): EngineStoreHandle { + return defineStore({ + init: (): PdfState => ({ byTab: {} }), + actions: { + /** @param draft - view state. @param tabId - owning tab. @param page - selected 1-based page. */ + page: (draft, tabId: TabId, page: number) => { + draft.byTab[tabId] = { ...draft.byTab[tabId], page }; + }, + /** @param draft - view state. @param tabId - owning tab. @param zoom - selected scale multiplier. */ + zoom: (draft, tabId: TabId, zoom: ZoomPreference) => { + draft.byTab[tabId] = { page: draft.byTab[tabId]?.page ?? 1, zoom }; + }, + /** @param draft - view state. @param tabId - closed tab whose preferences are discarded. */ + forget: (draft, tabId: TabId) => { + const remaining: PdfState["byTab"] = {}; + for (const [id, view] of Object.entries(draft.byTab) as [TabId, PdfView][]) { + if (id !== tabId) remaining[id] = view; + } + draft.byTab = remaining; + }, + }, + }); +} + +/** Store declaration used by the PDF body registration. */ +export type PdfStore = ReturnType; diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/text.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/text.ts new file mode 100644 index 000000000..2cf7e4f23 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/pdf/text.ts @@ -0,0 +1,38 @@ +/** PDF.js's viewer owns selection boundaries and copy normalization; the overlay follows its canvas. */ +// The official viewer resolves its version-matched engine through pdfjsLib. +import "pdfjs-dist"; +import { TextLayerBuilder } from "pdfjs-dist/web/pdf_viewer.mjs"; +import type { RenderPdfText } from "./document.ts"; + +/** + * Create a selectable overlay in a component-owned host. + * @param host - absolute overlay matching the displayed canvas dimensions. + * @returns renderer whose cancellation also releases its resize observer and DOM. + */ +export function pdfTextRenderer(host: HTMLDivElement): RenderPdfText { + return (page, viewport) => { + // cancel() releases the builder's shared selection listener after the last page leaves. + const layer = new TextLayerBuilder({ pdfPage: page }); + const container = layer.div; + container.style.setProperty("--total-scale-factor", String(viewport.scale * viewport.userUnit)); + container.style.setProperty("--scale-round-x", "1px"); + container.style.setProperty("--scale-round-y", "1px"); + host.append(container); + const resize = (): void => { + // Width fitting must compose with the viewer's page rotation and translation. + container.style.scale = String(host.getBoundingClientRect().width / viewport.width); + }; + const observer = new ResizeObserver(resize); + observer.observe(host); + resize(); + return { + // PDF.js 6 declares images as required, but its viewer also renders text-only layers without it. + promise: layer.render({ viewport } as Parameters[0]), + cancel() { + observer.disconnect(); + layer.cancel(); + container.remove(); + }, + }; + }; +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/rpc.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/rpc.ts new file mode 100644 index 000000000..d0f0480e8 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/rpc.ts @@ -0,0 +1,102 @@ +/** + * The paged read this type performs, bound to the Client Remote. + * + * Content is the consumer's business: the `file` resource carries metadata only, + * and the text arrives here one page of lines at a time. The endpoint takes a + * session and a workspace path while a tab carries a `dsh-resource://file/` + * session address, so this module also owns that translation. + */ +import type { RemoteResult } from "@deepseek-ai/dsh-api-remotes/client"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { + WorkspaceFileBytes, + WorkspaceFileRange, + WorkspaceFileText, +} from "@deepseek-ai/dsh-api-workspace-files/types"; +import { parseFileAddress } from "@deepseek-ai/dsh-util-workspace-path"; + +/** The slice of the Client Remote this package calls. */ +export interface WorkspaceFilesReadRemote { + readonly workspaceFiles: { + /** + * Read one page of lines. + * @param sessionId - the session whose workspace resolves `path`. + * @param path - workspace path, absolute or relative to the workspace root. + * @param range - 1-based start line; the Host's page cap applies when `limit` is absent. + * @param signal - cancels the call. + * @returns the page, or the failure the Host declares. + */ + read( + sessionId: SessionId, + path: string, + range: WorkspaceFileRange, + signal?: AbortSignal, + ): Promise>; + }; +} + +/** + * The read one page performs, injected so the face stays host-free. + * + * The session travels with the call because the endpoint resolves the workspace + * root from it: the same path means different files in different sessions. A + * Remote call does not reject: the result carries the failure. + */ +export type ReadWorkspaceFilePage = ( + sessionId: SessionId, + path: string, + offset: number, + signal: AbortSignal, +) => Promise>; + +/** The file one tab reads: the session the read runs under and the path handed to the Host. */ +export interface SessionFile { + /** The Session whose workspace resolves relative paths. */ + readonly sessionId: SessionId; + /** The path the Host receives, absolute or relative to the addressed Session's workspace. */ + readonly path: string; +} + +/** + * The session and path one `dsh-resource://file/…` address names. + * + * A `session` address names its own session and a relative or absolute path, so + * a tab addressed into another session reads from that session. An `absolute` + * address carries no session and cannot be read here. The registry routes only + * session-scoped `file` addresses to this type, so an address `parseFileAddress` + * rejects or that carries no session is a programming error and throws. + * @param address - a tab's `dsh-resource://file/…` address. + * @returns the session and the path to hand the endpoint. + */ +export function hostFileOf(address: string): SessionFile { + const parsed = parseFileAddress(address); + if (parsed?.scope !== "session") + throw new Error(`ui-sidebar-documentpreview: not a session file address "${address}"`); + // The address is a string boundary: its id segment is the Session id it names. + return { sessionId: parsed.sessionId as SessionId, path: parsed.path }; +} + +/** + * Bind the paged read to one Remote face. The page length is the Host's + * configured cap, so no `limit` travels. + * @param remote - the Client Remote carrying the `workspaceFiles` namespace. + * @returns the read the face performs. + */ +export function createReadPage(remote: WorkspaceFilesReadRemote): ReadWorkspaceFilePage { + return (sessionId, path, offset, signal) => + remote.workspaceFiles.read(sessionId, path, { offset }, signal); +} + +/** Document bytes borrowed read-only by renderers; copy before transferring to a Worker. */ +export type DocumentFileBytes = WorkspaceFileBytes>; + +/** + * Read a complete file through the Host endpoint. + * @param file - Session and path decoded from the tab address. + * @param signal - owning tab lifetime. + * @returns complete native bytes, including declared failures. + */ +export type ReadDocumentBytes = ( + file: SessionFile, + signal: AbortSignal, +) => Promise>; diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/store.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/store.ts new file mode 100644 index 000000000..7798432d3 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/store.ts @@ -0,0 +1,282 @@ +/** + * The preview's own state: the pages it has read, and how the reader views them. + * + * The `file` resource carries metadata only, so the text is this type's to fetch + * and keep — page by page, keyed by the 1-based line each page starts at. The + * view state (scroll offset, wrap, the navigation already answered) must outlive + * the body: a tab switched away from unmounts its body and must come back where + * it was rather than re-read or jump to its opening line again. Bucketed by tab + * id because two tabs of one file scroll independently. + * + * A bucket lives as long as its tab record: the face's first read of a tab arms + * one listener on the owner's `signal` that forgets the bucket when the record + * ends, and a tab that never read has no bucket to forget. + */ +import type { RemoteFailure } from "@deepseek-ai/dsh-api-remotes/client"; +import { defineStore, type EngineStoreHandle } from "@deepseek-ai/dsh-client-store"; +import type { TabId } from "@deepseek-ai/dsh-client-ui-dockkit"; +import type { WorkspaceFileText } from "@deepseek-ai/dsh-api-workspace-files/types"; +import type { DocumentFileBytes } from "./rpc.ts"; +import type { DocumentLoadMode } from "./document/registry.ts"; + +/** + * One page as the store keeps it: its text and the Host's line count, which + * tells a page past the file's last line (`lines: 0`) from a page holding one + * empty line (`lines: 1`, `text: ''`). + */ +export interface TextPage { + readonly text: string; + readonly lines: number; +} + +/** One tab's pages and view. */ +export interface TextTabState { + autoRefresh: boolean; + resourcesDirty: boolean; + /** Explicit viewer choice for this tab; absence follows automatic matching. */ + rendererId?: string; + /** Current display-loading mode; absent before the first read. */ + mode?: DocumentLoadMode; + /** Implementation owning source loading; absent for ordinary file reads. */ + contentRendererId?: string; + /** Current content revision, incremented whenever loaded content is discarded. */ + loadRevision: number; + /** Full byte result used by complete-file renderers. */ + complete?: DocumentFileBytes; + /** The file version the loaded pages belong to; absent before the first page. */ + version: string | undefined; + /** Metadata version observed when this tab began its current read generation. */ + observedVersion: string | undefined; + /** Pages by the 1-based line each starts at. */ + pages: Record; + /** Whether the last loaded page reached the end of the file. */ + eof: boolean; + /** A page read is in flight. */ + loading: boolean; + /** Why the last page read failed; cleared by the next page. */ + failure: RemoteFailure | undefined; + /** Scroll offset of the body, in px. */ + scrollTop: number; + /** Whether long lines wrap instead of scrolling horizontally; on until the reader turns it off. */ + wrap: boolean; + /** The `navigation.revision` the body already answered; absent before the first. */ + revision: number | undefined; +} + +/** Every tab's state, keyed by tab id. */ +export interface TextState { + byTab: Record; +} + +/** + * A tab's state before it reads, scrolls, toggles, or answers anything. + * @returns the empty bucket. + */ +export function fresh(): TextTabState { + return { + autoRefresh: true, + resourcesDirty: false, + loadRevision: 0, + version: undefined, + observedVersion: undefined, + pages: {}, + eof: false, + loading: false, + failure: undefined, + scrollTop: 0, + wrap: true, + revision: undefined, + }; +} + +/** The bucket for one tab, created on first write. */ +function bucket(state: TextState, tabId: TabId): TextTabState { + return (state.byTab[tabId] ??= fresh()); +} + +/** The preview store's write set; every action names the tab it writes. */ +type TextActions = { + toggledAutoRefresh: (draft: TextState, tabId: TabId) => void; + resourceChanged: (draft: TextState, tabId: TabId) => void; + selected: (draft: TextState, tabId: TabId, rendererId: string | undefined) => void; + loading: ( + draft: TextState, + tabId: TabId, + mode?: DocumentLoadMode, + observedVersion?: string, + contentRendererId?: string, + ) => void; + rendered: (draft: TextState, tabId: TabId, revision: number, version: string) => void; + rendererFailed: (draft: TextState, tabId: TabId, revision: number) => void; + complete: (draft: TextState, tabId: TabId, file: DocumentFileBytes) => void; + page: (draft: TextState, tabId: TabId, page: WorkspaceFileText) => void; + failed: (draft: TextState, tabId: TabId, failure: RemoteFailure) => void; + reset: (draft: TextState, tabId: TabId) => void; + scrolled: (draft: TextState, tabId: TabId, scrollTop: number) => void; + toggledWrap: (draft: TextState, tabId: TabId) => void; + navigated: (draft: TextState, tabId: TabId, revision: number) => void; + forget: (draft: TextState, tabId: TabId) => void; +}; + +/** + * Declare the preview's store. + * + * Constructed once in apply and shared by the body and the tools registrations, + * which the slot runtime allows because both are session-scoped. + * @returns the store handle to declare on both registrations. + */ +export function createTextStore(): EngineStoreHandle { + return defineStore({ + init: (): TextState => ({ byTab: {} }), + actions: { + toggledAutoRefresh: (d, tabId: TabId) => { + const state = bucket(d, tabId); + state.autoRefresh = !state.autoRefresh; + }, + resourceChanged: (d, tabId: TabId) => { + bucket(d, tabId).resourcesDirty = true; + }, + /** @param d - draft. @param tabId - owning tab. @param rendererId - manual choice, or automatic selection. */ + selected: (d, tabId: TabId, rendererId: string | undefined) => { + if (rendererId === undefined) delete bucket(d, tabId).rendererId; + else bucket(d, tabId).rendererId = rendererId; + }, + /** + * Mark a page read as in flight. + * @param d - draft state. + * @param tabId - the tab being drawn. + * @param mode - selected renderer's loading mode. + * @param observedVersion - metadata version at read start; later pages retain the initial observation. + * @param contentRendererId - implementation owning source loading. + */ + loading: ( + d, + tabId: TabId, + mode?: DocumentLoadMode, + observedVersion?: string, + contentRendererId?: string, + ) => { + const state = bucket(d, tabId); + if (state.version === undefined && !state.loading) state.observedVersion = observedVersion; + if (contentRendererId === undefined) delete state.contentRendererId; + else state.contentRendererId = contentRendererId; + state.loading = true; + state.failure = undefined; + if (mode !== undefined) state.mode = mode; + }, + /** + * @param d - draft. @param tabId - owning tab. + * @param revision - active content revision. @param version - displayed source version. + */ + rendered: (d, tabId: TabId, revision: number, version: string) => { + const state = d.byTab[tabId]; + if (state?.mode !== "renderer" || state.loadRevision !== revision) return; + state.version = version; + state.loading = false; + }, + /** @param d - draft. @param tabId - owning tab. @param revision - failed content revision. */ + rendererFailed: (d, tabId: TabId, revision: number) => { + const state = d.byTab[tabId]; + if (state?.mode !== "renderer" || state.loadRevision !== revision) return; + state.loading = false; + }, + /** @param d - draft. @param tabId - owning tab. @param file - complete byte result for this view. */ + complete: (d, tabId: TabId, file: DocumentFileBytes) => { + const state = bucket(d, tabId); + state.complete = file; + state.version = file.version; + state.eof = true; + state.loading = false; + state.failure = undefined; + }, + /** + * Keep one page. A page from a newer file version invalidates the pages + * of the older one, so the body never shows two versions at once. + * @param d - draft state. + * @param tabId - the tab being drawn. + * @param page - the page the Host returned. + */ + page: (d, tabId: TabId, page: WorkspaceFileText) => { + const state = bucket(d, tabId); + if (state.version !== undefined && state.version !== page.version) state.pages = {}; + state.version = page.version; + state.pages[page.offset] = { text: page.text, lines: page.lines }; + state.eof = page.eof; + state.loading = false; + state.failure = undefined; + }, + /** + * Record why a page read failed; the pages already held stay. + * @param d - draft state. + * @param tabId - the tab being drawn. + * @param failure - the settled Remote failure. + */ + failed: (d, tabId: TabId, failure: RemoteFailure) => { + const state = bucket(d, tabId); + state.loading = false; + state.failure = failure; + }, + /** + * Drop every page, keeping the view, for a re-read from the first line. + * @param d - draft state. + * @param tabId - the tab being drawn. + */ + reset: (d, tabId: TabId) => { + const state = bucket(d, tabId); + state.resourcesDirty = false; + state.loadRevision++; + state.pages = {}; + delete state.complete; + state.eof = false; + state.version = undefined; + state.observedVersion = undefined; + state.loading = false; + state.failure = undefined; + }, + /** + * Record where one tab's body is scrolled to. + * @param d - draft state. + * @param tabId - the tab being drawn. + * @param scrollTop - the body's scroll offset, in px. + */ + scrolled: (d, tabId: TabId, scrollTop: number) => { + bucket(d, tabId).scrollTop = scrollTop; + }, + /** + * Switch one tab between wrapped and unwrapped lines. + * @param d - draft state. + * @param tabId - the tab being drawn. + */ + toggledWrap: (d, tabId: TabId) => { + const state = bucket(d, tabId); + state.wrap = !state.wrap; + }, + /** + * Record that the body answered one navigation, so a remount restores the + * reader's position instead of jumping again. + * @param d - draft state. + * @param tabId - the tab being drawn. + * @param revision - the `navigation.revision` answered. + */ + navigated: (d, tabId: TabId, revision: number) => { + bucket(d, tabId).revision = revision; + }, + /** + * Drop one tab's state, for a tab record that is gone. + * @param d - draft state. + * @param tabId - the tab that went away. + */ + forget: (d, tabId: TabId) => { + const byTab: TextState["byTab"] = {}; + // Keys were written from tab ids; reading them back as ids is exact. + for (const [id, state] of Object.entries(d.byTab) as [TabId, TextTabState][]) { + if (id !== tabId) byTab[id] = state; + } + d.byTab = byTab; + }, + }, + }); +} + +/** The store handle type both registrations declare. */ +export type TextStore = ReturnType; diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/text/TextBody.tsx b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/text/TextBody.tsx new file mode 100644 index 000000000..70db2e6e0 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/text/TextBody.tsx @@ -0,0 +1,36 @@ +/** Plain source display for files without a more specific document renderer. */ +import type { ReactNode } from "react"; +import clsx from "clsx"; +import type { DocumentPreviewProps } from "../document/contract.ts"; +import { linesOf } from "./lines.ts"; +import css from "../TextPreview.module.css"; + +/** @param props - document contents and standard tab information. @returns source lines with navigation targets. */ +export function TextBody({ content, useTabInfo }: DocumentPreviewProps): ReactNode { + const { tab } = useTabInfo(); + const params = tab.navigation.params; + const target = params !== undefined && "line" in params ? params.line : undefined; + if (content.kind !== "text") return null; + return ( +
+ {content.pages.map((page) => ( +
+          {linesOf(page).map((text, index) => {
+            const number = page.offset + index;
+            return (
+              
+ {text} + {"\n"} +
+ ); + })} +
+ ))} +
+ ); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/text/index.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/text/index.ts new file mode 100644 index 000000000..3154ce387 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/text/index.ts @@ -0,0 +1,35 @@ +/** Plain text implementation registered through the same document extension points as other viewers. */ +import type { Context } from "@deepseek-ai/cordis"; +import type {} from "../index.ts"; +import type { DocumentPreviewDefinition } from "../document/registry.ts"; +import { TextBody } from "./TextBody.tsx"; + +/** Stable plain-text implementation identity within this package. */ +export const PLAIN_BODY_ID = "@deepseek-ai/dsh-client-ui-sidebar-documentpreview/text"; + +/** + * Describe the plain-text fallback. + * @param title - locale-owned implementation name. + * @returns plain-text registration metadata. + */ +export function textBodyDefinition(title: () => string): DocumentPreviewDefinition { + return { + id: PLAIN_BODY_ID, + extensions: [], + priority: "builtin", + title, + loading: "text-pages", + wrap: true, + }; +} + +/** @param ctx - owning plugin context. Register the fallback metadata and keyed body. */ +export function apply(ctx: Context): void { + const t = ctx.locale.bind("sidebarDocumentPreview"); + ctx.effect(() => ctx.documentPreviews.register(textBodyDefinition(() => t("viewer.text")))); + ctx.effect(() => + ctx.slots.inject("sidebar.right.tab.document", () => + ctx.slots.register({ name: "sidebar.right.tab.document", key: PLAIN_BODY_ID }, TextBody), + ), + ); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/text/lines.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/text/lines.ts new file mode 100644 index 000000000..ff2c89a2b --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/text/lines.ts @@ -0,0 +1,52 @@ +/** Source-line helpers shared by the plain renderer and the document scroller. */ +import type { DocumentTextPage } from "../document/contract.ts"; +import type { TextPage } from "../store.ts"; + +/** + * Split a loaded page into its source lines. + * @param page - source page. + * @returns its lines, preserving one empty line but excluding a zero-line page. + */ +export function linesOf(page: TextPage): string[] { + return page.lines === 0 ? [] : page.text.split("\n"); +} + +/** Loaded source page with its 1-based start position. */ +export type LoadedPage = DocumentTextPage; + +/** + * Order loaded pages by source position. + * @param pages - stored page table. + * @returns pages in source order. + */ +export function loadedPages(pages: Readonly>): LoadedPage[] { + return Object.entries(pages) + .map(([offset, page]) => ({ offset: Number(offset), ...page })) + .sort((left, right) => left.offset - right.offset); +} + +/** + * Find the end of the loaded source prefix. + * @param pages - ordered pages. + * @returns the last loaded source line, or zero. + */ +export function lastLineLoaded(pages: readonly LoadedPage[]): number { + const last = pages.at(-1); + return last === undefined ? 0 : last.offset + last.lines - 1; +} + +/** + * Reveal a plain-text or highlighted source line. + * @param body - scrolling document body or code-content viewport. + * @param line - 1-based source line to reveal. + * @returns Whether the current renderer exposes that line. + */ +export function scrollToLine(body: HTMLElement, line: number): boolean { + const innerCode = body.hasAttribute("data-code-block-content"); + const plain = body.querySelector(`[data-textpreview-line="${line}"]`); + const code = innerCode ? body.querySelectorAll("pre .line").item(line - 1) : null; + const row = plain ?? code; + if (!(row instanceof HTMLElement)) return false; + body.scrollTop = Math.max(0, row.offsetTop); + return true; +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/zoom/ZoomControls.module.css b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/zoom/ZoomControls.module.css new file mode 100644 index 000000000..4baa24395 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/zoom/ZoomControls.module.css @@ -0,0 +1,72 @@ +.panel { + position: absolute; + z-index: 2; + inset-inline-start: 50%; + bottom: 14px; + display: flex; + align-items: center; + gap: 2px; + box-sizing: border-box; + padding: 4px 6px; + border: 0.5px solid var(--dsw-alias-border-l3); + border-radius: var(--dsw-radius-lg); + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-bg-layer-1); + box-shadow: + 0 8px 24px light-dark(#0000001a, #00000060), + 0 2px 5px light-dark(#00000009, #00000035); + font-size: 12px; + line-height: 18px; + opacity: 0; + pointer-events: none; + transform: translate(-50%, 16px) scale(0.98); + transition: + opacity 160ms ease-in, + transform 160ms cubic-bezier(0.4, 0, 1, 1); + will-change: opacity, transform; +} + +.visible { + opacity: 1; + pointer-events: auto; + transform: translate(-50%, 0) scale(1); + transition: + opacity 140ms ease-out, + transform 180ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.controlAnchor { + display: flex; +} +.zoomButton { + width: 28px; + padding: 0; + flex: none; +} +.percent { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 2px; + min-width: 58px; + padding-inline: 6px; + font-variant-numeric: tabular-nums; +} +.zoomMenu { + min-width: 112px; +} + +@media (hover: none), (pointer: coarse) { + .panel { + opacity: 1; + pointer-events: auto; + transform: translate(-50%, 0) scale(1); + } +} + +@media (prefers-reduced-motion: reduce) { + .panel, + .visible { + transition-duration: 0.01ms; + } +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/zoom/ZoomControls.tsx b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/zoom/ZoomControls.tsx new file mode 100644 index 000000000..f5dc38588 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/zoom/ZoomControls.tsx @@ -0,0 +1,165 @@ +/** Floating controls shared by every zoomable document renderer. */ +import { forwardRef, useEffect, useImperativeHandle, useState, type ReactNode } from "react"; +import { + Button, + IconChevronDownOutlineRegular, + IconPlusOutlineRegular, + Menu, + Tooltip, + type MenuEntry, +} from "@deepseek-ai/dsh-client-ui-primitives"; +import { + MAX_FIXED_ZOOM, + MIN_FIXED_ZOOM, + ZOOM_OPTIONS, + ZOOM_STEP, + type ZoomLabels, +} from "./types.ts"; +import css from "./ZoomControls.module.css"; + +function steppedZoom(zoom: number, direction: -1 | 1): number { + const step = + direction === -1 ? Math.ceil(zoom / ZOOM_STEP) - 1 : Math.floor(zoom / ZOOM_STEP) + 1; + return Math.min(MAX_FIXED_ZOOM, Math.max(MIN_FIXED_ZOOM, step * ZOOM_STEP)); +} + +/** Live percentage display updated independently from the document tree. */ +export interface ZoomControlsHandle { + /** @param zoom - current gesture scale multiplier. */ + showZoom: (zoom: number) => void; +} + +/** + * Present fit-width, fixed presets, and incremental controls. + * @param props - current resolved zoom, preference mode, labels, and callbacks. + * @returns a localized zoom toolbar. + */ +export const ZoomControls = forwardRef< + ZoomControlsHandle, + { + readonly zoom: number; + readonly fitWidth: boolean; + readonly labels: ZoomLabels; + readonly visible: boolean; + readonly onZoom: (zoom: number) => void; + readonly onFitWidth: () => void; + readonly onActiveChange: (active: boolean) => void; + } +>(function ZoomControls( + { zoom, fitWidth, labels, visible, onZoom, onFitWidth, onActiveChange }, + ref, +): ReactNode { + const [displayZoom, setDisplayZoom] = useState(zoom); + const [open, setOpen] = useState(false); + const [hovered, setHovered] = useState(false); + const [focused, setFocused] = useState(false); + useImperativeHandle(ref, () => ({ showZoom: setDisplayZoom }), []); + useEffect(() => { + onActiveChange(open || hovered || focused); + }, [focused, hovered, onActiveChange, open]); + const items: readonly MenuEntry[] = [ + { id: "fit-width", label: labels.fitWidth }, + { type: "separator", id: "fit-scale" }, + ...ZOOM_OPTIONS.map((value) => ({ id: String(value), label: labels.value(value * 100) })), + ]; + const selectedId = fitWidth + ? "fit-width" + : ZOOM_OPTIONS.includes(displayZoom as (typeof ZOOM_OPTIONS)[number]) + ? String(displayZoom) + : undefined; + return ( +
{ + setHovered(true); + }} + onPointerLeave={() => { + setHovered(false); + }} + onFocusCapture={() => { + setFocused(true); + }} + onBlurCapture={() => { + setFocused(false); + }} + > + + + + + + { + event.preventDefault(); + }} + onClick={() => { + setOpen((value) => !value); + }} + > + {labels.value(Math.round(displayZoom * 100))} + + + } + items={items} + selectedId={selectedId} + onClose={() => { + setOpen(false); + }} + onSelect={(selected) => { + setOpen(false); + if (selected === "fit-width") onFitWidth(); + else onZoom(Number(selected)); + }} + /> + + + + + +
+ ); +}); diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/zoom/ZoomViewport.module.css b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/zoom/ZoomViewport.module.css new file mode 100644 index 000000000..51d4cadc2 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/zoom/ZoomViewport.module.css @@ -0,0 +1,30 @@ +.frame { + position: relative; + height: 100%; + min-height: 0; + overflow: hidden; +} + +.scrollport { + height: 100%; + overflow: auto; +} + +.surface { + max-width: none; +} + +.revealZone { + position: absolute; + inset: auto 0 0; + height: var(--document-zoom-reveal-height); + pointer-events: none; +} + +.frame[data-document-zoom-mode="fit-width"] .surface { + width: min(var(--document-zoom-width), 100%); +} + +.frame[data-document-zoom-mode="fixed"] .surface { + width: calc(var(--document-zoom-width) * var(--document-zoom)); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/zoom/ZoomViewport.tsx b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/zoom/ZoomViewport.tsx new file mode 100644 index 000000000..46a62e4d7 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/zoom/ZoomViewport.tsx @@ -0,0 +1,288 @@ +/** Shared zoom viewport, gesture handling, anchoring, and fit-width measurement. */ +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type CSSProperties, + type PointerEvent as ReactPointerEvent, + type ReactNode, + type RefCallback, +} from "react"; +import { ZoomControls, type ZoomControlsHandle } from "./ZoomControls.tsx"; +import { + FIT_WIDTH, + MAX_FIXED_ZOOM, + MIN_FIXED_ZOOM, + type ZoomLabels, + type ZoomPreference, +} from "./types.ts"; +import css from "./ZoomViewport.module.css"; + +const PINCH_SETTLE_DELAY_MS = 120; +const CONTROLS_HIDE_DELAY_MS = 420; +const CONTROLS_REVEAL_HEIGHT_PX = 72; + +interface PointerAnchor { + readonly surface: HTMLElement; + readonly x: number; + readonly y: number; + readonly clientX: number; + readonly clientY: number; +} + +function pointerAnchor(clientX: number, clientY: number): PointerAnchor | undefined { + const lookup: unknown = Reflect.get(document, "elementFromPoint"); + if (typeof lookup !== "function") return undefined; + const hit: unknown = Reflect.apply(lookup, document, [clientX, clientY]); + const surface = + hit instanceof Element ? hit.closest("[data-document-zoom-surface]") : null; + if (surface === null) return undefined; + const bounds = surface.getBoundingClientRect(); + return { surface, x: clientX - bounds.left, y: clientY - bounds.top, clientX, clientY }; +} + +/** @returns an actual-size scale that only shrinks content wider than the viewport. */ +export function fitWidthZoom( + viewportWidth: number, + intrinsicWidth: number | undefined, + horizontalInset = 0, +): number { + if (intrinsicWidth === undefined || intrinsicWidth <= 0) return 1; + return Math.min(1, Math.max(1, viewportWidth - horizontalInset) / intrinsicWidth); +} + +/** Shared viewport inputs for renderer-owned content and tab state. */ +export interface ZoomViewportProps { + readonly preference: ZoomPreference; + readonly intrinsicWidth: number | undefined; + readonly horizontalInset?: number; + readonly labels: ZoomLabels; + readonly signal: AbortSignal; + readonly scrollportRef: RefCallback; + readonly onPreference: (preference: ZoomPreference) => void; + /** Resolved scale after a gesture settles or fit-width measurement changes. */ + readonly onRenderZoom?: (zoom: number) => void; + readonly children: ReactNode; +} + +/** + * Keep zoom controls and trackpad gestures independent from document rendering. + * @param props - renderer content, intrinsic width, tab preference, and localized labels. + * @returns the owned scrollport and floating zoom controls. + */ +export function ZoomViewport(props: ZoomViewportProps): ReactNode { + const [viewportWidth, setViewportWidth] = useState(0); + const [controlsVisible, setControlsVisible] = useState(false); + const fitZoom = fitWidthZoom(viewportWidth, props.intrinsicWidth, props.horizontalInset); + const zoom = props.preference.kind === "fit-width" ? fitZoom : props.preference.scale; + const zoomRef = useRef(zoom); + const modeRef = useRef(props.preference.kind); + const frame = useRef(null); + const scrollport = useRef(null); + const controls = useRef(null); + const pinchTimer = useRef>(); + const controlsHideTimer = useRef>(); + const pointerInRevealZone = useRef(false); + const controlsActive = useRef(false); + const clearControlsHide = useCallback((): void => { + clearTimeout(controlsHideTimer.current); + controlsHideTimer.current = undefined; + }, []); + const showControls = useCallback((): void => { + clearControlsHide(); + setControlsVisible(true); + }, [clearControlsHide]); + const scheduleControlsHide = useCallback((): void => { + clearControlsHide(); + if (pointerInRevealZone.current || controlsActive.current || pinchTimer.current !== undefined) + return; + controlsHideTimer.current = setTimeout(() => { + controlsHideTimer.current = undefined; + setControlsVisible(false); + }, CONTROLS_HIDE_DELAY_MS); + }, [clearControlsHide]); + const handleControlsActive = useCallback( + (active: boolean): void => { + controlsActive.current = active; + if (active) showControls(); + else scheduleControlsHide(); + }, + [scheduleControlsHide, showControls], + ); + const handlePointerMove = useCallback( + (event: ReactPointerEvent): void => { + const inRevealZone = + event.pointerType === "mouse" && + event.clientY >= + event.currentTarget.getBoundingClientRect().bottom - CONTROLS_REVEAL_HEIGHT_PX; + pointerInRevealZone.current = inRevealZone; + if (inRevealZone) showControls(); + else scheduleControlsHide(); + }, + [scheduleControlsHide, showControls], + ); + const handlePointerLeave = useCallback((): void => { + pointerInRevealZone.current = false; + scheduleControlsHide(); + }, [scheduleControlsHide]); + const attachFrame = useCallback((node: HTMLElement | null): void => { + frame.current = node; + node?.style.setProperty("--document-zoom", String(zoomRef.current)); + }, []); + const attachScrollport = useCallback( + (node: HTMLDivElement | null): void => { + scrollport.current = node; + props.scrollportRef(node); + }, + [props.scrollportRef], + ); + const showZoom = useCallback((next: number, clientX?: number, clientY?: number): number => { + const node = scrollport.current as HTMLDivElement; + const previous = zoomRef.current; + next = Math.min(MAX_FIXED_ZOOM, Math.max(MIN_FIXED_ZOOM, next)); + if (next === previous) return previous; + const bounds = node.getBoundingClientRect(); + const x = clientX === undefined ? node.clientWidth / 2 : clientX - bounds.left; + const y = clientY === undefined ? node.clientHeight / 2 : clientY - bounds.top; + const anchor = pointerAnchor(bounds.left + x, bounds.top + y); + const previousScrollLeft = node.scrollLeft; + const previousScrollTop = node.scrollTop; + const ratio = next / previous; + zoomRef.current = next; + modeRef.current = "fixed"; + frame.current?.setAttribute("data-document-zoom-mode", "fixed"); + frame.current?.style.setProperty("--document-zoom", String(next)); + controls.current?.showZoom(next); + if (anchor === undefined) { + node.scrollLeft = (previousScrollLeft + x) * ratio - x; + node.scrollTop = (previousScrollTop + y) * ratio - y; + } else { + const nextBounds = anchor.surface.getBoundingClientRect(); + node.scrollLeft += nextBounds.left + anchor.x * ratio - anchor.clientX; + node.scrollTop += nextBounds.top + anchor.y * ratio - anchor.clientY; + } + return next; + }, []); + const setZoom = useCallback( + (next: number): void => { + clearTimeout(pinchTimer.current); + pinchTimer.current = undefined; + props.onPreference({ kind: "fixed", scale: showZoom(next) }); + }, + [props.onPreference, showZoom], + ); + const fitWidth = useCallback((): void => { + clearTimeout(pinchTimer.current); + pinchTimer.current = undefined; + const node = frame.current; + zoomRef.current = fitZoom; + modeRef.current = "fit-width"; + node?.setAttribute("data-document-zoom-mode", "fit-width"); + node?.style.setProperty("--document-zoom", String(fitZoom)); + controls.current?.showZoom(fitZoom); + props.onPreference(FIT_WIDTH); + }, [fitZoom, props.onPreference]); + + useLayoutEffect(() => { + const node = scrollport.current as HTMLDivElement; + const measure = (): void => { + setViewportWidth(node.clientWidth); + }; + measure(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(measure); + observer.observe(node); + return () => { + observer.disconnect(); + }; + }, []); + useEffect(() => { + if (pinchTimer.current !== undefined) return; + zoomRef.current = zoom; + modeRef.current = props.preference.kind; + frame.current?.setAttribute("data-document-zoom-mode", props.preference.kind); + frame.current?.style.setProperty("--document-zoom", String(zoom)); + controls.current?.showZoom(zoom); + props.onRenderZoom?.(zoom); + }, [props.onRenderZoom, props.preference.kind, zoom]); + useEffect(() => { + const node = scrollport.current as HTMLDivElement; + const clearPendingPinch = (): void => { + clearTimeout(pinchTimer.current); + pinchTimer.current = undefined; + clearControlsHide(); + }; + const wheel = (event: WheelEvent): void => { + if (!event.ctrlKey || props.signal.aborted) return; + event.preventDefault(); + showControls(); + const unit = + event.deltaMode === WheelEvent.DOM_DELTA_LINE + ? 16 + : event.deltaMode === WheelEvent.DOM_DELTA_PAGE + ? node.clientHeight + : 1; + const delta = Math.max(-40, Math.min(40, event.deltaY * unit)); + showZoom(zoomRef.current * Math.exp(-delta * 0.01), event.clientX, event.clientY); + clearTimeout(pinchTimer.current); + pinchTimer.current = setTimeout(() => { + pinchTimer.current = undefined; + props.onPreference({ kind: "fixed", scale: zoomRef.current }); + scheduleControlsHide(); + }, PINCH_SETTLE_DELAY_MS); + }; + node.addEventListener("wheel", wheel, { passive: false }); + props.signal.addEventListener("abort", clearPendingPinch, { once: true }); + return () => { + node.removeEventListener("wheel", wheel); + props.signal.removeEventListener("abort", clearPendingPinch); + clearPendingPinch(); + }; + }, [ + clearControlsHide, + props.onPreference, + props.signal, + scheduleControlsHide, + showControls, + showZoom, + ]); + + return ( +
+
+ {props.children} +
+
+ ); +} + +/** Shared surface class for actual-size and fit-width layout. */ +export const zoomSurfaceClass = css.surface as string; diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/zoom/locales.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/zoom/locales.ts new file mode 100644 index 000000000..6a063fc00 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/zoom/locales.ts @@ -0,0 +1,22 @@ +/** Shared zoom copy embedded into renderer-owned dictionaries. */ +export const zoomZh = { + zoomControls: "缩放控件", + zoomMenu: "选择缩放比例", + zoomOut: "缩小", + zoomIn: "放大", + zoomFitWidth: "适应宽度", + zoomValue: "{percent}%", +} as const; + +/** Shared English zoom copy. */ +export const zoomEn = { + zoomControls: "Zoom controls", + zoomMenu: "Choose zoom", + zoomOut: "Zoom out", + zoomIn: "Zoom in", + zoomFitWidth: "Fit width", + zoomValue: "{percent}%", +} satisfies Record; + +/** Shared zoom dictionary keys. */ +export type ZoomLocaleKey = keyof typeof zoomZh; diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/zoom/store.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/zoom/store.ts new file mode 100644 index 000000000..df412f0bc --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/zoom/store.ts @@ -0,0 +1,45 @@ +/** Tab-local zoom preferences for renderers without additional view state. */ +import { defineStore, type EngineStoreHandle } from "@deepseek-ai/dsh-client-store"; +import type { TabId } from "@deepseek-ai/dsh-client-ui-dockkit"; +import { FIT_WIDTH, type ZoomPreference } from "./types.ts"; + +/** Tab-lifetime callback supplied by a zoomable renderer registration. */ +export interface ZoomInjected { + /** @param tabId - owning tab. @param signal - complete tab-record lifetime. */ + readonly retainTab: (tabId: TabId, signal: AbortSignal) => void; +} + +/** Zoom state isolated by the owning tab record. */ +export interface ZoomState { + byTab: Record; +} + +type ZoomActions = { + zoom: (draft: ZoomState, tabId: TabId, preference: ZoomPreference) => void; + forget: (draft: ZoomState, tabId: TabId) => void; +}; + +/** + * Create tab-local zoom state for a document renderer registration. + * @returns a store declaration retaining zoom until its tab closes. + */ +export function createZoomStore(): EngineStoreHandle { + return defineStore({ + init: (): ZoomState => ({ byTab: {} }), + actions: { + zoom: (draft, tabId: TabId, preference: ZoomPreference) => { + draft.byTab[tabId] = preference; + }, + forget: (draft, tabId: TabId) => { + const { [tabId]: _closed, ...remaining } = draft.byTab; + draft.byTab = remaining; + }, + }, + }); +} + +/** Default zoom value for a tab without retained state. */ +export const DEFAULT_ZOOM = FIT_WIDTH; + +/** Store declaration used by image previews. */ +export type ZoomStore = ReturnType; diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/zoom/types.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/zoom/types.ts new file mode 100644 index 000000000..520e3aca8 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/client/zoom/types.ts @@ -0,0 +1,28 @@ +/** Automatic fit or an explicit actual-size scale selected by the reader. */ +export type ZoomPreference = + { readonly kind: "fit-width" } | { readonly kind: "fixed"; readonly scale: number }; + +/** Default preference for a newly opened zoomable preview. */ +export const FIT_WIDTH: ZoomPreference = { kind: "fit-width" }; + +/** Minimum scale retained by a fixed zoom preference. */ +export const MIN_FIXED_ZOOM = 0.25; + +/** Maximum scale retained by a fixed zoom preference. */ +export const MAX_FIXED_ZOOM = 4; + +/** Scale interval used by the incremental controls. */ +export const ZOOM_STEP = 0.25; + +/** Fixed scale choices shown in the zoom menu. */ +export const ZOOM_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const; + +/** Localized copy consumed by the framework-free zoom controls. */ +export interface ZoomLabels { + readonly controls: string; + readonly menu: string; + readonly out: string; + readonly into: string; + readonly fitWidth: string; + readonly value: (percent: number) => string; +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/config.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/config.ts new file mode 100644 index 000000000..ecb642a63 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/config.ts @@ -0,0 +1,52 @@ +/** Cache limits shared by the Host configuration and browser document previews. */ +import z from "@deepseek-ai/schemastery"; + +/** Transient Office conversion reuse within one Client connection. */ +export interface Config { + /** Retained PDF limits; pending conversions share cancellation by reader lifetime. */ + office: { + /** Maximum retained completed PDFs. */ + maxCachedEntries: number; + /** Maximum retained PDF bytes, counted by each binary buffer's byteLength. */ + maxCachedBytes: number; + /** Maximum unsettled Host conversion RPCs, including cancellation teardown. */ + maxPending: number; + /** Maximum readers including source and renderer metadata lookups. */ + maxReaders: number; + }; + /** Browser spreadsheet parser and dense cell allocation limits. */ + excel: { + /** Maximum source file bytes. */ + maxBytes: number; + /** Maximum combined rectangular cell area across worksheets. */ + maxCells: number; + /** Maximum parser Worker lifetime in milliseconds. */ + timeoutMs: number; + }; +} + +/** Deployment limits applied before Office preview registration. */ +export const Config: z< + { office?: Partial; excel?: Partial }, + Config +> = z.object({ + office: z.object({ + maxCachedEntries: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(8), + maxCachedBytes: z + .natural() + .min(1) + .max(Number.MAX_SAFE_INTEGER) + .default(64 * 1024 * 1024), + maxPending: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(8), + maxReaders: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(32), + }), + excel: z.object({ + maxBytes: z + .natural() + .min(1) + .max(Number.MAX_SAFE_INTEGER) + .default(16 * 1024 * 1024), + maxCells: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(250_000), + timeoutMs: z.natural().min(1).max(2_147_483_647).default(15_000), + }), +}); diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/css-modules.d.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/css-modules.d.ts new file mode 100644 index 000000000..b284bf80b --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module "*.module.css" { + const classes: Record; + export default classes; +} + +declare module "*.css"; diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/index.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/index.ts new file mode 100644 index 000000000..a94df7065 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/src/index.ts @@ -0,0 +1,17 @@ +/** Host configuration for browser document previews. */ +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 preview settings in browser pages. + * @param ctx - Host context serving browser pages. + * @param config - Cache limits 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_DOCUMENT_PREVIEW_CONFIG__", value: config }); + }); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-documentpreview/tsdown.config.ts b/apps/web-ui/packages/client/ui-sidebar-documentpreview/tsdown.config.ts new file mode 100644 index 000000000..9f3aad54e --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-documentpreview/tsdown.config.ts @@ -0,0 +1,178 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { Rolldown, type UserConfig } from "tsdown"; +import { clientBundle } from "../tsdown.client.ts"; + +const bundle = clientBundle( + "@deepseek-ai/dsh-client-ui-sidebar-documentpreview", + ["lib/types/index.js"], + { + clientBanner: (fileName) => + fileName.endsWith("client.pdf.js") + ? pdfLicenseBanner() + : fileName.endsWith("client.excel.js") + ? excelLicenseBanner() + : undefined, + }, +); +const require = createRequire(import.meta.url); +const workerSpecifier = "pdfjs-dist/build/pdf.worker.min.mjs?raw"; +const workerModule = "\0dsh-pdf-worker.mjs"; + +/** FortuneSheet omits its repository license from the npm payload. */ +function excelLicenseBanner(): string { + const fortune = readFileSync(join(import.meta.dirname, "licenses/FortuneSheet.txt"), "utf8"); + const excel = readFileSync( + join(dirname(require.resolve("exceljs/package.json")), "LICENSE"), + "utf8", + ); + const xml = readFileSync( + join(dirname(dirname(require.resolve("fast-xml-parser"))), "LICENSE"), + "utf8", + ); + const csv = readFileSync( + join(dirname(require.resolve("papaparse/package.json")), "LICENSE"), + "utf8", + ); + const zip = readFileSync( + join(dirname(require.resolve("fflate/package.json")), "LICENSE"), + "utf8", + ); + const xlsRoot = dirname(require.resolve("xlsx")); + const xls = readdirSync(xlsRoot) + .filter((name) => /^(LICENSE|NOTICE)(\.|$)/u.test(name)) + .sort() + .map((name) => readFileSync(join(xlsRoot, name), "utf8")) + .join("\n"); + return [ + "//! Bundled spreadsheet license notices", + ...`${fortune}\n${excel}\n${xml}\n${csv}\n${zip}\n${xls}` + .trimEnd() + .split("\n") + .map((line) => `// ${line}`), + ].join("\n"); +} + +/** License files for PDF.js and the data embedded beside its runtime. */ +function pdfLicenseFiles(root: string): string[] { + return [ + "LICENSE", + ...["cmaps", "standard_fonts", "wasm"].flatMap((directory) => + readdirSync(join(root, directory)) + .filter((name) => name.startsWith("LICENSE")) + .sort() + .map((name) => `${directory}/${name}`), + ), + ]; +} + +/** Keep every bundled PDF.js license visible in the published client artifact. */ +function pdfLicenseBanner(): string { + const root = dirname(require.resolve("pdfjs-dist/package.json")); + const notice = pdfLicenseFiles(root) + .map((name) => `${name}\n\n${readFileSync(join(root, name), "utf8").trimEnd()}`) + .join("\n\n"); + return [ + "//! Bundled PDF.js license notices", + ...notice.split("\n").map((line) => `// ${line}`), + ].join("\n"); +} + +/** Keep font mappings and image decoders in the same artifact as their PDF.js runtime. */ +function pdfAssets(): string { + const root = dirname(require.resolve("pdfjs-dist/package.json")); + return JSON.stringify( + Object.fromEntries( + [ + ["cMapUrl", "cmaps"], + ["standardFontDataUrl", "standard_fonts"], + ["wasmUrl", "wasm"], + ].map(([kind, directory]) => [ + kind, + Object.fromEntries( + readdirSync(join(root, directory!)) + .filter((name) => !name.startsWith("LICENSE")) + .sort() + .map((name) => [name, readFileSync(join(root, directory!, name)).toString("base64")]), + ), + ]), + ), + ); +} + +/** The dynamic client factory has no module URL from which to resolve a Worker file. */ +const pdfWorker: NonNullable = [ + { + name: "dsh-pdf-worker-source", + resolveId(source) { + return source === workerSpecifier ? workerModule : null; + }, + load(id) { + if (id !== workerModule) return null; + const path = require.resolve("pdfjs-dist/build/pdf.worker.min.mjs"); + this.addWatchFile(path); + return `export default ${JSON.stringify(readFileSync(path, "utf8"))};`; + }, + }, +]; + +/** Embed a self-contained browser parser without giving it a loader-module dependency. */ +const excelWorker: NonNullable = [ + { + name: "dsh-excel-worker-source", + resolveId(source) { + return source === "./worker.ts?raw" ? "\0dsh-excel-worker-source" : null; + }, + async load(id) { + if (id !== "\0dsh-excel-worker-source") return null; + const parent = this; + const worker = await Rolldown.rolldown({ + input: join(import.meta.dirname, "src/client/excel/worker.ts"), + platform: "browser", + resolve: { mainFields: ["browser", "module", "main"], aliasFields: [["browser"]] }, + transform: { define: { "process.env.NODE_ENV": JSON.stringify("production") } }, + plugins: [ + { + name: "dsh-excel-worker-dependencies", + async resolveId(source, importer) { + // The outer build's license analysis must also see imports embedded in Worker text. + if (importer?.startsWith(join(import.meta.dirname, "src")) && !source.startsWith(".")) + await parent.resolve(source, importer); + return null; + }, + }, + ], + }); + try { + const result = await worker.generate({ format: "iife", minify: true }); + const chunk = result.output[0]; + if (chunk?.type !== "chunk") + throw new Error("Excel parser did not emit a JavaScript chunk"); + for (const path of Object.keys(chunk.modules)) this.addWatchFile(path); + return `export default ${JSON.stringify(chunk.code)};`; + } finally { + await worker.close(); + } + }, + }, +]; + +export default (options: Parameters[0]): UserConfig[] => + bundle(options).map((config) => + config.name?.endsWith("/client") === true + ? { + ...config, + inputOptions: { + ...config.inputOptions, + resolve: { + ...config.inputOptions?.resolve, + mainFields: ["browser", "module", "main"], + aliasFields: [["browser"]], + }, + }, + plugins: [config.plugins, pdfWorker, excelWorker], + define: { ...config.define, __DSH_PDFJS_ASSETS__: pdfAssets() }, + } + : config, + ); diff --git a/apps/web-ui/packages/client/ui-sidebar-files/package.json b/apps/web-ui/packages/client/ui-sidebar-files/package.json new file mode 100644 index 000000000..1c0231467 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-files/package.json @@ -0,0 +1,40 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-sidebar-files", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./client": { + "types": "./src/client/index.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dependencies": { + "@deepseek-ai/dsh-client-store": "*", + "@deepseek-ai/dsh-client-ui-primitives": "*", + "@deepseek-ai/dsh-client-ui-slots": "*", + "@deepseek-ai/dsh-typert-protocol": "*", + "@deepseek-ai/dsh-util-workspace-path": "*", + "clsx": "^2.0.0", + "react": "^18.2.0" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-workspace-files", + "@deepseek-ai/dsh-client-ui-sidebar-right", + "@deepseek-ai/dsh-client-ui-session", + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-client-shortcuts" + ], + "platform": "web" + } + } +} diff --git a/apps/web-ui/packages/client/ui-sidebar-files/src/client/FilesBody.module.css b/apps/web-ui/packages/client/ui-sidebar-files/src/client/FilesBody.module.css new file mode 100644 index 000000000..2995bbfe3 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-files/src/client/FilesBody.module.css @@ -0,0 +1,178 @@ +/* The pane body is a block scroller with a definite height, so the tree takes + that height outright: the header row stays put and the levels below are the + one scroller. */ +.root { + display: flex; + flex: 1 1 auto; + flex-direction: column; + height: 100%; + min-height: 0; + color: var(--dsw-alias-label-primary); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: 1.5; +} + +/* One 38px row flush under the pane's strip: the root's path, then reload at + its end. With the strip's 38px above, its rule lies where the conversation + header's does (ui-conversation ConversationRoot `.header`, 76px with the + same rule). */ +.header { + display: flex; + flex: 0 0 auto; + gap: 4px; + align-items: center; + box-sizing: border-box; + height: 38px; + padding: 0 6px 0 16px; + border-bottom: 0.5px solid var(--dsw-alias-border-l3); +} + +/* The margin plus the header's gap keeps the path 16px clear of reload. */ +.path { + margin-right: 12px; +} + +.body { + flex: 1 1 auto; + min-height: 0; + /* The workspace list's 2px scrollbar offset (ui-workspace WorkspaceBrowser + `.list`): the bar sits 2px clear of the pane's edge, and the stable gutter + stands in for the right padding so the rows keep their inset whether or + not the tree overflows. */ + margin-right: 2px; + padding: 8px 0 8px 8px; + overflow: auto; + scrollbar-gutter: stable; +} + +/* The bar also stops 2px short of the body's ends, matching its 2px edge + offset. WebKit-only: the Firefox path has no track to inset. */ +.body::-webkit-scrollbar-track { + margin: 2px; +} + +.level { + margin: 0; + padding: 0; + list-style: none; +} + +/* Every nested level indents by one step; the root level sits under the header. */ +.level .level { + padding-left: 18px; +} + +.item { + margin: 0; + padding: 0; +} + +.row { + display: flex; + gap: 6px; + align-items: center; + width: 100%; + min-width: 0; + /* Rows abut, so the hover fill is the whole row; the tree's air is this + inset, not a gap between rows. */ + padding: 5px 10px; + color: inherit; + font: inherit; + text-align: left; + background: transparent; + border: 0; + border-radius: var(--dsw-radius-md); + cursor: pointer; +} + +.row:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +/* The folder glyph rides the tertiary ink, as the workspace tree's does (ui-workspace Rows `.slot`). */ +.icon { + flex: 0 0 auto; + color: var(--dsw-alias-label-tertiary); +} + +/* The type's coloured sheet at the folder glyph's 16px; its colour is its own. */ +.fileIcon { + flex: 0 0 auto; +} + +.name { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +/* Neither a file nor a directory: shown so the directory is reported whole, + greyed so nobody tries to open it. */ +.other { + color: var(--dsw-alias-label-tertiary); + cursor: default; +} + +.other:hover { + background: transparent; +} + +.note { + margin: 0; + padding: 3px 10px; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; +} + +.status { + display: flex; + flex-direction: column; + padding: 12px 10px; +} + +.statusLine { + margin: 0; + color: var(--dsw-alias-label-secondary); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: 1.6; +} + +/* The same icon button as the Sidebar's strip controls above it: a 28px + circle around a 15px glyph. */ +.tool { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 6px; + color: var(--dsw-alias-label-secondary); + line-height: 1; + background: transparent; + border: none; + border-radius: var(--dsw-radius-sm); + cursor: pointer; +} + +.tool svg { + width: 15px; + height: 15px; +} + +.tool:hover { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-interactive-bg-hover); +} + +.tool[aria-pressed="true"] { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-interactive-bg-hover); +} + +/* The folder sheet before the chip's text; the chip's title row centres it + on the text's line and spaces it. */ +.titleIcon { + flex: none; +} diff --git a/apps/web-ui/packages/client/ui-sidebar-files/src/client/FilesBody.tsx b/apps/web-ui/packages/client/ui-sidebar-files/src/client/FilesBody.tsx new file mode 100644 index 000000000..9ea60a311 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-files/src/client/FilesBody.tsx @@ -0,0 +1,334 @@ +/** + * The file tree's body: the session's workspace root, listed one level at a time. + * + * Everything the tree keeps lives in its store, keyed by tab; everything it asks + * for goes through its injected face. The component itself only decides what to + * draw for each absolute path and what a click means: a directory toggles, a + * file opens through the owner's `tabActions` for a `file:` viewer to claim, and + * anything else is shown but refuses to open. The header uses the shared + * PathLabel for the root, followed by reload and workspace directory actions. + */ +import { useEffect, useLayoutEffect, useRef } from "react"; +import type { ReactNode } from "react"; +import clsx from "clsx"; +import type { RemoteFailure } from "@deepseek-ai/dsh-api-remotes/client"; +import type { + PropsLocale, + PropsRenderSlots, + PropsRuntime, + PropsStore, + TranslateNS, +} from "@deepseek-ai/dsh-client-ui-slots"; +import { + FileTypeIcon, + IconFolderCloseRegular, + IconFolderOpenRegular, + IconRefreshOutlineRegular, + Tooltip, + classifyFileType, + IconPauseOutlineRegular, + IconPlayOutlineRegular, + PathLabel, +} from "@deepseek-ai/dsh-client-ui-primitives"; +import { fileAddressFor } from "@deepseek-ai/dsh-util-workspace-path"; +import type { WorkspaceDirectoryEntry } from "@deepseek-ai/dsh-api-workspace-files/types"; +import { childPath } from "./face.ts"; +import type { FilesInjected } from "./face.ts"; +import type {} from "./locales.ts"; +import type { FilesTabState, createFilesStore } from "./store.ts"; +import css from "./FilesBody.module.css"; + +/** The body's composed props: the tab it draws, its store, its face, and its copy. */ +export type FilesBodyProps = PropsRuntime<"sidebar.right.pane.tab"> & + PropsRenderSlots<"sidebar.right.tab.files.actions"> & + PropsStore> & + FilesInjected & + PropsLocale<"sidebarFiles">; + +/** Natural, case-insensitive name order, so `file2` precedes `file10`. */ +const byName = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); + +/** + * Order one level's entries for display: directories first, then everything + * else, each group by name. The endpoint's order is a listing fact; this is the + * reader's. + * @param entries - the listing as the endpoint returned it. + * @returns a new array, directories first, then by name within each group. + */ +export function orderEntries( + entries: readonly WorkspaceDirectoryEntry[], +): WorkspaceDirectoryEntry[] { + return [...entries].sort((left, right) => { + const group = Number(right.type === "directory") - Number(left.type === "directory"); + return group !== 0 ? group : byName.compare(left.name, right.name); + }); +} + +/** + * Say why a directory could not be listed, in terms of the directory. + * @param t - namespace-bound translate. + * @param failure - the settled Remote failure. + * @returns the line to show under the directory. + */ +export function failureLine(t: TranslateNS<"sidebarFiles">, failure: RemoteFailure): string { + switch (failure.code) { + case "workspace-file/not-found": + return t("error.notFound"); + case "workspace-file/outside-workspace": + return t("error.outsideWorkspace"); + case "workspace-file/not-directory": + return t("error.notDirectory"); + // Carrier and unclassified host failures reach the reader as themselves: + // this tree knows nothing useful to add to a transport-level message. + default: + return t("error.unavailable", { message: failure.message }); + } +} + +/** What every level shares: the tab's tree and the two gestures. */ +interface TreeContext { + readonly state: FilesTabState; + readonly onToggle: (parent: string, path: string) => void; + readonly onOpen: (path: string) => void; + readonly t: TranslateNS<"sidebarFiles">; +} + +/** One entry's row, and its children when it is an expanded directory. */ +function Entry({ + parent, + entry, + tree, +}: { + parent: string; + entry: WorkspaceDirectoryEntry; + tree: TreeContext; +}): ReactNode { + const path = childPath(parent, entry.name); + if (entry.type === "directory") { + const expanded = tree.state.expanded.includes(path); + return ( +
  • + + {expanded && ( +
      + +
    + )} +
  • + ); + } + if (entry.type === "file") { + return ( +
  • + +
  • + ); + } + return ( +
  • + + {entry.name} + +
  • + ); +} + +/** One directory's rows: its state while listing, its entries once listed. */ +function Level({ path, tree }: { path: string; tree: TreeContext }): ReactNode { + const { state, t } = tree; + const level = state.levels[path]; + if (level === undefined || level.kind === "loading") { + return ( +
  • + {t("loading")} +
  • + ); + } + if (level.kind === "failed") { + return ( +
  • + {failureLine(t, level.failure)} +
  • + ); + } + const entries = orderEntries(level.level.entries); + return ( + <> + {level.failure !== undefined && ( +
  • + {failureLine(t, level.failure)} +
  • + )} + {entries.length === 0 && ( +
  • + {t("empty")} +
  • + )} + {entries.map((entry) => ( + + ))} + {level.level.truncated && ( +
  • + {t("truncated")} +
  • + )} + + ); +} + +/** The file tree's body: the workspace root and whatever the reader has opened under it. */ +export function FilesBody({ + useTabInfo, + sessionId, + useSessions, + useStore, + actions, + start, + refresh, + setAutoRefresh, + toggle, + t, + renderSlot, +}: FilesBodyProps): ReactNode { + const { tab } = useTabInfo(); + useEffect( + () => + tab.actions.bindCommands({ + refresh: () => { + refresh(tab.id); + }, + }), + [tab.actions, tab.id, refresh], + ); + const { signal, actions: tabActions } = tab; + const cwd = useSessions((sessions) => sessions.byId[sessionId]?.cwd); + const state = useStore((store) => store.byTab[tab.id]); + const bodyRef = useRef(null); + const scrollTopRef = useRef(0); + // Come back where the reader was: loaded levels outlive the body in the + // store, so a remounted tree lays out at its full height before this runs + // and the stored offset re-lands exactly. A fresh tree stores 0. + const seeded = state !== undefined; + useLayoutEffect(() => { + const body = bodyRef.current; + if (seeded && body !== null) { + body.scrollTop = state.scrollTop; + scrollTopRef.current = body.scrollTop; + } + }, [seeded]); + // Scrolling only moves the ref; the store hears about it once, on unmount, + // so a scroll neither re-renders the tree nor writes after the owner's + // abort has forgotten the bucket. + useEffect( + () => () => { + if (seeded && !signal.aborted) actions.scrolled(tab.id, scrollTopRef.current); + }, + [seeded, signal, tab.id, actions], + ); + useEffect(() => { + // A bucket gone because the record aborted must not be re-seeded by a + // component that has not unmounted yet. + if (state !== undefined || cwd === undefined || signal.aborted) return; + start(tab.id, cwd, signal); + }, [state, cwd, tab.id, signal, start]); + + if (cwd === undefined) { + return ( +
    +

    {t("noWorkspace")}

    +
    + ); + } + if (state === undefined) return null; + const tree: TreeContext = { + state, + onToggle: (parent, path) => { + toggle(tab.id, parent, path, state.expanded, signal); + }, + // Every row is under the tree's root, so its address is session-relative. + onOpen: (path) => { + tabActions.openResource(fileAddressFor(sessionId, state.root, path)); + }, + t, + }; + const reload = (): void => { + refresh(tab.id); + }; + return ( +
    +
    + + + + + + {renderSlot("sidebar.right.tab.files.actions", { + absolutePath: state.root, + })} +
    +
    { + scrollTopRef.current = event.currentTarget.scrollTop; + }} + > +
      + +
    +
    +
    + ); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-files/src/client/FilesTitle.tsx b/apps/web-ui/packages/client/ui-sidebar-files/src/client/FilesTitle.tsx new file mode 100644 index 000000000..e941b0489 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-files/src/client/FilesTitle.tsx @@ -0,0 +1,27 @@ +/** + * The files type's chip title: the folder sheet before the type's label. + * Registered under `sidebar.right.pane.tab.title`; without it the chip would + * show the bare label. The tree in the body draws its own row glyphs and never + * this one. + */ +import type { ReactNode } from "react"; +import { FileTypeIcon } from "@deepseek-ai/dsh-client-ui-primitives"; +import type { PropsRuntime } from "@deepseek-ai/dsh-client-ui-slots"; +import css from "./FilesBody.module.css"; + +/** + * The title as the chip and a floating panel's header show it. + * @param props - the tab information hook. + * @returns the folder sheet followed by the tab's title text. + */ +export function FilesTitle({ + useTabInfo, +}: PropsRuntime<"sidebar.right.pane.tab.title">): ReactNode { + const { tab } = useTabInfo(); + return ( + <> + + {tab.title} + + ); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-files/src/client/definition.tsx b/apps/web-ui/packages/client/ui-sidebar-files/src/client/definition.tsx new file mode 100644 index 000000000..4849a4465 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-files/src/client/definition.tsx @@ -0,0 +1,42 @@ +/** + * Stage one of this package's registration: what the `files` tab type IS. + * + * The type is a page, not a viewer: it claims no address. The guide page offers + * it as an entry box, and the tree opens files through `tabActions.openResource` + * for the `dsh-resource://file` viewers to claim. + */ +import type { ShortcutCommandId } from "@deepseek-ai/dsh-client-shortcuts/client"; +import type { SidebarRightTabDefinition } from "@deepseek-ai/dsh-client-ui-sidebar-right/client"; +import type { TranslateNS } from "@deepseek-ai/dsh-client-locale/client"; +import type {} from "./locales.ts"; +import { GuideArtworkFiles } from "@deepseek-ai/dsh-client-ui-primitives"; + +/** The tab kind this package owns. */ +export const FILES_KIND = "files"; + +/** This implementation's identity in the tab system, and the key its body registers under. */ +export const FILES_ID = "@deepseek-ai/dsh-client-ui-sidebar-files"; + +/** + * The files type's registry definition. + * @param t - namespace-bound translate, read fresh on every label call. + * @returns the definition to register. + */ +export function filesDefinition(t: TranslateNS<"sidebarFiles">): SidebarRightTabDefinition { + return { + id: FILES_ID, + kind: FILES_KIND, + priority: "builtin", + title: () => t("type.label"), + guide: [ + { + id: "workspace", + commandId: "workspace.files" as ShortcutCommandId, + order: 10, + title: () => t("guide.title"), + description: () => t("guide.description"), + icon: GuideArtworkFiles, + }, + ], + }; +} diff --git a/apps/web-ui/packages/client/ui-sidebar-files/src/client/directory-node.ts b/apps/web-ui/packages/client/ui-sidebar-files/src/client/directory-node.ts new file mode 100644 index 000000000..011e114c8 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-files/src/client/directory-node.ts @@ -0,0 +1,171 @@ +/** Watch and read ownership for the Files panel's expanded directory tree. */ +import type { DirLevel } from "./store.ts"; + +/** One open directory and its active child nodes; cached view preferences live in the store. */ +export class DirectoryNode { + /** Open direct-child directories, keyed by absolute path. */ + readonly children = new Map(); + private readonly controller = new AbortController(); + private readonly signal: AbortSignal; + private task: Promise | undefined; + private reading: Promise | undefined; + private dirty = false; + private initialized = false; + private automatic = true; + + constructor( + readonly path: string, + private readonly load: (path: string, signal: AbortSignal) => Promise, + private readonly watch: ( + path: string, + signal: AbortSignal, + ) => AsyncIterable<"ready" | "change">, + private readonly failed: (path: string, error: unknown) => void, + lifetime: AbortSignal, + private restore: readonly string[] = [], + ) { + this.signal = AbortSignal.any([lifetime, this.controller.signal]); + } + + /** + * Start observation once; readiness triggers the initial listing. + * @returns this node. + */ + open(): this { + this.task ??= this.follow(); + return this; + } + + /** + * Find an active node in this subtree. + * @param path - absolute directory path. + * @returns the matching node, or undefined when that directory is closed. + */ + find(path: string): DirectoryNode | undefined { + if (path === this.path) return this; + for (const child of this.children.values()) { + const found = child.find(path); + if (found !== undefined) return found; + } + return undefined; + } + + /** + * Update the expansion preferences used by pending directory listings. + * @param expanded - latest expansion preferences from the store. + */ + setExpanded(expanded: readonly string[]): void { + this.restore = expanded; + for (const child of this.children.values()) child.setExpanded(expanded); + } + + /** + * Open a direct child using this node's lifetime and automatic-refresh setting. + * @param path - absolute direct-child directory path. + * @param restore - descendant expansion preferences to restore after listing. + * @returns the active child, or undefined after cancellation. + */ + expand(path: string, restore: readonly string[] = []): DirectoryNode | undefined { + if (this.signal.aborted) return undefined; + let child = this.children.get(path); + if (child === undefined) { + child = new DirectoryNode(path, this.load, this.watch, this.failed, this.signal, restore); + child.automatic = this.automatic; + this.children.set(path, child); + } + return child.open(); + } + + /** + * Remove a child and its pending restoration preferences. + * @param path - absolute direct-child directory path. + * @returns once the child subtree's reads and watches have ended. + */ + async collapse(path: string): Promise { + this.restore = this.restore.filter((value) => value !== path && !value.startsWith(`${path}/`)); + const child = this.children.get(path); + this.children.delete(path); + await child?.close(); + } + + /** + * Control subtree rereads without ending subscriptions. + * @param enabled - refresh dirty nodes automatically. + */ + setAutomatic(enabled: boolean): void { + this.automatic = enabled; + if (enabled && this.dirty) void this.refresh(); + for (const child of this.children.values()) child.setAutomatic(enabled); + } + + /** Queue a reread, coalescing with active work. @returns completion of the active read and its coalesced rereads. */ + refresh(): Promise { + this.dirty = true; + this.reading ??= this.read(); + return this.reading; + } + + /** Refresh this node and its open descendants. @returns once their listings settle. */ + async refreshTree(): Promise { + await this.refresh(); + await Promise.all([...this.children.values()].map((child) => child.refreshTree())); + } + + /** Cancel the active subtree. @returns once all owned reads and watches have ended. */ + async close(): Promise { + this.controller.abort(); + await Promise.all([ + this.task, + this.reading, + ...[...this.children.values()].map((child) => child.close()), + ]); + this.children.clear(); + } + + private async follow(): Promise { + try { + for await (const _event of this.watch(this.path, this.signal)) { + this.dirty = true; + if (!this.initialized || this.automatic) void this.refresh(); + } + } catch (error) { + if (!this.signal.aborted) { + if (!this.initialized) await this.refresh(); + if (!( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "workspace-file/watch-unsupported" + )) { + this.failed(this.path, error); + } + } + } + } + + private async read(): Promise { + const readAgain = (): boolean => this.dirty && this.automatic && !this.signal.aborted; + try { + do { + this.dirty = false; + const level = await this.load(this.path, this.signal); + if (this.signal.aborted || level === undefined) return; + this.initialized = true; + const directories = new Set( + level.entries + .filter((entry) => entry.type === "directory") + .map((entry) => `${this.path.replace(/[/\\]+$/, "")}/${entry.name}`), + ); + for (const path of this.children.keys()) { + if (!directories.has(path)) await this.collapse(path); + } + for (const path of directories) { + if (this.restore.includes(path)) this.expand(path, this.restore); + } + this.restore = []; + } while (readAgain()); + } finally { + this.reading = undefined; + } + } +} diff --git a/apps/web-ui/packages/client/ui-sidebar-files/src/client/face.ts b/apps/web-ui/packages/client/ui-sidebar-files/src/client/face.ts new file mode 100644 index 000000000..0437ba8fc --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-files/src/client/face.ts @@ -0,0 +1,265 @@ +/** + * The tree's asynchronous half: listing directories into the store. + * + * The component never awaits anything. It calls `start` / `refresh` / `toggle`, and + * this face performs the listing and writes the outcome through the store's own + * actions — the Slot-standard `inject` shape, so the session id is resolved by + * the framework and the write set stays the store's. + * + * The listing itself is bound here to the Client Remote face: the tree keys + * every level by absolute path and hands the endpoint that same absolute path; + * the endpoint answers with the directory's workspace-relative path as well, + * which the tree has no use for and drops. + * + * One level has one listing in force: asking for a level again — the reload + * gesture, a directory reopened after a reset — retires the listing still in + * flight for it, whose settlement then writes nothing. Cleanup rides the owner's + * `signal`: a request is not made for a record that already ended, and when the + * record goes away the bucket and the tab's listing bookkeeping are forgotten, + * so no later settlement writes to it. + */ +import type { ClientRemote, RemoteResult } from "@deepseek-ai/dsh-api-remotes/client"; +import type { BoundActions } from "@deepseek-ai/dsh-client-store"; +import type { TabId } from "@deepseek-ai/dsh-client-ui-dockkit"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { DirLevel, createFilesStore } from "./store.ts"; +import type { WorkspaceFileWatchFrame } from "@deepseek-ai/dsh-api-workspace-files/types"; +import { RemoteError } from "@deepseek-ai/dsh-typert-protocol"; +import { DirectoryNode } from "./directory-node.ts"; + +/** + * Observe one directory without recursively watching its descendants. + * @param sessionId - Session owning the directory tree. + * @param path - absolute directory path. + * @param signal - node lifetime. + * @returns readiness and invalidation notifications. + */ +export type WatchWorkspaceDirectory = ( + sessionId: SessionId, + path: string, + signal: AbortSignal, +) => AsyncIterable<"ready" | "change">; + +/** + * Bind directory observation to the Remote stream supervisor. + * @param remote - Client Remote with workspace file streams. + * @returns a watcher that awaits stream disposal when its node ends. + */ +export function createWatch(remote: ClientRemote): WatchWorkspaceDirectory { + return async function* (sessionId, path, signal) { + const aborted = (): boolean => signal.aborted; + if (aborted()) return; + const stream = remote.$stream({ + name: `directory ${path}`, + open: (lifetime) => remote.workspaceFiles.changes(sessionId, path, lifetime), + ended: () => new Error(`Directory watch ended: ${path}`), + }); + const abort = (): void => { + void stream.dispose(); + }; + signal.addEventListener("abort", abort, { once: true }); + try { + for await (const item of stream) { + if (aborted()) return; + if (item.value.kind === "ready") item.accept(); + yield item.value.kind; + } + } finally { + signal.removeEventListener("abort", abort); + await stream.dispose(); + } + }; +} + +/** + * One directory listing, bound to a Remote face. + * + * The session travels with the call because the endpoint resolves the workspace + * root from it: the same path means different directories in different sessions. + * A Remote call does not reject — the result carries the failure. + */ +export type ListWorkspaceDirectory = ( + sessionId: SessionId, + path: string, + signal: AbortSignal, +) => Promise>; + +/** + * The slice of the Client Remote face this package calls: the `workspaceFiles` + * namespace's `list`, exactly as the Host's generated client declares it. + */ +export type WorkspaceFilesListRemote = { + readonly workspaceFiles: Pick; +}; + +/** + * Bind the listing to one Remote face, keeping only what the tree stores. + * @param remote - the Client Remote face carrying the `workspaceFiles` namespace. + * @returns the listing the tree's face performs. + */ +export function createList(remote: WorkspaceFilesListRemote): ListWorkspaceDirectory { + return async (sessionId, path, signal) => { + const result = await remote.workspaceFiles.list(sessionId, path, signal); + if (!result.ok) return result; + return { + ok: true, + value: { entries: result.value.entries, truncated: result.value.truncated }, + }; + }; +} + +/** + * The absolute path of one child entry. + * + * Joined with `/` whatever the parent's separators: the Host resolves mixed + * separators, and the tree only needs a stable key. + * @param parent - absolute path of the listed directory. + * @param name - the entry's basename. + * @returns the child's absolute path. + */ +export function childPath(parent: string, name: string): string { + return `${parent.replace(/[/\\]+$/, "")}/${name}`; +} + +/** The tree's injected business face, as the body receives it. */ +export interface FilesInjected { + /** Refresh the open directory tree. @param tabId - owning tab. */ + readonly refresh: (tabId: TabId) => void; + /** Control automatic rereads without closing watches. @param tabId - owning tab. @param enabled - automatic-refresh setting. */ + readonly setAutoRefresh: (tabId: TabId, enabled: boolean) => void; + /** + * Seed this tab's tree and list its root. + * @param tabId - the tab being drawn. + * @param root - absolute path of the workspace root. + * @param signal - the tab record's lifetime. + */ + readonly start: (tabId: TabId, root: string, signal: AbortSignal) => void; + /** + * List one directory into the store. + * @param tabId - the tab being drawn. + * @param path - absolute directory path. + * @param signal - the tab record's lifetime. + */ + readonly load: (tabId: TabId, path: string, signal: AbortSignal) => void; + /** + * Open or collapse one directory, retaining intent during ancestor restoration. + * @param tabId - the tab being drawn. + * @param parentPath - the listed parent directory's exact tree key. + * @param path - absolute directory path. + * @param expanded - current expansion preferences, including descendants to restore. + * @param signal - the tab record's lifetime. + */ + readonly toggle: ( + tabId: TabId, + parentPath: string, + path: string, + expanded: readonly string[], + signal: AbortSignal, + ) => void; +} + +/** + * Bind the tree's face to one directory listing. + * @param list - the bound `workspaceFiles.list` call. + * @param watch - target-scoped directory observation. + * @returns the Slot `inject` factory: session and bound actions in, face out. + */ +export function filesFace( + list: ListWorkspaceDirectory, + watch: WatchWorkspaceDirectory, +): ( + sessionId: SessionId, + actions: BoundActions>, +) => FilesInjected { + return ( + sessionId: SessionId, + actions: BoundActions>, + ): FilesInjected => { + /** Per tab, per absolute path: the listing generation a settlement must match; the latest request wins. */ + const generations = new Map>(); + const roots = new Map(); + const nextGeneration = (tabId: TabId, path: string): number => { + const byPath = generations.get(tabId) ?? new Map(); + generations.set(tabId, byPath); + const generation = (byPath.get(path) ?? 0) + 1; + byPath.set(path, generation); + return generation; + }; + const load = async ( + tabId: TabId, + path: string, + signal: AbortSignal, + ): Promise => { + if (signal.aborted) return; + const generation = nextGeneration(tabId, path); + actions.loading(tabId, path); + return list(sessionId, path, signal).then((result) => { + // A newer listing of this level was asked for since, or the record is + // gone and its bookkeeping with it: nothing left for this one to write. + if (signal.aborted || generations.get(tabId)?.get(path) !== generation) return; + if (result.ok) actions.loaded(tabId, path, result.value); + else actions.failed(tabId, path, result.error); + return result.ok ? result.value : undefined; + }); + }; + return { + refresh: (tabId) => { + void roots.get(tabId)?.refreshTree(); + }, + setAutoRefresh: (tabId, enabled) => { + actions.autoRefresh(tabId, enabled); + roots.get(tabId)?.setAutomatic(enabled); + }, + start(tabId, root, signal) { + actions.start(tabId, root); + signal.addEventListener( + "abort", + () => { + void roots.get(tabId)?.close(); + roots.delete(tabId); + generations.delete(tabId); + actions.forget(tabId); + }, + { once: true }, + ); + roots.set( + tabId, + new DirectoryNode( + root, + (path, lifetime) => load(tabId, path, lifetime), + (path, lifetime) => watch(sessionId, path, lifetime), + (path, error) => { + if (!signal.aborted) + actions.failed( + tabId, + path, + new RemoteError( + "gateway/internal", + error instanceof Error ? error.message : String(error), + {}, + ), + ); + }, + signal, + ).open(), + ); + }, + load: (tabId, path, signal) => { + void load(tabId, path, signal); + }, + toggle(tabId, parentPath, path, expanded, signal) { + if (signal.aborted) return; + const root = roots.get(tabId); + if (root === undefined) return; + const parent = root.find(parentPath); + if (parent === undefined && !expanded.includes(parentPath)) return; + const collapsing = expanded.includes(path); + const next = collapsing ? expanded.filter((value) => value !== path) : [...expanded, path]; + root.setExpanded(next); + if (collapsing) void parent?.collapse(path); + else parent?.expand(path, next); + actions.toggled(tabId, path); + }, + }; + }; +} diff --git a/apps/web-ui/packages/client/ui-sidebar-files/src/client/index.ts b/apps/web-ui/packages/client/ui-sidebar-files/src/client/index.ts new file mode 100644 index 000000000..5d78fe880 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-files/src/client/index.ts @@ -0,0 +1,132 @@ +/** + * Browser half: register `files` as a right-Sidebar tab type. + * + * The public two-stage path, unmodified: the type into `ctx.sidebarRightTabs`, + * the body into the keyed `sidebar.right.pane.tab` seat and the chip title into + * the keyed `sidebar.right.pane.tab.title` seat, both under the type's `id`. + * + * The file split is this package's layering: what the type IS + * (`definition.tsx`), what it keeps (`store.ts`), how it lists (`face.ts`), what + * it draws (`FilesBody.tsx`, `FilesTitle.tsx`), what it says (`locales.ts`), + * and this module, which only wires them together. + */ +import type { ShortcutCommandId } from "@deepseek-ai/dsh-client-shortcuts/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-client-ui-renderer/client"; +import type {} from "@deepseek-ai/dsh-client-ui-session/client"; +import type {} from "@deepseek-ai/dsh-client-ui-sidebar-right/client"; +import { FILES_ID, filesDefinition } from "./definition.tsx"; +import { createList, createWatch, filesFace } from "./face.ts"; +import { FilesBody } from "./FilesBody.tsx"; +import { FilesTitle } from "./FilesTitle.tsx"; +import { en, zh } from "./locales.ts"; +import { createFilesStore } from "./store.ts"; + +export type { SidebarFilesKey } from "./locales.ts"; +export type { DirLevel, FilesState, FilesTabState, LevelState } from "./store.ts"; +export type { FilesInjected, ListWorkspaceDirectory, WorkspaceFilesListRemote } from "./face.ts"; +export type { FilesBodyProps } from "./FilesBody.tsx"; + +declare module "@deepseek-ai/dsh-client-ui-slots" { + interface SlotMap { + /** Workspace directory actions after the file tree's reload control. */ + "sidebar.right.tab.files.actions": { + kind: "list"; + scope: "session"; + owner: { + /** Absolute directory path displayed by the file tree. */ + readonly absolutePath: string; + }; + }; + } +} + +/** This package's copy namespace. */ +const NS = "sidebarFiles"; + +/** + * Required browser services: the tab registry, the keyed seat, the Remote + * carrier and its namespace, and copy. + */ +export const inject = [ + "slots", + "locale", + "sidebarRightTabs", + "sidebarRight", + "remote", + "remote.workspaceFiles", +]; + +/** + * Client plugin body: register the type, its dictionaries, its body, and its chip title. + * @param ctx - client root context carrying the registry, the slots, and the Remote face. + */ +export function apply(ctx: ClientContext): void { + const t = ctx.locale.bind(NS); + ctx.inject(["shortcuts"], (ctx) => { + ctx.effect( + () => + ctx.shortcuts.register({ + id: "workspace.files" as ShortcutCommandId, + label: () => t("guide.title"), + aliases: ["workspace files", "files"], + defaults: { + "desktop:macos": { code: "KeyP", modifiers: ["primary"] }, + "desktop:windows": { code: "KeyP", modifiers: ["primary"] }, + "desktop:linux": { code: "KeyP", modifiers: ["primary"] }, + "web:macos": { code: "KeyP", modifiers: ["primary", "alt"] }, + "web:windows": { code: "KeyP", modifiers: ["primary", "alt"] }, + }, + // Each tab plugin owns its command's availability, localized refusal, and tab kind. + /* jscpd:ignore-start */ + regions: ["page", "editable", "terminal"], + modals: [], + resolve: ({ target: element }) => { + const target = ctx.sidebarRight.commandTarget(element); + if (target === undefined) return { status: "blocked", reason: t("shortcut.noSession") }; + return { + status: "handled", + run: () => { + ctx.sidebarRight.openTabFromTarget("files", target); + }, + }; + }, + /* jscpd:ignore-end */ + }), + "ui-sidebar-files: shortcut", + ); + }); + ctx.effect( + () => ctx.sidebarRightTabs.register(filesDefinition(t)), + "ui-sidebar-files: files type", + ); + ctx.effect(() => ctx.locale.register(NS, { zh, en }), "ui-sidebar-files: dictionaries"); + + const store = createFilesStore(); + const inject = filesFace(createList(ctx.remote), createWatch(ctx.remote)); + ctx.effect( + () => + ctx.slots.inject("sidebar.right.pane.tab", () => + ctx.slots.register( + { + name: "sidebar.right.pane.tab", + key: FILES_ID, + locale: NS, + store, + inject, + children: { "sidebar.right.tab.files.actions": { kind: "list", scope: "session" } }, + }, + FilesBody, + ), + ), + "ui-sidebar-files: files tab body", + ); + ctx.effect( + () => + ctx.slots.inject("sidebar.right.pane.tab.title", () => + ctx.slots.register({ name: "sidebar.right.pane.tab.title", key: FILES_ID }, FilesTitle), + ), + "ui-sidebar-files: files tab title", + ); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-files/src/client/locales.ts b/apps/web-ui/packages/client/ui-sidebar-files/src/client/locales.ts new file mode 100644 index 000000000..30ffc013c --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-files/src/client/locales.ts @@ -0,0 +1,65 @@ +/** + * `sidebarFiles` namespace dictionaries, and the namespace's declaration. + * + * The failure lines name what the tree could not list, one code each, because a + * directory that is gone, one outside the workspace, and a path that is not a + * directory each suggest a different next step. + * + * The namespace merge lives with its key set so that any module naming + * `TranslateNS<'sidebarFiles'>` or `PropsLocale<'sidebarFiles'>` needs only this + * file, whichever entry a program loads first. + */ +import type {} from "@deepseek-ai/dsh-client-ui-slots"; + +declare module "@deepseek-ai/dsh-client-ui-slots" { + interface LocaleNamespaceMap { + /** File-tree type name, guide entry, row states, and failure lines. */ + sidebarFiles: SidebarFilesKey; + } +} + +/** Simplified Chinese dictionary and key-set source of truth. */ +export const zh = { + "shortcut.noSession": "请先选择会话", + "type.label": "文件", + "guide.title": "工作区文件", + "guide.description": "浏览会话工作区的文件", + loading: "正在读取…", + empty: "空目录", + truncated: "条目太多,只显示了一部分。", + noWorkspace: "这个会话没有工作区目录。", + reload: "重新读取", + autoRefresh: "自动刷新", + "autoRefresh.enable": "开启自动刷新", + "autoRefresh.disable": "关闭自动刷新", + "entry.other": "这不是文件或目录,没法打开。", + "error.notFound": "这个目录不在了。可能已被移动或删除。", + "error.outsideWorkspace": "这个目录在工作区之外,侧栏不会读取它。", + "error.notDirectory": "这不是一个目录。", + "error.unavailable": "读取失败:{message}", +} satisfies Record; + +/** Files dictionary key union. */ +export type SidebarFilesKey = keyof typeof zh; + +/** English dictionary, checked against the Chinese key set. */ +export const en = { + "shortcut.noSession": "Select a session first", + "type.label": "Files", + "guide.title": "Workspace files", + "guide.description": "Browse files in this session's workspace", + loading: "Reading…", + empty: "Empty directory", + truncated: "Too many entries, showing only some of them.", + noWorkspace: "This session has no workspace directory.", + reload: "Reload", + autoRefresh: "Auto refresh", + "autoRefresh.enable": "Enable auto refresh", + "autoRefresh.disable": "Disable auto refresh", + "entry.other": "Not a file or a directory, so it cannot be opened.", + "error.notFound": "That directory is gone. It may have been moved or deleted.", + "error.outsideWorkspace": + "That directory is outside the workspace, so the sidebar will not read it.", + "error.notDirectory": "That is not a directory.", + "error.unavailable": "Read failed: {message}", +} satisfies Record; diff --git a/apps/web-ui/packages/client/ui-sidebar-files/src/client/store.ts b/apps/web-ui/packages/client/ui-sidebar-files/src/client/store.ts new file mode 100644 index 000000000..3a4d3883f --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-files/src/client/store.ts @@ -0,0 +1,204 @@ +/** + * The file tree's view state: which directories are expanded, what each + * loaded level contains, and where the body is scrolled to. + * + * The tree is not one resource. A directory listing per level, expanded lazily, + * is state the type owns — so it lives in a Slot-standard exclusive store + * (one instance per session), bucketed by tab id because two tabs of this kind + * in one session expand independently. + * + * Writers run between `start` and `forget`: the owner's `signal` is what ends a + * bucket's life, and the face stops dispatching once it aborts. + */ +import { defineStore, type EngineStoreHandle } from "@deepseek-ai/dsh-client-store"; +import type { RemoteFailure } from "@deepseek-ai/dsh-api-remotes/client"; +import type { TabId } from "@deepseek-ai/dsh-client-ui-dockkit"; +import type { WorkspaceDirectoryEntry } from "@deepseek-ai/dsh-api-workspace-files/types"; + +/** + * One directory's contents, as one expanded level of the tree. + * + * The endpoint's listing also names the directory as a workspace-relative path; + * the tree keys every level by absolute path instead, so the adapter drops it. + */ +export interface DirLevel { + /** The directory's entries, in the endpoint's order. */ + readonly entries: readonly WorkspaceDirectoryEntry[]; + /** The listing hit the endpoint's entry cap, so entries are missing. */ + readonly truncated: boolean; +} + +/** What one directory level is doing right now. */ +export type LevelState = + | { readonly kind: "loading" } + | { readonly kind: "ready"; readonly level: DirLevel; readonly failure?: RemoteFailure } + | { readonly kind: "failed"; readonly failure: RemoteFailure }; + +/** + * One tab's tree: its root, the levels it has asked for, and what is open. + * + * Every path here is absolute: the root is the session's working directory as + * the Host reports it, and a child is the parent joined with the entry name. + */ +export interface FilesTabState { + autoRefresh: boolean; + /** Absolute path of the workspace root this tree is rooted at. */ + root: string; + /** Level state by absolute directory path; a path absent here was never asked for. */ + levels: Record; + /** Expanded absolute directory paths, root included. */ + expanded: string[]; + /** The body's scroll offset in px, so a remounted tree comes back where the reader was. */ + scrollTop: number; +} + +/** Every tab's tree, keyed by tab id. */ +export interface FilesState { + byTab: Record; +} + +/** + * One tab's bucket, which every writer after `start` relies on: the face only + * dispatches while the record's signal is live, and `forget` runs on its abort. + * @param state - the draft. + * @param tabId - the tab being written. + * @returns the tab's tree. + */ +function bucket(state: FilesState, tabId: TabId): FilesTabState { + const tree = state.byTab[tabId]; + if (tree === undefined) throw new Error(`ui-sidebar-files: no tree for tab "${tabId}"`); + return tree; +} + +/** The tree store's write set; every action names the tab it writes. */ +type FilesActions = { + autoRefresh: (draft: FilesState, tabId: TabId, enabled: boolean) => void; + start: (draft: FilesState, tabId: TabId, root: string) => void; + loading: (draft: FilesState, tabId: TabId, path: string) => void; + loaded: (draft: FilesState, tabId: TabId, path: string, level: DirLevel) => void; + failed: (draft: FilesState, tabId: TabId, path: string, failure: RemoteFailure) => void; + toggled: (draft: FilesState, tabId: TabId, path: string) => void; + scrolled: (draft: FilesState, tabId: TabId, scrollTop: number) => void; + reset: (draft: FilesState, tabId: TabId) => void; + forget: (draft: FilesState, tabId: TabId) => void; +}; + +/** + * Declare the file tree's store. + * + * A factory rather than a shared handle: the registration declares it as an + * exclusive store, so the framework mints one instance per session. + * @returns the store handle to declare on the registration. + */ +export function createFilesStore(): EngineStoreHandle { + return defineStore({ + init: (): FilesState => ({ byTab: {} }), + actions: { + autoRefresh: (d, tabId: TabId, enabled: boolean) => { + bucket(d, tabId).autoRefresh = enabled; + }, + /** + * Seed one tab's tree at its workspace root, with the root expanded. + * @param d - draft state. + * @param tabId - the tab being drawn. + * @param root - absolute path of the workspace root. + */ + start: (d, tabId: TabId, root: string) => { + d.byTab[tabId] = { root, levels: {}, expanded: [root], scrollTop: 0, autoRefresh: true }; + }, + /** + * Mark one directory as being listed. + * @param d - draft state. + * @param tabId - the tab being drawn. + * @param path - absolute directory path. + */ + loading: (d, tabId: TabId, path: string) => { + const state = bucket(d, tabId); + if (state.levels[path]?.kind !== "ready") state.levels[path] = { kind: "loading" }; + }, + /** + * Record one directory's contents. + * @param d - draft state. + * @param tabId - the tab being drawn. + * @param path - absolute directory path. + * @param level - the listing to show under it. + */ + loaded: (d, tabId: TabId, path: string, level: DirLevel) => { + const state = bucket(d, tabId); + const previous = state.levels[path]; + if (previous?.kind === "ready") { + const directories = new Set( + level.entries.filter((entry) => entry.type === "directory").map((entry) => entry.name), + ); + for (const entry of previous.level.entries) { + if (entry.type !== "directory" || directories.has(entry.name)) continue; + const removed = `${path.replace(/[/\\]+$/, "")}/${entry.name}`; + state.expanded = state.expanded.filter( + (value) => value !== removed && !value.startsWith(`${removed}/`), + ); + state.levels = Object.fromEntries( + Object.entries(state.levels).filter( + ([key]) => key !== removed && !key.startsWith(`${removed}/`), + ), + ); + } + } + state.levels[path] = { kind: "ready", level }; + }, + /** + * Record why one directory could not be listed. + * @param d - draft state. + * @param tabId - the tab being drawn. + * @param path - absolute directory path. + * @param failure - the settled Remote failure. + */ + failed: (d, tabId: TabId, path: string, failure: RemoteFailure) => { + const level = bucket(d, tabId).levels[path]; + bucket(d, tabId).levels[path] = + level?.kind === "ready" ? { ...level, failure } : { kind: "failed", failure }; + }, + /** + * Open a collapsed directory, or collapse an open one. + * + * A collapsed level keeps what it loaded, so reopening it draws at once. + * @param d - draft state. + * @param tabId - the tab being drawn. + * @param path - absolute directory path. + */ + toggled: (d, tabId: TabId, path: string) => { + const state = bucket(d, tabId); + const at = state.expanded.indexOf(path); + if (at >= 0) state.expanded.splice(at, 1); + else state.expanded.push(path); + }, + /** + * Record where one tab's body is scrolled to. + * @param d - draft state. + * @param tabId - the tab being drawn. + * @param scrollTop - the body's scroll offset, in px. + */ + scrolled: (d, tabId: TabId, scrollTop: number) => { + bucket(d, tabId).scrollTop = scrollTop; + }, + /** + * Drop every loaded level, keeping what is expanded. + * + * This is the reload gesture's first half: the expanded set says which + * levels to fetch again. + * @param d - draft state. + * @param tabId - the tab being drawn. + */ + reset: (d, tabId: TabId) => { + bucket(d, tabId).levels = {}; + }, + /** + * Forget one tab's tree, for a tab record that is gone. + * @param d - draft state. + * @param tabId - the tab that went away. + */ + forget: (d, tabId: TabId) => { + d.byTab = Object.fromEntries(Object.entries(d.byTab).filter(([id]) => id !== tabId)); + }, + }, + }); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-files/src/css-modules.d.ts b/apps/web-ui/packages/client/ui-sidebar-files/src/css-modules.d.ts new file mode 100644 index 000000000..b284bf80b --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-files/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module "*.module.css" { + const classes: Record; + export default classes; +} + +declare module "*.css"; diff --git a/apps/web-ui/packages/client/ui-sidebar-files/src/index.ts b/apps/web-ui/packages/client/ui-sidebar-files/src/index.ts new file mode 100644 index 000000000..154525a7c --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-files/src/index.ts @@ -0,0 +1,4 @@ +/** Pure host half; the whole tab type lives in the browser export. */ + +/** Host plugin body: the file tree contributes nothing to the host tree. */ +export function apply(): void {} diff --git a/apps/web-ui/packages/client/ui-sidebar-files/tsdown.config.ts b/apps/web-ui/packages/client/ui-sidebar-files/tsdown.config.ts new file mode 100644 index 000000000..169f0a6fa --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-files/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from "../tsdown.client.ts"; + +export default clientBundle("@deepseek-ai/dsh-client-ui-sidebar-files", ["lib/types/index.js"]); diff --git a/apps/web-ui/packages/client/ui-sidebar-right/package.json b/apps/web-ui/packages/client/ui-sidebar-right/package.json new file mode 100644 index 000000000..116d21177 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-right/package.json @@ -0,0 +1,42 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-sidebar-right", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./client": { + "types": "./src/client/index.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dependencies": { + "react": "^18.2.0", + "@deepseek-ai/dsh-client-ui-dockkit": "*", + "@deepseek-ai/dsh-client-store": "*", + "zod": "^4.4.3", + "picomatch": "^4.0.4", + "@deepseek-ai/dsh-util-crypto": "*", + "react-dom": "^18.2.0", + "@deepseek-ai/dsh-client-ui-primitives": "*" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-session-controller", + "@deepseek-ai/dsh-client-resources", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-layout", + "@deepseek-ai/dsh-client-ui-session", + "@deepseek-ai/dsh-client-shortcuts" + ], + "platform": "web" + } + } +} diff --git a/apps/web-ui/packages/client/ui-sidebar-right/src/client/contract/params.ts b/apps/web-ui/packages/client/ui-sidebar-right/src/client/contract/params.ts new file mode 100644 index 000000000..ad297266f --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-right/src/client/contract/params.ts @@ -0,0 +1,51 @@ +/** + * Navigation parameters, typed by what is being opened. + * + * Two declaration-merged maps. `SidebarRightResourceParamsMap` is keyed by + * resource type — the segment after `dsh-resource://` — and filled by the + * package that owns that type (the `file` provider adds `file: { line?: number }`); + * `SidebarRightTabParamsMap` is keyed by tab kind and filled by a page type that + * takes parameters (the Browser page adds `browser: { url?: string }`). Values are JSON-shaped by + * convention; nothing validates them at run time, because caller and body meet + * at a typed same-process boundary. A body narrows `navigation.params` by the + * scheme and type of `navigation.address`. + * + * The unions below are spelled as indexed accesses over a record rather than as + * `A | B`: in a program where no package has augmented a map, both sides of such + * a union resolve to `undefined`, which the type-aware lint reads as a duplicated + * constituent. The indexed access names the same union without the pair. + */ + +/** The values of a record, as one union. */ +type ValuesOf = T[keyof T]; + +/** Resource type → the parameters a resource of that type accepts. Merge-extensible. */ +export interface SidebarRightResourceParamsMap {} + +/** What `openResource` accepts as `params`: a declared resource type's parameters, or `undefined` for none. */ +export type SidebarRightResourceParams = ValuesOf<{ + declared: SidebarRightResourceParamsMap[keyof SidebarRightResourceParamsMap]; + none: undefined; +}>; + +/** Tab kind → the parameters a page of that kind accepts. Merge-extensible. */ +export interface SidebarRightTabParamsMap {} + +/** + * What `openTab(kind)` accepts as `params` for one kind: its declared parameters, + * or `undefined` for none; a kind that declares none accepts only `undefined`. + */ +export type SidebarRightTabParamsFor = + (K extends keyof SidebarRightTabParamsMap ? SidebarRightTabParamsMap[K] : never) | undefined; + +/** Every declared page kind's parameters, or `undefined` for none. */ +export type SidebarRightTabParams = ValuesOf<{ + declared: SidebarRightTabParamsMap[keyof SidebarRightTabParamsMap]; + none: undefined; +}>; + +/** What a body may find in `navigation.params`: either map's values, or `undefined` when the opener gave none. */ +export type SidebarRightNavigationParams = ValuesOf<{ + resource: SidebarRightResourceParams; + tab: SidebarRightTabParams; +}>; diff --git a/apps/web-ui/packages/client/ui-sidebar-right/src/client/contract/seed.ts b/apps/web-ui/packages/client/ui-sidebar-right/src/client/contract/seed.ts new file mode 100644 index 000000000..244c6ca6e --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-right/src/client/contract/seed.ts @@ -0,0 +1,48 @@ +/** + * The guide tab's identity, the page-address scheme, and default page selection. + * + * These live in the contract because two sides need them and neither may read + * the other: the store seeds new panes from the registry, and the guide + * domain registers the type under the same kind. + * + * The docking kit treats `kind` as opaque, so these strings mean something only + * here and in the registry. Both types go through the same two stages any other + * type would use — the guide is not special in the machinery, only in being + * always available. + */ +import type { SidebarRightTabRegistry } from "../tab-registry.ts"; + +/** One pane's initial page, resolved from the current registered guide entries. */ +export interface SidebarRightSeed { + readonly kind: string; + readonly title: string; +} + +/** + * Resolve the default page from the registered entry count. + * @param tabs - current tab registry. + * @returns the sole entry, or the guide when there are zero or multiple entries. + */ +export function defaultSeed(tabs: SidebarRightTabRegistry): SidebarRightSeed { + const [only, ...others] = tabs.guide(); + const single = only !== undefined && others.length === 0; + const kind = single ? only.kind : GUIDE_KIND; + const definition = tabs.get(kind); + if (definition === undefined) + throw new Error(`sidebarRight: default tab kind "${kind}" is not registered`); + return { kind, title: definition.title(pageAddress(kind)) }; +} + +/** The guide tab's kind. */ +export const GUIDE_KIND = "guide"; + +/** + * The address a page tab is recorded under: `sidebar://`. The scheme is + * this package's bookkeeping for `openTab`, spelled here and nowhere else; a + * caller names the kind and never sees or composes the address. + * @param kind - the page type's kind. + * @returns the page's address. + */ +export function pageAddress(kind: string): string { + return `sidebar://${kind}`; +} diff --git a/apps/web-ui/packages/client/ui-sidebar-right/src/client/contract/slots.ts b/apps/web-ui/packages/client/ui-sidebar-right/src/client/contract/slots.ts new file mode 100644 index 000000000..de6ca2104 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-right/src/client/contract/slots.ts @@ -0,0 +1,237 @@ +/** + * The right Sidebar's extension seats and its copy namespace. + * + * Four seats, each with a different reason to exist: + * - `sidebar.right.pane.tab` is how a tab type contributes a body. It is keyed by + * the type definition's `id`, so adding a type is a registration, never an + * edit here. The key domain stays the open string space because a tab type may + * ship from outside this repository. + * - `sidebar.right.pane.tab.title` is the same dispatch for what the chip shows + * as the tab's title. Registering is optional: without an entry the chip shows + * the title the registry captured when the tab opened. + * - `sidebar.right.tab.guide` lets a product replace the guide tab's contents + * without replacing the tab. It is a chain because the replacement decides for + * itself whether it applies, and the shipped guide is the owner's fallback. + * - `sidebar.right.tab.menu.item` extends a tab's actions menu. The kit owns the + * actions that are gestures on the layout itself; this seat is for actions that + * mean something about the tab's content. + * + * TYPE HOME RATIONALE: this package declares these slots at runtime, and anything + * registering into one already depends on it for the declaration. The types + * therefore live with their declarer. + */ +import type { ShortcutCatalogEntry } from "@deepseek-ai/dsh-client-shortcuts/client"; +import type {} from "@deepseek-ai/dsh-client-ui-slots"; +import type { RightbarOwnerProps } from "@deepseek-ai/dsh-client-ui-layout/client"; +// The locale plugin's own merge carries the shared `common` vocabulary that the +// lookup chain consults after this namespace misses. +import type {} from "@deepseek-ai/dsh-client-locale/client"; +import type { PaneId, TabId, TabRecord } from "@deepseek-ai/dsh-client-ui-dockkit"; +import type { SlotHookFactory } from "@deepseek-ai/dsh-client-ui-slots"; +import type { TabHookContext } from "../tab-info.ts"; +import type { SidebarRightKey } from "../locales.ts"; +import type { + SidebarRightNavigationParams, + SidebarRightResourceParams, + SidebarRightTabParamsFor, +} from "./params.ts"; + +declare module "@deepseek-ai/dsh-client-ui-slots" { + interface LocaleNamespaceMap { + /** Right-Sidebar chrome, docking-kit vocabulary, and guide copy. */ + sidebarRight: SidebarRightKey; + } + + interface SlotMap { + /** Session content selected by the root-scoped right Sidebar controller. */ + "rightbar.session": { + kind: "single"; + scope: "session"; + owner: RightbarOwnerProps & { + readonly active: boolean; + /** @param tabId - retained body. @param signal - tab occurrence lifetime. @returns releases the View-owned hold. */ + readonly retainTab: (tabId: TabId, signal: AbortSignal) => () => void; + }; + }; + /** + * One tab's body, dispatched with the `id` of the type in force for + * `tab.kind`. A tab type registers here under its definition's `id` and + * receives every tab of that kind, in every pane, docked or floating. A kind + * with no type in force renders the owner's "nothing can view this" notice + * rather than an empty pane. + */ + "sidebar.right.pane.tab": { + kind: "keyed"; + scope: "session"; + hookContext: TabHookContext; + inject: SidebarRightTabInjected; + }; + /** + * A tab's title as its chip (and a floating panel's header) shows it, + * dispatched with the same key and information hook as the body. A type with a + * live title — a terminal named after its shell, a chat after its first + * line — registers here and reads its own store; one without registers + * nothing and the chip shows the registry's `title(address)` text captured + * at open time. + */ + "sidebar.right.pane.tab.title": { + kind: "keyed"; + scope: "session"; + hookContext: TabHookContext; + inject: SidebarRightTabInjected; + }; + /** + * The guide tab's body. Selectors run in chain order and the first + * non-declining entry replaces the shipped guide entirely; with no entry, or + * with every entry declining, the shipped guide renders. + */ + "sidebar.right.tab.guide": { + kind: "chain"; + scope: "session"; + hookContext: UseSidebarRightTabInfo; + inject: { + hooks: { tabInfo: SlotHookFactory<"sidebar.right.tab.guide", UseSidebarRightTabInfo> }; + }; + }; + /** One provider's guide card, with the standard card as the owner's fallback. */ + "sidebar.right.tab.guide.entry": { + kind: "keyed"; + scope: "session"; + owner: SidebarRightGuideEntryOwnerProps; + hookContext: UseSidebarRightTabInfo; + inject: { + hooks: { + tabInfo: SlotHookFactory<"sidebar.right.tab.guide.entry", UseSidebarRightTabInfo>; + }; + }; + }; + /** + * Extra items at the end of one tab's actions menu, in registration order. + * Entries decide their own visibility from the tab they are given. Without a + * registrant the menu shows only the kit's own layout actions. + */ + "sidebar.right.tab.menu.item": { + kind: "list"; + scope: "session"; + owner: SidebarRightTabMenuOwnerProps; + }; + } +} + +/** Where a tab was last navigated to: what the `open` that created or revealed it carried. */ +export interface SidebarRightTabNavigation { + /** The address opened; for a tab record this is its `contentId`. */ + readonly address: string; + /** The opener's `params` (see `contract/params.ts`); `undefined` when it gave none. */ + readonly params: SidebarRightNavigationParams; + /** + * Incremented on every navigation to this tab, whether or not `params` + * changed, so a body can act on "navigated again" alone. `0` for a record + * nobody opened by address: a seeded guide, or a tab restored by undo. + */ + readonly revision: number; +} + +/** Where an open from a tab lands. Without any of these it lands in the pane holding the tab at call time. */ +export interface SidebarRightTabPlacement { + /** Land a new tab in this pane instead. */ + readonly paneId?: PaneId; + /** Prefer a new pane for new content; use the target pane when splitting is unavailable. */ + readonly preferNewPane?: boolean; + /** Resource tabs reveal existing content by default; `false` permits duplicates. Pages always deduplicate within the target pane. */ + readonly revealIfOpened?: boolean; + /** `true` opens in this tab's place — its pane and strip slot — and closes this tab in the same step. */ + readonly replaceTab?: boolean; +} + +/** Page-owned operations registered for a mounted tab body. */ +export interface SidebarRightTabCommands { + /** Refresh this page through its existing resource owner. */ + readonly refresh?: () => void; +} + +/** The actions one tab may take on itself; each acts on the session the tab is in. */ +export interface SidebarRightTabActions { + /** + * Bind page operations until the body unmounts or the tab lifetime ends. + * @param commands - operations supported by this page; omitted operations are unavailable. + * @returns disposer that cannot remove a newer body's registration. + */ + bindCommands(commands: SidebarRightTabCommands): () => void; + /** + * Open a resource from this tab; see `ISidebarRight.openResource`. + * @param address - a `dsh-resource://` address. + * @param options - placement and the resource's navigation parameters. + */ + openResource( + address: string, + options?: SidebarRightTabPlacement & { readonly params?: SidebarRightResourceParams }, + ): void; + /** + * Open a page type from this tab; see `ISidebarRight.openTab`. + * @param kind - the page type's kind. + * @param options - placement and that kind's navigation parameters. + */ + openTab( + kind: K, + options?: SidebarRightTabPlacement & { readonly params?: SidebarRightTabParamsFor }, + ): void; + /** Close this tab. */ + close(): void; +} + +/** Live information shared by a tab's body, title, and guide replacement. */ +export interface SidebarRightTabInfo { + readonly sidebar: { + readonly expanded: boolean; + /** Presentation selected by manual mode or viewport width; preserved while collapsed. */ + readonly fullscreen: boolean; + }; + readonly panel: { readonly id: PaneId }; + readonly tab: TabRecord & { + /** Effective refresh binding for this page's controls. */ + readonly refreshShortcut?: ShortcutCatalogEntry | undefined; + /** + * Only the foreground Session is visible. Docked bodies require expansion and selection; + * expanded titles include inactive tabs. Floats survive collapse. + */ + readonly visible: boolean; + readonly navigation: SidebarRightTabNavigation; + /** Aborted only when the record disappears or this plugin unloads, not on hide or session switch. */ + readonly signal: AbortSignal; + readonly actions: SidebarRightTabActions; + }; +} + +/** + * Read current tab information through the slot framework's subscriptions. + * @returns the sidebar presentation, containing pane, and live tab record. + */ +export type UseSidebarRightTabInfo = () => SidebarRightTabInfo; + +/** The slot-owned hook shared by every tab body and title registration. */ +export interface SidebarRightTabInjected { + hooks: { tabInfo: SlotHookFactory<"sidebar.right.pane.tab", UseSidebarRightTabInfo> }; +} + +/** Owner share of one tab-menu item occurrence. */ +export interface SidebarRightTabMenuOwnerProps { + /** The tab whose menu is open. */ + tab: TabRecord; + /** + * Dismiss the menu. + * + * An item that acts MUST call this: the menu is the kit's, and it closes on + * its own actions only. An item that leaves it open leaves a menu floating + * over content the action may have just replaced. + */ + dismiss: () => void; +} + +/** Resolved guide copy and entry identity supplied to a provider's card renderer. */ +export interface SidebarRightGuideEntryOwnerProps { + readonly entryId: string; + readonly kind: string; + readonly title: string; + readonly description?: string; +} diff --git a/apps/web-ui/packages/client/ui-sidebar-right/src/client/focus.ts b/apps/web-ui/packages/client/ui-sidebar-right/src/client/focus.ts new file mode 100644 index 000000000..1bcf39ec3 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-right/src/client/focus.ts @@ -0,0 +1,189 @@ +/** Live DOM ownership for docked and floating sidebar pages. */ +import type { LayoutState, PaneId, TabId } from "@deepseek-ai/dsh-client-ui-dockkit"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { TabOccurrence } from "./tab-domain.ts"; + +/** A page captured from the currently mounted Session and its current occurrence. */ +export interface SidebarRightTarget { + readonly sessionId: SessionId; + readonly paneId: PaneId; + readonly host: "dock" | "float"; + readonly tabId: TabId | undefined; + readonly occurrence: TabOccurrence | undefined; + readonly navigationRevision: number | undefined; +} + +/** + * Read pane and tab identity from live owner markup, including an embedding iframe. + * @param element - focused or pointer-activated element in the product document. + * @param sessionId - Session currently drawn by the sidebar. + * @param layout - current committed layout for that Session. + * @param occurrence - current occurrence lookup for a committed tab. + * @returns the captured page, or undefined for stale, hidden, or outside elements. + */ +export function sidebarTargetFromElement( + element: Element | null, + sessionId: SessionId, + layout: LayoutState, + occurrence: (tabId: TabId) => TabOccurrence, +): SidebarRightTarget | undefined { + if (element === null || !element.isConnected) return undefined; + const owner = element.closest("[data-sidebar-right-session]"); + if (owner?.dataset.sidebarRightSession !== sessionId) return undefined; + const container = element.closest("[data-dockkit-pane], [data-dockkit-float]"); + const paneId = (container?.dataset.dockkitPane ?? container?.dataset.dockkitFloat) as + PaneId | undefined; + if (paneId === undefined || container?.closest('[hidden], [aria-hidden="true"]') !== null) + return undefined; + const pane = layout.nodes[paneId]; + if (pane?.kind !== "pane" || (pane.host === "dock" && !layout.expanded)) return undefined; + const tabElement = element.closest("[data-dockkit-tab], [data-sidebar-right-tab]"); + const tabId = (tabElement?.dataset.dockkitTab ?? + tabElement?.dataset.sidebarRightTab ?? + pane.activeTabId) as TabId | undefined; + if (tabId !== undefined && (!pane.tabs.includes(tabId) || layout.tabs[tabId] === undefined)) + return undefined; + const held = tabId === undefined ? undefined : occurrence(tabId); + const marker = + element.closest("[data-sidebar-right-occurrence]") ?? + tabElement?.querySelector("[data-sidebar-right-occurrence]"); + if (marker !== null && marker !== undefined && marker.dataset.sidebarRightOccurrence !== held?.id) + return undefined; + return { + sessionId, + paneId, + host: pane.host, + tabId, + occurrence: held, + navigationRevision: held?.navigation.getSnapshot().revision, + }; +} + +/** + * Find a visible pane of one Session, preferring the requested pane over the active fallback. + * @param document - product document containing docked and floating panes. + * @param sessionId - Session whose panes may receive focus. + * @param paneId - preferred pane before an operation changed the layout. + * @returns a surviving visible pane, or undefined when the Session has none. + */ +export function visibleSidebarPane( + document: Document, + sessionId: SessionId, + paneId: PaneId, +): HTMLElement | undefined { + const panes = [ + ...document.querySelectorAll("[data-dockkit-pane], [data-dockkit-float]"), + ].filter((pane) => { + const owner = pane.closest("[data-sidebar-right-session]"); + return ( + owner?.dataset.sidebarRightSession === sessionId && + pane.closest('[hidden], [aria-hidden="true"]') === null && + (pane.hasAttribute("data-dockkit-float") || owner.hasAttribute("data-sidebar-right-open")) + ); + }); + return ( + panes.find((pane) => (pane.dataset.dockkitPane ?? pane.dataset.dockkitFloat) === paneId) ?? + panes.find( + (pane) => + pane.hasAttribute("data-dockkit-pane-active") || + pane.hasAttribute("data-dockkit-float-active"), + ) ?? + panes[0] + ); +} + +/** + * Observe pane focus and retain it when its DOM node is replaced, preserving text selections. + * @param document - product document whose sidebar owns the listener lifetime. + * @returns disposer for every document/window listener. + */ +export function observeSidebarFocus(document: Document): () => void { + let active = true; + let focused: + | { element: Element; sessionId: SessionId; paneId: PaneId; occurrence: string | undefined } + | undefined; + const removal = new MutationObserver(() => { + if (focused === undefined || focused.element.isConnected) return; + const previous = focused; + focused = undefined; + removal.disconnect(); + if (document.activeElement !== document.body) return; + const moved = + previous.occurrence === undefined + ? undefined + : [...document.querySelectorAll("[data-sidebar-right-occurrence]")] + .find((marker) => marker.dataset.sidebarRightOccurrence === previous.occurrence) + ?.closest("[data-dockkit-pane], [data-dockkit-float]"); + const paneId = (moved?.dataset.dockkitPane ?? moved?.dataset.dockkitFloat) as + PaneId | undefined; + visibleSidebarPane(document, previous.sessionId, paneId ?? previous.paneId)?.focus({ + preventScroll: true, + }); + }); + const capture = (): void => { + removal.disconnect(); + focused = undefined; + const element = document.activeElement; + const pane = element?.closest("[data-dockkit-pane], [data-dockkit-float]"); + if (element === null || pane === undefined || pane === null) return; + const owner = pane.closest("[data-sidebar-right-session]"); + const sessionId = owner?.dataset.sidebarRightSession as SessionId | undefined; + const paneId = (pane.dataset.dockkitPane ?? pane.dataset.dockkitFloat) as PaneId | undefined; + if (owner === null || sessionId === undefined || paneId === undefined) return; + const tab = + element.closest("[data-dockkit-tab]") ?? + pane.querySelector( + '[role="tab"][aria-selected="true"], [data-dockkit-float-title]', + ); + const marker = + element.closest("[data-sidebar-right-occurrence]") ?? + tab?.querySelector("[data-sidebar-right-occurrence]"); + focused = { element, sessionId, paneId, occurrence: marker?.dataset.sidebarRightOccurrence }; + removal.observe(owner, { childList: true, subtree: true }); + // Observe removal of the whole Session seat as well as its tab bodies. + if (owner.parentElement !== null) removal.observe(owner.parentElement, { childList: true }); + }; + const focusout = (event: FocusEvent): void => { + if (event.relatedTarget === null && focused?.element.isConnected) { + focused = undefined; + removal.disconnect(); + } + }; + const pointer = (event: PointerEvent): void => { + const element = event.composedPath().find((value) => value instanceof Element); + if (!(element instanceof Element)) return; + const pane = element.closest( + "[data-sidebar-right-session] [data-dockkit-pane], [data-sidebar-right-session] [data-dockkit-float]", + ); + if (pane === null) { + focused = undefined; + removal.disconnect(); + } + const control = element.closest( + "button, input, textarea, select, a, [contenteditable], [tabindex], iframe", + ); + if (pane !== null && (control === null || control === pane)) { + pane.focus({ preventScroll: true }); + } + }; + const blur = (): void => { + queueMicrotask(() => { + if (active) capture(); + }); + }; + document.addEventListener("focusin", capture); + document.addEventListener("focusout", focusout); + document.addEventListener("pointerdown", pointer, true); + document.defaultView?.addEventListener("blur", blur); + document.defaultView?.addEventListener("focus", capture); + capture(); + return () => { + active = false; + removal.disconnect(); + document.removeEventListener("focusin", capture); + document.removeEventListener("focusout", focusout); + document.removeEventListener("pointerdown", pointer, true); + document.defaultView?.removeEventListener("blur", blur); + document.defaultView?.removeEventListener("focus", capture); + }; +} diff --git a/apps/web-ui/packages/client/ui-sidebar-right/src/client/index.ts b/apps/web-ui/packages/client/ui-sidebar-right/src/client/index.ts new file mode 100644 index 000000000..2ffc8ce74 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-right/src/client/index.ts @@ -0,0 +1,390 @@ +/** + * Browser half: fill the frame's right column with the panel, put the expand + * button in the conversation header, and own the seats a tab type registers + * into. + * + * Two seats share one session-scoped store, which the slot runtime allows + * because both are session-scoped (a handle may not span scopes). The panel seat + * in the frame draws the surface normally or fullscreen, retaining the track + * on wide viewports; the header's corner seat draws the way back in + * while the panel is hidden. The store is the layout's only source of truth; the docking + * kit's pure planners compute every change and the store records them, one + * history entry per intent. + * + * The frame is a base package and never injects this one. What it needs — + * whether the panel is shown and whether it wants a track — arrives through its + * own `ctx.layout` action face, reported by the seat that knows both facts. + * + * Tab types register in two stages: the type itself into `ctx.sidebarRightTabs`, + * its body into the keyed `sidebar.right.pane.tab` seat under the same kind. The + * guide registers through those stages unmodified, exactly as a type shipped + * from another package does — `ui-sidebar-documentpreview` is the live proof. + */ +import type {} from "@deepseek-ai/dsh-client-shortcuts/client"; +import { observeSidebarFocus } from "./focus.ts"; +import { registerSidebarShortcuts } from "./shortcuts.ts"; +import type { Context as ClientContext } from "@deepseek-ai/cordis"; +import type {} from "@deepseek-ai/dsh-client-resources/client"; +import type {} from "@deepseek-ai/dsh-client-ui-renderer/client"; +import type {} from "@deepseek-ai/dsh-client-ui-session/client"; +import type { ILayout } from "@deepseek-ai/dsh-client-ui-layout/client"; +import type {} from "@deepseek-ai/dsh-client-ui-layout/client"; +import type {} from "@deepseek-ai/dsh-client-ui-conversation/client"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type {} from "./contract/slots.ts"; +import { GuideBody, type GuideInjected } from "./tabs/guide/GuideBody.tsx"; +import { GuideTitle } from "./tabs/guide/GuideTitle.tsx"; +import { ExpandButton } from "./shell/ExpandButton.tsx"; +import { RightbarSeat, type SidebarRightInjected } from "./shell/SidebarRight.tsx"; +import { closeWithPaneFocus, openWithPaneFocus } from "./shell/close-focus.ts"; +import { RightbarRoot, type RightbarRootInjected } from "./shell/RightbarRoot.tsx"; +import { SidebarSessionViews } from "./session-views.ts"; +import { createSidebarRightController, type SidebarRightController } from "./service.ts"; +import { SidebarRightTabRegistry } from "./tab-registry.ts"; +import { createSidebarRightStore } from "./stores.ts"; +import { en, zh } from "./locales.ts"; +import { GUIDE_ID, guideDefinition } from "./tabs/guide/definition.ts"; +import { guideTabInfoFactory, tabInfoFactory } from "./tab-info.ts"; +import type { TabId } from "@deepseek-ai/dsh-client-ui-dockkit"; +import { defaultSeed } from "./contract/seed.ts"; + +export type { SidebarRightTarget } from "./focus.ts"; +export type { + RightbarSeatProps, + SidebarRightInjected, + SidebarRightPresentation, +} from "./shell/SidebarRight.tsx"; +export type { GuideBodyProps, GuideInjected } from "./tabs/guide/GuideBody.tsx"; +export type { ExpandButtonProps } from "./shell/ExpandButton.tsx"; +export type { SidebarRightState, SurfaceState } from "./stores.ts"; +export type { + ISidebarRight, + SidebarRightOpenResourceOptions, + SidebarRightOpenTabOptions, + SidebarRightPlacement, + SidebarRightCloseHandler, + SurfaceActions, +} from "./service.ts"; +export type { + SidebarRightGuideBox, + SidebarRightGuideEntry, + SidebarRightTabClaim, + SidebarRightTabDefinition, + SidebarRightTabPriority, +} from "./tab-registry.ts"; +export type { + SidebarRightTabInfo, + SidebarRightTabInjected, + UseSidebarRightTabInfo, + SidebarRightTabActions, + SidebarRightTabMenuOwnerProps, + SidebarRightTabNavigation, + SidebarRightTabPlacement, + SidebarRightGuideEntryOwnerProps, +} from "./contract/slots.ts"; +export type { + SidebarRightNavigationParams, + SidebarRightResourceParams, + SidebarRightResourceParamsMap, + SidebarRightTabParams, + SidebarRightTabParamsFor, + SidebarRightTabParamsMap, +} from "./contract/params.ts"; +// The layout ids and rectangle the navigation face takes, so a caller needs no import from the kit. +export type { FloatRect, PaneId, TabId, TabRecord } from "@deepseek-ai/dsh-client-ui-dockkit"; +export type { + PinResource, + SidebarRightNavigator, + TabOccurrence, + SidebarRightOccurrenceId, +} from "./tab-domain.ts"; +export type { SidebarRightKey } from "./locales.ts"; +export type { OpenContentIntent } from "./stores.ts"; +export type { SidebarRightOpenTab } from "./tab-inventory.ts"; + +/** This package's copy namespace. */ +const NS = "sidebarRight"; + +/** Required browser services: the slot registry, the frame's panel actions, copy, and the resource model. */ +export const inject = [ + "slots", + "layout", + "locale", + "resources", + "sessions", + "uiSession", + "shortcuts", +]; + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Right-Sidebar navigation and presentation face. */ + sidebarRight: SidebarRightController; + /** Right-Sidebar tab-type registry (stage one of a tab type's registration). */ + sidebarRightTabs: SidebarRightTabRegistry; + } +} + +/** + * Client plugin body: provide the registry and the navigation face, register the + * panel seat and the rail seat over one store with their extension children, and + * register the guide type through the same public two-stage path any other type + * uses. + * @param ctx - client root context carrying the slot registry, the frame's face, and copy. + */ +export function apply(ctx: ClientContext): void { + // The registry and the face it backs are built here, at apply's top level, + // and never inside an effect. A registry other packages register into cannot + // have an effect-internal scope as its host: `register()` adds an effect to + // this fiber, and doing that from another plugin's apply while the effect is + // still the active scope stalls browser boot with no error at all. The + // template this follows (ui-conversation's definition registry) is built at + // its own apply top level for the same reason. + const t = ctx.locale.bind(NS); + const tabs = new SidebarRightTabRegistry(ctx); + const views = new SidebarSessionViews(ctx.sessions); + ctx.effect(() => { + const current = ctx.uiSession.adapter.current; + const sync = (): void => { + views.select(current.getSnapshot().key as SessionId | undefined); + }; + const unsubscribe = current.subscribe(sync); + sync(); + return () => { + unsubscribe(); + views.dispose(); + }; + }, "ui-sidebar-right: retained Session views"); + const layout: ILayout = ctx.layout; + // The automatic fullscreen rule as the seats last rendered it: the frame + // hands them its width as owner props, and every seat reports the same rule. + let autoFullscreen = false; + const { controller, adopt, forget, show, measure } = createSidebarRightController( + tabs, + (address, signal) => { + ctx.resources.pin(address, signal); + }, + { + autoFullscreen: () => autoFullscreen, + openWithFocus: (sessionId, open) => { + openWithPaneFocus(document, sessionId, open); + }, + closeWithFocus: (sessionId, paneId, close) => { + closeWithPaneFocus(document, sessionId, paneId, close); + }, + }, + ); + // The Session on screen: the selected one while the Conversation fills the + // main column. Both sources notify before React renders their change, so the + // service names the arriving Session before any component of that commit + // reads it, and its seat mints the Session's store in the same render. + ctx.effect(() => { + const sync = (): void => { + const selected = views.source.getSnapshot().find((view) => view.selected); + show(layout.panelInfo.getSnapshot().activePanelId === null ? selected?.sessionId : undefined); + }; + const unsubscribeViews = views.source.subscribe(sync); + const unsubscribePanel = layout.panelInfo.subscribe(sync); + sync(); + return () => { + unsubscribeViews(); + unsubscribePanel(); + show(undefined); + }; + }, "ui-sidebar-right: on-screen Session"); + const disposeRegistry = ctx.reflect.provide("sidebarRightTabs", tabs); + const disposeService = ctx.reflect.provide("sidebarRight", controller); + // Registered first, so it tears down last: the faces outlive every seat and + // type that reaches for them. provide()'s disposer settles asynchronously; + // teardown is synchronous fire-and-forget, matching ui-layout's root entry. + // Unloading aborts every tab occurrence, which releases every pin. + ctx.effect( + () => () => { + controller.tabDomain.dispose(); + void disposeService(); + void disposeRegistry(); + }, + "ui-sidebar-right: service faces", + ); + + ctx.effect(() => ctx.locale.register(NS, { zh, en }), "ui-sidebar-right: dictionaries"); + ctx.effect( + () => + registerSidebarShortcuts(ctx.shortcuts, controller, t, () => { + void ctx.shortcuts.closeWindow().catch((error: unknown) => { + console.error("Window close failed", error); + }); + }), + "ui-sidebar-right: shortcuts", + ); + if (typeof document !== "undefined") + ctx.effect(() => observeSidebarFocus(document), "ui-sidebar-right: focus"); + + ctx.effect(() => { + const handle = createSidebarRightStore(() => defaultSeed(tabs)); + // Each Session Context generation owns one Store. Background tab actions + // use the latest adoption for that Session. + const adoptions = new Map void>(); + const store: typeof handle = { + ...handle, + create: (scopeKey) => { + const instance = handle.create(scopeKey); + if (scopeKey !== undefined) { + const sessionId = scopeKey as SessionId; + adoptions.get(sessionId)?.(); + adoptions.set(sessionId, adopt(sessionId, instance)); + } + return { + ...instance, + clearPersisted() { + instance.clearPersisted(); + if (scopeKey !== undefined) forget(scopeKey as SessionId); + }, + }; + }, + }; + const injected: Omit< + SidebarRightInjected, + "keyedHooks" | "occurrence" | "closeTab" | "measureRoom" + > = { + syncPresentation({ shown, track, fullscreen }) { + if (shown) layout.openRightbar(track, fullscreen); + else layout.closeRightbar(); + }, + reportAutoFullscreen: (value) => { + autoFullscreen = value; + }, + splitPane: (paneId) => { + controller.split(paneId); + }, + toggleFullscreen: () => { + const target = controller.commandTarget(); + if (target !== undefined) controller.toggleFullscreen(target); + }, + openTab: (kind, options) => { + controller.openTab(kind, options); + }, + hooks: { + shortcuts: ctx.shortcuts.catalog, + tabTypes: { + subscribe: (listener) => tabs.subscribe(listener), + getSnapshot: () => tabs.entries(), + }, + }, + }; + + const disposeTypes = [tabs.register(guideDefinition(t))]; + const disposeSeat = ctx.slots.inject("rightbar", function* () { + yield ctx.slots.register( + { + name: "rightbar", + children: { "rightbar.session": { kind: "single", scope: "session" } }, + inject: (): RightbarRootInjected => ({ + hooks: { views: views.source }, + mountView: (reference) => views.mount(reference), + }), + }, + RightbarRoot, + ); + yield ctx.slots.register( + { + name: "rightbar.session", + locale: NS, + children: { + "sidebar.right.pane.tab": { + kind: "keyed", + scope: "session", + inject: { hooks: { tabInfo: tabInfoFactory } }, + }, + "sidebar.right.pane.tab.title": { + kind: "keyed", + scope: "session", + inject: { hooks: { tabInfo: tabInfoFactory } }, + }, + "sidebar.right.tab.menu.item": { kind: "list", scope: "session" }, + }, + store, + inject: (sessionId): SidebarRightInjected => ({ + ...injected, + measureRoom: (canSplitPane) => { + measure(sessionId, canSplitPane); + }, + closeTab: (tabId) => { + try { + controller.closeIn(sessionId, tabId); + } catch (error) { + console.error("Sidebar tab close failed:", error); + } + }, + keyedHooks: { + tabNavigation: (key) => + controller.tabDomain.occurrence(sessionId, { id: key as TabId }).navigation, + }, + occurrence: (tab) => controller.tabDomain.occurrence(sessionId, tab), + }), + }, + RightbarSeat, + ); + }); + // The expand button shares the panel's store: it only needs to know whether + // the panel is expanded, and to ask for it to be. The header's corner seat + // is its own place, past the utilities, so showing and hiding it moves + // nothing else in the row. + const disposeExpand = ctx.slots.inject("conversation.session.header.corner", () => + ctx.slots.register( + { + name: "conversation.session.header.corner", + locale: NS, + store, + inject: () => ({ hooks: { shortcuts: ctx.shortcuts.catalog } }), + }, + ExpandButton, + ), + ); + // Stage two for the guide: it declares the chain child it hosts and reads + // the registry's entry boxes, which an ordinary type has no reason to do. + const guideInjected: GuideInjected = { + hooks: { + shortcuts: ctx.shortcuts.catalog, + guideEntries: { + subscribe: (listener) => tabs.subscribe(listener), + getSnapshot: () => tabs.guide(), + }, + }, + }; + const disposeGuide = ctx.slots.inject("sidebar.right.pane.tab", () => + ctx.slots.register( + { + name: "sidebar.right.pane.tab", + key: GUIDE_ID, + children: { + "sidebar.right.tab.guide.entry": { + kind: "keyed", + scope: "session", + inject: { hooks: { tabInfo: guideTabInfoFactory } }, + }, + "sidebar.right.tab.guide": { + kind: "chain", + scope: "session", + inject: { hooks: { tabInfo: guideTabInfoFactory } }, + }, + }, + inject: () => guideInjected, + }, + GuideBody, + ), + ); + const disposeGuideTitle = ctx.slots.inject("sidebar.right.pane.tab.title", () => + ctx.slots.register({ name: "sidebar.right.pane.tab.title", key: GUIDE_ID }, GuideTitle), + ); + return () => { + disposeGuideTitle(); + disposeGuide(); + disposeExpand(); + disposeSeat(); + for (const dispose of disposeTypes.reverse()) dispose(); + for (const release of adoptions.values()) release(); + adoptions.clear(); + }; + }, "ui-sidebar-right: seats and shipped tab type"); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-right/src/client/labels.ts b/apps/web-ui/packages/client/ui-sidebar-right/src/client/labels.ts new file mode 100644 index 000000000..e68e393bc --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-right/src/client/labels.ts @@ -0,0 +1,48 @@ +/** + * The docking kit's vocabulary, in the product's language. + * + * The kit renders no string of its own, so every word a user reads inside it is + * handed over from here. This is a projection of the dictionary, not a second + * home for copy: the strings live in `locales.ts`. + */ +import type { ShortcutCatalogEntry } from "@deepseek-ai/dsh-client-shortcuts/client"; +import type { DockLabels } from "@deepseek-ai/dsh-client-ui-dockkit"; +import type { TranslateNS } from "@deepseek-ai/dsh-client-locale/client"; + +/** + * Project the dictionary into the kit's label contract. + * + * Called during render, so a language change reaches the kit with the next one — + * the kit caches no copy to invalidate. + * @param t - namespace-bound translate. + * @param split - effective split binding, when available. + * @param close - effective page-close binding, when available. + * @returns every string the kit renders. + */ +export function dockLabels( + t: TranslateNS<"sidebarRight">, + split?: ShortcutCatalogEntry, + close?: ShortcutCatalogEntry, +): DockLabels { + return { + emptyPane: t("dock.emptyPane"), + splitPane: t("dock.splitPane"), + splitPaneShortcut: split?.aria, + splitPaneKeys: split?.keys, + splitPaneDisabled: t("dock.splitPaneDisabled"), + splitPaneNarrow: t("dock.splitPaneNarrow"), + closeTab: t("dock.closeTab"), + closeTabShortcut: close?.aria, + closeTabKeys: close?.keys, + addTab: t("dock.addTab"), + dockFloat: t("dock.dockFloat"), + closeFloat: t("dock.closeFloat"), + dropZone: { + center: t("dock.drop.center"), + left: t("dock.drop.left"), + right: t("dock.drop.right"), + top: t("dock.drop.top"), + bottom: t("dock.drop.bottom"), + }, + }; +} diff --git a/apps/web-ui/packages/client/ui-sidebar-right/src/client/locales.ts b/apps/web-ui/packages/client/ui-sidebar-right/src/client/locales.ts new file mode 100644 index 000000000..185ed6914 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-right/src/client/locales.ts @@ -0,0 +1,86 @@ +/** + * `sidebarRight` namespace dictionaries. + * + * Everything a user reads in this column is here, including the strings handed + * to the docking kit — the kit renders no copy of its own, so its whole + * vocabulary is this package's to own and translate. + */ + +/** Simplified Chinese dictionary and key-set source of truth. */ +export const zh = { + "command.close": "关闭当前页面/窗口", + "command.refresh": "刷新当前页面", + "command.noRefresh": "当前页面不支持刷新", + "command.toggle": "展开/收起右侧栏", + "command.fullscreen": "面板全屏/退出全屏", + "command.noSession": "请先选择会话", + "command.noFocus": "请先聚焦右侧面板", + "command.stale": "页面已切换,请重新聚焦", + "command.collapsed": "请先展开右侧栏", + "command.float": "浮动面板不支持此操作", + "command.empty": "请先打开页面", + "command.budget": "已达两格上限", + "command.width": "栏宽不足,拖宽侧边栏后再分栏", + "chrome.expand": "打开侧边栏", + "chrome.expandAria": "打开右侧边栏", + "chrome.collapse": "收起侧边栏", + "chrome.collapseAria": "收起右侧边栏", + "chrome.toFullscreen": "全屏", + "chrome.exitFullscreen": "退出全屏", + "dock.emptyPane": "空面板", + "dock.splitPane": "分栏", + "dock.splitPaneDisabled": "已达两格上限", + "dock.splitPaneNarrow": "栏宽不足,拖宽侧边栏后再分栏", + "dock.closeTab": "关闭", + "dock.addTab": "新标签页", + "dock.dockFloat": "收回到侧边栏", + "dock.closeFloat": "关闭", + "dock.drop.center": "移到这里", + "dock.drop.left": "左分栏", + "dock.drop.right": "右分栏", + "dock.drop.top": "上分栏", + "dock.drop.bottom": "下分栏", + "tab.guide.title": "开始", + "tab.unavailable": "这类内容还没有可用的查看方式。", +} satisfies Record; + +/** Right-Sidebar dictionary key union. */ +export type SidebarRightKey = keyof typeof zh; + +/** English dictionary, checked against the Chinese key set. */ +export const en = { + "command.close": "Close current page or window", + "command.refresh": "Refresh current page", + "command.noRefresh": "This page cannot be refreshed", + "command.toggle": "Toggle right sidebar", + "command.fullscreen": "Toggle panel fullscreen", + "command.noSession": "Select a session first", + "command.noFocus": "Focus a right sidebar pane first", + "command.stale": "The page changed; focus it again", + "command.collapsed": "Expand the right sidebar first", + "command.float": "This action is unavailable in a floating panel", + "command.empty": "Open a page first", + "command.budget": "Two panes is the limit", + "command.width": "Not enough width to split, widen the sidebar", + "chrome.expand": "Open sidebar", + "chrome.expandAria": "Open right sidebar", + "chrome.collapse": "Collapse sidebar", + "chrome.collapseAria": "Collapse right sidebar", + "chrome.toFullscreen": "Fullscreen", + "chrome.exitFullscreen": "Exit fullscreen", + "dock.emptyPane": "Empty pane", + "dock.splitPane": "Split", + "dock.splitPaneDisabled": "Two panes is the limit", + "dock.splitPaneNarrow": "Not enough width to split, widen the sidebar", + "dock.closeTab": "Close", + "dock.addTab": "New tab", + "dock.dockFloat": "Send back to the sidebar", + "dock.closeFloat": "Close", + "dock.drop.center": "Move here", + "dock.drop.left": "Add left split", + "dock.drop.right": "Add right split", + "dock.drop.top": "Add top split", + "dock.drop.bottom": "Add bottom split", + "tab.guide.title": "Start", + "tab.unavailable": "Nothing here can view this kind of content yet.", +} satisfies Record; diff --git a/apps/web-ui/packages/client/ui-sidebar-right/src/client/persistence.ts b/apps/web-ui/packages/client/ui-sidebar-right/src/client/persistence.ts new file mode 100644 index 000000000..fcd161201 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-right/src/client/persistence.ts @@ -0,0 +1,169 @@ +/** Validated current-layout snapshots; undo history belongs to the live window. */ +import { z } from "zod"; +import { EMPTY_HISTORY, type LayoutState } from "@deepseek-ai/dsh-client-ui-dockkit"; +import type { SurfaceState } from "./stores.ts"; + +/** Persistence namespace shared by scoped stores and startup discovery. */ +export const sidebarPersistence = "dsh.sidebar-right.v1"; + +const paneId = z.string().regex(/^(?:pane|float)[1-9][0-9]*$/u); +const splitId = z.string().regex(/^split[1-9][0-9]*$/u); +const tabId = z.string().regex(/^tab[1-9][0-9]*$/u); +const nodeId = z.union([paneId, splitId]); +const rectangle = z.object({ + x: z.number(), + y: z.number(), + width: z.number().positive(), + height: z.number().positive(), +}); +const node = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("pane"), + id: paneId, + host: z.enum(["dock", "float"]), + tabs: z.array(tabId), + activeTabId: tabId.optional(), + rect: rectangle.optional(), + }), + z.object({ + kind: z.literal("split"), + id: splitId, + axis: z.enum(["row", "column"]), + children: z.array(nodeId).min(2), + sizes: z.array(z.number().positive()).min(2), + }), +]); +const layout = z.object({ + nodes: z.record(nodeId, node), + tabs: z.record( + tabId, + z.object({ + id: tabId, + kind: z.string().min(1), + contentId: z.string().min(1), + title: z.string(), + }), + ), + rootId: nodeId, + floats: z.array(paneId), + activePaneId: paneId, + expanded: z.boolean(), + mode: z.enum(["push", "fullscreen"]), +}); +const surface = z.object({ layout, minted: z.int().nonnegative() }); +const envelope = z.object({ bySession: z.record(z.string(), z.unknown()) }); + +function validateReferences(value: z.infer): void { + const { layout, minted } = value; + const reject: () => never = () => { + throw new Error("Invalid saved sidebar layout references"); + }; + const visited = new Set(); + const usedTabs = new Set(); + const pending = [ + { id: layout.rootId, host: "dock" }, + ...layout.floats.map((id) => ({ id, host: "float" })), + ]; + for (const [id, entry] of [...Object.entries(layout.nodes), ...Object.entries(layout.tabs)]) { + if (entry.id !== id || Number(id.replace(/^[a-z]+/u, "")) > minted) reject(); + } + for (const { id, host } of pending) { + const entry = layout.nodes[id]; + if (visited.has(id) || entry === undefined) reject(); + visited.add(id); + if (entry.kind === "split") { + if ( + host !== "dock" || + id !== layout.rootId || + entry.axis !== "row" || + entry.children.length !== 2 || + entry.children.length !== entry.sizes.length || + Math.abs(entry.sizes.reduce((sum, size) => sum + size, 0) - 1) > 1e-9 + ) + reject(); + pending.push(...entry.children.map((id) => ({ id, host }))); + } else { + if ( + entry.host !== host || + (host === "float" + ? entry.rect === undefined || entry.tabs.length !== 1 + : entry.rect !== undefined) + ) + reject(); + if ( + entry.tabs.length === 0 + ? entry.activeTabId !== undefined + : !entry.tabs.includes(entry.activeTabId ?? "") + ) + reject(); + for (const tab of entry.tabs) { + if (usedTabs.has(tab) || layout.tabs[tab] === undefined) reject(); + usedTabs.add(tab); + } + } + } + if ( + visited.size !== Object.keys(layout.nodes).length || + usedTabs.size !== Object.keys(layout.tabs).length || + layout.nodes[layout.activePaneId]?.kind !== "pane" + ) + reject(); +} + +/** + * Restore one validated Session layout with a fresh in-window undo history. + * @param sessionId - storage scope. + * @returns the saved surface, or undefined when absent, inaccessible or invalid. + */ +export function readSidebarLayout(sessionId: string): SurfaceState | undefined { + if (typeof localStorage === "undefined") return undefined; + let raw: string | null; + try { + raw = localStorage.getItem(`${sidebarPersistence}.${sessionId}`); + } catch (_storageUnavailable) { + return undefined; + } + if (raw === null) return undefined; + try { + const saved = envelope.parse(JSON.parse(raw)).bySession[sessionId]; + if (saved === undefined) return undefined; + const parsed = surface.parse(saved); + validateReferences(parsed); + return { + layout: parsed.layout as unknown as LayoutState, + minted: parsed.minted, + history: EMPTY_HISTORY, + }; + } catch (_invalidLayout) { + clearSidebarLayout(sessionId); + return undefined; + } +} + +/** + * Persist current layout and identity allocation without retaining undo entries. + * @param sessionId - storage scope. + * @param surface - current in-memory surface. + */ +export function writeSidebarLayout(sessionId: string, surface: SurfaceState): void { + if (typeof localStorage === "undefined") return; + const saved = { bySession: { [sessionId]: { layout: surface.layout, minted: surface.minted } } }; + try { + localStorage.setItem(`${sidebarPersistence}.${sessionId}`, JSON.stringify(saved)); + } catch (error) { + console.error("Sidebar layout persistence failed:", error); + } +} + +/** + * Remove only one Session's persisted layout. + * @param sessionId - storage scope to discard. + */ +export function clearSidebarLayout(sessionId: string): void { + if (typeof localStorage === "undefined") return; + try { + localStorage.removeItem(`${sidebarPersistence}.${sessionId}`); + } catch (_storageUnavailable) { + /* The invalid layout is still excluded from this window. */ + } +} diff --git a/apps/web-ui/packages/client/ui-sidebar-right/src/client/service.ts b/apps/web-ui/packages/client/ui-sidebar-right/src/client/service.ts new file mode 100644 index 000000000..8c051eac6 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-right/src/client/service.ts @@ -0,0 +1,845 @@ +/** + * `ctx.sidebarRight`: what other plugins may ask of this column. + * + * The surface is per session and its state lives in that session's store + * instance, which the slot runtime mints per session and a root service cannot + * reach on its own. The plugin adopts each session's store instance as the + * runtime mints it, so the controller reaches any session's store by id and + * syncs the Tab domain from that store's commits, on screen or not. + * + * The plugin also names the Session on screen — the selected Session while the + * Conversation fills the main column — from the selection and the main panel, + * before React renders either change, and publishes it as `mounted`. Every + * command on the public face acts on that Session through its adopted store; a + * command with no Session on screen, or with one whose store the runtime has not + * minted, has nothing to act on and fails loudly rather than writing into a + * surface nobody is drawing. The seats never publish which Session they draw: + * they report only what they render with, the room their docking kit measured + * and the automatic fullscreen rule of their frame width. + * + * A tab's own actions (`tabActions`) aim at the session the tab is in, not at + * the on-screen one: they run through that session's adopted store, so a callback + * fired after the user switched sessions still lands where its tab is, and they + * do nothing for a session whose store was never minted. + * + * `openResource` and `openTab` are the navigation controller, and every way + * into the column is a call to one of them: the conversation's file links, a + * tool row's line reference, the strip's add control, a guide entry box, a file + * tree's rows. A resource is claimed through the registry by address; a page is + * named by kind and recorded at the address this package composes for it. Both + * hand the store one settled intent and record the navigation in the Tab + * domain. Placement is the caller's option, never a type's property. + * + * The registration adopts Session stores, names the on-screen Session, and + * forwards the seats' reports; callers use the service's navigation methods. + */ +import { sidebarTargetFromElement, type SidebarRightTarget } from "./focus.ts"; +import { randomUUID } from "@deepseek-ai/dsh-util-crypto"; +import { createSnapshotStore, type ObservableSnapshot } from "@deepseek-ai/dsh-client-store"; +import type { FloatRect, PaneId, TabId, TabRecord } from "@deepseek-ai/dsh-client-ui-dockkit"; +import { + activeDockPaneId, + canSplit, + findContentTab, + findPaneContentTab, + dockPaneIds, + findTabPane, + getPane, +} from "@deepseek-ai/dsh-client-ui-dockkit"; +import type { BoundActions } from "@deepseek-ai/dsh-client-ui-slots"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { + SidebarRightNavigationParams, + SidebarRightResourceParams, + SidebarRightTabParamsFor, +} from "./contract/params.ts"; +import { pageAddress } from "./contract/seed.ts"; +import type { SidebarRightTabClaim, SidebarRightTabRegistry } from "./tab-registry.ts"; +import { canCloseTab, type SidebarRightState, type SurfaceState } from "./stores.ts"; +import type { createSidebarRightStore } from "./stores.ts"; +import { TabDomain, type PinResource } from "./tab-domain.ts"; +import { SidebarTabInventory } from "./tab-inventory.ts"; + +/** The seat's bound action set. */ +export type SurfaceActions = BoundActions>; + +/** One session's store instance as the slot runtime minted it: its actions and its observable snapshot. */ +export interface SidebarRightSurfaceStore { + readonly actions: SurfaceActions; + getSnapshot(): SidebarRightState; + subscribe(listener: () => void): () => void; +} + +/** One adoption of a session's store; the token a release compares against. */ +interface Adoption { + readonly store: SidebarRightSurfaceStore; + readonly unsubscribe: () => void; +} + +/** The room rule's verdict for a docked pane, as a seat's docking kit last measured it: whether two working halves would fit. */ +type RoomRule = (paneId: PaneId) => boolean; + +/** Plugin-owned Session state the controller reads; the plugin writes it through the factory's callbacks. */ +interface SidebarRightSessions { + /** Each Session's latest adopted store. */ + readonly adopted: ReadonlyMap; + /** Each Session's room rule as its seat last reported it; a Session without one has room in every pane. */ + readonly rooms: ReadonlyMap; + /** The Session the plugin names as on screen. */ + readonly onScreen: ObservableSnapshot; + /** Open tab metadata across saved and adopted Sessions. */ + readonly openTabs: SidebarTabInventory["source"]; +} + +/** + * Create the public controller and the plugin-private Session callbacks. + * Adoption reconciles restored records before any seat renders, then follows commits. + * @param tabs - registered tab types. + * @param pin - resource retention for an occurrence's lifetime. + * @param host - the viewport rule and focus continuity the commands use. + * @returns the controller; store adoption and scope removal; naming the on-screen Session; and recording a seat's room rule. + */ +export function createSidebarRightController( + tabs: SidebarRightTabRegistry, + pin: PinResource, + host: SidebarRightHost, +): { + controller: SidebarRightController; + adopt: (sessionId: SessionId, store: SidebarRightSurfaceStore) => () => void; + forget: (sessionId: SessionId) => void; + show: (sessionId: SessionId | undefined) => void; + measure: (sessionId: SessionId, canSplitPane: RoomRule) => void; +} { + const adopted = new Map(); + const rooms = new Map(); + const onScreen = createSnapshotStore(undefined); + const inventory = new SidebarTabInventory(); + const controller = new SidebarRightController(tabs, pin, host, { + adopted, + rooms, + onScreen, + openTabs: inventory.source, + }); + return { + controller, + forget: (sessionId) => { + inventory.remove(sessionId); + rooms.delete(sessionId); + }, + show: (sessionId) => { + if (onScreen.getSnapshot() !== sessionId) onScreen.set(sessionId); + }, + measure: (sessionId, canSplitPane) => { + rooms.set(sessionId, canSplitPane); + }, + adopt(sessionId, store) { + adopted.get(sessionId)?.unsubscribe(); + const sync = (): void => { + const surface = store.getSnapshot().bySession[sessionId]; + inventory.update(sessionId, Object.values(surface?.layout.tabs ?? {})); + if (surface !== undefined) controller.tabDomain.sync(sessionId, surface.layout); + }; + const adoption: Adoption = { store, unsubscribe: store.subscribe(sync) }; + adopted.set(sessionId, adoption); + sync(); + return () => { + adoption.unsubscribe(); + if (adopted.get(sessionId) === adoption) adopted.delete(sessionId); + }; + }, + }; +} + +/** What the controller needs from the page around it: the viewport rule and focus continuity. */ +export interface SidebarRightHost { + /** + * Whether the viewport is narrow enough that an expanded panel is presented fullscreen. + * @returns the rule's verdict for the frame width the seats last rendered at. + */ + readonly autoFullscreen: () => boolean; + /** + * Commit a page operation and focus the pane it selects. + * @param sessionId - the Session whose page is opening. + * @param open - the synchronous operation; returns the selected pane, or `undefined` when unchanged. + */ + readonly openWithFocus: (sessionId: SessionId, open: () => PaneId | undefined) => void; + /** + * Commit a keyboard/menu close and retain focus on a surviving visible pane. + * @param sessionId - the Session whose page is closing. + * @param paneId - the pane whose page is closing. + * @param close - the synchronous cleanup and removal. + */ + readonly closeWithFocus: (sessionId: SessionId, paneId: PaneId, close: () => void) => void; +} + +/** Where an open lands; every field is optional and the defaults are the common case. */ +export interface SidebarRightPlacement { + /** Land a new tab in this pane instead of the active docked one. */ + readonly paneId?: PaneId; + /** Prefer a new pane for new content; use the target pane when splitting is unavailable. */ + readonly preferNewPane?: boolean; + /** Take this tab's place — its pane and its strip slot — and close it in the same step. */ + readonly replaceTab?: TabId; + /** + * Resource tabs reveal an existing (kind, contentId) by default; `false` + * permits duplicates. Pages always deduplicate within the target pane. + */ + readonly revealIfOpened?: boolean; +} + +/** How a caller wants a resource opened. */ +export interface SidebarRightOpenResourceOptions extends SidebarRightPlacement { + /** Name the opening type instead of letting the registry rank claims; its `canOpen` still applies. */ + readonly kind?: string; + /** The resource's navigation parameters, typed by resource type; delivered as `navigation.params`. */ + readonly params?: SidebarRightResourceParams; +} + +/** How a caller wants a page type opened. */ +export interface SidebarRightOpenTabOptions< + K extends string = string, +> extends SidebarRightPlacement { + /** That kind's navigation parameters, typed by kind; delivered as `navigation.params`. */ + readonly params?: SidebarRightTabParamsFor; +} + +/** The scheme every resource address carries; anything else is not a resource this face opens. */ +const RESOURCE_SCHEME = "dsh-resource://"; + +/** Synchronous close/replacement hook; resource owners retain any background cleanup. */ +export type SidebarRightCloseHandler = (sessionId: SessionId, tab: TabRecord) => void; + +/** The outward right-Sidebar face (`ctx.sidebarRight`). */ +export interface ISidebarRight { + /** + * The Session on screen, or `undefined` while none is (a global panel fills + * the main column, or no Session is selected). It changes before React + * renders the selection or panel change that causes it, so every component + * rendered in that commit reads the arriving Session, and the commands act on + * it from that commit's effects: its seat mints its store in the same render. + * Moves only when the on-screen Session changes; the Session's own store + * commits are silent. + */ + readonly mounted: ObservableSnapshot; + /** + * Open a resource: claim it, place it, reveal the column, record the navigation. + * + * Without `options.kind` the registry ranks the types whose globs and + * `canOpen` accept the address and the best band wins; with it, that kind's + * type in force opens the address (its `canOpen` still applies). An address + * outside `dsh-resource://`, or one no type will open, is a wiring mistake, + * not a user error, so it throws. The column expands in the same step, + * because content the user cannot see is not opened. + * @param address - a `dsh-resource:///…` address. + * @param options - placement, the opening type, and navigation parameters. + */ + openResource(address: string, options?: SidebarRightOpenResourceOptions): void; + /** + * Open a page type by kind: the type in force for it, at the address this + * package records pages under. A kind nothing registered throws. + * @param kind - the page type's kind. + * @param options - placement and that kind's navigation parameters. + */ + openTab(kind: K, options?: SidebarRightOpenTabOptions): void; + /** + * Close one tab of the on-screen Session; the sole docked guide remains open. + * @param tabId - the tab to close. + */ + close(tabId: TabId): void; + /** + * The active tab of the active pane. + * @returns the record, or `undefined` with no Session on screen. + */ + active(): TabRecord | undefined; + /** + * Whether the column is currently showing its panel. + * @returns `true` while expanded; `false` while collapsed to its rail. + */ + isExpanded(): boolean; + /** Collapse the column, or expand it and focus its active dock pane. Recorded in the sequence. */ + toggleExpanded(): void; + /** + * Focus a tab and the pane holding it, raising a floating one. Recorded. + * @param tabId - the tab; one that does not exist is left alone. + */ + focus(tabId: TabId): void; + /** + * Split a docked pane to its right and seed the new pane, under the same + * pane budget and room rule as the strip's split control. Recorded when it + * splits. + * @param paneId - the pane to split; defaults to the active docked pane. + * @returns the new pane's id, or `undefined` when nothing was split: the pane + * is missing, floating, or empty, the budget is spent, or two halves would not fit. + */ + split(paneId?: PaneId): PaneId | undefined; + /** + * Take a docked tab out into a floating panel. Recorded. + * @param tabId - the tab; one that is missing or already floating is left alone. + * @param rect - the panel's rectangle; defaults to the cascade from the last panel. + */ + float(tabId: TabId, rect?: FloatRect): void; + /** + * Return a floating panel's tab to the active docked pane. Recorded. + * @param paneId - the floating pane; one that is missing or docked is left alone. + */ + dock(paneId: PaneId): void; +} + +/** Cross-plugin right-Sidebar face (ctx.sidebarRight). */ +export class SidebarRightController implements ISidebarRight { + /** Open tab metadata across saved and adopted Sessions, independent of visible seats. */ + readonly openTabs: SidebarTabInventory["source"]; + /** The on-screen Session; see {@link ISidebarRight.mounted}. */ + readonly mounted: ObservableSnapshot; + private readonly adopted: ReadonlyMap; + private readonly rooms: ReadonlyMap; + private readonly closeHandlers = new Map(); + + /** + * Register resource cleanup before explicit removal. Failure preserves the tab. + * @param kind - tab kind owned by the registering plugin. + * @param handler - saves any background cleanup before returning and allowing removal. + * @returns an effect-scoped unregister callback. + */ + registerCloseHandler(kind: string, handler: SidebarRightCloseHandler): () => void { + if (this.closeHandlers.has(kind)) + throw new Error(`sidebarRight: close handler already registered for ${kind}`); + this.closeHandlers.set(kind, handler); + return () => { + if (this.closeHandlers.get(kind) === handler) this.closeHandlers.delete(kind); + }; + } + + /** + * The Tab domain this controller navigates into; synced from each adopted + * store's commits, read by the seat for each body's owner share. + */ + readonly tabDomain: TabDomain; + + /** + * @param tabs - the tab-type registry consulted to claim an address. + * @param pin - `ctx.resources.pin`, which the Tab domain holds addresses with. + * @param host - the viewport rule and focus continuity the commands use. + * @param sessions - plugin-owned adopted stores, room rules, on-screen Session, and open tab metadata. + */ + constructor( + private readonly tabs: SidebarRightTabRegistry, + pin: PinResource, + private readonly host: SidebarRightHost, + sessions: SidebarRightSessions, + ) { + this.adopted = sessions.adopted; + this.rooms = sessions.rooms; + this.mounted = sessions.onScreen; + this.openTabs = sessions.openTabs; + this.tabDomain = new TabDomain(this, pin); + } + + /** + * Read the committed tabs of a Session so providers can restore their content. + * @param sessionId - Session whose layout has been adopted. + * @returns its open records, or an empty list before adoption. + */ + tabsIn(sessionId: SessionId): readonly TabRecord[] { + return Object.values( + this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId]?.layout.tabs ?? {}, + ); + } + + /** + * Open a resource: claim it, place it, reveal the column, record the navigation. + * @param address - a `dsh-resource:///…` address. + * @param options - placement, the opening type, and navigation parameters. + */ + openResource(address: string, options: SidebarRightOpenResourceOptions = {}): void { + const { sessionId, actions } = this.require(); + this.placeResource(sessionId, actions, address, options); + } + + /** + * Open a page type by kind at the address this package records pages under. + * @param kind - the page type's kind. + * @param options - placement and that kind's navigation parameters. + */ + openTab(kind: K, options: SidebarRightOpenTabOptions = {}): void { + const { sessionId, actions } = this.require(); + this.placeTab(sessionId, actions, kind, options); + } + + /** + * Open a resource in one session, for a tab's own action; nothing happens + * for a session whose store was never adopted or whose adoption was released. + * Not part of `ISidebarRight`: the Tab domain's path. + * @param sessionId - the session the acting tab is in. + * @param address - a `dsh-resource:///…` address. + * @param options - placement, the opening type, and navigation parameters. + */ + openResourceIn( + sessionId: SessionId, + address: string, + options: SidebarRightOpenResourceOptions = {}, + ): void { + const actions = this.actionsFor(sessionId); + if (actions !== undefined) this.placeResource(sessionId, actions, address, options); + } + + /** + * Open a page type in one session, for a tab's own action; nothing happens + * for a session whose store was never adopted or whose adoption was released. + * Not part of `ISidebarRight`: the Tab domain's path. + * @param sessionId - the session the acting tab is in. + * @param kind - the page type's kind. + * @param options - placement and that kind's navigation parameters. + */ + openTabIn( + sessionId: SessionId, + kind: K, + options: SidebarRightOpenTabOptions = {}, + ): void { + const actions = this.actionsFor(sessionId); + if (actions !== undefined) this.placeTab(sessionId, actions, kind, options); + } + + /** + * Close a tab of one session, preserving the sole docked guide; nothing happens + * for a session whose store was never adopted or whose adoption was released. + * Not part of `ISidebarRight`: the Tab domain's path. + * @param sessionId - the session the tab is in. + * @param tabId - the tab to close. + */ + closeIn(sessionId: SessionId, tabId: TabId): void { + const actions = this.actionsFor(sessionId); + const surface = this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId]; + if (actions === undefined || surface === undefined) return; + const tab = surface.layout.tabs[tabId]; + if (tab === undefined || !canCloseTab(surface, tabId)) return; + this.removeAfterCleanup(sessionId, tab, () => { + actions.closeTab(sessionId, tabId); + }); + } + + private removeAfterCleanup(sessionId: SessionId, tab: TabRecord, commit: () => void): void { + this.closeHandlers.get(tab.kind)?.(sessionId, tab); + commit(); + } + + /** Claim a resource and place it in one session; an address outside the scheme or one no type claims throws. */ + private placeResource( + sessionId: SessionId, + actions: SurfaceActions, + address: string, + options: SidebarRightOpenResourceOptions, + ): void { + if (!address.startsWith(RESOURCE_SCHEME)) { + throw new Error(`sidebarRight: no registered tab type claims "${address}"`); + } + this.place( + sessionId, + actions, + this.tabs.claim(address, options.kind), + address, + options, + options.params, + ); + } + + /** Place a page type in one session at the address pages are recorded under; an unregistered kind throws. */ + private placeTab( + sessionId: SessionId, + actions: SurfaceActions, + kind: K, + options: SidebarRightOpenTabOptions, + ): void { + const definition = this.tabs.get(kind); + if (definition === undefined) + throw new Error(`sidebarRight: no tab type is registered as "${kind}"`); + const address = + definition.multiple === true ? `${pageAddress(kind)}/${randomUUID()}` : pageAddress(kind); + this.place( + sessionId, + actions, + { kind, contentId: address, title: definition.title(address) }, + address, + options, + options.params, + ); + } + + /** The steps both opens share: one store intent, and the navigation record for the tab it settles on. */ + private place( + sessionId: SessionId, + actions: SurfaceActions, + claim: SidebarRightTabClaim, + address: string, + placement: SidebarRightPlacement, + params: SidebarRightNavigationParams, + ): void { + const surface = this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId]; + const targetPane = + surface === undefined ? undefined : (placement.paneId ?? activeDockPaneId(surface.layout)); + const target = targetPane === undefined ? undefined : surface?.layout.nodes[targetPane]; + const preferNewPane = + placement.preferNewPane === true && + placement.replaceTab === undefined && + surface !== undefined && + target?.kind === "pane" && + target.host === "dock" && + target.tabs.length > 0 && + canSplit(surface.layout) && + dockPaneIds(surface.layout).length < 2 && + this.mounted.getSnapshot() === sessionId && + this.canSplitPane(sessionId, target.id); + const commit = (): void => { + actions.openContent( + sessionId, + { + kind: claim.kind, + contentId: claim.contentId, + title: claim.title, + ...(placement.paneId === undefined ? {} : { paneId: placement.paneId }), + ...(preferNewPane ? { preferNewPane: true } : {}), + ...(placement.replaceTab === undefined ? {} : { replaceTab: placement.replaceTab }), + ...(placement.revealIfOpened === undefined + ? {} + : { revealIfOpened: placement.revealIfOpened }), + }, + (tabId) => { + this.tabDomain.navigate(sessionId, tabId, { address, params }); + }, + ); + }; + const layout = surface?.layout; + const replaced = + placement.replaceTab === undefined ? undefined : layout?.tabs[placement.replaceTab]; + const replacementPane = + replaced === undefined || layout === undefined ? undefined : findTabPane(layout, replaced.id); + const revealed = + layout === undefined + ? undefined + : claim.contentId === pageAddress(claim.kind) + ? findPaneContentTab( + layout, + replacementPane?.host === "dock" + ? replacementPane.id + : (placement.paneId ?? activeDockPaneId(layout)), + claim.contentId, + claim.kind, + ) + : placement.revealIfOpened === false + ? undefined + : findContentTab(layout, claim.contentId, claim.kind); + if (replaced === undefined || replaced.id === revealed) { + commit(); + return; + } + this.removeAfterCleanup(sessionId, replaced, commit); + } + + /** + * Close one tab of the on-screen Session; the sole docked guide remains open. + * @param tabId - the tab to close. + */ + close(tabId: TabId): void { + this.closeIn(this.require().sessionId, tabId); + } + + /** + * The active tab of the active pane. + * @returns the record, or `undefined` without an on-screen surface. + */ + active(): TabRecord | undefined { + const layout = this.mountedSurface()?.layout; + if (layout === undefined) return undefined; + const { activeTabId } = getPane(layout, layout.activePaneId); + return Object.values(layout.tabs).find((tab) => tab.id === activeTabId); + } + + /** + * Whether the column is currently showing its panel. + * @returns `true` while expanded; `false` while collapsed or without an on-screen surface. + */ + isExpanded(): boolean { + return this.mountedSurface()?.layout.expanded ?? false; + } + + /** Collapse the column, or expand it and focus its active dock pane after rendering. */ + toggleExpanded(): void { + const { sessionId, actions } = this.require(); + this.host.openWithFocus(sessionId, () => { + actions.toggleExpanded(sessionId); + const layout = this.mountedSurface()?.layout; + return layout?.expanded ? activeDockPaneId(layout) : undefined; + }); + } + + /** + * Focus a tab and the pane holding it; a missing tab is left alone. + * @param tabId - the tab to focus. + */ + focus(tabId: TabId): void { + const { sessionId, actions } = this.require(); + if (this.mountedSurface()?.layout.tabs[tabId] === undefined) return; + actions.focusTab(sessionId, tabId); + } + + /** + * Capture the page owning current DOM focus; outside focus never uses layout history. + * @param element - explicit input target, including an embedding iframe; defaults to live document focus. + * @returns current focused page identity, or undefined outside a visible sidebar page. + */ + focusedTarget(element: Element | null = document.activeElement): SidebarRightTarget | undefined { + const screen = this.screen(); + return screen === undefined + ? undefined + : sidebarTargetFromElement(element, screen.sessionId, screen.surface.layout, (tabId) => + this.tabDomain.occurrence(screen.sessionId, { id: tabId }), + ); + } + + /** + * Choose a focused sidebar pane, or the on-screen Session's active dock pane for an outside open. + * @param element - live command input target; stale sidebar markup never falls back to another pane. + * @returns captured target, or undefined without an on-screen surface. + */ + commandTarget(element: Element | null = document.activeElement): SidebarRightTarget | undefined { + const focused = this.focusedTarget(element); + if (focused !== undefined || element?.closest("[data-sidebar-right-session]")) return focused; + const screen = this.screen(); + if (screen === undefined) return undefined; + const { + sessionId, + surface: { layout }, + } = screen; + const pane = getPane(layout, activeDockPaneId(layout)); + const tabId = pane.activeTabId; + const held = + tabId === undefined ? undefined : this.tabDomain.occurrence(sessionId, { id: tabId }); + return { + sessionId, + paneId: pane.id, + host: pane.host, + tabId, + occurrence: held, + navigationRevision: held?.navigation.getSnapshot().revision, + }; + } + + /** + * Open from a captured pane using the guide's replacement and floating placement rules. + * @param kind - registered page kind. + * @param target - captured live pane and optional page. + */ + openTabFromTarget(kind: string, target: SidebarRightTarget): void { + if (!this.isTargetCurrent(target)) return; + const { sessionId, actions } = this.require(); + this.host.openWithFocus(sessionId, () => { + const tab = + target.tabId === undefined ? undefined : this.mountedSurface()?.layout.tabs[target.tabId]; + if (target.host === "float" && tab?.kind === kind && this.tabs.get(kind)?.multiple !== true) { + actions.setExpanded(sessionId, true); + this.focus(tab.id); + } else { + this.openTab(kind, { + ...(target.host === "dock" ? { paneId: target.paneId } : {}), + ...(tab?.kind === "guide" ? { replaceTab: tab.id } : {}), + }); + } + return this.mountedSurface()?.layout.activePaneId; + }); + } + + /** + * Test explicit close eligibility without using the last selected page. + * @param target - captured focused page. + * @returns whether this current page can be removed or its sole guide pane collapsed. + */ + canCloseTarget(target: SidebarRightTarget): boolean { + return this.isTargetCurrent(target) && target.tabId !== undefined; + } + + /** + * Remove a captured page through its resource cleanup handler, or collapse the sole docked guide. + * Cleanup errors propagate and preserve the page. + * @param target - page captured while resolving the command. + * @returns closed after removal or collapse, unavailable without a page, or stale after identity changes. + */ + closeTarget(target: SidebarRightTarget): "closed" | "unavailable" | "stale" { + if (!this.isTargetCurrent(target)) return "stale"; + const surface = this.mountedSurface(); + if (surface === undefined || target.tabId === undefined) return "unavailable"; + const tabId = target.tabId; + const { sessionId, actions } = this.require(); + this.host.closeWithFocus(sessionId, target.paneId, () => { + if (canCloseTab(surface, tabId)) this.close(tabId); + else actions.setExpanded(sessionId, false); + }); + return "closed"; + } + + /** + * Check a captured target before acting, including reopened records and intervening navigation. + * @param target - identity captured while resolving the input. + * @returns whether the on-screen Session, pane, tab lifetime and navigation still match. + */ + isTargetCurrent(target: SidebarRightTarget): boolean { + if (this.mounted.getSnapshot() !== target.sessionId) return false; + const layout = this.mountedSurface()?.layout; + const pane = layout?.nodes[target.paneId]; + if (pane?.kind !== "pane" || pane.host !== target.host) return false; + if (target.tabId === undefined) return pane.activeTabId === undefined; + if (!pane.tabs.includes(target.tabId) || layout?.tabs[target.tabId] === undefined) return false; + const held = this.tabDomain.occurrence(target.sessionId, { id: target.tabId }); + return ( + held === target.occurrence && + !held.signal.aborted && + held.navigation.getSnapshot().revision === target.navigationRevision + ); + } + + /** + * Explain the same geometry and pane-budget rule used by the split control. + * @param target - captured command or button target. + * @returns a localizable block discriminator, or undefined when splitting is available. + */ + splitBlock( + target: SidebarRightTarget, + ): "stale" | "collapsed" | "float" | "empty" | "budget" | "width" | undefined { + const surface = this.mountedSurface(); + if (surface === undefined || !this.isTargetCurrent(target)) return "stale"; + const { layout } = surface; + if (target.host === "float") return "float"; + if (!layout.expanded) return "collapsed"; + if (getPane(layout, target.paneId).tabs.length === 0) return "empty"; + if (!canSplit(layout) || dockPaneIds(layout).length >= 2) return "budget"; + if (!this.canSplitPane(target.sessionId, target.paneId)) return "width"; + return undefined; + } + + /** + * Toggle the right panel's display mode through the same action as its chrome button. + * @param target - captured dock pane; floating and stale targets are unchanged. + */ + toggleFullscreen(target: SidebarRightTarget): void { + if (!this.isTargetCurrent(target) || target.host === "float" || !this.isExpanded()) return; + const { sessionId, actions } = this.require(); + const autoFullscreen = this.host.autoFullscreen(); + const fullscreen = autoFullscreen || this.mountedSurface()?.layout.mode === "fullscreen"; + if (fullscreen && autoFullscreen) actions.setExpanded(sessionId, false); + actions.setMode(sessionId, fullscreen ? "push" : "fullscreen"); + } + + /** + * Split a docked pane to its right when the budget and the room rule allow. + * @param paneId - the pane to split; defaults to the active docked pane. + * @returns the new pane's id, or `undefined` when nothing was split. + */ + split(paneId?: PaneId): PaneId | undefined { + const { sessionId, actions } = this.require(); + const layout = this.mountedSurface()?.layout; + if (layout === undefined) return undefined; + const target = paneId ?? activeDockPaneId(layout); + const node = layout.nodes[target]; + if (node === undefined || node.kind !== "pane" || node.host !== "dock") return undefined; + if ( + !canSplit(layout) || + dockPaneIds(layout).length >= 2 || + !this.canSplitPane(sessionId, target) + ) + return undefined; + let created: PaneId | undefined; + this.host.openWithFocus(sessionId, () => { + actions.splitPane(sessionId, target, (id) => { + created = id; + }); + return created; + }); + return created; + } + + /** + * Take a docked tab out into a floating panel; a missing or floating tab is left alone. + * @param tabId - the tab to float. + * @param rect - the panel's rectangle; defaults to the cascade from the last panel. + */ + float(tabId: TabId, rect?: FloatRect): void { + const { sessionId, actions } = this.require(); + const layout = this.mountedSurface()?.layout; + if (layout === undefined || layout.tabs[tabId] === undefined) return; + if (findTabPane(layout, tabId).host !== "dock") return; + actions.floatTab(sessionId, tabId, rect); + } + + /** + * Return a floating panel's tab to the active docked pane; a missing or docked pane is left alone. + * @param paneId - the floating pane. + */ + dock(paneId: PaneId): void { + const { sessionId, actions } = this.require(); + const node = this.mountedSurface()?.layout.nodes[paneId]; + if (node === undefined || node.kind !== "pane" || node.host !== "float") return; + actions.unfloatPane(sessionId, paneId); + } + + /** + * Step the on-screen Session's surface back one intent. + * + * @internal Not part of the product: the sequence is an architectural fact + * with no user-facing control yet. Kept reachable for tests. + */ + _undo(): void { + const { sessionId, actions } = this.require(); + actions.undo(sessionId); + } + + /** + * Step the on-screen Session's surface forward one intent. + * + * @internal See `_undo`. + */ + _redo(): void { + const { sessionId, actions } = this.require(); + actions.redo(sessionId); + } + + /** + * The on-screen Session and its committed surface; `undefined` with no Session + * on screen, before the runtime mints its store, or before its first open. + */ + private screen(): { readonly sessionId: SessionId; readonly surface: SurfaceState } | undefined { + const sessionId = this.mounted.getSnapshot(); + if (sessionId === undefined) return undefined; + const surface = this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId]; + return surface === undefined ? undefined : { sessionId, surface }; + } + + /** The on-screen Session's surface; see {@link SidebarRightController.screen}. */ + private mountedSurface(): SurfaceState | undefined { + return this.screen()?.surface; + } + + /** Whether a Session's docked pane has room for two working halves, as its seat last measured; unmeasured panes do. */ + private canSplitPane(sessionId: SessionId, paneId: PaneId): boolean { + return this.rooms.get(sessionId)?.(paneId) ?? true; + } + + /** + * The store actions a tab's own action on `sessionId` runs through: that + * session's adopted store. `undefined` — nothing to act on — for a session + * whose store was never minted or whose adoption was released. + */ + private actionsFor(sessionId: SessionId): SurfaceActions | undefined { + return this.adopted.get(sessionId)?.store.actions; + } + + private require(): { readonly sessionId: SessionId; readonly actions: SurfaceActions } { + // Reads answer for the no-session case (there is nothing expanded), but a + // write has no session to write to. Callers are UI gestures and tool + // results, both of which belong to a session that is on screen. + const sessionId = this.mounted.getSnapshot(); + const actions = sessionId === undefined ? undefined : this.actionsFor(sessionId); + if (sessionId === undefined || actions === undefined) { + throw new Error("sidebarRight: no session surface is mounted"); + } + return { sessionId, actions }; + } +} diff --git a/apps/web-ui/packages/client/ui-sidebar-right/src/client/session-view.ts b/apps/web-ui/packages/client/ui-sidebar-right/src/client/session-view.ts new file mode 100644 index 000000000..0c6dc56a8 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-right/src/client/session-view.ts @@ -0,0 +1,93 @@ +/** One Sidebar view's Session reference, retained bodies and committed mount lifetime. */ +import type { ISessions, SessionReference } from "@deepseek-ai/dsh-api-session-controller/client"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { TabId } from "@deepseek-ai/dsh-client-ui-dockkit"; + +declare module "@deepseek-ai/dsh-api-session-controller/client" { + interface SessionReferenceSourceMap { + sidebarView: unknown; + } +} + +/** Owns its reference until retirement finishes or the Sidebar plugin shuts down. */ +export class SidebarSessionView { + /** Session reference released exclusively by this View. */ + readonly reference: SessionReference; + private readonly tabs = new Map(); + private mounts = 0; + private retired = false; + private disposed = false; + + /** + * @param sessionId - Session displayed by this view. + * @param sessions - allocator for this view's independent reference. + * @param onDispose - removes this view from the collection's index before reference release. + * @param onTabRelease - reconsiders retention after a body releases its hold. + */ + constructor( + readonly sessionId: SessionId, + sessions: ISessions, + private readonly onDispose: (view: SidebarSessionView) => void, + private readonly onTabRelease: (view: SidebarSessionView) => void, + ) { + this.reference = sessions.retain(sessionId, { source: "sidebarView" }); + void this.reference.ready.catch((error: unknown) => { + console.error("Sidebar Session opening failed:", error); + }); + } + + /** Whether an initialized retained body still needs this view. */ + get hasRetainedTabs(): boolean { + return this.tabs.size > 0; + } + + /** + * Keep a retired View's reference until its committed roots finish unmounting. + * @returns cleanup to call once for this root; the final cleanup releases a retired View. + */ + mount(): () => void { + this.mounts += 1; + return () => { + this.mounts -= 1; + if (this.retired && this.mounts === 0) this.dispose(); + }; + } + + /** + * Hold an initialized body until unmount or occurrence cancellation. + * @param tabId - initialized body identity. + * @param signal - occurrence lifetime; closing and undoing a tab creates a new lifetime. + * @returns idempotent release of this body's hold. + */ + readonly retainTab = (tabId: TabId, signal: AbortSignal): (() => void) => { + if (signal.aborted || this.disposed) return () => {}; + this.tabs.set(tabId, (this.tabs.get(tabId) ?? 0) + 1); + let held = true; + const release = (): void => { + if (!held) return; + held = false; + signal.removeEventListener("abort", release); + const count = this.tabs.get(tabId) as number; + if (count === 1) this.tabs.delete(tabId); + else this.tabs.set(tabId, count - 1); + this.onTabRelease(this); + }; + signal.addEventListener("abort", release, { once: true }); + return release; + }; + + /** End a withdrawn view after its existing committed mounts finish. */ + retire(): void { + this.retired = true; + if (this.mounts === 0) this.dispose(); + } + + /** Release once; plugin shutdown does not wait for remaining React mounts. */ + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.retired = true; + this.onDispose(this); + this.reference.release(); + } +} diff --git a/apps/web-ui/packages/client/ui-sidebar-right/src/client/session-views.ts b/apps/web-ui/packages/client/ui-sidebar-right/src/client/session-views.ts new file mode 100644 index 000000000..2db3046c0 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-right/src/client/session-views.ts @@ -0,0 +1,99 @@ +/** Selection and retention policy for independently owned Sidebar Session views. */ +import type { ISessions, SessionReference } from "@deepseek-ai/dsh-api-session-controller/client"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import { createSnapshotStore } from "@deepseek-ai/dsh-client-store"; +import { SidebarSessionView } from "./session-view.ts"; + +/** The reference is exclusively a framework SessionProvider target, not a business-component service. */ +export interface SidebarSessionViewSnapshot { + readonly sessionId: SessionId; + readonly reference: SessionReference; + readonly selected: boolean; + /** Stable for this View's lifetime, independently of Session injection bindings. */ + readonly retainTab: SidebarSessionView["retainTab"]; +} + +/** Selects and retires views; the Sidebar plugin's injected dependencies own global teardown. */ +export class SidebarSessionViews { + /** Selected and retained View targets observed by the root renderer. */ + readonly source = createSnapshotStore([]); + private readonly views = new Map(); + private readonly viewsByReference = new Map(); + private selected: SessionId | undefined; + private closed = false; + + constructor(private readonly sessions: ISessions) {} + + /** + * Change the foreground Session while preserving retained background Views. + * @param sessionId - main selection, or absence. + */ + select(sessionId: SessionId | undefined): void { + if (this.closed || this.selected === sessionId) return; + this.selected = sessionId; + if (sessionId !== undefined && !this.views.has(sessionId)) { + const view = new SidebarSessionView( + sessionId, + this.sessions, + (disposed) => { + this.viewsByReference.delete(disposed.reference); + }, + (released) => { + this.prune(released); + }, + ); + this.views.set(sessionId, view); + this.viewsByReference.set(view.reference, view); + } + for (const view of this.views.values()) this.prune(view); + this.publish(); + } + + /** + * Bind a committed root; release retired references after that root unmounts. + * @param reference - framework target published by this owner. + * @returns releases this committed mount. + */ + mount(reference: SessionReference): () => void { + const view = this.viewsByReference.get(reference); + if (view === undefined) { + if (this.closed) return () => {}; + throw new Error("Sidebar Session view reference is no longer owned"); + } + return view.mount(); + } + + /** Plugin shutdown releases every view, including any awaiting a React unmount. */ + dispose(): void { + this.closed = true; + this.views.clear(); + this.source.set([]); + for (const view of this.viewsByReference.values()) view.dispose(); + } + + private prune(view: SidebarSessionView): void { + if ( + view.sessionId === this.selected || + view.hasRetainedTabs || + this.views.get(view.sessionId) !== view + ) + return; + this.views.delete(view.sessionId); + // Render targets must be withdrawn before an unmounted view can release its reference. + this.publish(); + view.retire(); + } + + private publish(): void { + this.source.set( + [...this.views.values()] + .sort((a, b) => a.sessionId.localeCompare(b.sessionId)) + .map((view) => ({ + sessionId: view.sessionId, + reference: view.reference, + selected: view.sessionId === this.selected, + retainTab: view.retainTab, + })), + ); + } +} diff --git a/apps/web-ui/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.module.css b/apps/web-ui/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.module.css new file mode 100644 index 000000000..0ea624bae --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.module.css @@ -0,0 +1,17 @@ +/* Align with the shared compact Button used by the adjacent More action. */ +.button { + flex: none; + width: 28px; + padding: 0; + color: var(--dsw-alias-label-secondary); +} + +.button svg { + width: 15px; + height: 15px; +} + +/* The left sidebar's panel icon, mirrored: the divider moves to the right side. */ +.icon { + transform: scaleX(-1); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.tsx b/apps/web-ui/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.tsx new file mode 100644 index 000000000..42bf43944 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.tsx @@ -0,0 +1,66 @@ +/** + * The way into a hidden panel: one button in the conversation header's corner + * seat, shown only while the panel is collapsed. + * + * It lives in the conversation's own header rather than in the frame's right + * column so that a collapsed Sidebar costs the conversation nothing — no rail, + * no width, and the transcript's scrollbar stays at the column's edge. The + * corner seat is its own, past the utilities' edge, so the button never joins + * the utilities row; while the panel is shown this renders nothing, and the + * seat collapses with it. It shares the panel's per-session store, which the + * slot runtime allows because both seats are session-scoped. + * + * The glyph is the left sidebar's collapse icon mirrored: the same affordance, + * on the other edge. + */ +import type { ShortcutCatalogEntry } from "@deepseek-ai/dsh-client-shortcuts/client"; +import type { InjectFace, HostObservable } from "@deepseek-ai/dsh-client-ui-slots"; +import type { ReactNode } from "react"; +import { + Button, + IconPanelLeftOutlineRegular, + Tooltip, +} from "@deepseek-ai/dsh-client-ui-primitives"; +import type { PropsLocale, PropsRuntime, PropsStore } from "@deepseek-ai/dsh-client-ui-slots"; +import type {} from "@deepseek-ai/dsh-client-ui-conversation/client"; +import type { createSidebarRightStore } from "../stores.ts"; +import css from "./ExpandButton.module.css"; + +/** The button's props: the header corner seat, the shared store, and copy. */ +export type ExpandButtonProps = PropsRuntime<"conversation.session.header.corner"> & + PropsStore> & + PropsLocale<"sidebarRight"> & + InjectFace<{ hooks: { shortcuts: HostObservable } }>; + +/** The expand control while the panel is collapsed; nothing while it is shown. */ +export function ExpandButton({ + sessionId, + useStore, + actions, + t, + useShortcuts, +}: ExpandButtonProps): ReactNode { + // A session with no surface yet is collapsed: the panel seat materializes the + // surface on its own mount, and until then there is nothing expanded. + const shortcut = useShortcuts((entries) => + entries.find((entry) => entry.id === "sidebar.right.toggle"), + ); + const expanded = useStore((state) => state.bySession[sessionId]?.layout.expanded ?? false); + if (expanded) return null; + return ( + + + + ); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-right/src/client/shell/RightbarRoot.tsx b/apps/web-ui/packages/client/ui-sidebar-right/src/client/shell/RightbarRoot.tsx new file mode 100644 index 000000000..09566455a --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-right/src/client/shell/RightbarRoot.tsx @@ -0,0 +1,69 @@ +/** Root-scoped controller for the right Sidebar's Session content. */ +import { useLayoutEffect } from "react"; +import type { + HostObservable, + InjectFace, + PropsRenderSlots, + PropsRuntime, +} from "@deepseek-ai/dsh-client-ui-slots"; +import type { SessionReference } from "@deepseek-ai/dsh-api-session-controller/client"; +import type { SidebarSessionViewSnapshot } from "../session-views.ts"; +import type {} from "../contract/slots.ts"; +import css from "./SidebarRight.module.css"; + +/** Root-only retained Session targets and their committed mount lifetimes. */ +export interface RightbarRootInjected { + readonly hooks: { readonly views: HostObservable }; + readonly mountView: (reference: SessionReference) => () => void; +} + +type RootProps = PropsRuntime<"rightbar"> & + PropsRenderSlots<"rightbar.session"> & + InjectFace; + +function SessionView({ + view, + visible, + SessionProvider, + renderSlot, + mountView, + width, + viewportWidth, + canShow, +}: Pick< + RootProps, + "SessionProvider" | "renderSlot" | "mountView" | "width" | "viewportWidth" | "canShow" +> & { readonly view: SidebarSessionViewSnapshot; readonly visible: boolean }) { + useLayoutEffect(() => mountView(view.reference), [mountView, view.reference]); + const active = visible && view.selected; + return ( + + ); +} + +/** + * Keep independent Session subtrees and hide those outside the selected Conversation. + * @param props - frame geometry, view targets and the authorized Session renderer. + * @returns the foreground and retained background Sidebars. + */ +export function RightbarRoot({ usePanelInfo, useViews, ...props }: RootProps) { + const visible = usePanelInfo((info) => info.activePanelId === null); + const views = useViews((value) => value); + return ( + <> + {views.map((view) => ( + + ))} + + ); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css b/apps/web-ui/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css new file mode 100644 index 000000000..954919d70 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css @@ -0,0 +1,177 @@ +/* + * The Sidebar's own chrome only: the panel box, its two strip-end controls, and + * the placeholder tab bodies. Everything inside the split tree is styled by the + * docking kit; the expand button in the conversation header has its own sheet. + * Tokens only. + */ + +/* + * One content tree uses the frame's width normally and fills the viewport in + * fullscreen. The frame retains a shown wide panel's track in both modes. + * + * Hidden, docked Grid items translate off the frame's right edge rather than + * unmounted, so showing and hiding are one slide in both presentations. The + * transform rides the same variables as the frame's track transition (ui-theme + * base.css); while squeezing, the panel's left edge and the conversation's right + * edge therefore travel together. Visibility flips after the slide so the box + * is out of reach once off-edge, and flips back before the slide starts. + * + * The normal panel is below frame overlays (20); fullscreen (40) covers the + * frame and remains below independently floating panels (60). The common + * content root owns no stacking context or transform. + */ +.session { + display: contents; +} + +.session[hidden] { + display: none; +} + +.panel { + --dsh-dockkit-dock-layer: 10; + --dsh-dockkit-float-layer: 60; + position: absolute; + top: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + min-width: 0; + pointer-events: none; +} + +/* Only docked Grid items slide. Their common ancestor remains untransformed, + unclipped and without a stacking context, so fixed floating children escape. */ +.panel :global([data-dockkit-host="dock"]), +.panel :global([data-dockkit-empty]), +.panel :global([data-dockkit-divider]) { + transform: translateX(var(--dsh-sidebar-width)); + visibility: hidden; + transition: + transform var(--ds-transition-duration-slow) var(--ds-ease-in-out), + visibility 0s linear var(--ds-transition-duration-slow); +} + +.panel[data-sidebar-right-open] :global([data-dockkit-host="dock"]), +.panel[data-sidebar-right-open] :global([data-dockkit-empty]), +.panel[data-sidebar-right-open] :global([data-dockkit-divider]) { + transform: none; + visibility: visible; + transition: transform var(--ds-transition-duration-slow) var(--ds-ease-in-out); +} + +.panel[data-sidebar-right-panel="fullscreen"] { + --dsh-dockkit-dock-layer: 40; +} + +/* No whole-box subtraction here: Electron drag regions are window geometry, + not stacking, so a strip row inside this fullscreen content that marks itself + `data-window-drag` (ui-dockkit) still owns the drag over its own box, and + ui-web base.css subtracts interactive elements and portalled overlays. 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"] { + max-width: calc(100vw - var(--dsh-windows-sidebar-width)); +} + +:global([data-windows-titlebar]) + .panel[data-sidebar-right-panel="fullscreen"] + :global([data-dockkit-column="0"][data-dockkit-pane]) { + border-radius: var(--dsh-windows-content-radius) 0 0 0; + corner-shape: round; +} + +/* macOS fullscreen: the panel reaches the window's top-left corner, where the + hiddenInset traffic lights (x 16..~68) float over the first pane's tab + strip. Widen that strip's start inset (dockkit publishes the variable); + the second Grid column sits clear of the lights and uses the ordinary inset. */ +:global([data-platform="darwin"]) + .panel[data-sidebar-right-panel="fullscreen"] + :global([data-dockkit-host="dock"][data-dockkit-column="0"]) { + /* Lights end near x 68; 88 leaves the same 20px of air the conversation + header's leading controls keep after them (ui-sidebar HeaderLeadingControls). */ + --dsh-dockkit-strip-inline-start: 88px; +} + +:global([data-platform="darwin"]) + .panel[data-sidebar-right-panel="fullscreen"] + :global([data-dockkit-host="dock"][data-dockkit-column="1"]) { + --dsh-dockkit-strip-inline-start: 10px; +} + +/* Window fullscreen hides the traffic lights (html[data-fullscreen], desktop + preload); the first pane's strip keeps only the ordinary inset. */ +:global([data-platform="darwin"][data-fullscreen]) + .panel[data-sidebar-right-panel="fullscreen"] + :global([data-dockkit-host="dock"][data-dockkit-column="0"]) { + --dsh-dockkit-strip-inline-start: 10px; +} + +@media (prefers-reduced-motion: reduce) { + .panel :global([data-dockkit-host="dock"]), + .panel :global([data-dockkit-empty]), + .panel :global([data-dockkit-divider]) { + transition: none; + } +} + +/* The geometry of a message's action row button (ui-chat MessageIconActions), + shared with the kit's split control beside it: a 28px circle around a 15px glyph. */ +.iconButton { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 6px; + color: var(--dsw-alias-label-secondary); + line-height: 1; + background: transparent; + border: none; + border-radius: var(--dsw-radius-sm); + cursor: pointer; +} + +.iconButton svg { + width: 15px; + height: 15px; +} + +.iconButton:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +/* The expand button's glyph, so collapse and expand share one affordance. */ +.collapseGlyph { + transform: scaleX(-1); +} + +.panelBody { + display: flex; + flex: 1 1 auto; + min-height: 0; +} + +.unavailable { + margin: 0; + padding: 12px; + color: var(--dsw-alias-label-tertiary); + font-size: var(--dsh-content-font-size-secondary, 13px); +} + +/* Keep the title's icon and text in dockkit's flex row while retaining occurrence markers. */ +.tabTitle { + display: contents; +} + +.tabBody { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + min-width: 0; + overflow: hidden; +} diff --git a/apps/web-ui/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx b/apps/web-ui/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx new file mode 100644 index 000000000..da26b3257 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx @@ -0,0 +1,646 @@ +/** + * The Sidebar's seat in the frame, and the panel it draws. + * + * The frame owns the right column's geometry; this package owns one content + * tree at the column width or spanning the viewport. A shown wide panel + * retains its track in fullscreen, preserving the conversation width. Below + * 768px fullscreen is derived from viewport width, without changing manual mode. + * + * Docked content stays mounted while collapsed, translated off the frame's right + * edge, so opening and closing are one gesture in both presentations: a slide + * from and to that edge. Normal presentation moves the frame's tracks with + * the panel. A fullscreen opening reserves its underlying track only after + * the panel covers the frame, without animating those hidden columns. + * + * The panel has no header of its own: its two controls — presentation switch + * and collapse — ride the docking kit's chrome seat at the end of the top-right + * pane's tab strip, so the strip is the panel's whole top edge. The way back in + * while collapsed is not here either: it is one button in the conversation + * header (`ExpandButton.tsx`), because it exists only while this panel is + * hidden. Floating panels remain in the same content tree; untransformed + * ancestors let their fixed-position frames cross the column and conversation. + * + * Tab bodies do not live here. Each one is a registration under its type's kind, + * dispatched through the keyed `sidebar.right.pane.tab` seat (and a live chip + * title through `sidebar.right.pane.tab.title`), so a new tab type needs no edit + * to this file. What a body receives beyond the record — navigation, lifetime + * signal, actions — is read through the slot-owned useTabInfo hook. The Tab + * domain follows each session's store commits, including sessions off screen. + */ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react"; +import type { CSSProperties, ReactNode, RefObject } from "react"; +import type { ShortcutCatalogEntry } from "@deepseek-ai/dsh-client-shortcuts/client"; +import { IconPanelLeftOutlineRegular, Tooltip } from "@deepseek-ai/dsh-client-ui-primitives"; +import type { + HostObservable, + InjectFace, + PropsLocale, + PropsRenderSlots, + PropsRuntime, + PropsStore, +} from "@deepseek-ai/dsh-client-ui-slots"; +import type {} from "../contract/slots.ts"; +import type { + DockIntents, + DockMode, + FloatRect, + TabId, + TabRecord, + TabRenderer, +} from "@deepseek-ai/dsh-client-ui-dockkit"; +import { + canSplit, + dockPaneIds, + DockLayout, + findPaneContentTab, +} from "@deepseek-ai/dsh-client-ui-dockkit"; +import type { HalvesFit, LayoutState, PaneId } from "@deepseek-ai/dsh-client-ui-dockkit"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import { GUIDE_KIND, pageAddress } from "../contract/seed.ts"; +import { dockLabels } from "../labels.ts"; +import type { SidebarRightOpenTabOptions } from "../service.ts"; +import type { SidebarRightTabDefinition } from "../tab-registry.ts"; +import type { createSidebarRightStore, SurfaceState } from "../stores.ts"; +import { canCloseTab } from "../stores.ts"; +import type { TabOccurrence } from "../tab-domain.ts"; +import type { SidebarRightTabNavigation } from "../contract/slots.ts"; +import type { TabHookContext } from "../tab-info.ts"; +import css from "./SidebarRight.module.css"; + +/** The store share the seat receives. */ +type Store = PropsStore>; + +/** The child seats this component renders. */ +type Children = PropsRenderSlots< + "sidebar.right.pane.tab" | "sidebar.right.pane.tab.title" | "sidebar.right.tab.menu.item" +>; + +/** What the panel reports to the frame: drawn or not, and whether it wants a track. */ +export interface SidebarRightPresentation { + /** Whether the panel is drawn at all. */ + readonly shown: boolean; + /** Whether the drawn panel wants the conversation to make room for it. */ + readonly track: boolean; + /** Whether the panel fills the viewport, independently of its retained track. */ + readonly fullscreen: boolean; +} + +/** What this package needs from its host beyond the framework shares. */ +export interface SidebarRightInjected { + /** + * Report the panel's presentation to the frame. + * + * The frame sizes the track and places the resize handle; this only tells it + * the composition of the facts this package owns, and is called whenever that + * composition changes. + */ + readonly syncPresentation: (presentation: SidebarRightPresentation) => void; + /** + * Record this seat's room rule for `ctx.sidebarRight`'s splits, replacing its previous one. + * + * The docking kit measures its panes after it renders; the seat forwards each + * reading, and the service applies it when it splits this seat's Session. + * @param canSplitPane - whether two working halves of a docked pane would fit; unmeasured panes fit. + */ + readonly measureRoom: (canSplitPane: (paneId: PaneId) => boolean) => void; + /** + * Record whether the frame width this seat renders at presents an expanded panel fullscreen automatically. + * + * The fullscreen command reads the latest report: while it holds, the command + * closes the panel instead of switching its recorded mode. + * @param autoFullscreen - whether the frame is narrower than the automatic fullscreen width. + */ + readonly reportAutoFullscreen: (autoFullscreen: boolean) => void; + /** + * The navigation face's `openTab`, for the strip's add control: a new tab is + * the guide opened by kind, through the same path as every other open. + */ + readonly openTab: (kind: string, options?: SidebarRightOpenTabOptions) => void; + /** Close through the resource owner's cleanup handler. */ + readonly closeTab: (tabId: TabId) => void; + /** Split through the same controller as keyboard commands. */ + readonly splitPane: (paneId: PaneId) => void; + /** Toggle the dock panel using its current display mode. */ + readonly toggleFullscreen: () => void; + readonly hooks: { + readonly shortcuts: HostObservable; + readonly tabTypes: HostObservable; + }; + readonly keyedHooks: { + readonly tabNavigation: (key: string) => HostObservable; + }; + /** Read a committed record's lifetime; never creates an occurrence. */ + readonly occurrence: (tab: Pick) => TabOccurrence; +} + +/** The column seat's props: session scope, so the session arrives as a standard prop. */ +export type RightbarSeatProps = PropsRuntime<"rightbar.session"> & + Children & + Store & + PropsLocale<"sidebarRight"> & + InjectFace; + +/** Everything the panel needs, already bound to one session. */ +interface PanelProps { + readonly sessionId: SessionId; + readonly surface: SurfaceState; + readonly actions: Store["actions"]; + readonly t: RightbarSeatProps["t"]; + readonly renderSlot: Children["renderSlot"]; + readonly openTab: SidebarRightInjected["openTab"]; + readonly closeTab: SidebarRightInjected["closeTab"]; + readonly splitPane: SidebarRightInjected["splitPane"]; + readonly toggleFullscreen: SidebarRightInjected["toggleFullscreen"]; + readonly shortcuts: readonly ShortcutCatalogEntry[]; + readonly useTabTypes: RightbarSeatProps["useTabTypes"]; + readonly useTabNavigation: RightbarSeatProps["useTabNavigation"]; + readonly useStore: Store["useStore"]; + readonly occurrence: SidebarRightInjected["occurrence"]; + readonly fullscreen: boolean; + readonly autoFullscreen: boolean; + readonly active: boolean; + readonly retainTab: RightbarSeatProps["retainTab"]; + /** Receives the kit's room-rule readings for the service's `split`. */ + readonly reportRoom: (fits: ReadonlyMap) => void; +} + +/** The guide tab one pane holds, if any: a pane holds at most one. */ +function guideIn(layout: LayoutState, paneId: PaneId): TabId | undefined { + return findPaneContentTab(layout, paneId, pageAddress(GUIDE_KIND), GUIDE_KIND); +} + +/** + * Build the kit's intent face for one session out of the store's actions. + * @param sessionId - the session the seat draws; every action is bound to it. + * @param actions - the seat's bound store actions. + * @param openTab - the navigation face's `openTab`, which the strip's add control asks for a guide through. + * @returns the intents the kit reports gestures to. + */ +export function intentsFor( + sessionId: SessionId, + actions: Store["actions"], + openTab: PanelProps["openTab"], + closeTab?: PanelProps["closeTab"], + splitPane?: PanelProps["splitPane"], +): DockIntents { + return { + focusTab: (tabId) => { + actions.focusTab(sessionId, tabId); + }, + focusPane: (paneId) => { + actions.focusPane(sessionId, paneId); + }, + splitPane: + splitPane ?? + ((paneId) => { + actions.splitPane(sessionId, paneId); + }), + // The guide is unique per pane: the control is drawn only while its pane + // holds none (`canAddTab` below) and asks for one there without regard to + // guides in other panes; the store settles the open on a guide the pane + // already holds, so the ask is idempotent all the same. + addTab: (paneId) => { + openTab(GUIDE_KIND, { paneId, revealIfOpened: false }); + }, + closeTab: + closeTab ?? + ((tabId) => { + actions.closeTab(sessionId, tabId); + }), + duplicateTab: (tabId) => { + actions.duplicateTab(sessionId, tabId); + }, + floatTab: (tabId, rect?: FloatRect) => { + actions.floatTab(sessionId, tabId, rect); + }, + unfloatPane: (paneId) => { + actions.unfloatPane(sessionId, paneId); + }, + placeTab: (tabId, toPaneId, index) => { + actions.placeTab(sessionId, tabId, toPaneId, index); + }, + dropTab: (tabId, paneId, zone) => { + actions.dropTab(sessionId, tabId, paneId, zone); + }, + moveFloat: (paneId, x, y) => { + actions.moveFloat(sessionId, paneId, x, y); + }, + resizeFloat: (paneId, rect) => { + actions.resizeFloat(sessionId, paneId, rect); + }, + resizeSplit: (splitId, sizes) => { + actions.resizeSplit(sessionId, splitId, sizes); + }, + }; +} + +/** One tab's slot dispatch: which seat, and what to render when no type registered. */ +interface TabSlotProps extends Pick< + PanelProps, + | "renderSlot" + | "occurrence" + | "useTabTypes" + | "useTabNavigation" + | "useStore" + | "fullscreen" + | "active" + | "retainTab" + | "shortcuts" +> { + readonly tab: TabRecord; + readonly seat: "sidebar.right.pane.tab" | "sidebar.right.pane.tab.title"; + readonly fallback: ReactNode; +} + +/** + * Dispatch one tab's body or title with stable framework hooks and record lifetime. + */ +function TabSlot({ + renderSlot, + occurrence, + useTabTypes, + useTabNavigation, + useStore, + fullscreen, + shortcuts, + active, + retainTab, + tab, + seat, + fallback, +}: TabSlotProps): ReactNode { + const { id, signal, tabActions } = occurrence(tab); + const definition = useTabTypes((types) => + types.find((definition) => definition.kind === tab.kind), + ); + const retained = seat === "sidebar.right.pane.tab" && definition?.keepMounted === true; + useLayoutEffect( + () => (retained ? retainTab(tab.id, signal) : undefined), + [retained, retainTab, tab.id, signal], + ); + const hookContext = useMemo( + (): TabHookContext => ({ + tabId: tab.id, + shortcuts, + title: seat === "sidebar.right.pane.tab.title", + fullscreen, + active, + signal, + actions: tabActions, + useStore, + useTabNavigation, + }), + [tab.id, seat, fullscreen, active, signal, tabActions, useStore, useTabNavigation, shortcuts], + ); + const content = renderSlot( + seat, + {}, + { entryKey: definition?.id ?? tab.kind, fallback, hookContext }, + ); + return seat === "sidebar.right.pane.tab.title" ? ( + + {content} + + ) : ( +
    + {content} +
    + ); +} + +/** + * Dispatch a tab's body to its registered type. + * + * A kind with no registrant is a real state, not a defect: a session log can + * carry a tab whose type shipped in a plugin that is no longer mounted. Saying so + * is better than an empty pane. + */ +function bodiesFor(panel: PanelProps): TabRenderer { + const { t, ...rest } = panel; + // Slot dispatch selects a type; the record key separates bodies of that type. + return (tab) => ( + + {t("tab.unavailable")} +

    + } + /> + ); +} + +/** Dispatch a tab's title to its registered type; without one the chip shows the title captured at open time. */ +function titlesFor(panel: PanelProps): TabRenderer { + return (tab) => ( + + ); +} + +/** Expand-to-viewport glyph from the shared product artwork. */ +function FullscreenGlyph(): ReactNode { + return ( + + ); +} + +/** Restore-from-fullscreen glyph from the shared product artwork. */ +function ExitFullscreenGlyph(): ReactNode { + return ( + + ); +} + +/** The panel's two controls, placed by the kit at the top-right pane's strip end. */ +function PanelChrome({ + sessionId, + fullscreen, + actions, + t, + shortcuts, + toggleFullscreen, +}: Pick< + PanelProps, + "sessionId" | "actions" | "t" | "fullscreen" | "shortcuts" | "toggleFullscreen" +>): ReactNode { + const next: DockMode = fullscreen ? "push" : "fullscreen"; + const modeLabel = fullscreen ? t("chrome.exitFullscreen") : t("chrome.toFullscreen"); + const mode = shortcuts.find((entry) => entry.id === "pane.fullscreen.toggle"); + const toggle = shortcuts.find((entry) => entry.id === "sidebar.right.toggle"); + return ( + <> + + + + + + + + ); +} + +/** + * The panel: the docked surface with the two controls in its top-right strip, + * anchored to the frame's right edge and slid off it while collapsed. + */ +function SidebarPanel( + panel: PanelProps & { width: number; panelRef: RefObject }, +): ReactNode { + const { + sessionId, + surface, + actions, + t, + renderSlot, + openTab, + width, + reportRoom, + fullscreen, + panelRef, + } = panel; + const { expanded } = surface.layout; + const types = panel.useTabTypes((value) => value); + return ( +
    +
    + guideIn(surface.layout, paneId) === undefined} + canCloseTab={(tabId) => canCloseTab(surface, tabId)} + intents={intentsFor(sessionId, actions, openTab, panel.closeTab, panel.splitPane)} + labels={dockLabels( + t, + panel.shortcuts.find((entry) => entry.id === "pane.split"), + panel.shortcuts.find((entry) => entry.id === "page.close"), + )} + renderTab={bodiesFor(panel)} + renderTabTitle={titlesFor(panel)} + active={panel.active} + keepMounted={(tab) => types.find((type) => type.kind === tab.kind)?.keepMounted === true} + renderTabMenuItems={(tab, dismiss) => + renderSlot("sidebar.right.tab.menu.item", { tab, dismiss }) + } + chrome={ + + } + onRoom={reportRoom} + /> +
    +
    + ); +} + +/** + * The right column's occupant: stable tab containers, docked or floating. + * It is also where the frame learns the panel's presentation, because this is + * the seat that knows it. `ctx.sidebarRight` names the on-screen Session + * itself; this seat reports only what it renders with: the room its kit + * measured and the automatic fullscreen rule of its frame width. + */ +export function RightbarSeat({ + sessionId, + width, + viewportWidth, + canShow, + useStore, + actions, + t, + renderSlot, + syncPresentation, + measureRoom, + reportAutoFullscreen, + openTab, + closeTab, + useTabTypes, + useTabNavigation, + occurrence, + retainTab, + active, + useShortcuts, + splitPane, + toggleFullscreen, +}: RightbarSeatProps): ReactNode { + // One store instance per session, so this map holds this session's surface. + const shortcuts = useShortcuts((entries) => entries); + const surface = useStore((state) => state.bySession[sessionId]); + const shown = active && surface !== undefined && surface.layout.expanded; + const autoFullscreen = viewportWidth < 768; + const fullscreen = autoFullscreen || surface?.layout.mode === "fullscreen"; + const panelRef = useRef(null); + // A reading never re-renders anything: the service applies it when it splits. + const reportRoom = useCallback( + (fits: ReadonlyMap): void => { + measureRoom((paneId) => fits.get(paneId)?.row !== false); + }, + [measureRoom], + ); + const track = shown && !autoFullscreen; + + useEffect(() => { + if (active && surface === undefined) actions.open(sessionId); + }, [actions, sessionId, surface, active]); + + useLayoutEffect(() => { + if (shown && !fullscreen && !canShow) actions.setExpanded(sessionId, false); + }, [actions, sessionId, shown, fullscreen, canShow]); + + // Read only when the fullscreen command runs, after this commit has settled. + useLayoutEffect(() => { + reportAutoFullscreen(autoFullscreen); + }, [reportAutoFullscreen, autoFullscreen]); + + // 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. + useLayoutEffect(() => { + if (!active) return; + let disposed = false; + const reportWhenCovered = (): void => { + if (disposed) return; + // A shown panel renders unconditionally and attaches its ref before this effect. + const entering = + shown && fullscreen + ? (panelRef.current as HTMLDivElement) + .getAnimations({ subtree: true }) + .filter( + (animation) => + "transitionProperty" in animation && + animation.transitionProperty === "transform" && + animation.playState !== "finished" && + animation.playState !== "idle", + ) + : []; + if (entering.length === 0) { + syncPresentation({ shown, track, fullscreen }); + return; + } + // Cancellation can replace the transition or remove it for reduced motion. + void Promise.allSettled(entering.map((animation) => animation.finished)).then( + reportWhenCovered, + ); + }; + reportWhenCovered(); + return () => { + disposed = true; + }; + }, [sessionId, shown, track, fullscreen, syncPresentation, active]); + // Leaving is part of that report: a seat that unmounts with its session must + // hand the track back rather than leave one sized for a surface nobody draws. + useLayoutEffect( + () => + active + ? () => { + syncPresentation({ shown: false, track: false, fullscreen: false }); + } + : undefined, + [syncPresentation, active], + ); + + // The Tab domain is not synced here: the controller adopted this session's + // store as the runtime minted it and reconciles on the store's own commits, + // on screen or not. + + if (surface === undefined) return null; + const panel: PanelProps = { + sessionId, + actions, + t, + renderSlot, + surface, + openTab, + closeTab, + useTabTypes, + useTabNavigation, + useStore, + occurrence, + fullscreen, + autoFullscreen, + reportRoom, + active, + retainTab, + shortcuts, + splitPane, + toggleFullscreen, + }; + return ; +} diff --git a/apps/web-ui/packages/client/ui-sidebar-right/src/client/shell/close-focus.ts b/apps/web-ui/packages/client/ui-sidebar-right/src/client/shell/close-focus.ts new file mode 100644 index 000000000..681a8f181 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-right/src/client/shell/close-focus.ts @@ -0,0 +1,49 @@ +/** Focus continuity after page operations replace docked or floating pane elements. */ +import { flushSync } from "react-dom"; +import type { PaneId } from "@deepseek-ai/dsh-client-ui-dockkit"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import { visibleSidebarPane } from "../focus.ts"; + +/** + * Focus the page selected by an open or split after its DOM has committed. + * @param document - product document owning the input focus. + * @param sessionId - Session whose page is opening. + * @param open - synchronous operation returning its selected pane, or undefined when unchanged. + */ +export function openWithPaneFocus( + document: Document, + sessionId: SessionId, + open: () => PaneId | undefined, +): void { + let paneId: PaneId | undefined; + flushSync(() => { + paneId = open(); + }); + if (paneId !== undefined) + visibleSidebarPane(document, sessionId, paneId)?.focus({ preventScroll: true }); +} + +/** + * Commit a focused page's removal before focusing a surviving visible pane. + * @param document - product document owning the input focus. + * @param sessionId - Session whose page is closing. + * @param paneId - pane whose page is closing; preferred if it survives. + * @param close - synchronous cleanup and layout removal; errors preserve focus. + */ +export function closeWithPaneFocus( + document: Document, + sessionId: SessionId, + paneId: PaneId, + close: () => void, +): void { + const before = document.activeElement; + const owner = before?.closest("[data-sidebar-right-session]"); + const source = before?.closest("[data-dockkit-pane], [data-dockkit-float]"); + const retain = + owner?.dataset.sidebarRightSession === sessionId && + (source?.dataset.dockkitPane ?? source?.dataset.dockkitFloat) === paneId; + flushSync(close); + if (!retain || (document.activeElement !== document.body && document.activeElement !== before)) + return; + visibleSidebarPane(document, sessionId, paneId)?.focus({ preventScroll: true }); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-right/src/client/shortcuts.ts b/apps/web-ui/packages/client/ui-sidebar-right/src/client/shortcuts.ts new file mode 100644 index 000000000..4a1a32b01 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-right/src/client/shortcuts.ts @@ -0,0 +1,171 @@ +/** Sidebar-owned commands resolved against the currently mounted page. */ +import type { + Shortcuts, + ShortcutBinding, + ShortcutCommandId, +} from "@deepseek-ai/dsh-client-shortcuts/client"; +import type { TranslateNS } from "@deepseek-ai/dsh-client-locale/client"; +import { closeTopModal } from "@deepseek-ai/dsh-client-ui-primitives"; +import type { SidebarRightController } from "./service.ts"; +import type { SidebarRightTarget } from "./focus.ts"; +import type {} from "./locales.ts"; + +/** + * Register the sidebar commands over the controller used by its visible controls. + * @param shortcuts - effective-binding registry for this window. + * @param sidebar - current Session and page owner. + * @param t - current localized command and unavailable labels. + * @param closeWindow - private native close operation using the current configuration revision. + * @returns release callback for the commands. + */ +export function registerSidebarShortcuts( + shortcuts: Pick, + sidebar: SidebarRightController, + t: TranslateNS<"sidebarRight">, + closeWindow: () => void, +): () => void { + const reason = (kind: "split" | "fullscreen", target: SidebarRightTarget): string | null => { + if (kind === "split") { + const block = sidebar.splitBlock(target); + return block === undefined ? null : t(`command.${block}`); + } + if (target.host === "float") return t("command.float"); + return null; + }; + const disposers = [ + shortcuts.register({ + id: "sidebar.right.toggle" as ShortcutCommandId, + label: () => t("command.toggle"), + aliases: ["right sidebar", "toggle right panel"], + defaults: { + "desktop:macos": { code: "KeyB", modifiers: ["primary", "alt"] }, + "desktop:windows": { code: "KeyB", modifiers: ["primary", "alt"] }, + "desktop:linux": { code: "KeyB", modifiers: ["primary", "alt"] }, + "web:macos": { code: "KeyB", modifiers: ["primary", "shift"] }, + "web:windows": { code: "KeyB", modifiers: ["primary", "shift"] }, + }, + regions: ["page", "editable", "terminal"], + modals: [], + resolve: () => { + const target = sidebar.commandTarget(null); + if (target === undefined) return { status: "blocked", reason: t("command.noSession") }; + return { + status: "handled", + run: () => { + if (sidebar.isTargetCurrent(target)) sidebar.toggleExpanded(); + }, + }; + }, + }), + ]; + for (const kind of ["split", "fullscreen"] as const) { + const binding: ShortcutBinding = + kind === "split" + ? { code: "Backslash", modifiers: ["primary"] } + : { code: "Enter", modifiers: ["primary", "alt"] }; + disposers.push( + shortcuts.register({ + id: (kind === "split" ? "pane.split" : "pane.fullscreen.toggle") as ShortcutCommandId, + label: () => t(kind === "split" ? "dock.splitPane" : "command.fullscreen"), + aliases: [kind, "panel"], + defaults: { + "desktop:macos": binding, + "desktop:windows": binding, + "desktop:linux": binding, + "web:macos": binding, + "web:windows": binding, + }, + regions: ["page", "editable", "terminal"], + modals: [], + resolve: ({ target: element }) => { + const target = sidebar.focusedTarget(element); + if (target === undefined) return { status: "blocked", reason: t("command.noFocus") }; + const unavailable = reason(kind, target); + if (unavailable !== null) return { status: "blocked", reason: unavailable }; + return { + status: "handled", + run: () => { + if (!sidebar.isTargetCurrent(target)) return; + if (kind === "split") sidebar.split(target.paneId); + else sidebar.toggleFullscreen(target); + }, + }; + }, + }), + ); + } + for (const kind of ["close", "refresh"] as const) { + const desktop: ShortcutBinding = { + code: kind === "close" ? "KeyW" : "KeyR", + modifiers: ["primary"], + }; + const web: ShortcutBinding = { ...desktop, modifiers: ["primary", "alt"] }; + disposers.push( + shortcuts.register({ + id: `page.${kind}` as ShortcutCommandId, + label: () => t(`command.${kind}`), + aliases: [kind, "page"], + defaults: { + "desktop:macos": desktop, + "desktop:windows": desktop, + "desktop:linux": desktop, + "web:windows": web, + ...(kind === "close" ? { "web:macos": web } : {}), + }, + regions: ["page", "editable", "terminal"], + modals: kind === "close" ? ["settings", "shortcuts", "other"] : [], + resolve: ({ target: element, source, modal }) => { + if (kind === "close" && modal !== null) { + return { + status: "handled", + run: () => { + closeTopModal(document); + }, + }; + } + const target = sidebar.focusedTarget(element); + if ( + target === undefined && + (source === "iframe" || element?.closest("[data-sidebar-right-session]")) + ) { + return { status: "blocked", reason: t("command.stale") }; + } + if (kind === "refresh") { + const refresh = target?.occurrence?.commands.refresh; + if (target === undefined || refresh === undefined) + return { status: "blocked", reason: t("command.noRefresh") }; + return { + status: "handled", + run: () => { + if ( + sidebar.isTargetCurrent(target) && + target.occurrence?.commands.refresh === refresh + ) + refresh(); + }, + }; + } + if (target !== undefined && sidebar.canCloseTarget(target)) { + return { + status: "handled", + run: () => { + sidebar.closeTarget(target); + }, + }; + } + if (shortcuts.runtime !== "desktop") + return { status: "blocked", reason: t("command.noFocus") }; + return { + status: "handled", + run: () => { + if (target === undefined || sidebar.isTargetCurrent(target)) closeWindow(); + }, + }; + }, + }), + ); + } + return () => { + for (const dispose of disposers.reverse()) dispose(); + }; +} diff --git a/apps/web-ui/packages/client/ui-sidebar-right/src/client/stores.ts b/apps/web-ui/packages/client/ui-sidebar-right/src/client/stores.ts new file mode 100644 index 000000000..cf30dd11e --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-right/src/client/stores.ts @@ -0,0 +1,613 @@ +/** + * The store shell over the docking kit: one surface per session, held as plain + * data so the kit's pure functions are the only thing that ever computes a + * layout. + * + * Every action follows the same steps — mint the ids the intent needs, ask the + * kit's planner what operations carry it out, let the settle planner keep every + * pane populated, record it all as one history entry — and then assigns the + * session's whole surface back in one go. Nothing here reaches into a draft to + * edit a layout in place, which is what keeps the kit testable without a store + * and keeps snapshot identity honest. + * + * The settle step is this product's rule, not the kit's: an intent never leaves + * an expanded column with an empty pane — emptied side panes merge away, and an + * empty root pane seeds the default page. A collapsed column may stand empty; + * the seed waits for the expansion that would otherwise show nothing. + * + * A focus that changes nothing — a tab already active in its already-active + * pane, a pane already active — plans nothing and records nothing, whoever + * asks: the kit's chip click and `ctx.sidebarRight.focus` alike. + * + * So is page uniqueness: a pane holds at most one tab of each page kind (a tab + * whose content is the kind's own page address — the guide, the explorer). + * Opening a page into a pane that shows it focuses that tab, in that pane and + * nowhere else, and a page dragged, dropped, or docked into such a pane merges + * into the pane's own — the arriving tab closes and the pane's own is focused. + * The kit plans none of this; it is decided here before its planners run. + */ +import { defineStore, type EngineStoreHandle } from "@deepseek-ai/dsh-client-store"; +import { clearSidebarLayout, readSidebarLayout, writeSidebarLayout } from "./persistence.ts"; +import type { + DockMode, + DockZone, + FloatRect, + History, + LayoutOp, + LayoutState, + Mint, + PaneId, + SplitId, + TabId, + TabRecord, +} from "@deepseek-ai/dsh-client-ui-dockkit"; +import { + activeDockPaneId, + createInitialState, + dockPaneIds, + EMPTY_HISTORY, + findContentTab, + findPaneContentTab, + findTabPane, + getPane, + planDropTab, + planDuplicateTab, + planFloatTab, + planOpenContent, + planPlaceTab, + planResizeSplit, + planSetExpanded, + planSetMode, + planSettle, + planSplitPane, + planUnfloatPane, + record, + replay, + stepBack, + stepForward, +} from "@deepseek-ai/dsh-client-ui-dockkit"; +import { GUIDE_KIND, pageAddress, type SidebarRightSeed } from "./contract/seed.ts"; + +/** One session's docking surface: the layout, its sequence, and the id counter. */ +export interface SurfaceState { + readonly layout: LayoutState; + readonly history: History; + /** How many ids this surface has minted; carried so replay stays reproducible. */ + readonly minted: number; +} + +/** + * Every session's surface, keyed by session id. + * + * Written mutable because an action receives this type as its draft; the + * immutability that matters is behavioural — actions only ever assign a whole + * new map, never reach into one. + */ +export interface SidebarRightState { + bySession: Record; +} + +/** A planner call, as the store needs it: state and a mint in, operations out. */ +type SurfacePlan = ( + state: LayoutState, + mint: Mint, + makeTab: (id: TabId) => TabRecord, +) => readonly LayoutOp[]; + +/** + * Decide whether an explicit close may remove a tab. + * @param surface - current surface. + * @param tabId - tab requested for closing. + * @returns false for a missing tab or the guide standing as the only docked tab. + */ +export function canCloseTab(surface: SurfaceState, tabId: TabId): boolean { + const tab = surface.layout.tabs[tabId]; + return tab !== undefined && !(tab.kind === GUIDE_KIND && soleDockedTab(surface.layout, tabId)); +} + +/** Build the currently selected default tab. */ +function seedRecord(id: TabId, seed: () => SidebarRightSeed): TabRecord { + const initial = seed(); + return { id, kind: initial.kind, title: initial.title, contentId: pageAddress(initial.kind) }; +} + +/** + * What the navigation controller asks the store to open, the address already + * claimed. Placement is by `replaceTab` first, then `paneId`, then the active pane. + */ +export interface OpenContentIntent { + readonly kind: string; + readonly contentId: string; + readonly title: string; + /** Land a new tab in this pane. */ + readonly paneId?: PaneId; + /** Split the target pane and put new content alone in the new pane. */ + readonly preferNewPane?: boolean; + /** Take this tab's pane and slot, and close it in the same entry. */ + readonly replaceTab?: TabId; + /** Resource tabs reveal an existing identity by default; `false` permits duplicates. Pages always deduplicate within the target pane. */ + readonly revealIfOpened?: boolean; +} + +/** A mint that counts, so the surface can carry its position forward. */ +function counting(from: number): { mint: Mint; used: () => number } { + let counter = from; + // The mint is where a string becomes a branded id: the prefix names the kind, + // the counter keeps every id of this surface unique. + const mint = ((prefix: string): string => { + counter += 1; + return `${prefix}${counter}`; + }) as Mint; + return { mint, used: () => counter }; +} + +/** + * The surface a session starts with: collapsed, one pane, no tabs. The default + * page is not seeded here — the settle rule seeds it when the column first + * expands still empty, so a collapsed column never holds a page nobody asked + * for, and an open into a fresh surface shows only what it opened. + * @returns the initial surface. + */ +export function createSurface(): SurfaceState { + const counter = counting(0); + return { + layout: createInitialState({ next: counter.mint }), + history: EMPTY_HISTORY, + minted: counter.used(), + }; +} + +/** The tab showing `kind`'s page in a pane, if any. */ +function panePage(state: LayoutState, paneId: PaneId, kind: string): TabId | undefined { + return findPaneContentTab(state, paneId, pageAddress(kind), kind); +} + +/** + * The kind whose page a tab shows, or `undefined` for a resource tab. A pane + * holds at most one page of each kind, so a page tab is never copied. + */ +function pageKind(state: LayoutState, tabId: TabId): string | undefined { + const tab = state.tabs[tabId]; + return tab !== undefined && tab.contentId === pageAddress(tab.kind) ? tab.kind : undefined; +} + +/** + * Whether a tab stands alone on the docked surface: its pane is the sole docked + * pane and holds nothing else. Floating panels do not count — they render + * whether or not the column is expanded. + * @param state - current layout. + * @param tabId - the tab asked about. + * @returns `true` for the docked surface's only tab. + */ +export function soleDockedTab(state: LayoutState, tabId: TabId): boolean { + const pane = findTabPane(state, tabId); + return pane.host === "dock" && pane.tabs.length === 1 && dockPaneIds(state).length === 1; +} + +/** Focus a tab: nothing to plan while it is its pane's active tab and its pane is the active one. */ +function planFocusTab(state: LayoutState, tabId: TabId): readonly LayoutOp[] { + const pane = findTabPane(state, tabId); + return pane.activeTabId === tabId && state.activePaneId === pane.id + ? [] + : [{ type: "focusTab", tabId }]; +} + +/** Focus a pane: nothing to plan while it is the active one. */ +function planFocusPane(state: LayoutState, paneId: PaneId): readonly LayoutOp[] { + return state.activePaneId === paneId ? [] : [{ type: "focusPane", paneId }]; +} + +/** + * Plan a tab's arrival in a docked pane: a page arriving where its kind's page + * already shows merges into it, anything else plans as the kit does. + * @param state - current layout. + * @param tabId - the arriving tab. + * @param toPaneId - the pane it arrives in. + * @param otherwise - the kit's plan for the move. + * @returns the operations. + */ +function arriving( + state: LayoutState, + tabId: TabId, + toPaneId: PaneId, + otherwise: () => readonly LayoutOp[], +): readonly LayoutOp[] { + const kind = pageKind(state, tabId); + if (kind === undefined) return otherwise(); + const existing = panePage(state, toPaneId, kind); + if (existing === undefined || existing === tabId) return otherwise(); + return [ + { type: "closeTab", tabId }, + { type: "focusTab", tabId: existing }, + ]; +} + +/** + * Run one planner against a surface, settle what it left behind, and record the + * whole intent as one history entry. + * @param surface - the session's current surface. + * @param plan - the kit planner to consult. + * @param seed - the registered default page for an empty docked pane. + * @returns the next surface, or the same one when the intent changes nothing. + */ +function advance( + surface: SurfaceState, + plan: SurfacePlan, + seed: () => SidebarRightSeed, +): SurfaceState { + const counter = counting(surface.minted); + const makeTab = (id: TabId): TabRecord => seedRecord(id, seed); + const planned = plan(surface.layout, counter.mint, makeTab); + if (planned.length === 0) return surface; + // The settle planner reads the state the intent produces, so it is applied + // to a scratch copy first; the record then applies both parts once. The + // seed factory is withheld while the intent leaves the column collapsed: + // an empty collapsed column stays empty until the expansion that shows it. + const after = replay(surface.layout, planned); + const settled = planSettle(after, counter.mint, after.expanded ? makeTab : undefined); + const stepped = record(surface.history, surface.layout, [...planned, ...settled]); + return { layout: stepped.state, history: stepped.history, minted: counter.used() }; +} + +/** + * Replace one session's surface, leaving every other session by reference. + * + * A session with no surface yet gets its initial one even when the intent + * changes nothing: materializing is itself the change. + */ +function seat( + state: SidebarRightState, + sessionId: string, + next: (surface: SurfaceState) => SurfaceState, +): Record { + const existing = state.bySession[sessionId]; + const updated = next(existing ?? createSurface()); + return updated === existing ? state.bySession : { ...state.bySession, [sessionId]: updated }; +} + +/** Declared write set; each entry is one settled intent. */ +type SidebarRightActions = { + open: (draft: SidebarRightState, sessionId: string) => void; + setExpanded: (draft: SidebarRightState, sessionId: string, expanded: boolean) => void; + toggleExpanded: (draft: SidebarRightState, sessionId: string) => void; + setMode: (draft: SidebarRightState, sessionId: string, mode: DockMode) => void; + splitPane: ( + draft: SidebarRightState, + sessionId: string, + paneId?: PaneId, + settled?: (paneId: PaneId) => void, + ) => void; + openContent: ( + draft: SidebarRightState, + sessionId: string, + intent: OpenContentIntent, + settled: (tabId: TabId) => void, + ) => void; + duplicateTab: (draft: SidebarRightState, sessionId: string, tabId: TabId) => void; + closeTab: (draft: SidebarRightState, sessionId: string, tabId: TabId) => void; + focusTab: (draft: SidebarRightState, sessionId: string, tabId: TabId) => void; + focusPane: (draft: SidebarRightState, sessionId: string, paneId: PaneId) => void; + placeTab: ( + draft: SidebarRightState, + sessionId: string, + tabId: TabId, + toPaneId: PaneId, + index: number, + ) => void; + dropTab: ( + draft: SidebarRightState, + sessionId: string, + tabId: TabId, + paneId: PaneId, + zone: DockZone, + ) => void; + floatTab: (draft: SidebarRightState, sessionId: string, tabId: TabId, rect?: FloatRect) => void; + unfloatPane: (draft: SidebarRightState, sessionId: string, paneId: PaneId) => void; + moveFloat: ( + draft: SidebarRightState, + sessionId: string, + paneId: PaneId, + x: number, + y: number, + ) => void; + resizeFloat: ( + draft: SidebarRightState, + sessionId: string, + paneId: PaneId, + rect: FloatRect, + ) => void; + resizeSplit: ( + draft: SidebarRightState, + sessionId: string, + splitId: SplitId, + sizes: readonly number[], + ) => void; + undo: (draft: SidebarRightState, sessionId: string) => void; + redo: (draft: SidebarRightState, sessionId: string) => void; +}; + +/** One direction through the recorded sequence. */ +type HistoryStepper = ( + history: History, + state: LayoutState, +) => { history: History; state: LayoutState } | undefined; + +/** Step a surface through the history in one direction. */ +function stepped(surface: SurfaceState, step: HistoryStepper): SurfaceState { + const moved = step(surface.history, surface.layout); + return moved === undefined + ? surface + : { ...surface, layout: moved.state, history: moved.history }; +} + +/** + * Create the Sidebar store handle with per-Session JSON persistence in localStorage. + * + * The default page arrives as a thunk: a pane is seeded when a split or an + * expansion of an empty column needs one, which can be long after the store was + * built and in a language the user has since changed to. + * @param seed - the registered default page, read at each mint. + * @returns the handle (spec, type, identity, and factory in one). + */ +export function createSidebarRightStore( + seed: () => SidebarRightSeed, +): EngineStoreHandle { + const handle = defineStore({ + init: (): SidebarRightState => ({ bySession: {} }), + actions: { + // Materialize a session's surface without changing it, so the first read + // after a session switch sees the collapsed empty column rather than nothing. + open: (d, sessionId: string) => { + d.bySession = seat(d, sessionId, (surface) => surface); + }, + setExpanded: (d, sessionId: string, expanded: boolean) => { + d.bySession = seat(d, sessionId, (s) => + advance(s, (state) => planSetExpanded(state, expanded), seed), + ); + }, + toggleExpanded: (d, sessionId: string) => { + d.bySession = seat(d, sessionId, (s) => + advance(s, (state) => planSetExpanded(state, !state.expanded), seed), + ); + }, + // Switching presentation is recorded like any other change, so stepping + // back through the sequence puts the surface back the way it was drawn. + setMode: (d, sessionId: string, mode: DockMode) => { + d.bySession = seat(d, sessionId, (s) => + advance(s, (state) => planSetMode(state, mode), seed), + ); + }, + // `settled` reports the pane the split created, synchronously, because + // actions return nothing; it is not called when nothing was split. + splitPane: (d, sessionId: string, paneId?: PaneId, settled?: (paneId: PaneId) => void) => { + d.bySession = seat(d, sessionId, (s) => { + const next = advance( + s, + (state, mint, makeTab) => + dockPaneIds(state).length >= 2 || + getPane(state, paneId ?? activeDockPaneId(state)).tabs.length === 0 + ? [] + : planSplitPane(state, mint, paneId, makeTab), + seed, + ); + if (settled !== undefined && next !== s) { + const before = new Set(dockPaneIds(s.layout)); + for (const id of dockPaneIds(next.layout)) { + if (!before.has(id)) settled(id); + } + } + return next; + }); + }, + // One entry carries the whole open: revealing the column (an open behind + // a collapsed panel is not an open), focusing or seating the tab, and + // closing the tab it replaces. `settled` reports the tab the planner + // landed on, synchronously, because actions return nothing. + openContent: (d, sessionId: string, intent, settled) => { + d.bySession = seat(d, sessionId, (s) => + advance( + s, + (state, mint) => { + const { kind, contentId, title, replaceTab: replace } = intent; + const ops: LayoutOp[] = [...planSetExpanded(state, true)]; + // A replaced tab lends its pane and slot; one that floats cannot (a + // floating pane holds one tab), so the new tab lands as if unplaced. + const replaced = replace === undefined ? undefined : findTabPane(state, replace); + const lent = + replace !== undefined && replaced !== undefined && replaced.host === "dock" + ? replaced + : undefined; + const paneId = lent?.id ?? intent.paneId; + const index = + lent === undefined || replace === undefined + ? undefined + : lent.tabs.indexOf(replace); + // A page is unique per pane, not per surface: the pane it would land + // in may already show it, which is then the tab this open settles on. + // The same page in another pane never draws the open away — the kit's + // cross-pane reveal is for resources only. + const page = contentId === pageAddress(kind); + const held = page + ? panePage(state, paneId ?? activeDockPaneId(state), kind) + : undefined; + const revealed = page + ? held + : intent.revealIfOpened === false + ? undefined + : findContentTab(state, contentId, kind); + let openedInNewPane: TabId | undefined; + const split = + intent.preferNewPane === true && replace === undefined && revealed === undefined + ? planSplitPane(state, mint, paneId, (id) => { + openedInNewPane = id; + return { id, kind, contentId, title }; + }) + : []; + const planned = + revealed !== undefined + ? { ops: [{ type: "focusTab" as const, tabId: revealed }], tabId: revealed } + : openedInNewPane !== undefined + ? { ops: split, tabId: openedInNewPane } + : planOpenContent(state, mint, { + kind, + contentId, + title, + ...(paneId === undefined ? {} : { paneId }), + ...(index === undefined ? {} : { index }), + ...(page + ? { revealIfOpened: false } + : intent.revealIfOpened === undefined + ? {} + : { revealIfOpened: intent.revealIfOpened }), + }); + ops.push(...planned.ops); + if (replace !== undefined && replace !== planned.tabId) + ops.push({ type: "closeTab", tabId: replace }); + settled(planned.tabId); + return ops; + }, + seed, + ), + ); + }, + // A page is never copied: the copy would sit beside it in the same pane. + duplicateTab: (d, sessionId: string, tabId: TabId) => { + d.bySession = seat(d, sessionId, (s) => + advance( + s, + (state, mint) => + pageKind(state, tabId) !== undefined ? [] : planDuplicateTab(state, mint, tabId).ops, + seed, + ), + ); + }, + // A tab already gone — closed twice by a racing callback and the user — is + // left alone rather than handed to the kit, which refuses an unknown tab. + // The docked surface's last tab follows the close rule, whoever asks: the + // guide stays (the kit hides its close routes through `canCloseTab`, and + // this plan refuses the programmatic path), and anything else closes + // together with the column, which stays empty until an expansion seeds + // the then-current default page. + closeTab: (d, sessionId: string, tabId: TabId) => { + d.bySession = seat(d, sessionId, (s) => + advance( + s, + (state) => { + if (!canCloseTab(s, tabId)) return []; + if (!soleDockedTab(state, tabId)) return [{ type: "closeTab", tabId }]; + // The collapse also leaves fullscreen: the reopened column shows only + // the reseeded default page, which never earns the whole window. + return [ + { type: "closeTab", tabId }, + ...planSetMode(state, "push"), + ...planSetExpanded(state, false), + ]; + }, + seed, + ), + ); + }, + focusTab: (d, sessionId: string, tabId: TabId) => { + d.bySession = seat(d, sessionId, (s) => + advance(s, (state) => planFocusTab(state, tabId), seed), + ); + }, + focusPane: (d, sessionId: string, paneId: PaneId) => { + d.bySession = seat(d, sessionId, (s) => + advance(s, (state) => planFocusPane(state, paneId), seed), + ); + }, + placeTab: (d, sessionId: string, tabId: TabId, toPaneId: PaneId, index: number) => { + d.bySession = seat(d, sessionId, (s) => + advance( + s, + (state) => + arriving(state, tabId, toPaneId, () => planPlaceTab(state, tabId, toPaneId, index)), + seed, + ), + ); + }, + // Only a centre release lands in the target pane; an edge release makes a + // new pane, where nothing can already be — and when the release drags a + // pane's only tab to that pane's own edge, the default page backfills it. + dropTab: (d, sessionId: string, tabId: TabId, paneId: PaneId, zone: DockZone) => { + d.bySession = seat(d, sessionId, (s) => + advance( + s, + (state, mint, makeTab) => { + if (zone === "top" || zone === "bottom") return []; + if (zone !== "center" && dockPaneIds(state).length >= 2) return []; + const plan = (): readonly LayoutOp[] => + planDropTab(state, mint, tabId, paneId, zone, makeTab); + return zone === "center" ? arriving(state, tabId, paneId, plan) : plan(); + }, + seed, + ), + ); + }, + floatTab: (d, sessionId: string, tabId: TabId, rect?: FloatRect) => { + d.bySession = seat(d, sessionId, (s) => + advance(s, (state, mint) => planFloatTab(state, mint, tabId, rect).ops, seed), + ); + }, + // A floating pane holds one tab; docking it back lands in the active + // docked pane, and only a page is subject to the merge rule there. + unfloatPane: (d, sessionId: string, paneId: PaneId) => { + d.bySession = seat(d, sessionId, (s) => + advance( + s, + (state) => { + const floated = getPane(state, paneId).tabs[0]; + const plan = (): readonly LayoutOp[] => planUnfloatPane(state, paneId); + /* v8 ignore next -- a floating pane holds exactly one tab. */ + return floated === undefined + ? plan() + : arriving(state, floated, activeDockPaneId(state), plan); + }, + seed, + ), + ); + }, + moveFloat: (d, sessionId: string, paneId: PaneId, x: number, y: number) => { + d.bySession = seat(d, sessionId, (s) => + advance(s, () => [{ type: "moveFloat", paneId, x, y }], seed), + ); + }, + resizeFloat: (d, sessionId: string, paneId: PaneId, rect: FloatRect) => { + d.bySession = seat(d, sessionId, (s) => + advance(s, () => [{ type: "resizeFloat", paneId, rect }], seed), + ); + }, + resizeSplit: (d, sessionId: string, splitId: SplitId, sizes: readonly number[]) => { + d.bySession = seat(d, sessionId, (s) => + advance(s, () => planResizeSplit(splitId, sizes, 0.2), seed), + ); + }, + undo: (d, sessionId: string) => { + d.bySession = seat(d, sessionId, (s) => stepped(s, stepBack)); + }, + redo: (d, sessionId: string) => { + d.bySession = seat(d, sessionId, (s) => stepped(s, stepForward)); + }, + }, + }); + return { + ...handle, + create(scopeKey) { + const instance = handle.create(scopeKey); + if (scopeKey === undefined) return instance; + const saved = readSidebarLayout(scopeKey); + if (saved !== undefined) instance.store.set({ bySession: { [scopeKey]: saved } }); + instance.subscribe(() => { + const surface = instance.getSnapshot().bySession[scopeKey]; + if (surface !== undefined) writeSidebarLayout(scopeKey, surface); + }); + return { + ...instance, + clearPersisted: () => { + clearSidebarLayout(scopeKey); + }, + }; + }, + }; +} diff --git a/apps/web-ui/packages/client/ui-sidebar-right/src/client/tab-domain.ts b/apps/web-ui/packages/client/ui-sidebar-right/src/client/tab-domain.ts new file mode 100644 index 000000000..12282e151 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-right/src/client/tab-domain.ts @@ -0,0 +1,243 @@ +/** + * The Tab domain: what a tab record carries that the layout does not. + * + * One `TabOccurrence` per open record, keyed by (session, tab id): where the + * tab was navigated to, an abort signal spanning the record's life, and the + * actions the tab may take on itself. Holding a record also pins its address in + * the resource model, so switching tabs unmounts a body without dropping its + * content. + * + * `sync` reconciles occurrences against one session's layout after every + * commit: a record that appeared is pinned, one that vanished (closed, or its + * open undone) is aborted and dropped. A record restored by undo is a new + * occurrence and is fetched again if the resource model already let it go. The + * controller syncs each session from that session's adopted store on every + * commit, on screen or not, so a record closed from another session's seat is + * aborted on that commit. The slot framework binds the navigation sources for + * each record's `useTabInfo` reader. + */ +import type { Branded } from "@deepseek-ai/dsh-brand"; +import { randomUUID } from "@deepseek-ai/dsh-util-crypto"; +import type { LayoutState, PaneId, TabId, TabRecord } from "@deepseek-ai/dsh-client-ui-dockkit"; +import { findTabPane } from "@deepseek-ai/dsh-client-ui-dockkit"; +import { createSnapshotStore, type SnapshotStore } from "@deepseek-ai/dsh-client-store"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { SidebarRightNavigationParams } from "./contract/params.ts"; +import type { + SidebarRightTabActions, + SidebarRightTabCommands, + SidebarRightTabNavigation, + SidebarRightTabPlacement, +} from "./contract/slots.ts"; +import type { + SidebarRightOpenResourceOptions, + SidebarRightOpenTabOptions, + SidebarRightPlacement, +} from "./service.ts"; + +/** + * The navigation face a tab's actions call back into, aimed at the session the + * tab is in; nothing happens for a session whose store is not adopted. + */ +export interface SidebarRightNavigator { + /** Open a resource in one session; see `ISidebarRight.openResource`. */ + openResourceIn( + sessionId: SessionId, + address: string, + options?: SidebarRightOpenResourceOptions, + ): void; + /** Open a page type in one session; see `ISidebarRight.openTab`. */ + openTabIn(sessionId: SessionId, kind: string, options?: SidebarRightOpenTabOptions): void; + /** Close a tab of one session. */ + closeIn(sessionId: SessionId, tabId: TabId): void; +} + +/** `ctx.resources.pin`: hold an address's content open for as long as `signal` lives. */ +export type PinResource = (address: string, signal: AbortSignal) => void; + +/** Identity of one open lifetime; a restored tab receives a new value. */ +export type SidebarRightOccurrenceId = Branded<"SidebarRightOccurrenceId">; + +/** What one open tab record holds beyond its layout entry. */ +export interface TabOccurrence { + /** Operations of the currently mounted page body. */ + readonly commands: SidebarRightTabCommands; + + /** Distinguishes a reopened record from its previous lifetime. */ + readonly id: SidebarRightOccurrenceId; + readonly sessionId: SessionId; + readonly tabId: TabId; + /** Aborted when the record disappears or this package unloads. */ + readonly signal: AbortSignal; + /** The latest navigation aimed at the record; `set` on every `navigate`. */ + readonly navigation: SnapshotStore; + /** Stable for the occurrence's life, so a body may hold it. */ + readonly tabActions: SidebarRightTabActions; +} + +/** An occurrence plus what only the domain touches. */ +interface Held extends TabOccurrence { + commands: SidebarRightTabCommands; + + readonly controller: AbortController; + /** The docked pane holding the record at the last sync; `undefined` while it floats or before any sync. */ + paneId: PaneId | undefined; + /** Whether the resource model has been asked to hold the address. */ + pinned: boolean; +} + +/** Every session's occurrences. */ +export class TabDomain { + private readonly bySession = new Map>(); + + /** + * @param navigator - where tab actions go, aimed at the tab's session; the navigation controller. + * @param pin - `ctx.resources.pin`, called once per occurrence at its first sync. + */ + constructor( + private readonly navigator: SidebarRightNavigator, + private readonly pin: PinResource, + ) {} + + /** + * Reconcile one session's occurrences with its committed layout. + * + * Called by the seat after every commit, and only then: aborting a vanished + * record runs the types' cleanup, which writes their stores. + * @param sessionId - the session whose layout committed. + * @param layout - that session's layout as committed. + */ + sync(sessionId: SessionId, layout: LayoutState): void { + const held = this.session(sessionId); + for (const [tabId, occurrence] of held) { + if (layout.tabs[tabId] !== undefined) continue; + held.delete(tabId); + occurrence.controller.abort(); + } + for (const tab of Object.values(layout.tabs)) { + const occurrence = + held.get(tab.id) ?? + this.hold(sessionId, tab.id, { address: tab.contentId, params: undefined, revision: 0 }); + const pane = findTabPane(layout, tab.id); + occurrence.paneId = pane.host === "dock" ? pane.id : undefined; + if (occurrence.pinned) continue; + occurrence.pinned = true; + this.pin(occurrence.navigation.getSnapshot().address, occurrence.signal); + } + } + + /** + * Read an occurrence created by navigation or committed-store reconciliation. + * @param sessionId - the session the record is in. + * @param tab - the record being drawn. + * @returns its occurrence. + * @throws when the record has not been reconciled or has disappeared. + */ + occurrence(sessionId: SessionId, tab: Pick): TabOccurrence { + const occurrence = this.bySession.get(sessionId)?.get(tab.id); + if (occurrence === undefined) + throw new Error( + `sidebarRight: tab "${tab.id}" has no committed occurrence in session "${sessionId}"`, + ); + return occurrence; + } + + /** + * Record that an `open` settled on a tab. + * + * A record the layout has not yet shown the seat gets its occurrence here, so + * the body's first render already carries the opener's `params`. + * @param sessionId - the session opened into. + * @param tabId - the tab the open settled on. + * @param target - the address and the opener's params. + */ + navigate( + sessionId: SessionId, + tabId: TabId, + target: { address: string; params: SidebarRightNavigationParams }, + ): void { + const existing = this.session(sessionId).get(tabId); + if (existing === undefined) { + this.hold(sessionId, tabId, { ...target, revision: 1 }); + return; + } + existing.navigation.set({ + ...target, + revision: existing.navigation.getSnapshot().revision + 1, + }); + } + + /** Abort every occurrence of every session; the package is unloading. */ + dispose(): void { + for (const held of this.bySession.values()) { + for (const occurrence of held.values()) occurrence.controller.abort(); + } + this.bySession.clear(); + } + + private session(sessionId: SessionId): Map { + let held = this.bySession.get(sessionId); + if (held === undefined) { + held = new Map(); + this.bySession.set(sessionId, held); + } + return held; + } + + private hold(sessionId: SessionId, tabId: TabId, navigation: SidebarRightTabNavigation): Held { + const controller = new AbortController(); + const { navigator } = this; + // Where an open from this tab lands, read at call time because the tab may + // have been dragged since: `replaceTab: true` names this tab; otherwise the + // pane holding it, unless the caller named another. + const place = (placement: SidebarRightTabPlacement): SidebarRightPlacement => ({ + ...(placement.replaceTab === true + ? { replaceTab: tabId } + : held.paneId === undefined + ? {} + : { paneId: held.paneId }), + ...(placement.paneId === undefined ? {} : { paneId: placement.paneId }), + ...(placement.preferNewPane === undefined ? {} : { preferNewPane: placement.preferNewPane }), + ...(placement.revealIfOpened === undefined + ? {} + : { revealIfOpened: placement.revealIfOpened }), + }); + const held: Held = { + id: randomUUID() as SidebarRightOccurrenceId, + sessionId, + tabId, + controller, + commands: {}, + signal: controller.signal, + navigation: createSnapshotStore(navigation), + paneId: undefined, + pinned: false, + tabActions: { + bindCommands: (commands) => { + if (controller.signal.aborted) return () => {}; + held.commands = commands; + const release = (): void => { + if (held.commands === commands) held.commands = {}; + controller.signal.removeEventListener("abort", release); + }; + controller.signal.addEventListener("abort", release, { once: true }); + return release; + }, + openResource: (address, options = {}) => { + navigator.openResourceIn(sessionId, address, { + ...place(options), + params: options.params, + }); + }, + openTab: (kind, options = {}) => { + navigator.openTabIn(sessionId, kind, { ...place(options), params: options.params }); + }, + close: () => { + navigator.closeIn(sessionId, tabId); + }, + }, + }; + this.session(sessionId).set(tabId, held); + return held; + } +} diff --git a/apps/web-ui/packages/client/ui-sidebar-right/src/client/tab-info.ts b/apps/web-ui/packages/client/ui-sidebar-right/src/client/tab-info.ts new file mode 100644 index 000000000..913ffa6b6 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-right/src/client/tab-info.ts @@ -0,0 +1,89 @@ +/** Slot-owned tab information derived from framework-bound store and navigation hooks. */ +import type { ShortcutCatalogEntry } from "@deepseek-ai/dsh-client-shortcuts/client"; +import { useMemo } from "react"; +import { findTabPane } from "@deepseek-ai/dsh-client-ui-dockkit"; +import type { TabId } from "@deepseek-ai/dsh-client-ui-dockkit"; +import type { + KeyedSnapshotSelectorHook, + PropsStore, + SlotHookFactory, +} from "@deepseek-ai/dsh-client-ui-slots"; +import type { + SidebarRightTabActions, + SidebarRightTabNavigation, + UseSidebarRightTabInfo, +} from "./contract/slots.ts"; +import type { createSidebarRightStore } from "./stores.ts"; + +/** Stable dispatch identity and framework hooks; never passed as tab component props. */ +export interface TabHookContext { + readonly shortcuts: readonly ShortcutCatalogEntry[]; + readonly tabId: TabId; + readonly title: boolean; + readonly fullscreen: boolean; + readonly active: boolean; + readonly signal: AbortSignal; + readonly actions: SidebarRightTabActions; + readonly useStore: PropsStore>["useStore"]; + readonly useTabNavigation: KeyedSnapshotSelectorHook; +} + +/** + * Bind a tab occurrence without subscribing or creating records during factory evaluation. + * @param standard - framework session identity. + * @param context - stable record lifetime and framework-bound readers. + * @returns the tab information hook. + */ +export const tabInfoFactory: SlotHookFactory<"sidebar.right.pane.tab", UseSidebarRightTabInfo> = ( + standard, + context, +) => { + const { sessionId } = standard; + const { + tabId, + title, + fullscreen, + active, + signal, + actions, + useStore, + useTabNavigation, + shortcuts, + } = context; + return function useTabInfo() { + const layout = useStore((state) => state.bySession[sessionId]?.layout); + const navigation = useTabNavigation(tabId); + return useMemo(() => { + const tab = layout?.tabs[tabId]; + if (layout === undefined || tab === undefined || navigation === undefined) { + throw new Error(`sidebarRight: tab "${tabId}" is not committed in session "${sessionId}"`); + } + const pane = findTabPane(layout, tabId); + return { + sidebar: { expanded: layout.expanded, fullscreen }, + panel: { id: pane.id }, + tab: { + ...tab, + visible: + active && + (pane.host === "float" || (layout.expanded && (title || pane.activeTabId === tabId))), + navigation, + signal, + actions, + refreshShortcut: shortcuts.find((row) => row.id === "page.refresh"), + }, + }; + }, [layout, navigation, tabId, title, fullscreen, active, signal, actions, shortcuts]); + }; +}; + +/** + * Forward the framework-bound tab hook to guide content. + * @param _standard - the guide's framework standard props. + * @param useTabInfo - the enclosing tab's framework-bound reader. + * @returns the same reader for the replacement. + */ +export const guideTabInfoFactory = ( + _standard: unknown, + useTabInfo: UseSidebarRightTabInfo, +): UseSidebarRightTabInfo => useTabInfo; diff --git a/apps/web-ui/packages/client/ui-sidebar-right/src/client/tab-inventory.ts b/apps/web-ui/packages/client/ui-sidebar-right/src/client/tab-inventory.ts new file mode 100644 index 000000000..3dc527960 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-right/src/client/tab-inventory.ts @@ -0,0 +1,77 @@ +/** Metadata inventory of saved and adopted layouts without mounting their content. */ +import { createSnapshotStore, type ObservableSnapshot } from "@deepseek-ai/dsh-client-store"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { TabId, TabRecord } from "@deepseek-ai/dsh-client-ui-dockkit"; +import { readSidebarLayout, sidebarPersistence } from "./persistence.ts"; + +/** One open occurrence; resource recovery belongs to its kind's provider. */ +export interface SidebarRightOpenTab { + readonly sessionId: SessionId; + readonly tabId: TabId; + readonly kind: string; + readonly contentId: string; +} + +/** Derived membership only; layout stores remain the persisted authority. */ +export class SidebarTabInventory { + private readonly sessions = new Map(); + private readonly snapshot = createSnapshotStore([]); + /** Read-only metadata observable shared with content providers. */ + readonly source: ObservableSnapshot = this.snapshot; + + /** Read saved layouts once before the root service is published. */ + constructor() { + try { + if (typeof localStorage === "undefined") return; + const keys = Array.from({ length: localStorage.length }, (_, index) => + localStorage.key(index), + ); + for (const key of keys) { + if (key === null || !key.startsWith(`${sidebarPersistence}.`)) continue; + const sessionId = key.slice(sidebarPersistence.length + 1) as SessionId; + const saved = readSidebarLayout(sessionId); + if (saved !== undefined) + this.sessions.set( + sessionId, + Object.values(saved.layout.tabs).map((tab) => ({ + sessionId, + tabId: tab.id, + kind: tab.kind, + contentId: tab.contentId, + })), + ); + } + this.publish(); + } catch (_storageUnavailable) { + /* Adopted in-memory layouts still publish their open tabs. */ + } + } + + /** + * Replace membership from the authoritative in-window store. + * @param sessionId - adopted Session. + * @param tabs - current committed records. + */ + update(sessionId: SessionId, tabs: readonly TabRecord[]): void { + this.sessions.set( + sessionId, + tabs.map((tab) => ({ sessionId, tabId: tab.id, kind: tab.kind, contentId: tab.contentId })), + ); + this.publish(); + } + + /** + * Forget a permanently cleared scope. + * @param sessionId - removed Session scope. + */ + remove(sessionId: SessionId): void { + this.sessions.delete(sessionId); + this.publish(); + } + + private publish(): void { + const next = [...this.sessions.values()].flat(); + if (JSON.stringify(next) !== JSON.stringify(this.snapshot.getSnapshot())) + this.snapshot.set(next); + } +} diff --git a/apps/web-ui/packages/client/ui-sidebar-right/src/client/tab-registry.ts b/apps/web-ui/packages/client/ui-sidebar-right/src/client/tab-registry.ts new file mode 100644 index 000000000..d7535ec48 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-right/src/client/tab-registry.ts @@ -0,0 +1,450 @@ +/** + * Stage one of tab-type registration: what a type IS. + * + * A registration is purely static — which addresses the type recognizes, how it + * ranks against other types that recognize the same one, what the tab chip says, + * and whether the type offers an entry box on the guide page. Nothing here is + * per-tab, per-session, or a runtime hook: stage two is the keyed + * `sidebar.right.pane.tab` registration that supplies the body under the same + * `kind`, and everything a body needs at runtime arrives in its props. + * + * Address recognition follows VS Code's editor resolver: a glob declaration + * narrows the candidates, an optional `canOpen` predicate vetoes, and the + * survivors are ranked by priority band, then by matched-pattern length, then by + * registration order. Addresses are `scheme://` URIs; the one local change to + * VS Code's glob rule is that a pattern containing `:` matches the whole address + * (`dsh-resource://file/**`, `sidebar://guide`) rather than the URI's path. + * + * A kind may carry one `builtin` and one `extension` registration at once: the + * extension is the one in force — claims, `get`, the guide page, and the body + * and title, which the seat finds under the definition's own `id` — and the + * builtin resumes when the extension unregisters. Everything else colliding on + * a kind throws, as does a second registration of an `id`. + * + * Thunked copy (`title`, `guide[].title`, `guide[].description`) is read again + * on every use, so a language change needs no re-registration. + */ +import type { ShortcutCommandId } from "@deepseek-ai/dsh-client-shortcuts/client"; +import type { ComponentType } from "react"; +import type { Context } from "@deepseek-ai/cordis"; +import type { IconProps } from "@deepseek-ai/dsh-client-ui-primitives"; +import { notifySubscribers } from "@deepseek-ai/dsh-client-store"; +// The POSIX build: the browser bundle must not reach for node's `path`, and +// addresses are `/`-separated regardless of the host platform. +import picomatch from "picomatch/posix"; + +/** + * How strongly a type wants an address it recognizes, as one of three literal + * bands (a string, not an imported constant, so a type shipped from another + * package needs no runtime import from here). + * + * - `extension` — a type from outside the product, and the highest: a type that + * declares nothing outranks every viewer shipped here, exactly as in VS Code. + * It is also the band that may take over a `builtin` kind. + * - `builtin` — the ordinary band for types shipped with the product. + * - `fallback` — plain-content viewers that anything more specific should beat. + * VS Code's text editor holds this position implicitly; ours is a separate + * package, so it says so. + */ +export type SidebarRightTabPriority = "extension" | "builtin" | "fallback"; + +/** Rank of each band, highest first. */ +const RANKS: Readonly> = { + extension: 3, + builtin: 2, + fallback: 1, +}; + +/** The band a definition that names none is in. */ +const DEFAULT_BAND: SidebarRightTabPriority = "extension"; + +/** One entry capsule the guide page offers, contributed by the type it opens (picking it opens that type as a page). */ +export interface SidebarRightGuideEntry { + /** Effective shortcut shown on this entry, when the provider registers one. */ + readonly commandId?: ShortcutCommandId; + /** Stable entry identity within its provider. */ + readonly id: string; + /** Ascending position among every registered type's entries. */ + readonly order: number; + /** + * The capsule's title. + * @returns the title in the current language. + */ + readonly title: () => string; + /** + * One line under the title on what picking the capsule opens. The guide shows + * it only while it lists few enough entries to stay light; a crowded guide + * falls back to titles alone, so a type must stand on its title. + * @returns the description in the current language. + */ + readonly description?: () => string; + /** Optional glyph, drawn before the title; without one the guide draws its cube placeholder. */ + readonly icon?: ComponentType; +} + +/** A guide entry as the registry lists it: with the kind of the type that contributed it, which is what picking it opens. */ +export interface SidebarRightGuideBox extends SidebarRightGuideEntry { + /** Active implementation identity used to dispatch the entry renderer. */ + readonly providerId: string; + readonly kind: string; +} + +/** One registered tab type: its static face, and nothing else. */ +export interface SidebarRightTabDefinition { + /** + * This implementation's identity in the tab system, unique across every + * registration (a package name is the natural value). A kind is not unique — + * an extension may take a builtin's over — so the implementation carries its + * own name, and it is the key its body and title register under in the + * `sidebar.right.pane.tab` and `sidebar.right.pane.tab.title` seats. + */ + readonly id: string; + /** Type discriminator: what the tabs of this type are, and what `openTab` names. */ + readonly kind: string; + /** Each open by kind creates independent content; omission keeps one page per kind in each pane. */ + readonly multiple?: boolean; + /** Lazily keep a visited body mounted through hiding, Session changes and docking; default false. */ + readonly keepMounted?: boolean; + /** + * Resource-address globs this type recognizes; omit for a page type, which is + * opened by kind and recognizes no address. + * + * A pattern containing `:` is matched against the whole address + * (`dsh-resource://file/**`); one without is matched against the URI's path at + * any depth (`*.md` matches `dsh-resource://file/session/s1/home/me/notes.md`), + * and an address that is not a URI matches no such pattern. Matching ignores + * case and does not hide dotfiles. + */ + readonly patterns?: readonly string[]; + /** Defaults to `extension`: a type that says nothing is one from outside the product. */ + readonly priority?: SidebarRightTabPriority; + /** + * Veto an address this type's globs matched. + * + * Synchronous and cheap: it runs on every routing decision. Omit it to accept + * every match. + * @param address - the matched address. + * @returns whether this type will open it. + */ + readonly canOpen?: (address: string) => boolean; + /** + * The tab chip's initial text, captured into the layout record at open time. + * @param address - the address being opened. + * @returns the title in the current language. + */ + readonly title: (address: string) => string; + /** Entry boxes for the guide page. Omit to stay off it. */ + readonly guide?: readonly SidebarRightGuideEntry[]; +} + +/** What a routing decision settles on: who draws the address, and as what. */ +export interface SidebarRightTabClaim { + /** The claiming type. */ + readonly kind: string; + /** + * Stable identity of the content, which is the address itself. + * + * Two opens of the same address are the same tab, which is what makes opening + * idempotent. + */ + readonly contentId: string; + /** Title for the tab chip. */ + readonly title: string; +} + +/** A registered type with its patterns compiled. */ +interface Registered { + readonly definition: SidebarRightTabDefinition; + readonly band: SidebarRightTabPriority; + /** One matcher per declared pattern, in declaration order. */ + readonly matchers: readonly { + readonly pattern: string; + readonly test: (address: string) => boolean; + }[]; + /** Registration position across every kind, the last tiebreaker. */ + readonly order: number; +} + +/** + * Everything registered under one kind: the registration in force and, while + * an `extension` holds a kind a `builtin` also registered, the builtin it + * shadows. A kind is in the registry's map exactly while something is in force + * for it, so a held slot always answers. + */ +interface KindSlot { + inForce: Registered; + shadowed: Registered | undefined; +} + +/** + * Whether a band may join a held kind: an `extension` and a `builtin` pair up + * once, and a `fallback` shares its kind with nothing. + */ +function coexists(slot: KindSlot, band: SidebarRightTabPriority): boolean { + return ( + band !== "fallback" && + slot.inForce.band !== "fallback" && + slot.inForce.band !== band && + slot.shadowed === undefined + ); +} + +/** How a candidate ranked, kept only while `candidates` is sorting. */ +interface Ranked { + readonly definition: SidebarRightTabDefinition; + readonly rank: number; + /** Length of the longest pattern that matched, VS Code's specificity measure. */ + readonly length: number; + /** Registration position, the last tiebreaker. */ + readonly order: number; +} + +/** + * The address's URI path: what a pattern with no scheme separator matches + * against. `dsh-resource://file/session/s1/home/me/b.md` gives `/session/s1/home/me/b.md`; + * `sidebar://guide` gives `''`; an address that is not a URI gives nothing. + */ +function pathOf(address: string): string | undefined { + try { + return new URL(address).pathname; + } catch { + // The only thrower is the URL parser rejecting a non-URI address, which by + // the rule above matches no path pattern. + return undefined; + } +} + +/** Compile one declared pattern into the test the router runs. */ +function matcherFor(pattern: string): (address: string) => boolean { + // `basename: true` is what makes `*.md` match at any depth; it applies only to + // patterns without a separator, which is exactly the path case. + const whole = pattern.includes(":"); + const match = picomatch(pattern, { + nocase: true, + dot: true, + ...(whole ? {} : { basename: true }), + }); + return (address) => { + if (whole) return match(address); + const path = pathOf(address); + return path !== undefined && match(path); + }; +} + +/** + * The registered tab types. + * + * Registration order is part of the contract: it breaks ties between types that + * recognize an address equally well. + */ +export class SidebarRightTabRegistry { + private readonly kinds = new Map(); + private readonly ids = new Set(); + private readonly listeners = new Set<() => void>(); + private registrations = 0; + private cached: readonly SidebarRightTabDefinition[] = []; + private guideEntries: readonly SidebarRightGuideBox[] = []; + + /** @param ctx - Context whose effects own the contributed types. */ + constructor(private readonly ctx: Context) {} + + /** + * Register one tab type for the caller's lifetime. + * + * The caller holds the returned disposer inside its own `ctx.effect`, so a + * type's registration lives exactly as long as the plugin that contributed it. + * An `extension` may register a kind a `builtin` already holds and takes it + * over until it unregisters; a second registration in the same band, or any + * registration meeting a `fallback` of the same kind, is a wiring mistake, and + * so is an `id` already in use. + * @param definition - the contributed type. + * @returns idempotent disposer. + * @throws when the id is taken, or the kind is already registered in a way this one cannot coexist with. + */ + register(definition: SidebarRightTabDefinition): () => void { + const { id, kind } = definition; + const entries = definition.guide ?? []; + if (new Set(entries.map((entry) => entry.id)).size !== entries.length) + throw new Error(`sidebarRight: duplicate guide entry id in "${id}"`); + const band = definition.priority ?? DEFAULT_BAND; + if (this.ids.has(id)) + throw new Error(`sidebarRight: tab type id "${id}" is already registered`); + const held = this.kinds.get(kind); + if (held !== undefined && !coexists(held, band)) { + throw new Error( + `sidebarRight: tab kind "${kind}" is already registered (${held.inForce.band})`, + ); + } + this.registrations += 1; + const entry: Registered = { + definition, + band, + matchers: (definition.patterns ?? []).map((pattern) => ({ + pattern, + test: matcherFor(pattern), + })), + order: this.registrations, + }; + const dispose = this.ctx.effect( + () => { + this.ids.add(id); + const slot = this.enter(kind, entry); + this.refresh(); + return () => { + this.ids.delete(id); + this.leave(kind, slot, entry); + this.refresh(); + }; + }, + `sidebarRight.tabs.register(${JSON.stringify(id)})`, + ); + return () => { + void dispose(); + }; + } + + /** Add a registration to its kind's slot, the higher band in force; `coexists` has already admitted it. */ + private enter(kind: string, entry: Registered): KindSlot { + const held = this.kinds.get(kind); + if (held === undefined) { + const slot: KindSlot = { inForce: entry, shadowed: undefined }; + this.kinds.set(kind, slot); + return slot; + } + if (RANKS[entry.band] > RANKS[held.inForce.band]) { + held.shadowed = held.inForce; + held.inForce = entry; + } else { + held.shadowed = entry; + } + return held; + } + + /** Remove a registration from its kind's slot: a shadowed builtin resumes, and an emptied kind is freed. */ + private leave(kind: string, slot: KindSlot, entry: Registered): void { + if (slot.inForce !== entry) { + slot.shadowed = undefined; + } else if (slot.shadowed === undefined) { + this.kinds.delete(kind); + } else { + slot.inForce = slot.shadowed; + slot.shadowed = undefined; + } + } + + /** Every kind's registration in force, in registration order. */ + private active(): Registered[] { + return [...this.kinds.values()] + .map((slot) => slot.inForce) + .sort((left, right) => left.order - right.order); + } + + /** + * Registered types in registration order. + * @returns reference-stable entries. + */ + entries(): readonly SidebarRightTabDefinition[] { + return this.cached; + } + + /** + * Every type in force's guide entries, in `order`, each naming the kind it opens. + * @returns reference-stable entries. + */ + guide(): readonly SidebarRightGuideBox[] { + return this.guideEntries; + } + + /** + * The type in force for a kind. + * @param kind - the type discriminator. + * @returns the type, or `undefined` when nothing registered it. + */ + get(kind: string): SidebarRightTabDefinition | undefined { + return this.kinds.get(kind)?.inForce.definition; + } + + /** + * Every type that would open an address, best first. + * + * Ranked by priority band, then by the length of the pattern that matched, + * then by registration order. Types whose `canOpen` vetoes are absent. + * @param address - the address a caller wants opened. + * @returns the ranked types; empty when nothing recognizes the address. + */ + candidates(address: string): readonly SidebarRightTabDefinition[] { + const ranked: Ranked[] = []; + for (const { definition, band, matchers, order } of this.active()) { + let length = -1; + for (const matcher of matchers) { + if (matcher.test(address) && matcher.pattern.length > length) + length = matcher.pattern.length; + } + if (length < 0) continue; + if (definition.canOpen !== undefined && !definition.canOpen(address)) continue; + ranked.push({ definition, rank: RANKS[band], length, order }); + } + ranked.sort( + (left, right) => + right.rank - left.rank || right.length - left.length || left.order - right.order, + ); + return ranked.map((entry) => entry.definition); + } + + /** + * Decide which type opens an address, and as what. + * + * Without `kind`, the best candidate wins. With `kind`, that type opens the + * address if its `canOpen` agrees — its globs are not consulted, because + * naming the type IS the decision. + * + * An address no type will open is a wiring mistake, not a user error, so this + * throws rather than reporting absence. + * @param address - the address a caller wants opened. + * @param kind - a type named by the caller, overriding the ranking. + * @returns the claiming type and the record to open. + */ + claim(address: string, kind?: string): SidebarRightTabClaim { + if (kind !== undefined) { + const definition = this.get(kind); + if (definition === undefined) { + throw new Error(`sidebarRight: no tab type is registered as "${kind}"`); + } + if (definition.canOpen !== undefined && !definition.canOpen(address)) { + throw new Error(`sidebarRight: tab type "${kind}" refuses "${address}"`); + } + return { kind, contentId: address, title: definition.title(address) }; + } + const [chosen] = this.candidates(address); + if (chosen === undefined) { + throw new Error(`sidebarRight: no registered tab type claims "${address}"`); + } + return { kind: chosen.kind, contentId: address, title: chosen.title(address) }; + } + + /** + * Observe low-frequency registry changes. + * @param listener - synchronous invalidation callback. + * @returns unsubscribe callback. + */ + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + private refresh(): void { + this.cached = this.active().map((entry) => entry.definition); + this.guideEntries = this.cached + .flatMap((definition) => + (definition.guide ?? []).map((entry) => ({ + ...entry, + kind: definition.kind, + providerId: definition.id, + })), + ) + .sort((left, right) => left.order - right.order); + notifySubscribers(this.listeners, "[ui-sidebar-right] tab registry"); + } +} diff --git a/apps/web-ui/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.module.css b/apps/web-ui/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.module.css new file mode 100644 index 000000000..f6dfc79a1 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.module.css @@ -0,0 +1,126 @@ +/* The guide body's own sheet: its domain owns these rules, so the shell's + stylesheet does not carry them. Tokens only. */ + +/* The capsules form one centred column at the column's own 14px gap, lifted + above the body's middle by the spacer below. The minimum height centres a + short guide while allowing a tall guide to grow into the pane's scroller. */ +.guide { + --dsl-guide-entry-radius: var(--dsw-radius-xl); + display: flex; + flex-direction: column; + gap: 14px; + align-items: center; + justify-content: center; + box-sizing: border-box; + min-height: 100%; + padding: 0 24px; +} + +/* The lift, as a share of the pane's height (a column flex basis resolves + against it): the centred column rises 5% of the pane, and a short pane + gives it up before the capsules clip. */ +.guide::after { + content: ""; + flex: 0 1 10%; +} + +/* The guide tab's own compass above the capsules, at 56px on watermark ink — + not a control. The ink is pinned to the pure-gray static ramp per theme + because the label aliases all ride the bluish ramp, whose cast shows at + this size. Its margin over the column's gap holds the first capsule 30px + below it; the capsules keep the bare gap between them. */ +.hero { + display: flex; + margin-bottom: 16px; + color: var(--dsw-static-neutral-200); +} + +:global(body[data-ds-dark-theme]) .hero { + color: var(--dsw-static-neutral-700); +} + +/* A filled pill, 380px wide by default, its icon and text anchored at the + left inset; `max-width: 100%` shrinks it inside the column's own 24px + insets, so a narrow pane still keeps that gutter. The 56px minimum is the + title-only height; a capsule with a description grows on its vertical + padding. Long text ellipsizes inside rather than widening it. */ +.entry { + display: flex; + gap: 14px; + align-items: center; + box-sizing: border-box; + width: 380px; + max-width: 100%; + min-height: 56px; + padding: 14px 20px; + color: var(--dsw-alias-label-primary); + font: inherit; + text-align: left; + background: var(--dsw-alias-bg-layer-1); + border: 0.5px solid var(--dsw-alias-border-l3); + border-radius: var(--dsl-guide-entry-radius); + cursor: pointer; +} + +.entry:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +/* A fixed 26px box — the described glyph's size — so an entry's glyph sits + centred and every capsule's words start at the same left edge while glyph + sizes differ. */ +.entryIcon { + display: flex; + flex: none; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + color: var(--dsw-alias-label-secondary); +} + +/* The capsule's placeholder cube on ink one step quieter than a registered + glyph's, marking the unclaimed slot. */ +.placeholderInk { + color: var(--dsw-alias-label-tertiary); +} + +/* The capsule's words in one column beside the glyph: the title, and the + description while the guide shows one. */ +.entryText { + display: flex; + flex: 1; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.entryTitle { + overflow: hidden; + font-size: 14px; + line-height: 1.4; + white-space: nowrap; + text-overflow: ellipsis; +} + +/* One line under the title on the quieter ink; it ellipsizes like the title. */ +.entryDescription { + overflow: hidden; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 1.4; + white-space: nowrap; + text-overflow: ellipsis; +} + +/* The chip title's compass, beside the label the strip lays out; the glyph + draws on currentColor, so this ink is the chip rendering's own. */ +.titleIcon { + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +.entryCell { + width: 380px; + max-width: 100%; +} diff --git a/apps/web-ui/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx b/apps/web-ui/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx new file mode 100644 index 000000000..77fd5eb18 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx @@ -0,0 +1,154 @@ +/** + * The guide tab's body: a chain host, and the guide it falls back to. + * + * The chain is the replacement seam. A product with its own idea of what an + * empty sidebar should say registers into `sidebar.right.tab.guide`, and its entry + * takes the whole body; with no entry, or with every entry declining, the guide + * below renders. The shipped guide is the owner's fallback rather than a chain + * entry of its own, so there is always exactly one body and the shipped one + * cannot be outvoted by accident. + * + * The shipped guide is a muted compass over the entry capsules every + * registered type contributed, centred in the body, and nothing else — no + * heading, as a browser start page shows its doors without a caption. While + * at most four entries are listed, a capsule with a description shows it + * under the title; a longer list drops every description to stay light. + * Picking one opens that type as a page + * in this tab's place, so the guide is a doorway rather than a page that stays + * open. + */ +import { ShortcutKeys } from "@deepseek-ai/dsh-client-ui-primitives"; +import type { ShortcutCatalogEntry } from "@deepseek-ai/dsh-client-shortcuts/client"; +import type { ReactNode } from "react"; +import type { ObservableSnapshot } from "@deepseek-ai/dsh-client-store"; +import type { + ChainRenderOpts, + HookContextOf, + InjectFace, + PropsRenderSlots, + PropsRuntime, +} from "@deepseek-ai/dsh-client-ui-slots"; +import type { SidebarRightGuideBox } from "../../tab-registry.ts"; +import { CompassGlyph, CubeGlyph } from "./GuideTitle.tsx"; +import css from "./GuideBody.module.css"; + +/** What the guide body needs from its host beyond the framework shares. */ +export interface GuideInjected { + /** The registry's guide entries in `order`; observable, so a type registering later appears. */ + readonly hooks: { + readonly shortcuts: ObservableSnapshot; + readonly guideEntries: ObservableSnapshot; + }; +} + +/** The guide body's composed props: the tab it draws, its chain child, and the entries. */ +export type GuideBodyProps = PropsRuntime<"sidebar.right.pane.tab"> & + PropsRenderSlots<"sidebar.right.tab.guide" | "sidebar.right.tab.guide.entry"> & + InjectFace; + +/** Entry count past which the guide drops the capsules' descriptions to stay light. */ +const MAX_DESCRIBED_ENTRIES = 4; + +/** One entry capsule: the contributing type's glyph and title, and its description while the guide is short. */ +function EntryBox({ + entry, + described, + onPick, + shortcut, +}: { + shortcut: ShortcutCatalogEntry | undefined; + entry: SidebarRightGuideBox; + described: boolean; + onPick: (entry: SidebarRightGuideBox) => void; +}): ReactNode { + const Icon = entry.icon ?? CubeGlyph; + const description = described ? entry.description?.() : undefined; + return ( + + ); +} + +/** The shipped guide: the tab's own compass over the doors out of the column. */ +function ShippedGuide({ children }: { children: ReactNode }): ReactNode { + return ( +
    + + {children} +
    + ); +} + +/** The guide tab's body, replaceable through its chain child. */ +export function GuideBody({ + useTabInfo, + useGuideEntries, + renderSlot, + renderSlotChain, + useShortcuts, +}: GuideBodyProps): ReactNode { + const shortcuts = useShortcuts((entries) => entries); + const { tab } = useTabInfo(); + const entries = useGuideEntries((entries) => entries); + const options = { + hookContext: useTabInfo, + fallback: ( + + {entries.map((entry) => { + const described = entries.length <= MAX_DESCRIBED_ENTRIES; + const description = described ? entry.description?.() : undefined; + return ( +
    + {renderSlot( + "sidebar.right.tab.guide.entry", + { + entryId: entry.id, + kind: entry.kind, + title: entry.title(), + ...(description === undefined ? {} : { description }), + }, + { + entryKey: entry.providerId, + hookContext: useTabInfo, + fallback: ( + shortcut.id === entry.commandId)} + onPick={(selected) => { + tab.actions.openTab(selected.kind, { replaceTab: true }); + }} + /> + ), + }, + )} +
    + ); + })} +
    + ), + } satisfies ChainRenderOpts & { hookContext: HookContextOf<"sidebar.right.tab.guide"> }; + return renderSlotChain("sidebar.right.tab.guide", {}, options); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideTitle.tsx b/apps/web-ui/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideTitle.tsx new file mode 100644 index 000000000..fd43880ea --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideTitle.tsx @@ -0,0 +1,88 @@ +/** + * The guide type's chip title: the compass before the type's label. Registered + * under `sidebar.right.pane.tab.title`; without it the chip would show the + * bare label. Both guide glyphs live here: the compass the chip and the body's + * hero draw, and the cube the body's icon-less capsules fall back to. + */ +import type { ReactNode } from "react"; +import type { IconProps } from "@deepseek-ai/dsh-client-ui-primitives"; +import type { PropsRuntime } from "@deepseek-ai/dsh-client-ui-slots"; +import css from "./GuideBody.module.css"; + +/** + * The compass: a ring with the needle's rhombus pointing north-east, on + * `currentColor` so each rendering picks its own ink. + * @param props - rendered size and class. + * @returns the compass glyph. + */ +export function CompassGlyph({ size = 16, className }: IconProps): ReactNode { + return ( + + ); +} + +/** + * The cube: an isometric box — hexagonal silhouette, the top face's two edges, + * and the front seam — in straight strokes with softly rounded joins, on + * `currentColor`. The guide body draws it in a capsule whose type registered + * no glyph of its own. + * @param props - rendered size and class. + * @returns the cube glyph. + */ +export function CubeGlyph({ size = 16, className }: IconProps): ReactNode { + return ( + + ); +} + +/** + * The title as the chip and a floating panel's header show it. + * @param props - the tab information hook. + * @returns the compass followed by the tab's title text. + */ +export function GuideTitle({ + useTabInfo, +}: PropsRuntime<"sidebar.right.pane.tab.title">): ReactNode { + const { tab } = useTabInfo(); + return ( + <> + + {tab.title} + + ); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-right/src/client/tabs/guide/definition.ts b/apps/web-ui/packages/client/ui-sidebar-right/src/client/tabs/guide/definition.ts new file mode 100644 index 000000000..9091932b6 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-right/src/client/tabs/guide/definition.ts @@ -0,0 +1,27 @@ +/** + * Stage one of the guide type's registration: what it IS. + */ +import type { TranslateNS } from "@deepseek-ai/dsh-client-locale/client"; +import type { SidebarRightTabDefinition } from "../../tab-registry.ts"; +import { GUIDE_KIND } from "../../contract/seed.ts"; + +/** The shipped guide implementation's identity: the key its body registers under. */ +export const GUIDE_ID = "@deepseek-ai/dsh-client-ui-sidebar-right/guide"; + +/** + * The guide type's registry definition. + * + * A page type: it recognizes no resource address, because a guide views + * nothing, and is opened by kind; `builtin` is the ordinary band for a type + * shipped here. + * @param t - namespace-bound translate, read fresh on every title call. + * @returns the definition to register. + */ +export function guideDefinition(t: TranslateNS<"sidebarRight">): SidebarRightTabDefinition { + return { + id: GUIDE_ID, + kind: GUIDE_KIND, + priority: "builtin", + title: () => t("tab.guide.title"), + }; +} diff --git a/apps/web-ui/packages/client/ui-sidebar-right/src/css-modules.d.ts b/apps/web-ui/packages/client/ui-sidebar-right/src/css-modules.d.ts new file mode 100644 index 000000000..b284bf80b --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-right/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module "*.module.css" { + const classes: Record; + export default classes; +} + +declare module "*.css"; diff --git a/apps/web-ui/packages/client/ui-sidebar-right/src/index.ts b/apps/web-ui/packages/client/ui-sidebar-right/src/index.ts new file mode 100644 index 000000000..ec75b7679 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-right/src/index.ts @@ -0,0 +1,4 @@ +/** Pure host half; the whole Sidebar lives in the browser export. */ + +/** Host plugin body: the Sidebar contributes nothing to the host tree. */ +export function apply(): void {} diff --git a/apps/web-ui/packages/client/ui-sidebar-right/tsdown.config.ts b/apps/web-ui/packages/client/ui-sidebar-right/tsdown.config.ts new file mode 100644 index 000000000..f3a6948e5 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-right/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from "../tsdown.client.ts"; + +export default clientBundle("@deepseek-ai/dsh-client-ui-sidebar-right", ["lib/types/index.js"]); diff --git a/apps/web-ui/packages/client/ui-sidebar-terminal/package.json b/apps/web-ui/packages/client/ui-sidebar-terminal/package.json new file mode 100644 index 000000000..5766c397d --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-terminal/package.json @@ -0,0 +1,44 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-sidebar-terminal", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./client": { + "types": "./src/client/index.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dependencies": { + "@deepseek-ai/dsh-client-store": "*", + "@deepseek-ai/dsh-client-ui-primitives": "*", + "@deepseek-ai/dsh-client-ui-slots": "*", + "@deepseek-ai/dsh-client-ui-theme": "*", + "@deepseek-ai/dsh-typert-protocol": "*", + "@xterm/addon-fit": "0.11.0", + "@xterm/addon-serialize": "0.14.0", + "@xterm/headless": "6.0.0", + "@xterm/xterm": "6.0.0", + "clsx": "^2.0.0", + "react": "^18.2.0" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-terminal-controller", + "@deepseek-ai/dsh-client-ui-sidebar-right", + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-client-ui-theme", + "@deepseek-ai/dsh-client-shortcuts" + ], + "platform": "web" + } + } +} diff --git a/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/LazyTerminalBody.tsx b/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/LazyTerminalBody.tsx new file mode 100644 index 000000000..2538bd6d2 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/LazyTerminalBody.tsx @@ -0,0 +1,20 @@ +/** Load xterm only after a terminal body is mounted. */ +import { lazy, Suspense, type ReactNode } from "react"; +import type { TerminalBodyProps } from "./terminal.tsx"; + +const LoadedTerminalBody = lazy(async () => ({ + default: (await import("./terminal.tsx")).TerminalBody, +})); + +/** + * Suspend while the package-local terminal chunk arrives. + * @param props - Terminal body props supplied by the sidebar slot. + * @returns the deferred terminal renderer. + */ +export function LazyTerminalBody(props: TerminalBodyProps): ReactNode { + return ( + + + + ); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.module.css b/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.module.css new file mode 100644 index 000000000..50be32985 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.module.css @@ -0,0 +1,31 @@ +.stack { + position: fixed; + right: 20px; + bottom: 20px; + display: grid; + gap: 8px; + max-width: min(440px, calc(100vw - 40px)); + pointer-events: auto; +} +.notice { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + border: 0.5px solid var(--dsw-alias-border-l3); + border-radius: var(--dsw-radius-md); + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-bg-l1); + box-shadow: 0 4px 20px #0002; + font-size: 13px; + overflow-wrap: anywhere; +} +.notice button { + flex-shrink: 0; + padding: 4px 8px; + border: 0.5px solid var(--dsw-alias-border-l3); + border-radius: var(--dsw-radius-sm); + color: inherit; + background: transparent; + cursor: pointer; +} diff --git a/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.tsx b/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.tsx new file mode 100644 index 000000000..fbb677309 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.tsx @@ -0,0 +1,53 @@ +/** Failed background cleanup remains actionable after the originating tab disappears. */ +import type { ReactNode } from "react"; +import type { TerminalCloseFailure } from "@deepseek-ai/dsh-api-terminal-controller/client"; +import type { WebTerminalId } from "@deepseek-ai/dsh-api-terminal-controller/types"; +import type { + HostObservable, + InjectFace, + PropsLocale, + PropsRuntime, +} from "@deepseek-ai/dsh-client-ui-slots"; +import type {} from "@deepseek-ai/dsh-client-ui-layout/client"; +import type {} from "./locales.ts"; +import css from "./TerminalCleanup.module.css"; + +/** The model owns cleanup; this seat only shows failures and requests retries. */ +export interface TerminalCleanupInjected { + readonly hooks: { readonly closeFailures: HostObservable }; + /** @param id - failed cleanup identity. */ + readonly retryClose: (id: WebTerminalId) => void; +} + +/** + * Render failed cleanup tasks without recreating or blocking any sidebar tab. + * @param props - root overlay hooks, retry command and localized copy. + * @returns a compact alert stack, empty when no close has failed. + */ +export function TerminalCleanup({ + useCloseFailures, + retryClose, + t, +}: PropsRuntime<"shell.overlay"> & + PropsLocale<"sidebarTerminal"> & + InjectFace): ReactNode { + const failures = useCloseFailures((value) => value); + if (failures.length === 0) return null; + return ( +
    + {failures.map((failure) => ( +
    + {t("cleanupFailed", { title: failure.title, message: failure.message })} + +
    + ))} +
    + ); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/TerminalGuide.module.css b/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/TerminalGuide.module.css new file mode 100644 index 000000000..37412c3b9 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/TerminalGuide.module.css @@ -0,0 +1,87 @@ +.entry { + position: relative; + box-sizing: border-box; + display: flex; + align-items: center; + gap: 14px; + width: 100%; + min-height: 56px; + padding: 14px 20px; + overflow: hidden; + border: 0.5px solid var(--dsw-alias-border-l3); + border-radius: var(--dsl-guide-entry-radius); + background: var(--dsw-alias-bg-layer-1); +} +.main { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border-radius: 0; +} +.main:focus-visible { + outline-offset: -2px; +} +.icon { + position: relative; + display: flex; + align-items: center; + justify-content: center; + flex: none; + width: 26px; + height: 26px; + color: var(--dsw-alias-label-secondary); + pointer-events: none; +} +.text { + position: relative; + flex: 1; + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; + pointer-events: none; +} +.titleRow { + display: flex; + align-items: center; + min-width: 0; + gap: 4px; +} +.title { + overflow: hidden; + color: var(--dsw-alias-label-primary); + font-size: 14px; + line-height: 1.4; + white-space: nowrap; + text-overflow: ellipsis; +} +.description { + overflow: hidden; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 1.4; + white-space: nowrap; + text-overflow: ellipsis; +} +.trigger { + flex: none; + height: 20px; + width: 20px; + padding: 0; + border-radius: 4px; +} +.trigger svg { + width: 14px; + height: 14px; + color: var(--dsw-alias-label-tertiary); +} +.menu { + display: flex; + flex: none; + pointer-events: auto; +} +.shortcut { + position: relative; + pointer-events: none; +} diff --git a/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/TerminalGuide.tsx b/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/TerminalGuide.tsx new file mode 100644 index 000000000..35458779e --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/TerminalGuide.tsx @@ -0,0 +1,156 @@ +/** Shell launch menu owned by the terminal provider's guide entry. */ +import { useEffect, useState, type ReactNode } from "react"; +import type { ShortcutCatalogEntry } from "@deepseek-ai/dsh-client-shortcuts/client"; +import type { HostObservable } from "@deepseek-ai/dsh-client-ui-slots"; +import { + PluginArtworkTerminal, + ShortcutKeys, + Button, + IconChevronDownOutlineRegular, + Menu, +} from "@deepseek-ai/dsh-client-ui-primitives"; +import type { TerminalLaunchShells } from "@deepseek-ai/dsh-api-terminal-controller/client"; +import type { InjectFace, PropsLocale, PropsRuntime } from "@deepseek-ai/dsh-client-ui-slots"; +import type {} from "@deepseek-ai/dsh-client-ui-sidebar-right/client"; +import type {} from "./locales.ts"; +import css from "./TerminalGuide.module.css"; + +/** Provider-owned discovery and browser preference writes. */ +export interface TerminalGuideInjected { + readonly hooks: { readonly shortcuts: HostObservable }; + /** @param signal - open menu lifetime. @returns current Host choices and browser preference. */ + readonly loadShells: (signal: AbortSignal) => Promise; + /** @param path - Host-discovered shell selected for the next terminal. */ + readonly selectShell: (path: string) => void; +} + +/** Session guide owner props, framework hooks and terminal actions. */ +export type TerminalGuideProps = PropsRuntime<"sidebar.right.tab.guide.entry"> & + PropsLocale<"sidebarTerminal"> & + InjectFace; + +type MenuState = + | { phase: "loading" } + | { phase: "ready"; choices: TerminalLaunchShells } + | { phase: "failed"; message: string }; + +/** + * Open the remembered shell from the card or choose another shell from its menu. + * The card displays its effective shortcut; the shell menu sits beside the title. + * @param props - guide copy, enclosing tab actions and cancellable discovery. + * @returns separate launch and menu buttons within one guide card. + */ +export function TerminalGuide({ + title, + description, + kind, + useTabInfo, + loadShells, + selectShell, + t, + useShortcuts, +}: TerminalGuideProps): ReactNode { + const shortcut = useShortcuts((entries) => entries.find((entry) => entry.id === "terminal.new")); + const { tab } = useTabInfo(); + const [open, setOpen] = useState(false); + const [attempt, setAttempt] = useState(0); + const [state, setState] = useState({ phase: "loading" }); + useEffect(() => { + if (!open) return; + const lifetime = new AbortController(); + void loadShells(lifetime.signal).then( + (choices) => { + if (!lifetime.signal.aborted) setState({ phase: "ready", choices }); + }, + (error: unknown) => { + if (!lifetime.signal.aborted) + setState({ + phase: "failed", + message: error instanceof Error ? error.message : String(error), + }); + }, + ); + return () => { + lifetime.abort(); + }; + }, [open, attempt, loadShells]); + const items = + state.phase === "ready" + ? state.choices.shells.map((shell) => ({ id: shell.path, label: shell.name })) + : state.phase === "loading" + ? [{ id: "loading", label: t("shellLoading"), disabled: true }] + : [ + { id: "error", label: t("failed", { message: state.message }), disabled: true }, + { id: "retry", label: t("retry") }, + ]; + return ( +
    + + } + /> + + {description !== undefined && ( + + )} + + {shortcut !== undefined && shortcut.keys.length > 0 && ( + + )} +
    + ); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx b/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx new file mode 100644 index 000000000..463f15a0d --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx @@ -0,0 +1,15 @@ +/** Terminal glyph for sidebar tab titles. */ +import type { ReactNode } from "react"; + +/** + * Render the tab title's terminal prompt in the surrounding text color. + * @returns a decorative sixteen-pixel line glyph. + */ +export function TerminalIcon(): ReactNode { + return ( + + ); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/TerminalRecovery.tsx b/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/TerminalRecovery.tsx new file mode 100644 index 000000000..dc553ce25 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/TerminalRecovery.tsx @@ -0,0 +1,52 @@ +/** A Session header lifetime restores retained Host terminals without saving sidebar layout. */ +import { useEffect, useState, type ReactNode } from "react"; +import type { PropsLocale, PropsRuntime } from "@deepseek-ai/dsh-client-ui-slots"; +import type {} from "@deepseek-ai/dsh-client-ui-conversation/client"; +import type {} from "./locales.ts"; + +/** The plugin coordinates once-per-page recovery; the component only owns an error notice. */ +export interface TerminalRecoveryInjected { + /** @returns after retained terminals have been opened as sidebar tabs. */ + readonly restore: () => Promise; +} + +/** + * Restore terminals when a Session is displayed, with a retry action on lookup failure. + * @param props - Session header lifetime, restoration callback and localized copy. + * @returns nothing on success, or an unobtrusive retry control. + */ +export function TerminalRecovery({ + restore, + t, +}: PropsRuntime<"conversation.session.header.actions"> & + PropsLocale<"sidebarTerminal"> & + TerminalRecoveryInjected): ReactNode { + const [error, setError] = useState(); + const [attempt, setAttempt] = useState(0); + useEffect(() => { + let active = true; + void restore().then( + () => { + if (active) setError(undefined); + }, + (reason: unknown) => { + if (active) setError(reason instanceof Error ? reason.message : String(reason)); + }, + ); + return () => { + active = false; + }; + }, [restore, attempt]); + return error === undefined ? null : ( + + ); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/TerminalTitle.module.css b/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/TerminalTitle.module.css new file mode 100644 index 000000000..908eddd48 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/TerminalTitle.module.css @@ -0,0 +1,18 @@ +.title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.name { + width: 120px; + min-width: 48px; + max-width: 100%; + padding: 0 4px; + color: inherit; + font: inherit; + background: var(--dsw-alias-bg-l1); + border: 0.5px solid var(--dsw-alias-border-l3); + border-radius: var(--dsw-radius-sm); + outline: none; +} diff --git a/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/TerminalTitle.tsx b/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/TerminalTitle.tsx new file mode 100644 index 000000000..1e884e436 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/TerminalTitle.tsx @@ -0,0 +1,88 @@ +/** Live terminal names in docked and floating tab chrome. */ +import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import type { InjectFace, PropsLocale, PropsRuntime } from "@deepseek-ai/dsh-client-ui-slots"; +import { TerminalIcon } from "./TerminalIcon.tsx"; +import type { TerminalInjected } from "./face.ts"; +import type {} from "./locales.ts"; +import css from "./TerminalTitle.module.css"; + +/** + * Render the terminal name, editable in place on double-click. + * @param props - sidebar occurrence, terminal model and localized copy. + * @returns the terminal icon and current name or its editor. + */ +export function TerminalTitle({ + useTabInfo, + useTerminal, + view, + t, +}: PropsRuntime<"sidebar.right.pane.tab.title"> & + PropsLocale<"sidebarTerminal"> & + InjectFace): ReactNode { + const { tab } = useTabInfo(); + const title = useTerminal(tab.id, (state) => state?.info?.title ?? state?.title) ?? tab.title; + const [editing, setEditing] = useState(false); + const input = useRef(null); + const label = useRef(null); + const cancelled = useRef(false); + useLayoutEffect(() => { + if (editing) return; + // Dockkit captures the pointer on its drag handle, which receives the double-click. + const chip = label.current?.closest("[data-dockkit-tab], [data-dockkit-float-grip]"); + const rename = (event: Event): void => { + event.stopPropagation(); + cancelled.current = false; + setEditing(true); + }; + chip?.addEventListener("dblclick", rename); + return () => { + chip?.removeEventListener("dblclick", rename); + }; + }, [editing]); + useLayoutEffect(() => { + if (!editing) return; + input.current?.focus(); + input.current?.select(); + }, [editing]); + return ( + <> + + {editing ? ( + { + event.stopPropagation(); + }} + onClick={(event) => { + event.stopPropagation(); + }} + onDoubleClick={(event) => { + event.stopPropagation(); + }} + onBlur={(event) => { + setEditing(false); + const next = event.currentTarget.value.trim(); + if (!cancelled.current && next !== "" && next !== title) void view(tab.id).rename(next); + }} + onKeyDown={(event) => { + event.stopPropagation(); + // oxlint-disable-next-line typescript/no-deprecated -- Some IMEs report composition only through keyCode 229. + if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return; + if (event.key === "Escape") { + cancelled.current = true; + event.currentTarget.blur(); + } else if (event.key === "Enter") event.currentTarget.blur(); + }} + /> + ) : ( + + {title} + + )} + + ); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/face.ts b/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/face.ts new file mode 100644 index 000000000..358ef1710 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/face.ts @@ -0,0 +1,27 @@ +/** Injected terminal commands and keyed observable state. */ +import type { WebTerminalId } from "@deepseek-ai/dsh-api-terminal-controller/types"; +import type { + TerminalView, + TerminalViewState, +} from "@deepseek-ai/dsh-api-terminal-controller/client"; +import type { HostObservable } from "@deepseek-ai/dsh-client-ui-slots"; +import type { ThemeSnapshot } from "@deepseek-ai/dsh-client-ui-theme/client"; + +/** The terminal's React-free model is resolved by sidebar occurrence. */ +export interface TerminalInjected { + /** @param key - sidebar occurrence key. @returns its terminal commands. */ + readonly view: (key: string) => TerminalView; + readonly keyedHooks: { readonly terminal: (key: string) => HostObservable }; +} + +/** The terminal screen follows the resolved application theme through a framework hook. */ +export interface TerminalBodyInjected extends TerminalInjected { + readonly hooks: { readonly theme: HostObservable }; +} + +declare module "@deepseek-ai/dsh-client-ui-sidebar-right/client" { + interface SidebarRightTabParamsMap { + /** An existing Host terminal selected from the Session terminal list. */ + terminal: { terminalId: WebTerminalId } | { shellPath: string }; + } +} diff --git a/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/index.ts b/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/index.ts new file mode 100644 index 000000000..c24d18111 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/index.ts @@ -0,0 +1,224 @@ +/** Register interactive terminal tabs and explicit process cleanup with the sidebar. */ +import type { ShortcutCommandId } from "@deepseek-ai/dsh-client-shortcuts/client"; +import type { Context } from "@deepseek-ai/cordis"; +import type { WebTerminalId } from "@deepseek-ai/dsh-api-terminal-controller/types"; +import type { + SidebarRightTabParamsMap, + TabId, +} from "@deepseek-ai/dsh-client-ui-sidebar-right/client"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type {} from "@deepseek-ai/dsh-api-terminal-controller/client"; +import type {} from "@deepseek-ai/dsh-client-ui-sidebar-right/client"; +import type {} from "@deepseek-ai/dsh-client-ui-renderer/client"; +import type {} from "@deepseek-ai/dsh-client-locale/client"; +import type {} from "@deepseek-ai/dsh-client-ui-session/client"; +import type {} from "@deepseek-ai/dsh-client-ui-theme/client"; +import { PluginArtworkTerminal } from "@deepseek-ai/dsh-client-ui-primitives"; +import { TerminalGuide, type TerminalGuideInjected } from "./TerminalGuide.tsx"; +import { LazyTerminalBody } from "./LazyTerminalBody.tsx"; +import { TerminalTitle } from "./TerminalTitle.tsx"; +// import { TerminalRecovery, type TerminalRecoveryInjected } from './TerminalRecovery.tsx' +// import { TerminalCleanup, type TerminalCleanupInjected } from './TerminalCleanup.tsx' +import type { TerminalBodyInjected, TerminalInjected } from "./face.ts"; +import { en, zh } from "./locales.ts"; + +/** Services needed by the terminal's two sidebar seats. */ +export const inject = [ + "slots", + "locale", + "sidebarRight", + "sidebarRightTabs", + "webTerminals", + "theme", + "shortcuts", +]; + +/** + * Register the terminal type, observable views and background process cleanup. + * @param ctx - Client root Context with sidebar and terminal services. + */ +export function apply(ctx: Context): void { + // let disposed = false + // const recovered = new Map>() + // ctx.effect(() => () => { disposed = true; recovered.clear() }, 'ui-sidebar-terminal.lifetime') + ctx.effect(() => { + const sync = (): void => { + ctx.webTerminals.retainTabs( + ctx.sidebarRight.openTabs.getSnapshot().filter((tab) => tab.kind === "terminal"), + ); + }; + const unsubscribe = ctx.sidebarRight.openTabs.subscribe(sync); + sync(); + return () => { + unsubscribe(); + ctx.webTerminals.retainTabs([]); + }; + }, "ui-sidebar-terminal.window-holds"); + const target = ( + sessionId: SessionId, + key: string, + ): SidebarRightTabParamsMap["terminal"] | undefined => + ctx.sidebarRight.tabDomain.occurrence(sessionId, { id: key as TabId }).navigation.getSnapshot() + .params; + const terminalId = (sessionId: SessionId, key: string): WebTerminalId | undefined => { + const params = target(sessionId, key); + return params !== undefined && "terminalId" in params ? params.terminalId : undefined; + }; + const view = (sessionId: SessionId, key: string) => { + const params = target(sessionId, key); + const contentId = ctx.sidebarRight.tabDomain + .occurrence(sessionId, { id: key as TabId }) + .navigation.getSnapshot().address; + return ctx.webTerminals.view( + sessionId, + key, + contentId, + terminalId(sessionId, key), + params !== undefined && "shellPath" in params ? params.shellPath : undefined, + ); + }; + const namespace = "sidebarTerminal"; + const id = "@deepseek-ai/dsh-client-ui-sidebar-terminal"; + const t = ctx.locale.bind(namespace); + ctx.effect( + () => + ctx.shortcuts.register({ + id: "terminal.new" as ShortcutCommandId, + label: () => t("new"), + aliases: ["new terminal", "shell"], + defaults: { + "desktop:macos": { code: "Backquote", modifiers: ["control"] }, + "desktop:windows": { code: "Backquote", modifiers: ["control"] }, + "desktop:linux": { code: "Backquote", modifiers: ["control"] }, + "web:macos": { code: "Backquote", modifiers: ["control"] }, + "web:windows": { code: "Backquote", modifiers: ["control"] }, + }, + regions: ["page", "editable", "terminal"], + modals: [], + resolve: ({ target: element }) => { + const target = ctx.sidebarRight.commandTarget(element); + if (target === undefined) return { status: "blocked", reason: t("shortcut.noSession") }; + return { + status: "handled", + run: () => { + ctx.sidebarRight.openTabFromTarget("terminal", target); + }, + }; + }, + }), + "ui-sidebar-terminal: shortcut", + ); + ctx.effect(() => ctx.locale.register(namespace, { zh, en }), "ui-sidebar-terminal.copy"); + ctx.effect( + () => + ctx.sidebarRightTabs.register({ + id, + kind: "terminal", + multiple: true, + priority: "builtin", + title: () => t("title"), + guide: [ + { + id: "new", + order: 20, + title: () => t("new"), + description: () => t("description"), + icon: PluginArtworkTerminal, + }, + ], + }), + "ui-sidebar-terminal.type", + ); + ctx.effect( + () => + ctx.sidebarRight.registerCloseHandler("terminal", (sessionId, tab) => { + ctx.webTerminals.close(sessionId, tab.id, tab.contentId, terminalId(sessionId, tab.id)); + }), + "ui-sidebar-terminal.close", + ); + const inject = (sessionId: SessionId): TerminalInjected => ({ + view: (key) => view(sessionId, key), + keyedHooks: { terminal: (key) => view(sessionId, key).state }, + }); + const theme: TerminalBodyInjected["hooks"]["theme"] = { + getSnapshot: () => ctx.theme.getTheme(), + subscribe: (listener) => ctx.on("theme/change", listener), + }; + ctx.effect( + () => + ctx.slots.inject("sidebar.right.tab.guide.entry", () => + ctx.slots.register( + { + name: "sidebar.right.tab.guide.entry", + key: id, + locale: namespace, + inject: (sessionId): TerminalGuideInjected => ({ + hooks: { shortcuts: ctx.shortcuts.catalog }, + loadShells: (signal) => ctx.webTerminals.launchShells(sessionId, signal), + selectShell: (path) => { + ctx.webTerminals.selectShell(path); + }, + }), + }, + TerminalGuide, + ), + ), + "ui-sidebar-terminal.guide", + ); + ctx.effect( + () => + ctx.slots.inject("sidebar.right.pane.tab", () => + ctx.slots.register( + { + name: "sidebar.right.pane.tab", + key: id, + locale: namespace, + inject: (sessionId): TerminalBodyInjected => ({ + ...inject(sessionId), + hooks: { theme }, + }), + }, + LazyTerminalBody, + ), + ), + "ui-sidebar-terminal.body", + ); + ctx.effect( + () => + ctx.slots.inject("sidebar.right.pane.tab.title", () => + ctx.slots.register( + { name: "sidebar.right.pane.tab.title", key: id, locale: namespace, inject }, + TerminalTitle, + ), + ), + "ui-sidebar-terminal.title", + ); + // ctx.effect(() => ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register({ + // name: 'conversation.session.header.actions', id, locale: namespace, + // inject: (sessionId): TerminalRecoveryInjected => ({ + // restore: () => { + // let pending = recovered.get(sessionId) + // if (pending === undefined) { + // for (const tab of ctx.sidebarRight.tabsIn(sessionId)) { + // if (tab.kind === 'terminal') view(sessionId, tab.id) + // } + // pending = ctx.webTerminals.recover(sessionId).then((terminals) => { + // if (disposed) return + // for (const info of terminals) ctx.sidebarRight.openTabIn(sessionId, 'terminal', { + // params: { terminalId: info.id }, + // }) + // }).catch((error: unknown) => { recovered.delete(sessionId); throw error }) + // recovered.set(sessionId, pending) + // } + // return pending + // }, + // }), + // }, TerminalRecovery)), 'ui-sidebar-terminal.recovery') + // ctx.effect(() => ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + // name: 'shell.overlay', id, locale: namespace, + // inject: (): TerminalCleanupInjected => ({ + // hooks: { closeFailures: ctx.webTerminals.closeFailures }, + // retryClose: (terminalId) => { ctx.webTerminals.retryClose(terminalId) }, + // }), + // }, TerminalCleanup)), 'ui-sidebar-terminal.cleanup') +} diff --git a/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/locales.ts b/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/locales.ts new file mode 100644 index 000000000..1fb22185b --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/locales.ts @@ -0,0 +1,73 @@ +/** Copy owned by the sidebar terminal feature. */ +import type {} from "@deepseek-ai/dsh-client-ui-slots"; + +declare module "@deepseek-ai/dsh-client-ui-slots" { + interface LocaleNamespaceMap { + sidebarTerminal: keyof typeof zh; + } +} + +/** Simplified Chinese terminal copy. */ +export const zh = { + "shortcut.noSession": "请先选择会话", + recoveryFailed: "恢复终端失败:{message}", + retryRecovery: "重试恢复终端", + shell: "选择 Shell", + shellLoading: "正在读取 Shell…", + shellEmpty: "没有可用的 Shell", + description: "在会话工作区运行命令", + title: "终端", + new: "新建终端", + loading: "正在读取终端环境…", + creating: "正在启动…", + connecting: "正在连接…", + disconnected: "连接已断开。", + reconnect: "重新连接", + readonly: "此页面当前只读。", + control: "接管输入", + closed: "终端已关闭。", + exited: "进程已退出({code})", + failed: "终端错误:{message}", + rename: "终端名称", + unavailable: "不可用", + retry: "重试", + cleanupFailed: "终端「{title}」未能结束:{message}", + missingTerminal: "此终端已不存在,请新建终端。", + inputFull: "输入缓冲区已满,请重新连接后重试。", + attachmentEnded: "终端连接已结束,请重新连接。", + invalidOutput: "终端画面传输异常,请重新连接。", + terminalLimit: "终端数量已达上限,请关闭不用的终端后重试。已退出的终端也计入数量。", +} satisfies Record; + +/** English terminal copy. */ +export const en = { + "shortcut.noSession": "Select a session first", + recoveryFailed: "Terminal recovery failed: {message}", + retryRecovery: "Retry terminal recovery", + shell: "Choose shell", + shellLoading: "Loading shells…", + shellEmpty: "No shells available", + description: "Run commands in the Session workspace", + title: "Terminal", + new: "New terminal", + loading: "Reading terminal environment…", + creating: "Starting…", + connecting: "Connecting…", + disconnected: "Disconnected.", + reconnect: "Reconnect", + readonly: "This view is read-only.", + control: "Take control", + closed: "Terminal closed.", + exited: "Process exited ({code})", + failed: "Terminal error: {message}", + rename: "Terminal name", + unavailable: "Unavailable", + retry: "Retry", + cleanupFailed: "Terminal “{title}” could not be ended: {message}", + missingTerminal: "This terminal no longer exists. Open a new terminal.", + inputFull: "The input buffer is full. Reconnect and try again.", + attachmentEnded: "The terminal connection ended. Reconnect to continue.", + invalidOutput: "The terminal screen could not be received. Reconnect to recover it.", + terminalLimit: + "The terminal limit has been reached. Close unused terminals and try again. Exited terminals also count toward the limit.", +} satisfies Record; diff --git a/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/terminal-cursor.ts b/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/terminal-cursor.ts new file mode 100644 index 000000000..c3e4710a0 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/terminal-cursor.ts @@ -0,0 +1,79 @@ +/** Contrast-aware styling for xterm's DOM cursor, independent of its ANSI palette. */ +import type { IDisposable, Terminal } from "@xterm/xterm"; + +/* oxlint-disable typescript/no-non-null-assertion -- The mounted xterm DOM supplies elements and sRGB channels. */ + +/** + * Keep the cursor visible on default, indexed, true-color and inverse cell backgrounds. + * @param terminal - opened DOM-rendered emulator. + * @param node - screen root containing the emulator and scoped cursor CSS variables. + * @param preferredCursor - current DSH or application cursor color. + * @returns listener disposer; no terminal input or palette changes are emitted. + */ +export function observeTerminalCursor( + terminal: Terminal, + node: HTMLElement, + preferredCursor: () => string, +): IDisposable { + return terminal.onRender(() => { + const cursor = node.querySelector(".xterm-cursor"); + if (cursor === null) return; + // Removing only the cursor marker exposes the cell's rendered colors, including reverse video. + cursor.classList.remove("xterm-cursor"); + let background: string; + let foreground: string; + try { + const style = getComputedStyle(cursor); + foreground = style.color; + background = opaqueColor( + style.backgroundColor, + () => getComputedStyle(node.querySelector(".xterm-scrollable-element")!).backgroundColor, + ); + } finally { + cursor.classList.add("xterm-cursor"); + } + const preferred = opaqueColor(preferredCursor(), () => background); + const backgroundLuminance = luminance(background); + const preferredLuminance = luminance(preferred); + // WCAG contrast is 3:1 for the cursor and 4.5:1 for the character inside it. + const contrast = + (Math.max(backgroundLuminance, preferredLuminance) + 0.05) / + (Math.min(backgroundLuminance, preferredLuminance) + 0.05); + const fill = contrast >= 3 ? preferred : backgroundLuminance > 0.179 ? "#000000" : "#ffffff"; + node.style.setProperty("--terminal-cursor", fill); + node.style.setProperty( + "--terminal-cursor-accent", + luminance(fill) > 0.179 ? "#000000" : "#ffffff", + ); + node.style.setProperty("--terminal-cursor-cell-background", background); + node.style.setProperty("--terminal-cursor-cell-foreground", foreground); + }); +} + +// xterm emits sRGB colors; OSC values are normalized to six-digit hex by TerminalTheme. +function luminance(color: string): number { + const [r, g, b] = colorChannels(color).map((channel) => { + const value = channel / 255; + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!; +} + +function colorChannels(color: string): number[] { + return color.startsWith("#") + ? [color.slice(1, 3), color.slice(3, 5), color.slice(5, 7)].map((value) => + Number.parseInt(value, 16), + ) + : color.match(/[\d.]+/gu)!.map(Number); +} + +function opaqueColor(color: string, background: () => string): string { + const channels = colorChannels(color); + const alpha = channels[3] ?? 1; + if (alpha === 1) return color; + const surface = colorChannels(background()); + const rgb = channels + .slice(0, 3) + .map((channel, index) => Math.round(channel * alpha + surface[index]! * (1 - alpha))); + return `rgb(${rgb.join(", ")})`; +} diff --git a/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/terminal-theme.ts b/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/terminal-theme.ts new file mode 100644 index 000000000..f31950a28 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/terminal-theme.ts @@ -0,0 +1,144 @@ +/** DSH defaults and application OSC colors for one xterm screen. */ +import type { IDisposable, ITheme, Terminal } from "@xterm/xterm"; + +/* oxlint-disable typescript/no-non-null-assertion -- OSC widths/indices are checked; DSH defaults precede cursor reads. */ +const ansiKeys = [ + "black", + "red", + "green", + "yellow", + "blue", + "magenta", + "cyan", + "white", + "brightBlack", + "brightRed", + "brightGreen", + "brightYellow", + "brightBlue", + "brightMagenta", + "brightCyan", + "brightWhite", +] as const; +const specialKeys = ["foreground", "background", "cursor"] as const; + +/** Keep program color overrides separate from the DSH defaults used by OSC resets. */ +export class TerminalTheme implements IDisposable { + private defaults: ITheme = {}; + private readonly indexed = new Map(); + private readonly special = new Map<(typeof specialKeys)[number], string>(); + private readonly subscriptions: IDisposable[]; + + /** + * Observe complete OSC commands without consuming xterm's queries or color handling. + * @param terminal - opened emulator owned by the mounted screen. + */ + constructor(private readonly terminal: Terminal) { + const observe = (code: number, change: (data: string) => void): IDisposable => + terminal.parser.registerOscHandler(code, (data) => { + change(data); + return false; + }); + this.subscriptions = [ + observe(4, (data) => { + const parts = data.split(";"); + for (let i = 0; i + 1 < parts.length; i += 2) { + const index = colorIndex(parts[i]!); + const color = oscColor(parts[i + 1]!); + if (index !== undefined && color !== undefined) this.indexed.set(index, color); + } + }), + observe(104, (data) => { + if (data === "") this.indexed.clear(); + else + for (const part of data.split(";")) { + const index = colorIndex(part); + if (index !== undefined) this.indexed.delete(index); + } + this.apply(); + }), + ...specialKeys.flatMap((key, offset) => [ + observe(10 + offset, (data) => { + for (const [i, part] of data.split(";").entries()) { + const target = specialKeys[offset + i]; + const color = oscColor(part); + if (target !== undefined && color !== undefined) this.special.set(target, color); + } + }), + observe(110 + offset, () => { + this.special.delete(key); + this.apply(); + }), + ]), + ]; + } + + /** + * Apply changed DSH colors without replacing application-defined palette entries. + * @param background - resolved opaque DSH background. + * @param foreground - resolved DSH text color. + */ + update(background: string, foreground: string): void { + if (this.defaults.background === background && this.defaults.foreground === foreground) return; + this.defaults = { + background, + foreground, + cursor: foreground, + cursorAccent: background, + selectionBackground: foreground, + selectionForeground: background, + selectionInactiveBackground: foreground, + }; + this.apply(); + } + + /** Preferred cursor color after the initial DSH update, including OSC 12 overrides. */ + get cursor(): string { + return this.special.get("cursor") ?? this.defaults.cursor!; + } + + /** Remove parser observers before the emulator is disposed. */ + dispose(): void { + for (const subscription of this.subscriptions) subscription.dispose(); + } + + private apply(): void { + const theme: ITheme = { ...this.defaults, ...Object.fromEntries(this.special) }; + for (const [index, color] of this.indexed) { + if (index < ansiKeys.length) theme[ansiKeys[index]!] = color; + else (theme.extendedAnsi ??= [])[index - ansiKeys.length] = color; + } + this.terminal.options.theme = theme; + } +} + +function colorIndex(value: string): number | undefined { + return /^\d+$/u.test(value) && Number(value) < 256 ? Number(value) : undefined; +} + +// XParseColor hashes truncate channels; rgb: channels scale. CSS #rgb has different semantics. +function oscColor(value: string): string | undefined { + const rgb = /^rgb:([\da-f]{1,4})\/([\da-f]{1,4})\/([\da-f]{1,4})$/iu.exec(value); + let channels: string[]; + if (rgb !== null) { + channels = rgb.slice(1); + if (!channels.every((channel) => channel.length === channels[0]!.length)) return; + } else { + if (!/^#(?:[\da-f]{3}){1,4}$/iu.test(value)) return; + const width = (value.length - 1) / 3; + channels = [ + value.slice(1, width + 1), + value.slice(width + 1, 2 * width + 1), + value.slice(2 * width + 1), + ]; + } + return `#${channels + .map((channel) => { + const n = + rgb === null + ? Number.parseInt(channel.padEnd(2, "0").slice(0, 2), 16) + : Math.round((Number.parseInt(channel, 16) * 255) / (16 ** channel.length - 1)); + return n.toString(16).padStart(2, "0"); + }) + .join("")}`; +} diff --git a/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/terminal.module.css b/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/terminal.module.css new file mode 100644 index 000000000..e6a95860b --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/terminal.module.css @@ -0,0 +1,98 @@ +.root { + display: flex; + flex: 1; + flex-direction: column; + box-sizing: border-box; + height: 100%; + min-height: 0; + padding-top: 8px; + color: var(--dsw-alias-label-primary); + font-size: 13px; +} +.screen { + flex: 1; + min-height: 0; + min-width: 0; + overflow: hidden; + padding: 8px; + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-bg-base); +} +.screen :global(.xterm) { + height: 100%; +} +.screen :global(.xterm-viewport) { + background: var(--dsw-alias-bg-base); +} +.status { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 5px 12px; + background: var(--dsw-alias-bg-l2); +} +.error { + margin: 0; + padding: 10px 12px; + overflow-wrap: anywhere; + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-bg-l2); +} +.empty { + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + min-height: 0; + padding: 24px; + overflow: auto; + text-align: center; +} +.emptyMessage { + max-width: 28ch; + margin: 0; + line-height: 1.6; + overflow-wrap: anywhere; +} +.actionIcon { + display: flex; +} + +/* Cursor colors depend on its cell, so they must not update xterm's shared ANSI theme. */ +.screen :global(.xterm-cursor) { + opacity: 1; +} +.screen :global(.xterm .xterm-rows .xterm-cursor.xterm-cursor-block) { + background-color: var(--terminal-cursor); + color: var(--terminal-cursor-accent); +} +.screen :global(.xterm .xterm-rows .xterm-cursor.xterm-cursor-block:not(.xterm-cursor-blink)) { + background-color: var(--terminal-cursor) !important; + color: var(--terminal-cursor-accent) !important; +} +.screen :global(.xterm .xterm-rows .xterm-cursor.xterm-cursor-outline) { + outline-color: var(--terminal-cursor); +} +.screen :global(.xterm .xterm-rows .xterm-cursor.xterm-cursor-bar) { + box-shadow: 1px 0 0 var(--terminal-cursor) inset; +} +.screen :global(.xterm .xterm-rows .xterm-cursor.xterm-cursor-underline) { + border-bottom-color: var(--terminal-cursor); +} +.screen + :global(.xterm .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-block.xterm-cursor-blink) { + animation: terminal-cursor-blink 1s step-end infinite; +} +@keyframes terminal-cursor-blink { + 0% { + background-color: var(--terminal-cursor); + color: var(--terminal-cursor-accent); + } + 50% { + background-color: var(--terminal-cursor-cell-background); + color: var(--terminal-cursor-cell-foreground); + } +} diff --git a/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/terminal.tsx b/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/terminal.tsx new file mode 100644 index 000000000..99ec565b3 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-terminal/src/client/terminal.tsx @@ -0,0 +1,281 @@ +/** Sidebar terminal screen and connection recovery. */ +import { useEffect, useLayoutEffect, useRef, type ReactNode } from "react"; +import { Terminal } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; +import { Button, IconPlusOutlineRegular } from "@deepseek-ai/dsh-client-ui-primitives"; +import type { + TerminalViewState, + TerminalView, +} from "@deepseek-ai/dsh-api-terminal-controller/client"; +import type { InjectFace, PropsLocale, PropsRuntime } from "@deepseek-ai/dsh-client-ui-slots"; +import type { ThemeSnapshot } from "@deepseek-ai/dsh-client-ui-theme/client"; +import type { TerminalBodyInjected } from "./face.ts"; +import type {} from "./locales.ts"; +import "@xterm/xterm/css/xterm.css"; +import css from "./terminal.module.css"; +import { TerminalTheme } from "./terminal-theme.ts"; +import { observeTerminalCursor } from "./terminal-cursor.ts"; + +/** Standard sidebar owner share plus terminal model and localized copy. */ +export type TerminalBodyProps = PropsRuntime<"sidebar.right.pane.tab"> & + PropsLocale<"sidebarTerminal"> & + InjectFace; + +/** + * Render the retained terminal with the application theme. + * @param props - sidebar occurrence, model lookup and translated copy. + * @returns the terminal screen and any pending or exceptional state. + */ +export function TerminalBody({ + useTabInfo, + useTerminal, + useTheme, + view, + t, +}: TerminalBodyProps): ReactNode { + const { tab } = useTabInfo(); + const theme = useTheme((value) => value); + const model = view(tab.id); + const state = useTerminal(tab.id); + useEffect(() => model.mount(), [model]); + if (state === undefined) return null; + const newTerminal = ( + + ); + if (state.issue === "missingTerminal") + return ( +
    +
    + +

    + {t("missingTerminal")} +

    + {newTerminal} +
    +
    + ); + const error = + state.phase === "disconnected" + ? undefined + : state.issue === undefined + ? (state.error ?? state.info?.error) + : t(state.issue); + let status: string | undefined; + if (state.phase === "idle" || state.phase === "loading") status = t("loading"); + else if ( + state.phase === "creating" || + state.phase === "connecting" || + state.phase === "disconnected" + ) + status = t(state.phase); + else if (state.info?.state === "exited") + status = t("exited", { code: String(state.info.exitCode ?? "—") }); + else if (state.info?.state === "failed") status = t("unavailable"); + else if (state.phase === "closed") status = t("closed"); + const ended = state.info?.state === "exited" || state.phase === "closed"; + const retry = !ended && (state.phase === "failed" || state.phase === "disconnected"); + const readOnly = + state.phase === "connected" && state.info?.state === "running" && !state.writable; + return ( +
    + {(status !== undefined || retry || readOnly) && ( +
    + {status} + {readOnly && ( + <> + {t("readonly")}{" "} + + + )} + {retry && + (state.info === undefined ? ( + + ) : ( + + ))} + {ended && newTerminal} +
    + )} + {state.info !== undefined && ( + + )} + {error !== undefined && ( +

    + {t("failed", { message: error })} +

    + )} +
    + ); +} + +/* oxlint-disable typescript/no-non-null-assertion -- React sets the DOM ref, then these effects initialize and use the emulator. */ +function TerminalScreen({ + state, + model, + visible, + label, + theme, +}: { + state: TerminalViewState; + model: TerminalView; + visible: boolean; + label: string; + theme: ThemeSnapshot; +}): ReactNode { + const element = useRef(null); + const terminal = useRef(); + const fit = useRef(); + const colors = useRef(); + const lastRevision = useRef(0); + const current = useRef({ state, visible }); + current.current = { state, visible }; + + useLayoutEffect(() => { + const node = element.current!; + const xterm = new Terminal({ + minimumContrastRatio: 4.5, + cursorBlink: true, + fontSize: 13, + fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", + scrollback: current.current.state.environment?.scrollback ?? 0, + }); + const addon = new FitAddon(); + xterm.loadAddon(addon); + xterm.open(node); + const palette = new TerminalTheme(xterm); + colors.current = palette; + const cursor = observeTerminalCursor(xterm, node, () => palette.cursor); + xterm.textarea?.setAttribute("aria-label", label); + terminal.current = xterm; + fit.current = addon; + lastRevision.current = 0; + const input = xterm.onData((data) => { + model.write(data); + }); + const measure = (): void => { + if ( + !current.current.visible || + !current.current.state.writable || + node.clientWidth === 0 || + node.clientHeight === 0 + ) + return; + fitScreen(xterm, addon, current.current.state, model); + }; + const observer = new ResizeObserver(measure); + observer.observe(node); + return () => { + observer.disconnect(); + input.dispose(); + cursor.dispose(); + palette.dispose(); + xterm.dispose(); + terminal.current = undefined; + fit.current = undefined; + }; + }, [model]); + + useLayoutEffect(() => { + const style = getComputedStyle(element.current!); + colors.current!.update(style.backgroundColor, style.color); + }, [theme, model]); + + useLayoutEffect(() => { + const xterm = terminal.current!; + const render = state.render; + if (render === undefined || render.revision <= lastRevision.current) return; + lastRevision.current = render.revision; + if (render.frame.type === "snapshot") { + xterm.reset(); + xterm.resize(render.frame.info.cols, render.frame.info.rows); + } + xterm.write(render.frame.type === "snapshot" ? render.frame.screen : render.frame.data, () => { + model.acknowledge(render.revision); + }); + }, [state.render, model]); + + useLayoutEffect(() => { + const xterm = terminal.current!; + xterm.options.disableStdin = !state.writable; + if (visible && state.writable && element.current?.clientWidth && element.current.clientHeight) { + fitScreen(xterm, fit.current!, state, model); + } else if (state.info !== undefined && !state.writable) + xterm.resize(state.info.cols, state.info.rows); + }, [visible, state.writable, state.info?.cols, state.info?.rows, model]); + useEffect(() => { + terminal.current!.textarea?.setAttribute("aria-label", label); + }, [label]); + useEffect(() => { + if (visible && state.writable) terminal.current!.focus(); + }, [visible, state.writable]); + return
    ; +} +/* oxlint-enable typescript/no-non-null-assertion */ + +function fitScreen( + xterm: Terminal, + fit: FitAddon, + state: TerminalViewState, + model: TerminalView, +): void { + const dimensions = fit.proposeDimensions(); + const environment = state.environment; + if (dimensions === undefined || environment === undefined) return; + const cols = Math.min(dimensions.cols, environment.maxCols); + const rows = Math.min(dimensions.rows, environment.maxRows); + if (cols < 2 || rows < 1) return; + xterm.resize(cols, rows); + model.resize(cols, rows); +} diff --git a/apps/web-ui/packages/client/ui-sidebar-terminal/src/css-modules.d.ts b/apps/web-ui/packages/client/ui-sidebar-terminal/src/css-modules.d.ts new file mode 100644 index 000000000..b284bf80b --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-terminal/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module "*.module.css" { + const classes: Record; + export default classes; +} + +declare module "*.css"; diff --git a/apps/web-ui/packages/client/ui-sidebar-terminal/src/index.ts b/apps/web-ui/packages/client/ui-sidebar-terminal/src/index.ts new file mode 100644 index 000000000..d7b0a4904 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-terminal/src/index.ts @@ -0,0 +1,4 @@ +/** Host companion for the interactive terminal Client plugin. */ + +/** Mount the browser-only plugin through the Client loader. */ +export function apply(): void {} diff --git a/apps/web-ui/packages/client/ui-sidebar-terminal/tsdown.config.ts b/apps/web-ui/packages/client/ui-sidebar-terminal/tsdown.config.ts new file mode 100644 index 000000000..900dd2a92 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar-terminal/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from "../tsdown.client.ts"; + +export default clientBundle("@deepseek-ai/dsh-client-ui-sidebar-terminal", ["lib/types/index.js"]); diff --git a/apps/web-ui/packages/client/ui-sidebar/package.json b/apps/web-ui/packages/client/ui-sidebar/package.json new file mode 100644 index 000000000..1ce6f257c --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar/package.json @@ -0,0 +1,40 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-sidebar", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./client": { + "types": "./src/client/index.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dependencies": { + "@deepseek-ai/dsh-client-store": "*", + "@deepseek-ai/dsh-client-ui-slots": "*", + "react": "^18.2.0", + "clsx": "^2.0.0", + "@deepseek-ai/dsh-client-ui-primitives": "*" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-workspace-controller", + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-layout", + "@deepseek-ai/dsh-client-ui-session", + "@deepseek-ai/dsh-client-ui-workspace", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-shortcuts" + ], + "platform": "web" + } + } +} diff --git a/apps/web-ui/packages/client/ui-sidebar/src/client/HeaderLeadingControls.module.css b/apps/web-ui/packages/client/ui-sidebar/src/client/HeaderLeadingControls.module.css new file mode 100644 index 000000000..20fd3d495 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar/src/client/HeaderLeadingControls.module.css @@ -0,0 +1,29 @@ +/* The frame's leadingSeat (ui-layout AppFrame) owns placement — position, + traffic-light clearance, no-drag; this box only lays out the two controls. + Same 8px rhythm as the conversation header's trailing controls + (ConversationRoot .headerUtilities/.headerCorner), so both ends of the + title row read as one control set. */ +.controls { + display: flex; + align-items: center; + gap: 8px; +} + +.iconButton { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: var(--dsw-radius-sm); + background: transparent; + color: var(--dsw-alias-label-secondary); + cursor: pointer; +} + +.iconButton:hover { + background: var(--dsw-alias-interactive-bg-hover); +} diff --git a/apps/web-ui/packages/client/ui-sidebar/src/client/HeaderLeadingControls.tsx b/apps/web-ui/packages/client/ui-sidebar/src/client/HeaderLeadingControls.tsx new file mode 100644 index 000000000..7467ceb25 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar/src/client/HeaderLeadingControls.tsx @@ -0,0 +1,65 @@ +/** Window-chrome controls for the fully hidden sidebar (frame shell.leading seat). */ +import { + IconNewChatOutlineRegular, + IconPanelLeftOutlineRegular, + Tooltip, +} from "@deepseek-ai/dsh-client-ui-primitives"; +import type { InjectFace, PropsLocale, PropsRuntime } from "@deepseek-ai/dsh-client-ui-slots"; +// Type-only: pulls the frame's shell.leading slot declaration. +import type {} from "@deepseek-ai/dsh-client-ui-layout/client"; +import type { SidebarRootInjected } from "./contract/slots.ts"; +import css from "./HeaderLeadingControls.module.css"; + +/** Full props of the shell.leading occupant. */ +export type HeaderLeadingControlsProps = PropsRuntime<"shell.leading"> & + InjectFace & + PropsLocale<"sidebar">; + +/** + * Sidebar-open and New Session controls in the frame's window-chrome seat. + * On macOS desktop a collapsed sidebar hides entirely (no rail), taking both + * controls off screen; this occupant puts them back beside the traffic + * lights. The frame mounts the seat only in that state and owns its + * placement, so the occupant renders unconditionally. + * @param props - Injected sidebar actions plus the sidebar locale seat. + * @returns the two window-chrome controls. + */ +export function HeaderLeadingControls({ + toggleSidebar, + startSession, + useShortcuts, + t, +}: HeaderLeadingControlsProps) { + const shortcut = useShortcuts((rows) => rows.find((row) => row.id === "sidebar.left.toggle")); + const newShortcut = useShortcuts((rows) => rows.find((row) => row.id === "session.new")); + return ( +
    + + + + + + +
    + ); +} diff --git a/apps/web-ui/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/apps/web-ui/packages/client/ui-sidebar/src/client/SidebarRoot.module.css new file mode 100644 index 000000000..f1751d979 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -0,0 +1,666 @@ +/* Sidebar column (figma 133:7629): vertical stack, padding 12/6, sidebar + fill + 1px right border painted by the layout column. Collapse is a + slide + crossfade, not a morph: the content holds its frozen expanded + layout (inline width set by the component) and fades in place (.fading) + while the sliding column (AppFrame grid tracks) clips it; the rail layout + (.collapsed) only applies after the fade settles, so nothing reflows + mid-slide. */ + +.root { + /* Codex sidebar text hierarchy, shared by navigation, projects and Threads. + Keep these local so conversation/Composer and semantic status colors do + not become a second theme. Native also disables synthetic font weight. */ + --ch-sidebar-text-primary: #1a1c1f; + --ch-sidebar-text-muted: rgba(26, 28, 31, 0.495); + --dsw-font-family: -apple-system, "system-ui", "Segoe UI", sans-serif; + --dsw-alias-label-primary: color-mix(in oklab, var(--ch-sidebar-text-primary) 85%, transparent); + --dsw-alias-label-secondary: color-mix(in srgb, var(--ch-sidebar-text-primary) 65%, transparent); + --dsw-alias-label-tertiary: var(--ch-sidebar-text-muted); + --dsw-alias-label-caption: var(--ch-sidebar-text-muted); + --dsh-sidebar-inline-padding: 12px; + display: flex; + flex-direction: column; + height: 100%; + padding: 6px var(--dsh-sidebar-inline-padding); + box-sizing: border-box; + background: var(--dsw-specific-sidebar-fill); + color: var(--dsw-alias-label-primary); + font-family: var(--dsw-font-family); + font-size: 14px; + font-weight: 430; + line-height: 20px; + font-synthesis-weight: none; + /* The sidebar is elevated above the conversation surface, so a revealed + scrollbar uses the l2 pair. .quietBars hides it without changing layout. */ + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); +} + +:global(body[data-ds-dark-theme]) .root { + --ch-sidebar-text-primary: #ffffff; + --ch-sidebar-text-muted: rgba(255, 255, 255, 0.498); +} + +/* macOS desktop: the AppFrame column owns the translucent sidebar tint; an + opaque fill here would sit on top of it and block the window vibrancy. */ +:global([data-platform="darwin"]) .root { + background: transparent; +} + +/* Rail geometry (figma rail spec): 36x36 control boxes centered in the 56px + rail (10px side padding), 12px vertical rhythm, 18px from the rail top to + the whale's box (24px to the 24-wide whale glyph itself). */ +.root.collapsed { + padding: 18px 10px 6px; +} + +/* The brand stays in the sidebar; fixed caption controls survive zero-width collapse. */ +:global([data-windows-titlebar]) .root .logoRow { + height: 40px; + margin: 0; + padding: 0; +} + +:global([data-windows-titlebar]) .toggle { + position: fixed; + top: calc((var(--dsh-windows-titlebar-height) - 28px) / 2); + left: 12px; + z-index: 30; + -webkit-app-region: no-drag; +} + +:global([data-windows-titlebar]) .root.collapsed .logoRow { + height: 0; +} + +:global([data-windows-titlebar]) .root:not(.collapsed) .brand { + padding-left: 4px; +} + +:global([data-windows-titlebar]) .brandIdentity { + transform: translateY(1px); +} + +:global([data-windows-titlebar]) .brandMark { + transform: translateX(1px); +} + +:global([data-windows-titlebar]) .brandName { + font-weight: 400; +} + +:global([data-windows-titlebar]) .root.collapsed { + padding: 0; +} + +:global([data-windows-titlebar]) .root:not(.collapsed) .newSession { + margin-top: 8px; + margin-left: 0; +} + +:global([data-windows-titlebar]) .collapsed .panelList, +:global([data-windows-titlebar]) .collapsed .regionArea, +:global([data-windows-titlebar]) .collapsed .footArea { + display: none; +} + +:global(html[data-windows-titlebar]:has([data-sidebar-collapsed="true"])) { + --dsh-windows-menu-start: 84px; +} + +:global([data-windows-titlebar]) .collapsed .newSession { + position: fixed; + top: calc((var(--dsh-windows-titlebar-height) - 28px) / 2); + left: 48px; + margin: 0; + padding: 0; + border: none; + z-index: 30; + -webkit-app-region: no-drag; +} + +:global([data-windows-titlebar]) .railIn .iconButton, +:global([data-windows-titlebar]) .railIn .newSession { + animation: none; +} + +:global([data-windows-titlebar]) .collapsed .toggle, +:global([data-windows-titlebar]) .collapsed .newSession { + width: 28px; + height: 28px; + border-radius: 50%; + corner-shape: round; + color: var(--dsw-alias-label-secondary); +} + +:global([data-windows-titlebar]) .collapsed .toggle .panelIcon { + display: inline; +} + +/* Scrollbars in the column are a pointer affordance: the shell adds this + class whenever the pointer is not inside (SidebarRoot.tsx owns the linger), + and rebinding ui-theme's indirection pair to `transparent` takes the thumb + out of every scroll region nested under it — the workspace browser's + session list. `transparent` rather than `display: none` on the bar: + the reservation (`scrollbar-gutter: stable` on the list) stays in force, so + revealing the thumb never reflows a row. Rebinding contract and the two + rendering paths it reaches: ui-theme's README. */ +.root.quietBars { + --dsh-scrollbar-thumb: transparent; + --dsh-scrollbar-thumb-hover: transparent; +} + +/* Collapse phase 1: the whole frozen-width content fades out in place over + 150ms; at settle the children unmount/snap to the rail layout. */ +.fading > * { + opacity: 0; + transition: opacity 150ms var(--ds-ease-in-out); +} + +/* Wide-only content fades back in on expand remount. */ +.wide { + animation: wide-in 200ms var(--ds-ease-in-out); +} + +@keyframes wide-in { + from { + opacity: 0; + } +} + +/* At the 150ms rail settle, the upper controls enter from the former + rail right edge over the remaining 150ms of the AppFrame track transition. + The bottom-pinned settings seat shares their opacity timeline but stays + horizontally fixed. Only a live collapse gets .railIn; a cold collapsed + render stays static. */ +.railIn .iconButton, +.railIn .newSession, +.railIn .panelList, +.railIn .regionArea { + animation: rail-in 150ms var(--ds-ease-in-out) backwards; +} + +.railIn .footArea { + animation: rail-fade-in 150ms var(--ds-ease-in-out) backwards; +} + +@keyframes rail-in { + from { + opacity: 0; + transform: translateX(49px); + } +} + +@keyframes rail-fade-in { + from { + opacity: 0; + } +} + +/* 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). 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; + align-items: center; + justify-content: flex-end; + height: 52px; + box-sizing: border-box; + margin: -6px calc(-1 * var(--dsh-sidebar-inline-padding)) 0; + /* The 2px foot centres the 28px toggle at y 25, level with the collapsed + seat (ui-layout .leadingSeat, top 11) and the conversation title row, so + collapse/expand never shifts it vertically. */ + padding: 0 12px 2px; +} + +/* Window fullscreen hides the traffic lights (html[data-fullscreen], desktop + preload): the toggle takes their spot at the strip's left edge. */ +:global([data-fullscreen]) .topStrip { + justify-content: flex-start; +} + +/* The strip's full 52px foot leaves the wordmark too low; the content column + tucks 12px back under it, still clear of the toggle (28px control centered + in the strip ends at y 40). */ +.topStrip + .logoRow { + margin-top: -12px; +} + +/* 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. 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; + align-items: center; + justify-content: flex-end; + gap: 8px; + height: 60px; + padding: 8px 0 8px 4px; + margin-bottom: 4px; + box-sizing: border-box; + overflow: hidden; +} + +.collapsed .logoRow { + height: 36px; + padding: 0; + margin-bottom: 12px; + justify-content: flex-start; + /* The settled rail's 36px toggle is wider than this 35px row; the base row's clip would cut + the control's right edge, so the rail stops clipping once the slide has settled. */ + overflow: visible; +} + +/* 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 + pointer cursor is the sole affordance — no hover chrome on the mark. */ +.brand { + flex: 1; + min-width: 0; + display: inline-flex; + align-items: center; + overflow: hidden; + padding: 0; + border: none; + background: transparent; + color: inherit; + cursor: pointer; +} + +/* On macOS the brand is a window-drag surface, not a New Session button; + a pointer cursor would promise a click that drags the window instead. */ +:global([data-platform="darwin"]) .brand { + cursor: default; +} + +/* Mark and name are independent slots; the shell owns their shared baseline, + spacing, and height so either occupant can vary in width. */ +.brandIdentity { + display: inline-flex; + align-items: center; + gap: 8px; + height: 24px; + min-width: 0; +} + +.brandMark { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.brandName { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + height: 24px; + font-size: 18px; + font-weight: 600; + line-height: 24px; + letter-spacing: 0.04em; +} + +.fallbackBrandName { + font-size: 17px; + letter-spacing: 0px; + white-space: nowrap; +} + +.localBuildBrand { + flex: none; + display: inline-flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 1px; + height: 24px; + white-space: nowrap; +} + +.localBuildTitle { + font-size: 12px; + line-height: 13px; + letter-spacing: 0; +} + +.iconButton { + position: relative; + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: var(--dsw-radius-sm); + padding: 0; + background: transparent; + cursor: pointer; + color: var(--dsw-alias-label-secondary); +} + +.iconButton:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +/* The toggle is flush with .logoRow, which clips its own overflow, so an outward ring loses + the pixels that leave the row. Draw this control's ring inside its own box instead. */ +.iconButton.toggle:focus-visible { + outline: var(--dsw-focus-ring-width) solid + var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: -2px; +} + +.collapsed .iconButton { + width: 36px; + height: 36px; + /* Rail hovers share the panel rows' and New Session's 12px rounding. */ + border-radius: var(--dsw-radius-md); +} + +/* Rail logo swap: collapsed, the toggle rests as the whale mark (brand ink, + no hover circle) and hovering reveals the panel icon — the expand + affordance (figma sidebar-hover flow). Expanded it is a plain panel icon. */ +.collapsed .toggle .panelIcon { + display: none; +} + +.collapsed .toggle:hover .panelIcon { + display: inline; +} + +.collapsed .toggle:hover .railMark { + display: none; +} + +.railMark { + display: inline-flex; + align-items: center; + justify-content: center; +} + +/* Rail icons ride the primary ink (figma rail spec); expanded keeps the + secondary icon-button ink. */ +.collapsed .iconButton { + color: var(--dsw-alias-label-primary); +} + +.buildVersion { + flex: none; + display: inline-flex; + align-items: center; + height: 10px; + padding: 0 3px; + border-radius: 2px; + color: var(--dsw-alias-label-primary-inverted); + background: var(--dsw-alias-label-primary); + font-family: var(--ds-font-family-code); + font-size: 6px; + font-weight: 500; + line-height: 10px; + white-space: nowrap; +} + +/* New chat is a quiet navigation row, not a raised call-to-action card. */ +.newSession { + container-type: inline-size; + position: relative; + flex: none; + display: flex; + align-items: center; + justify-content: center; + gap: 0; + height: 36px; + padding: 6px 8px; + margin: 0 0 2px; + box-sizing: border-box; + border: none; + border-radius: var(--dsw-radius-md); + background: transparent; + color: var(--dsw-alias-label-primary); + font-family: inherit; + font-size: 14px; + font-weight: 430; + line-height: 21px; + cursor: pointer; + overflow: hidden; +} + +.newSession:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +/* macOS: the themed elevated fill reads as a foreign slab on the vibrancy + sidebar; a white wash sits in the glass like native macOS sidebar + controls. Dark keeps the same white ink at lower alpha. */ +:global([data-platform="darwin"]) .newSession { + background: rgb(255 255 255 / 0.55); +} + +:global([data-platform="darwin"]) .newSession:hover { + background: rgb(255 255 255 / 0.3); +} + +:global([data-platform="darwin"]) :global([data-ds-dark-theme]) .newSession { + background: rgb(255 255 255 / 0.15); +} + +:global([data-platform="darwin"]) :global([data-ds-dark-theme]) .newSession:hover { + background: rgb(255 255 255 / 0.2); +} + +.collapsed .newSession { + align-self: flex-start; + width: 36px; + height: 36px; + padding: 0; + margin: 0 0 12px; + gap: 0; + border-color: transparent; + background: transparent; +} + +.collapsed .newSession:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.newSessionLabel { + max-width: 200px; + overflow: hidden; + white-space: nowrap; +} + +.collapsed .newSessionLabel { + max-width: 0; +} + +.newSessionLabelMask { + flex: 1; + min-width: 0; +} +.newSession:is(:hover, :focus-visible) .newSessionLabelMask:has(+ .newSessionShortcut) { + overflow: hidden; + mask-image: linear-gradient(to right, black calc(100% - 16px), transparent); +} +.newSessionContent { + display: flex; + width: 100cqw; + align-items: center; + justify-content: flex-start; + gap: 8px; +} +.newSessionContent > svg { + flex: none; +} + +.collapsed .newSessionContent { + justify-content: center; +} + +@media (hover: none), (pointer: coarse) { + .root:not(.collapsed) .newSession, + .root:not(.collapsed) .panelRow { + min-height: 44px; + } +} + +.newSessionShortcut { + opacity: 0; + display: inline-flex; + flex: none; + pointer-events: none; + font-weight: 400; +} + +.newSession:is(:hover, :focus-visible) .newSessionShortcut { + opacity: 1; +} + +.panelList { + flex: none; + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 16px; +} + +/* Panel entries share the New Session bar's 12px radius and 2px inset, the + session rows' hover/selected surface, and the primary ink. */ +.panelRow { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + min-height: 36px; + padding: 7px 8px; + box-sizing: border-box; + border: none; + border-radius: var(--dsw-radius-md); + background: transparent; + color: var(--dsw-alias-label-primary); + font: inherit; + line-height: 22px; + text-align: left; + cursor: pointer; +} + +.panelRow:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.panelRow.panelActive { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} + +.panelRow:focus-visible { + outline: var(--dsw-focus-ring-width) solid + var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: -2px; +} + +.panelGlyph { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.panelTitle { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.collapsed .panelList { + gap: 12px; + margin-bottom: 12px; +} + +.collapsed .panelRow { + width: 36px; + height: 36px; + justify-content: center; + margin: 0; + padding: 0; + color: var(--dsw-alias-label-primary); +} + +/* Region seat: always mounted so the foot never moves. Its trailing margin + cancels the wide shell inset so the nested scrollbar can sit at the sidebar + edge; the browser restores that inset inside its own rows. */ +.regionArea { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + margin-left: -4px; + margin-right: calc(-1 * var(--dsh-sidebar-inline-padding)); + padding-left: 4px; + overflow: hidden; +} + +.collapsed .regionArea { + margin-left: 0; + margin-right: 0; + padding-left: 0; +} + +/* Footer seats: additive actions stack above Settings. Each occupant owns its + button geometry and hover chrome. */ +.footArea { + flex: none; + display: flex; + flex-direction: column; +} + +.settingsArea, +.footerActions { + flex: none; + min-width: 0; + width: 100%; +} + +.footerActions { + display: flex; +} + +.collapsed .footArea { + align-items: center; +} + +.collapsed .settingsArea, +.collapsed .footerActions { + display: flex; + justify-content: center; + width: auto; +} + +@media (prefers-reduced-motion: reduce) { + .wide, + .fading > *, + .railIn .iconButton, + .railIn .newSession, + .railIn .panelList, + .railIn .footArea, + .railIn .regionArea { + transition: none; + animation: none; + } +} diff --git a/apps/web-ui/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/apps/web-ui/packages/client/ui-sidebar/src/client/SidebarRoot.tsx new file mode 100644 index 000000000..f01bd55aa --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -0,0 +1,378 @@ +/** + * Sidebar shell: column geometry and global panel navigation. + * Collapse is a slide plus crossfade: + * content freezes at its expanded width (inline style) and fades out in place + * while the sliding column (AppFrame grid tracks) clips it — nothing reflows + * mid-slide. At settle the wide-only content unmounts and the upper + * controls enter the 56px rail from the same horizontal offset (one icon each, + * same top-down order) on one fade that ends with the slide. The bottom-pinned + * settings control only fades. The workspace/session browsing region between + * global panel rows and the foot is the `sidebar.workspaces` registrant's, + * and the foot holds `sidebar.settings` plus `sidebar.footer.action`; the shell + * hands them the wide flag (plus an expand request callback for the browser). + * + * The column also owns whether the scroll regions nested in it draw a + * scrollbar at all: the shell tracks the pointer and rebinds ui-theme's + * scrollbar indirection away while it is elsewhere, so a list the user is not + * pointing at carries no bar. + */ +import { useEffect, useRef, useState } from "react"; +import clsx from "clsx"; +import { + FishLogo, + IconNewChatOutlineMedium, + IconNewChatOutlineRegular, + IconPanelLeftOutlineRegular, + isDarwinDesktop, + ShortcutKeys, + Tooltip, +} from "@deepseek-ai/dsh-client-ui-primitives"; +import type { InjectFace, PropsRenderSlots, PropsRuntime } from "@deepseek-ai/dsh-client-ui-slots"; +import type { + SidebarPanelMetadata, + SidebarRootComponentProps, + SidebarRootInjected, + SidebarSectionOwnerProps, +} from "./contract/slots.ts"; +import css from "./SidebarRoot.module.css"; + +/** Wide-content unmount delay; matches the 150ms wide-content fade-out. */ +const COLLAPSE_SETTLE_MS = 150; + +/** + * How long the column's scrollbars stay drawn after the pointer leaves it. + * The bar is a pointer affordance here, and hiding it on the leave event + * itself makes it blink out while the pointer is only crossing the column's + * edge — on the way to the conversation, or around a portalled menu. + */ +const SCROLLBAR_LINGER_MS = 2000; + +/** Format complete-build metadata for the local brand badge. */ +function localBuildVersion(): string | undefined { + const version = process.env.DSH_CLIENT_VERSION; + if (version === undefined) return undefined; + const commit = process.env.DSH_CLIENT_COMMIT_HASH; + return ( + version + + (commit === undefined ? "" : `-${commit}`) + + (process.env.DSH_CLIENT_GIT_DIRTY === "true" ? "-dirty" : "") + ); +} + +type PanelRowProps = Pick & + Pick & + Pick, "usePanelInfo"> & + Pick, "selectPanel"> & + PropsRenderSlots<"sidebar.panellist">; + +/** Each panel row subscribes only to its own selection state. */ +function PanelRow({ id, label, wide, usePanelInfo, selectPanel, renderSlot }: PanelRowProps) { + const active = usePanelInfo((info) => info.activePanelId === id); + return ( + + + + ); +} + +/** + * Render the sidebar column shell. + * @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts). + * @returns the sidebar element tree. + */ +export function SidebarRoot({ + collapsed, + width, + startSession, + toggleSidebar, + selectPanel, + usePanels, + useShortcuts, + usePanelInfo, + t, + renderSlot, +}: SidebarRootComponentProps) { + const panels = usePanels((snapshot) => snapshot); + const shortcut = useShortcuts((rows) => rows.find((row) => row.id === "sidebar.left.toggle")); + const newShortcut = useShortcuts((rows) => rows.find((row) => row.id === "session.new")); + const toggleLabel = collapsed ? t("toggle.open") : t("toggle.collapse"); + // Wide content stays mounted while the collapse animates (fading via + // .collapsed .wide), unmounts at settle, and remounts right away on expand. + const [settled, setSettled] = useState(collapsed); + useEffect(() => { + if (!collapsed) { + setSettled(false); + return; + } + const timer = window.setTimeout(() => { + setSettled(true); + }, COLLAPSE_SETTLE_MS); + return () => { + window.clearTimeout(timer); + }; + }, [collapsed]); + const windowsTitlebar = document.documentElement.hasAttribute("data-windows-titlebar"); + const wide = windowsTitlebar ? !collapsed : !collapsed || !settled; + // The Windows caption menus occupy the strip to the right of these controls + // (that is what --dsh-windows-menu-start reserves), so a right-side bubble + // lands under their text. Below the caption is the only clear side. + const captionTooltipSide = windowsTitlebar ? "bottom" : "right"; + // Freeze the content at its expanded width while it fades out (collapsed + // && wide): the sliding column then clips it instead of reflowing it. The + // rail layout (.collapsed styles) only applies once the fade settles. + const lastWideWidth = useRef(width); + if (!collapsed) lastWideWidth.current = width; + + // Rail-in only crossfades a live collapse: a refresh straight into the + // collapsed state renders the rail statically (no delay-hidden icons). + const everWide = useRef(!collapsed); + if (!collapsed) everWide.current = true; + + // Scrollbars in the column follow the pointer (.quietBars rebinds them + // away): drawn while it is inside, and for SCROLLBAR_LINGER_MS after it + // leaves. A pointer that returns within that window cancels the pending + // hide rather than restarting from a hidden bar. + const column = useRef(null); + const [pointerInside, setPointerInside] = useState(false); + const lingerTimer = useRef(undefined); + const armLinger = (): void => { + if (lingerTimer.current !== undefined) return; + lingerTimer.current = window.setTimeout(() => { + lingerTimer.current = undefined; + setPointerInside(false); + }, SCROLLBAR_LINGER_MS); + }; + const cancelLinger = (): void => { + window.clearTimeout(lingerTimer.current); + lingerTimer.current = undefined; + }; + // Leaving is decided by the column's BOX, not by DOM containment, and only + // while the bars are drawn. ui-settings renders its full-viewport panel as a + // fixed-position DESCENDANT of this column, so a pointer moved onto that + // panel — or onto the conversation once it closes — fires no `pointerleave` + // here, and the bars would stay drawn over a column nobody is pointing at. + // The element's own leave stays as the one signal geometry cannot give: a + // pointer that leaves the window emits no further moves. + useEffect(() => { + if (!pointerInside) return; + const onMove = (event: PointerEvent): void => { + const rect = column.current?.getBoundingClientRect(); + /* v8 ignore next -- the listener only exists while the column is mounted and revealed. */ + if (rect === undefined) return; + const inside = + event.clientX >= rect.left && + event.clientX < rect.right && + event.clientY >= rect.top && + event.clientY < rect.bottom; + if (inside) cancelLinger(); + else armLinger(); + }; + document.addEventListener("pointermove", onMove); + return () => { + document.removeEventListener("pointermove", onMove); + cancelLinger(); + }; + }, [pointerInside]); + + const buildVersion = localBuildVersion(); + + const darwinDesktop = isDarwinDesktop(); + // Rail resting state is the whale mark; hovering swaps in the panel icon + // (the expand affordance, figma sidebar-hover flow). Expanded it is a plain + // panel icon. + const toggle = ( + + + + ); + + return ( +
    { + cancelLinger(); + setPointerInside(true); + }} + onPointerLeave={() => { + armLinger(); + }} + > + {/* macOS hiddenInset titlebar: the strip shares the row with the + traffic lights and keeps the toggle at the sidebar's top-right. */} + {darwinDesktop && ( +
    + {toggle} +
    + )} +
    + {/* Expanded, the brand doubles as a New Session shortcut — except on + macOS, where it stays part of the logo row's window-drag surface + (a button would subtract itself through the global no-drag rule); + the collapsed rail's logo is the expand toggle below instead. */} + {wide && + (() => { + const identity = ( + + ); + return darwinDesktop ? ( + {identity} + ) : ( + + ); + })()} + {!darwinDesktop && toggle} +
    + + {/* The label fades before the hover/focus shortcut, including on translucent backgrounds. */} + + + + + {panels.length > 0 && ( + + )} + + {/* The browsing region fills the column between the controls and the + foot in both states; its rail icon column rides the same slot. */} +
    + {renderSlot("sidebar.workspaces", { + wide, + expandSidebar: () => { + if (collapsed) toggleSidebar(); + }, + })} +
    + + {/* Footer actions stack above Settings in both sidebar widths. */} +
    +
    {renderSlot("sidebar.footer.action", { wide })}
    +
    {renderSlot("sidebar.settings", { wide })}
    +
    +
    + ); +} diff --git a/apps/web-ui/packages/client/ui-sidebar/src/client/contract/slots.ts b/apps/web-ui/packages/client/ui-sidebar/src/client/contract/slots.ts new file mode 100644 index 000000000..42e6f96b9 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar/src/client/contract/slots.ts @@ -0,0 +1,156 @@ +/** + * Sidebar slot contract: the registrant-side props composition for the + * layout-owned `sidebar` slot, plus the holes this shell declares. The shell + * owns column geometry, the brand row, New Session, and global panel rows; + * everything between the workspace section header and the list bottom is the + * `sidebar.workspaces` registrant's (ui-workspace), and the foot is the + * `sidebar.settings` registrant's (ui-settings), followed by optional footer + * actions in `sidebar.footer.action`. + */ +import type { + InjectFace, + PropsLocale, + PropsRenderSlots, + PropsRuntime, +} from "@deepseek-ai/dsh-client-ui-slots"; +import type { ObservableSnapshot } from "@deepseek-ai/dsh-client-store"; +import type { WorkspaceId } from "@deepseek-ai/dsh-api-workspace-controller/client"; +import type { ShortcutCatalogEntry } from "@deepseek-ai/dsh-client-shortcuts/client"; +import type { MainPanelId } from "@deepseek-ai/dsh-client-ui-layout/client"; + +declare module "@deepseek-ai/dsh-client-ui-slots" { + interface SlotMap { + /** Non-interactive notification inside the collapsed sidebar expand button. */ + "sidebar.toggle.badge": { kind: "single"; scope: "root"; owner: Record }; + /** + * Brand mark rendered in the expanded brand row and collapsed rail. + * Declared by this package's `sidebar` entry; deployments may replace + * the shell's fish fallback without replacing the surrounding controls. + */ + "sidebar.brand.mark": { kind: "single"; scope: "root"; owner: SidebarBrandMarkOwnerProps }; + /** + * Brand name rendered beside the expanded mark. Declared by this + * package's `sidebar` entry; the shell supplies a generic text fallback. + */ + "sidebar.brand.name": { kind: "single"; scope: "root"; owner: SidebarBrandNameOwnerProps }; + /** + * Global panel icons. Each list id addresses the matching main panel; + * the sidebar owns the button and resolves its label from list metadata. + */ + "sidebar.panellist": { kind: "list"; scope: "root"; owner: SidebarPanelIconOwnerProps }; + /** + * The workspace/session browsing region: section header, search, the + * grouped/flat session list, and every workspace dialog. Declared by this + * package's 'sidebar' entry (declaring is claiming); ui-workspace + * registers the browser. + */ + "sidebar.workspaces": { kind: "single"; scope: "root"; owner: SidebarSectionOwnerProps }; + /** + * The settings seat at the sidebar foot. Declared by this package's + * 'sidebar' entry; ui-settings registers its trigger row + modal panel. + * The sidebar passes only its column state — it holds no settings state. + */ + "sidebar.settings": { kind: "single"; scope: "root"; owner: SidebarSettingsOwnerProps }; + /** + * Optional actions beside Settings at the sidebar foot. Declared by this + * package's 'sidebar' entry; each action receives only the column state. + */ + "sidebar.footer.action": { kind: "list"; scope: "root"; owner: SidebarFooterActionOwnerProps }; + } +} + +/** Geometry supplied to the sidebar brand-mark occupant. */ +export interface SidebarBrandMarkOwnerProps { + /** Requested square edge in pixels. */ + size: number; +} + +/** Empty owner share for the sidebar brand-name occupant. */ +export interface SidebarBrandNameOwnerProps { + /** Marker field: the occupant owns its own content and width. */ + children?: never; +} + +/** Icon presentation supplied by the global panel row. */ +export interface SidebarPanelIconOwnerProps { + /** Requested square edge in pixels. */ + size: number; + /** Whether this panel is selected in the main column. */ + active: boolean; +} + +/** Serializable metadata for one active global panel list registration. */ +export interface SidebarPanelMetadata { + /** List id and matching main panel key. */ + id: MainPanelId; + /** Ascending row order; ties retain registration order. */ + order: number; + /** Row title and accessible name: resolved label, or the id when omitted. */ + label: string; +} + +/** + * Owner share of the browser hole — the only facts crossing the shell/region + * boundary. Business data and actions arrive through the region's own inject. + */ +export interface SidebarSectionOwnerProps { + /** Shell fold-state output: wide renders the full browser, rail the icon column. */ + wide: boolean; + /** Rail icons request expansion; the browser rides the wide flip for focus. */ + expandSidebar: () => void; +} + +/** + * Owner share of the sidebar settings seat: the column display state the + * occupant's trigger row must render against (wide row vs rail icon). + */ +export interface SidebarSettingsOwnerProps { + /** Whether the sidebar renders wide content (false = 56px rail). */ + wide: boolean; +} + +/** Owner share of an action rendered beside Settings at the sidebar foot. */ +export interface SidebarFooterActionOwnerProps { + /** Whether the sidebar renders wide content (false = 56px rail). */ + wide: boolean; +} + +/** + * Registrant-private injected share (arrives via the register inject + * factory). The renderer binds the panel metadata source to usePanels. + */ +export type SidebarRootInjected = { + /** + * Start a New Session: with a workspace, reuse-or-create its blank session + * and open it; without one, inherit the current Session Workspace, then the + * recent Workspace, or clear into the New Session pure view when none exist. + */ + startSession: (workspaceId?: WorkspaceId) => void; + /** Toggle the sidebar column through the layout service. */ + toggleSidebar: () => void; + /** Select the global panel addressed by a sidebar row. */ + selectPanel: (id: MainPanelId) => void; + /** Private reactive sources bound to framework selector hooks. */ + hooks: { + panels: ObservableSnapshot; + shortcuts: ObservableSnapshot; + }; +}; + +/** + * Full component props: layout owner state/actions plus the declared holes' + * render shares, this package's injected callbacks, and the standard locale + * seat. Panel metadata arrives through an injected observable. + */ +export type SidebarRootComponentProps = PropsRuntime<"sidebar"> & + PropsRenderSlots< + | "sidebar.brand.mark" + | "sidebar.brand.name" + | "sidebar.toggle.badge" + | "sidebar.panellist" + | "sidebar.workspaces" + | "sidebar.settings" + | "sidebar.footer.action" + > & + InjectFace & + PropsLocale<"sidebar">; diff --git a/apps/web-ui/packages/client/ui-sidebar/src/client/index.ts b/apps/web-ui/packages/client/ui-sidebar/src/client/index.ts new file mode 100644 index 000000000..59be58032 --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar/src/client/index.ts @@ -0,0 +1,137 @@ +/** Registers the sidebar shell and global panel navigation. */ +import type {} from "@deepseek-ai/dsh-client-product-analytics/client"; +import type { Context as ClientContext } from "@deepseek-ai/cordis"; +import { createSnapshotStore } from "@deepseek-ai/dsh-client-store"; +import { resolveSlotLabel } from "@deepseek-ai/dsh-client-ui-slots"; +import type { MainPanelId } from "@deepseek-ai/dsh-client-ui-layout/client"; +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from "@deepseek-ai/dsh-client-locale/client"; +// Type-only: pulls the SlotRegistry service merge (ctx.slots). +import type {} from "@deepseek-ai/dsh-client-ui-renderer/client"; +// Type-only: pulls the Session root standard-props merge. +import type {} from "@deepseek-ai/dsh-client-ui-session/client"; +import type { SidebarPanelMetadata, SidebarRootInjected } from "./contract/slots.ts"; +import { HeaderLeadingControls } from "./HeaderLeadingControls.tsx"; +import { SidebarRoot } from "./SidebarRoot.tsx"; +import { en, zh, type SidebarKey } from "./locales.ts"; + +export type { + SidebarBrandMarkOwnerProps, + SidebarBrandNameOwnerProps, + SidebarFooterActionOwnerProps, + SidebarPanelIconOwnerProps, + SidebarPanelMetadata, + SidebarRootComponentProps, + SidebarRootInjected, + SidebarSectionOwnerProps, + SidebarSettingsOwnerProps, +} from "./contract/slots.ts"; +export type { SidebarKey } from "./locales.ts"; + +declare module "@deepseek-ai/dsh-client-ui-slots" { + interface LocaleNamespaceMap { + /** Sidebar controls and global panel copy. */ + sidebar: SidebarKey; + } +} + +/** Dictionary namespace owned by this plugin. */ +const NS = "sidebar"; + +interface WorkspaceNavigation { + startSession(workspaceId?: Parameters[0]): void; +} + +/** Services required by the sidebar plugin. */ +export const inject = ["slots", "layout", "uiWorkspace", "locale", "shortcuts"]; + +/** Registers the sidebar shell and its service callbacks. + * @param ctx - Client root context. + */ +export function apply(ctx: ClientContext): void { + const workspaceNavigation = ctx.get("uiWorkspace") as unknown as WorkspaceNavigation; + ctx.effect(() => ctx.locale.register(NS, { zh, en }), "ui-sidebar: dictionaries"); + const panels = createSnapshotStore([]); + const syncPanels = (): void => { + const next = ctx.slots + .entriesOfSlot("sidebar.panellist") + .map(({ options }) => { + // The list registration requires an id; StoredEntry erases the slot kind. + const id = options.id as MainPanelId; + return { id, order: options.order ?? 0, label: resolveSlotLabel(options.label) ?? id }; + }) + .sort((a, b) => a.order - b.order); + const previous = panels.getSnapshot(); + if ( + previous.length === next.length && + previous.every((panel, index) => { + const candidate = next[index] as SidebarPanelMetadata; + return ( + panel.id === candidate.id && + panel.order === candidate.order && + panel.label === candidate.label + ); + }) + ) + return; + panels.set(next); + }; + ctx.effect( + () => ctx.slots.subscribe("sidebar.panellist", syncPanels), + "ui-sidebar: panel entries", + ); + ctx.effect(() => ctx.locale.subscribe(syncPanels), "ui-sidebar: panel labels"); + + const injectProps = (): SidebarRootInjected => ({ + // The shell's New Session button rides the Workspace UI's shared action + // (current Session Workspace, then recent Workspace). + startSession: (workspaceId) => { + workspaceNavigation.startSession(workspaceId); + }, + toggleSidebar: () => { + ctx.layout.toggleSidebar(); + }, + selectPanel: (id) => { + if (id === "plugins" || id === "schedules") + ctx + .get("productAnalytics") + ?.track("sidebar_menu_click", { menu_name: id === "plugins" ? "plugin" : "cron" }); + ctx.layout.selectPanel(id); + }, + hooks: { panels, shortcuts: ctx.shortcuts.catalog }, + }); + ctx.slots.inject("sidebar", () => + ctx.slots.register( + { + name: "sidebar", + locale: NS, + children: { + "sidebar.brand.mark": { kind: "single", scope: "root" }, + "sidebar.brand.name": { kind: "single", scope: "root" }, + "sidebar.toggle.badge": { kind: "single", scope: "root" }, + "sidebar.panellist": { kind: "list", scope: "root" }, + "sidebar.workspaces": { kind: "single", scope: "root" }, + "sidebar.settings": { kind: "single", scope: "root" }, + "sidebar.footer.action": { kind: "list", scope: "root" }, + }, + inject: injectProps, + }, + SidebarRoot, + ), + ); + // macOS desktop hides the collapsed sidebar entirely, so the open/New + // Session controls move into the frame's window-chrome seat beside the + // traffic lights; the occupant reuses the shell's injected actions, and + // the AppFrame mounts the seat only while the column is fully hidden. + ctx.slots.inject("shell.leading", () => + ctx.slots.register( + { + name: "shell.leading", + locale: NS, + inject: injectProps, + }, + HeaderLeadingControls, + ), + ); + syncPanels(); +} diff --git a/apps/web-ui/packages/client/ui-sidebar/src/client/locales.ts b/apps/web-ui/packages/client/ui-sidebar/src/client/locales.ts new file mode 100644 index 000000000..d58b7558b --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar/src/client/locales.ts @@ -0,0 +1,22 @@ +/** `sidebar` namespace dictionaries for shell controls and global panels. */ + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + "session.new": "新会话", + "session.new.label": "新建会话", + "toggle.open": "打开侧边栏", + "toggle.collapse": "收起侧边栏", + "panels.label": "全局面板", +} satisfies Record; + +/** The sidebar namespace key union. */ +export type SidebarKey = keyof typeof zh; + +/** English dictionary, checked complete against the zh key set. */ +export const en = { + "session.new": "New Session", + "session.new.label": "New session", + "toggle.open": "Open sidebar", + "toggle.collapse": "Collapse sidebar", + "panels.label": "Global panels", +} satisfies Record; diff --git a/apps/web-ui/packages/client/ui-sidebar/src/css-modules.d.ts b/apps/web-ui/packages/client/ui-sidebar/src/css-modules.d.ts new file mode 100644 index 000000000..b284bf80b --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module "*.module.css" { + const classes: Record; + export default classes; +} + +declare module "*.css"; diff --git a/apps/web-ui/packages/client/ui-sidebar/src/index.ts b/apps/web-ui/packages/client/ui-sidebar/src/index.ts new file mode 100644 index 000000000..9b3516b9c --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar/src/index.ts @@ -0,0 +1,4 @@ +/** Host loader entry for the browser-only sidebar plugin. */ + +/** Provides no host-side behavior. */ +export function apply(): void {} diff --git a/apps/web-ui/packages/client/ui-sidebar/tsdown.config.ts b/apps/web-ui/packages/client/ui-sidebar/tsdown.config.ts new file mode 100644 index 000000000..ba7dd4e7e --- /dev/null +++ b/apps/web-ui/packages/client/ui-sidebar/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from "../tsdown.client.ts"; + +export default clientBundle("@deepseek-ai/dsh-client-ui-sidebar", ["lib/types/index.js"]); diff --git a/apps/web-ui/packages/client/ui-slots/package.json b/apps/web-ui/packages/client/ui-slots/package.json new file mode 100644 index 000000000..8a6c7f028 --- /dev/null +++ b/apps/web-ui/packages/client/ui-slots/package.json @@ -0,0 +1,16 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-slots", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dependencies": {} +} diff --git a/apps/web-ui/packages/client/ui-slots/src/index.ts b/apps/web-ui/packages/client/ui-slots/src/index.ts new file mode 100644 index 000000000..1c72e8fe9 --- /dev/null +++ b/apps/web-ui/packages/client/ui-slots/src/index.ts @@ -0,0 +1,1812 @@ +/** + * Slot registry pure core. Owners declare slot + * contracts by merging into {@link SlotMap}; one `register` call contributes a + * component AND (optionally) declares child slots, a store seat, and the + * registrant's business face. Zero runtime dependencies (React types only). + * + * SlotMap and the standard-kit interfaces live directly in this entry module: + * consumer `declare module` augmentation merges with declarations lexically in + * the augmented module, not with re-exports. + */ +/* oxlint-disable typescript/no-redundant-type-constituents -- + * `keyof SlotMap & string` is the declare-merge key pattern: SlotMap is empty + * in THIS compilation unit (so the intersection reads as `never`), but every + * consumer merges keys in and the intersection is what keeps them string-typed. + * The rule fires on the empty-map view, not on real redundancy. */ +import type { ReactNode } from "react"; +import type { + BoundActions, + HandleOf, + PropsStore, + SnapshotSelectorHook, + StoreDecl, +} from "@deepseek-ai/dsh-client-store"; +import type { HostObservable, KeyedStandardSource } from "./renderer.ts"; + +export * from "./store.ts"; +export * from "./renderer.ts"; + +/** Slot contract table. Owners extend via declaration merging; entries are {@link SlotEntryDef}. */ +export interface SlotMap {} + +/** Reusable Component Factory contract table, extended through declaration merging. */ +export interface SlotFactoryMap {} + +/** + * Locale namespace table. Dictionary owners extend via declaration merging + * (exactly like {@link SlotMap}, and declared in this entry module for the + * same lexical-merge reason): the key is the namespace string, the value is + * the union of its dictionary keys. Register sites declare one of these + * namespaces (`locale:`), which puts the typed `t` standard seat on the + * component props. + */ +export interface LocaleNamespaceMap {} + +/** + * Resource protocol (URL scheme) → the value its provider streams. Declared + * empty here, the zero-dependency merge point; each protocol owner merges its + * own member (`file`, later `chat`), and `useResource

    (address)` narrows its + * value by `P`. The resource service itself lives in `dsh-client-resources`. + */ +export interface ResourceProtocolMap {} + +/** + * Translate a dictionary key with optional `{name}` template params. + * `K` narrows the accepted keys to the owning namespace's dictionary union + * (plus the shared common vocabulary where composed). + */ +export type Translate = ( + key: K, + params?: Record, +) => string; + +/** + * The shared `common` vocabulary keys as merged by the locale plugin; + * resolves to `never` in programs without the merge (this package's tests), + * keeping the union collapse harmless. + */ +export type CommonKeyOf = LocaleNamespaceMap extends { common: infer C } ? C & string : never; + +/** + * Key domain of a namespace-bound translate: the namespace's own dictionary + * union plus the shared common vocabulary (the lookup chain consults common + * after the namespace misses). + */ +export type LocaleKeysOf = + (LocaleNamespaceMap[N] & string) | CommonKeyOf; + +/** + * Namespace-addressed translate — the developer-facing alias over + * {@link Translate}: `TranslateNS<'model'>` is the translate function of the + * `model` namespace (key domain = its dictionary union plus the shared + * common vocabulary), the exact type of the framework-injected `t` seat and + * of the locale service's typed `bind`. + */ +export type TranslateNS = Translate>; + +/** + * Dictionary shape for a declared namespace: exactly the keys the namespace + * merged into {@link LocaleNamespaceMap} — a missing or extra key at a typed + * registration site is a compile error. + */ +export type LocaleDictOf = Record< + LocaleNamespaceMap[N] & string, + string +>; + +/** + * Locale share of the composed component props: the framework-injected `t` + * seat, present exactly on entries whose registration declares `locale:`. + */ +export type PropsLocale = N extends keyof LocaleNamespaceMap & string + ? { + /** Translate a dictionary key of the declared namespace (or the shared common vocabulary). */ + t: TranslateNS; + } + : object; + +/** Slot cardinality: single occupant, ordered list, key-dispatched, or selector-routed chain. */ +export type SlotKind = "single" | "list" | "keyed" | "chain"; + +/** Slot data context: global, current-session-optional, or strict session-bound. */ +export type SlotScope = "root" | "session-maybe" | "session"; + +/** Declaration-merged explicit target types for non-root scope Providers. */ +export interface SlotScopeTargetMap {} + +/** + * One SlotMap entry: kind/scope axes plus the optional owner-supplied props + * share (`owner` is what the parent passes at its renderSlot call site; the + * framework standard kit and the registrant's injected share never enter this + * table — full component props compose at the component from the framework + * shares, see {@link ComposedProps}). + */ +export interface SlotEntryDef { + kind: SlotKind; + scope: SlotScope; + owner?: object; + /** + * Optional keyed-entry prop table. A keyed registration contributes one + * literal key and receives the corresponding prop share; ordinary owner + * props remain common to every key. + */ + keyProps?: Record; + /** + * Optional opaque context carried by one renderSlot occurrence. Only + * function-valued members of the slot-level injected hooks compartment + * receive it; the slot machinery never interprets the value. + */ + hookContext?: unknown; + /** + * Optional Slot-level inject face supplied by the parent registration's + * child declaration. Every registered entry receives its bound component + * face; child registrants do not own or replace this common capability. + */ + inject?: object; +} + +/** One caller-selected Component position inside a reusable Factory. */ +export interface FactoryLocalSlotDef { + scope: SlotScope; + props?: object; +} + +/** Complete static definition of one reusable Component Factory. */ +export interface SlotFactoryDef { + scope: SlotScope; + props?: object; + children?: ChildrenDecl; + store?: StoreDecl; + inject?: object; + locale?: keyof LocaleNamespaceMap & string; + slots?: Record; +} + +/** + * Runtime dispatch spec for one slot, recorded from a register call's + * `children` value. The literal is compile-time checked against the SlotMap + * entry (`SlotSpec` in {@link ChildrenDecl}), so kind, scope, and + * any common inject face are declared at one point and validate each other. + */ +export type SlotSpec = { + kind: E["kind"]; + scope: E["scope"]; +} & ("inject" extends keyof E + ? E extends { inject: infer Injected extends object } + ? { inject: Injected } + : { inject?: object } + : { inject?: never }); + +/** + * Child-slot declaration table for register(): keys are the declared (and + * thereby render-authorized) slot names, values are their runtime dispatch + * specs. Declaring is claiming: the registering entry becomes the only entry + * allowed to render these keys. + */ +export type ChildrenDecl = { [P in keyof SlotMap & string]?: SlotSpec }; + +/** Owner-supplied props share for a slot key ({} for entries declaring no `owner`). */ +export type OwnerOf = SlotMap[K] extends { + owner: infer O extends object; +} + ? O + : object; + +/** Registration/dispatch key domain of one keyed slot. */ +export type EntryKeyOf = SlotMap[K] extends { + kind: "keyed"; + keyProps: infer P extends object; +} + ? keyof P & string + : string; + +/** Key-dependent props supplied by the owner at one keyed dispatch site. */ +export type KeyPropsOf< + K extends keyof SlotMap & string, + EntryKey extends EntryKeyOf, +> = SlotMap[K] extends { kind: "keyed"; keyProps: infer P extends object } + ? EntryKey extends keyof P + ? P[EntryKey] extends object + ? P[EntryKey] + : never + : never + : object; + +/** Opaque per-render occurrence context declared by one slot. */ +export type HookContextOf = SlotMap[K] extends { + hookContext: infer Context; +} + ? Context + : never; + +/** Common render-occurrence inject face declared by one slot. */ +export type SlotInjectOf = SlotMap[K] extends { + inject: infer Injected extends object; +} + ? Injected + : object; + +/** Scope axis of a slot key's SlotMap entry. */ +export type ScopeOf = SlotMap[K]["scope"]; + +/** + * Framework standard kit delivered to every session-scope slot component. + * Declared empty here (zero-dependency layer): `ui-session` merges the + * Session lifecycle hook, projection hook, and Session identity; domain UI + * adapters merge their own standard hooks exactly as consumers merge SlotMap keys. + */ +export interface SessionStandardProps {} + +/** + * Framework standard kit delivered to session-optional slots. Its hooks stay + * callable while no session is selected and return `undefined` until one + * becomes current; `ui-session` and domain UI adapters merge the concrete + * members. + */ +export interface SessionMaybeStandardProps {} + +/** + * Framework standard kit delivered to EVERY slot component (the global seat). + * Declared empty here; each owning UI adapter merges the global selector hooks + * that shared page composition consumes. + */ +export interface GlobalStandardProps {} + +/** + * The session id type as `ui-session`'s SessionStandardProps merge declares it + * (branded); falls back to `string` in programs without the merge (this + * package's own tests). + */ +export type SessionIdOf = SessionStandardProps extends { sessionId: infer S } ? S : string; + +/** Standard props selected by one declared scope. */ +export type ScopeStandardProps = (S extends "session" + ? SessionStandardProps + : S extends "session-maybe" + ? SessionMaybeStandardProps + : object) & + GlobalStandardProps; + +/** + * Runtime props share for a slot key: owner share (parent's renderSlot call + * site) + session standard kit (session scope only) + the global seat. + */ +export type PropsRuntime< + K extends keyof SlotMap & string, + EntryKey extends EntryKeyOf = EntryKeyOf, +> = OwnerOf & + KeyPropsOf & + SlotInjectFace> & + ScopeStandardProps>; + +/** renderSlot dispatch options: keyed dispatch key, list filtering, and empty fallback. */ +export interface RenderOpts { + entryKey?: EntryKey; + only?: string; + fallback?: ReactNode; + /** Type-erased runtime seat; PropsRenderSlots narrows or removes it per slot declaration. */ + hookContext?: unknown; +} + +/** renderSlotChain dispatch options. */ +export interface ChainRenderOpts { + /** The owner's fallback body, rendered when every entry's selector declines. */ + fallback?: ReactNode; + /** Render only the owner fallback without resolving or dispatching the chain's scope. */ + fallbackOnly?: boolean; + /** + * Keep the fallback permanently mounted: an election hides it (wrapped, + * display:none) instead of unmounting it, and the all-decline case shows it + * as-is — fallback-held state (composer drafts, DOM state) survives a + * takeover. Chain kind only; the sole consumer is the + * `conversation.composer` chain. + */ + overlay?: boolean; +} + +/** + * Chain-entry selector: the routing decision of one chain contribution. + * Runs at render time in chain order (ascending `priority`, default 0, lower + * tries first; ties keep registration = assembly order); the first non-null + * return elects its entry + * and becomes the component's `matched` prop; `null` passes to the next + * entry; all-null falls to the owner's {@link ChainRenderOpts} fallback. + * MUST be pure — a function of the owner props only, no external mutable + * reads, no side effects (the decline decision lives here, never in a + * mounted component probing its own props). + */ +export type ChainSelect = (owner: O) => M | null; + +/** Keys of a slot-key union whose SlotMap entry is chain-kind (renderSlotChain's dispatch domain). */ +export type ChainKeysOf = S extends unknown + ? SlotMap[S]["kind"] extends "chain" + ? S + : never + : never; + +/** Keys in a render share whose dispatch occurrence requires hookContext. */ +type ContextualKeysOf = S extends unknown + ? SlotMap[S] extends { hookContext: unknown } + ? S + : never + : never; + +/** Keys in a render share with the ordinary optional options bag. */ +type OrdinaryKeysOf = Exclude>; + +/** + * Plain and contextual child dispatch signatures. Keeping them as separate + * call signatures preserves ordinary renderSlot assignability while making a + * declared hookContext mandatory only for the Slot keys that need it. + */ +type RenderSlotFn = ([ContextualKeysOf] extends [never] + ? object + : { + , EntryKey extends EntryKeyOf = EntryKeyOf>( + key: K, + owner: OwnerOf & KeyPropsOf>, + opts: RenderOpts & { hookContext: HookContextOf }, + ): ReactNode; + }) & + ([OrdinaryKeysOf] extends [never] + ? object + : { + , EntryKey extends EntryKeyOf = EntryKeyOf>( + key: K, + owner: OwnerOf & KeyPropsOf>, + opts?: Omit, "hookContext">, + ): ReactNode; + }); + +/** + * Chain matched share: a chain-slot component receives its selector's + * non-null result as the framework-injected `matched` prop; other kinds add + * nothing to the composed constraint. + */ +export type MatchedShare = E["kind"] extends "chain" + ? { matched: M } + : object; + +/** Props of the standard-kit SessionProvider seat. */ +export interface SessionAreaProps { + /** + * Explicit Session-scope target. Omit this property to inherit the surrounding + * binding; pass `undefined` to establish an explicitly absent binding. + */ + readonly session?: SlotScopeTargetMap[keyof SlotScopeTargetMap & "session"] | undefined; + /** No-session body (also covers a current id whose session cannot be resolved). */ + empty?: (() => ReactNode) | undefined; + /** Session body; scoped entries apply their declared remount semantics. */ + children: ReactNode; +} + +/** + * Framework-wired session area component. `ui-session` supplies the current + * Controller binding through the renderer scope adapter; entries that declare + * `session` or `session-maybe` children receive this component without importing it. + */ +export type SessionProviderComponent = (props: SessionAreaProps) => ReactNode; + +/** + * Child-slot render share: `renderSlot` statically narrowed to the entry's + * declared children keys. Delegation is plain props passing (hand + * `props.renderSlot` down); the authorizing identity stays the registering + * entry. `__renders` is a phantom variance anchor (never materialized): + * generic method signatures compare loosely across differing key unions, so + * this contravariant marker is what actually enforces "component key set ⊆ + * children declaration" at the register call site. + */ +export type PropsRenderSlots = { + /** + * Render a declared non-chain child slot (chain keys dispatch through + * `renderSlotChain` — their routing lives in entry selectors). + * @param key - declared child key. + * @param owner - owner props share for that key (decided at the render site). + * @param opts - kind dispatch options. + * @returns rendered node(s). + */ + renderSlot: RenderSlotFn>>; + readonly __renders?: ((key: S) => void) | undefined; +} & ([ChainKeysOf] extends [never] + ? object + : { + /** + * Render a declared chain child slot: entry selectors run in chain order + * over `owner`; the first non-null match renders its component with the + * selector result injected as `matched`; all-null renders `opts.fallback`. + * @param key - declared chain child key. + * @param owner - owner props share (the selectors' routing input). + * @param opts - fallback body for the all-null case. + * @returns rendered node(s). + */ + renderSlotChain: >( + key: K, + owner: OwnerOf, + opts?: ChainRenderOpts, + ) => ReactNode; + }) & + ([Extract, "session" | "session-maybe">] extends [never] + ? object + : { SessionProvider: SessionProviderComponent }); + +/** + * Registration-position component shape: the bare call signature, so composed + * constraints check through clean parameter contravariance (FC statics add + * covariant noise rejecting legitimate narrowings). + */ +export type SlotComponent

    = (props: P) => ReactNode; + +type FactoryDefOf = SlotFactoryMap[F] & SlotFactoryDef; +type FactoryInputPropsOf = SlotFactoryMap[F] extends { + props: infer P extends object; +} + ? P + : object; +type FactoryChildrenOf = SlotFactoryMap[F] extends { + children: infer D extends Record; +} + ? D + : Record; +type FactoryStoreOf = SlotFactoryMap[F] extends { + store: infer H extends StoreDecl; +} + ? HandleOf + : undefined; +type FactoryInjectOf = SlotFactoryMap[F] extends { + inject: infer I extends object; +} + ? I + : object; +type FactoryLocaleOf = SlotFactoryMap[F] extends { + locale: infer N extends keyof LocaleNamespaceMap & string; +} + ? N + : undefined; +type FactoryLocalSlotsOf = SlotFactoryMap[F] extends { + slots: infer S extends Record; +} + ? S + : Record; +type FactoryLocalNameOf = keyof FactoryLocalSlotsOf & + string; +type FactoryLocalDefOf< + F extends keyof SlotFactoryMap & string, + N extends FactoryLocalNameOf, +> = FactoryLocalSlotsOf[N] & FactoryLocalSlotDef; +type FactoryLocalInputPropsOf< + F extends keyof SlotFactoryMap & string, + N extends FactoryLocalNameOf, +> = FactoryLocalDefOf extends { props: infer P extends object } ? P : object; + +type FactoryRenderPropsOf = [ + keyof FactoryChildrenOf & keyof SlotMap & string, +] extends [never] + ? object + : PropsRenderSlots & keyof SlotMap & string>; + +/** Framework-derived props shared by a Factory definition and its local Components. */ +export type FactoryRegistrationPropsOf = + FactoryRenderPropsOf & + PropsStore> & + InjectFace> & + PropsLocale> & + PropsRenderFactories; + +/** Complete props received by a registered Factory Component. */ +export type FactoryComponentPropsOf = + FactoryInputPropsOf & + FactoryRegistrationPropsOf & + ScopeStandardProps["scope"]> & { useFactorySlot: UseFactorySlot }; + +/** Complete props received by one caller-selected or fallback local Component. */ +export type FactoryLocalComponentPropsOf< + F extends keyof SlotFactoryMap & string, + N extends FactoryLocalNameOf, +> = FactoryLocalInputPropsOf & + FactoryRegistrationPropsOf & + ScopeStandardProps["scope"]>; + +/** Component accepted for one declared local Factory position. */ +export type FactoryLocalComponent< + F extends keyof SlotFactoryMap & string, + N extends FactoryLocalNameOf, +> = SlotComponent>; + +/** + * Hook exposed only to a Factory definition for selecting a local Component. + * @param name - declared local position. + * @param fallback - Component used when the occurrence caller makes no selection. + * @returns a stable Component accepting only the local occurrence props. + */ +export type UseFactorySlot = < + N extends FactoryLocalNameOf, +>( + name: N, + fallback: FactoryLocalComponent, +) => SlotComponent>; + +/** + * Render one independently keyed occurrence of a registered Factory. + * @param name - declaration-merged Factory name. + * @param props - caller-owned occurrence input. + * @param options - local Component selections and the missing-definition fallback. + * @returns the Factory occurrence or fallback. + */ +export type RenderFactorySlot = ( + name: F, + props: FactoryInputPropsOf, + options?: { + slots?: Partial<{ [N in FactoryLocalNameOf]: FactoryLocalComponent }>; + fallback?: ReactNode; + }, +) => ReactNode; + +/** Factory rendering capability supplied to every renderer-created Component. */ +export interface PropsRenderFactories { + renderFactorySlot: RenderFactorySlot; +} + +/** + * Registrant hooks compartment: bare observable sources (getSnapshot + + * subscribe pairs) supplied under the reserved `hooks` key of an entry's + * inject face. These retain the original source-to-selector binding and do + * not participate in render-occurrence context. + */ +export type HooksSources = Record>; + +/** Registrant keyed-hooks compartment: stable key-to-observable resolvers. */ +export type KeyedHooksSources = Record; + +/** Selector Hook over an open family of keyed observable sources. */ +export type KeyedSnapshotSelectorHook = { + /** @param key - source key. @returns the current value, or absence when the source is unavailable. */ + (key: string): Snapshot | undefined; + /** + * @param key - source key. + * @param selector - projection over the current keyed value. + * @param equal - optional selected-value equality. + * @returns the selected value. + */ + ( + key: string, + selector: (value: Snapshot | undefined) => Selected, + equal?: (left: Selected, right: Selected) => boolean, + ): Selected; +}; + +/** Framework-owned props visible while a slot-level contextual Hook is bound. */ +export type StandardPropsOf = (ScopeOf extends "session" + ? SessionStandardProps + : ScopeOf extends "session-maybe" + ? SessionMaybeStandardProps + : object) & + GlobalStandardProps; + +/** + * One function-valued slot-level inject.hooks member. The factory is pure and + * returns the actual custom Hook; it must not invoke a Hook while being bound. + */ +export type SlotHookFactory< + K extends keyof SlotMap & string, + Hook extends (...args: never[]) => unknown, +> = (standard: StandardPropsOf, hookContext: HookContextOf) => Hook; + +/** Component-side Hook produced from one slot-level inject.hooks member. */ +type BoundHookOf = + Definition extends HostObservable + ? SnapshotSelectorHook + : Definition extends (...args: never[]) => infer Hook + ? Hook extends (...args: never[]) => unknown + ? Hook + : never + : never; + +/** + * Selector-hook share synthesized from a hooks compartment: each source + * `name` becomes a `use` selector hook over its snapshot type. + */ +export type PropsSlotHooks = { + [N in keyof HS & string as `use${Capitalize}`]: BoundHookOf; +}; + +/** Component-side view of a slot dispatcher's common inject face. */ +export type SlotInjectFace = I extends { hooks: infer HS extends object } + ? Omit & PropsSlotHooks + : I; + +/** Selector-hook share synthesized from an entry inject hooks compartment. */ +export type PropsHooks = { + [N in keyof HS & string as `use${Capitalize}`]: SnapshotSelectorHook< + HS[N] extends HostObservable ? T : never + >; +}; + +/** Selector-hook share synthesized from an entry inject keyed-hooks compartment. */ +export type PropsKeyedHooks = { + [N in keyof HS & string as `use${Capitalize}`]: KeyedSnapshotSelectorHook< + HS[N] extends (key: string) => HostObservable | undefined ? T : never + >; +}; + +/** + * The component-side view of an inject face: the reserved `hooks` + * compartment (when declared) arrives as bound `use` selector hooks; + * every other member passes through verbatim. + */ +export type InjectFace = I extends { hooks: infer HS extends HooksSources } + ? I extends { keyedHooks: infer KS extends KeyedHooksSources } + ? Omit & PropsHooks & PropsKeyedHooks + : Omit & PropsHooks + : I extends { keyedHooks: infer KS extends KeyedHooksSources } + ? Omit & PropsKeyedHooks + : I; + +/** + * The composed component props intersection: runtime share (SlotMap) + + * child-render share (children declaration) + store share (declared handle) + + * the registrant's injected business face (its hooks compartment bound, see + * {@link InjectFace}) + the locale `t` seat (declared namespace, see + * {@link PropsLocale}). Each share derives from its single source of truth; + * components reference this composition, never re-type it. + */ +export type ComposedProps< + K extends keyof SlotMap & string, + EntryKey extends EntryKeyOf, + S extends keyof SlotMap & string, + H, + I extends object, + M = never, + N = undefined, +> = PropsRuntime & + PropsRenderSlots & + PropsRenderFactories & + PropsStore & + InjectFace & + MatchedShare & + PropsLocale; + +/** + * Inject factory parameter list, derived from the registration's declaration: + * strict session slots receive a definite framework-resolved `sessionId`; + * session-maybe slots receive the current id or `undefined`; a declared store + * appends the baked `actions` (the same callbacks the component receives). + * Business data access happens through the apply closure's ctx — no binding + * object parameter exists. + */ +export type InjectParams = + ScopeOf extends "session" + ? [H] extends [StoreDecl] + ? [sessionId: SessionIdOf, actions: BoundActions>] + : [sessionId: SessionIdOf] + : ScopeOf extends "session-maybe" + ? [H] extends [StoreDecl] + ? [sessionId: SessionIdOf | undefined, actions: BoundActions> | undefined] + : [sessionId: SessionIdOf | undefined] + : [H] extends [StoreDecl] + ? [actions: BoundActions>] + : []; + +/** Factory inject parameters use the same scope/store matrix as ordinary registrations. */ +export type FactoryInjectParams = + FactoryDefOf["scope"] extends "session" + ? [FactoryStoreOf] extends [StoreDecl] + ? [sessionId: SessionIdOf, actions: BoundActions>] + : [sessionId: SessionIdOf] + : FactoryDefOf["scope"] extends "session-maybe" + ? [FactoryStoreOf] extends [StoreDecl] + ? [sessionId: SessionIdOf | undefined, actions: BoundActions> | undefined] + : [sessionId: SessionIdOf | undefined] + : [FactoryStoreOf] extends [StoreDecl] + ? [actions: BoundActions>] + : []; + +type FactoryField< + F extends keyof SlotFactoryMap & string, + K extends keyof SlotFactoryDef, + Value, +> = K extends keyof SlotFactoryMap[F] ? { [P in K]-?: Value } : { [P in K]?: never }; + +type RuntimeFactorySlots = { + [N in FactoryLocalNameOf]: { scope: FactoryLocalDefOf["scope"] }; +}; + +type FactoryInjectCollisionKeys = Extract< + keyof InjectFace>, + keyof (FactoryRenderPropsOf & + PropsStore> & + PropsLocale> & + PropsRenderFactories & + ScopeStandardProps["scope"]> & { useFactorySlot: UseFactorySlot }) +>; + +type FactoryInputCollisionKeys = Extract< + keyof FactoryInputPropsOf, + keyof (FactoryRegistrationPropsOf & + ScopeStandardProps["scope"]> & { useFactorySlot: UseFactorySlot }) +>; + +type FactoryLocalCollisionKeys = { + [N in FactoryLocalNameOf]: Extract< + keyof FactoryLocalInputPropsOf, + keyof (FactoryRegistrationPropsOf & ScopeStandardProps["scope"]>) + >; +}[FactoryLocalNameOf]; + +type FactoryRegistrationLocalScopeCollisionKeys = { + [N in FactoryLocalNameOf]: Extract< + keyof FactoryRegistrationPropsOf, + keyof ScopeStandardProps["scope"]> + >; +}[FactoryLocalNameOf]; + +type FactoryCollisionCheck = [ + | FactoryInjectCollisionKeys + | FactoryInputCollisionKeys + | FactoryLocalCollisionKeys + | FactoryRegistrationLocalScopeCollisionKeys, +] extends [never] + ? unknown + : { + "Factory declaration has overlapping prop ownership": + | FactoryInjectCollisionKeys + | FactoryInputCollisionKeys + | FactoryLocalCollisionKeys + | FactoryRegistrationLocalScopeCollisionKeys; + }; + +type FactoryChildMismatchKeys = { + [K in keyof FactoryChildrenOf]: K extends keyof SlotMap + ? FactoryChildrenOf[K] extends SlotSpec + ? never + : K + : K; +}[keyof FactoryChildrenOf]; + +type FactoryChildrenCheck = [ + FactoryChildMismatchKeys, +] extends [never] + ? unknown + : { "Factory children must match SlotMap": FactoryChildMismatchKeys }; + +/** Registration options checked against the complete declaration-merged Factory definition. */ +export type RegisterFactoryOptions = { + name: F; + scope: FactoryDefOf["scope"]; +} & FactoryField> & + FactoryField | (() => FactoryStoreOf)> & + FactoryField) => FactoryInjectOf> & + FactoryField> & + FactoryField> & + FactoryCollisionCheck & + FactoryChildrenCheck; + +/** + * Typed registration method implemented by the renderer-owned SlotRegistry service. + * @param options - runtime values checked against the Factory declaration. + * @param component - reusable definition Component. + * @returns an idempotent definition disposer. + */ +export interface RegisterFactory { + ( + options: RegisterFactoryOptions, + component: SlotComponent>, + ): () => void; +} + +/** Type-erased Factory definition stored by the runtime registry. */ +export interface StoredFactory { + readonly name: string; + readonly component: unknown; + readonly scope: SlotScope; + readonly children?: Readonly>> | undefined; + readonly store?: StoreDecl | undefined; + readonly inject?: ((...args: never[]) => Record) | undefined; + readonly locale?: string | undefined; + readonly slots?: Readonly> | undefined; + /** Diagnostics label of who registered the definition. */ + readonly registrant?: string | undefined; +} + +/** + * A list-entry display label: a plain string, or a thunk re-evaluated per + * read so registration-time text (nav rows, tabs) follows the active locale + * without re-registration. Owners resolve through {@link resolveSlotLabel}. + */ +export type SlotLabel = string | (() => string); + +/** + * Kind shape fields carried in register options (keyed dispatch key; list + * id/order/label; chain select/priority; non-chain priority = cell shadowing rank). + */ +export type KindOptions< + K extends keyof SlotMap & string, + EntryKey extends EntryKeyOf, + M = never, +> = SlotMap[K]["kind"] extends "keyed" + ? { + key: EntryKey; + /** Cell shadowing rank (ascending, default 0, lowest renders; same key + same priority throws — see {@link SlotCore.register}). */ + priority?: number; + } + : SlotMap[K]["kind"] extends "list" + ? { + id: string; + order?: number; + label?: SlotLabel; + /** Cell shadowing rank (ascending, default 0, lowest renders; same id + same priority throws — see {@link SlotCore.register}). */ + priority?: number; + } + : SlotMap[K]["kind"] extends "chain" + ? { + /** Routing selector, mandatory on chain entries; `M` (the component's `matched` prop) infers from its return. */ + select: ChainSelect; + /** Explicit chain position (ascending, default 0, lower tries first); ties keep registration = assembly order. */ + priority?: number; + } + : { + /** + * Cell shadowing rank (ascending, default 0, lowest renders; a + * same-priority second registration throws — see {@link SlotCore.register}). + */ + priority?: number; + }; + +/** + * Compile-time presence check: an entry declaring children MUST consume + * `renderSlot` (or `renderSlotChain` when its only children are chain slots) + * — declaring is claiming; an entry that does not render its children should + * not declare them. Evaluates to an unsatisfiable intersection member naming + * the declared keys when violated. + */ +type RendersCheck = [keyof D & keyof SlotMap & string] extends [never] + ? unknown + : C extends (props: infer P) => ReactNode + ? "renderSlot" extends keyof P + ? unknown + : "renderSlotChain" extends keyof P + ? unknown + : { + "children declared but the component consumes no renderSlot": keyof D & + keyof SlotMap & + string; + } + : unknown; + +/** Common register options share (see {@link SlotCore.register} for semantics). */ +type BaseOptions< + K extends keyof SlotMap & string, + EntryKey extends EntryKeyOf, + D extends ChildrenDecl, + H, + M = never, + N = undefined, +> = { + /** Target slot key (the entry contributes INTO this slot). */ + name: K; + /** Child-slot declaration + render authorization + runtime spec, in one table. */ + children?: D; + /** Store seat: a shared handle (apply-constructed) or an exclusive factory (framework-called per entry x scope). */ + store?: H; + /** + * Dictionary namespace of this entry's copy. Declaring it puts the + * framework-synthesized `t` seat (typed to the namespace's dictionary + * union) on the component props; rendering requires an installed locale + * face — fails loud otherwise. + */ + locale?: N; + /** Registrant identity label for diagnostics (the runtime Service wrapper stamps the caller's fiber name). */ + registrant?: string; +} & KindOptions; + +/** + * One stored registration, as recorded by the core and read by the render + * machinery (type-erased at this boundary; the registration contract already proved + * the shares against the component). + */ +export interface StoredEntry { + component: unknown; + options: { key?: string; id?: string; order?: number; label?: SlotLabel; priority?: number }; + /** Chain routing selector (type-erased like `inject`; present exactly on chain-slot entries). */ + select?: ((owner: never) => unknown) | undefined; + /** Registrant business face; positional params derive from the declaration (sessionId?, actions?). */ + inject?: ((...args: never[]) => Record) | undefined; + /** Child-slot declaration table (declaration + authorization + runtime spec in one). */ + children?: Readonly>> | undefined; + /** Declared store seat (instance resolution and lifecycle live with the host machinery). */ + store?: StoreDecl | undefined; + /** Declared dictionary namespace (the render machinery synthesizes the `t` seat from it). */ + locale?: string | undefined; + /** Diagnostics label of who registered. */ + registrant?: string | undefined; +} + +/** + * Resolve a possibly-thunked list label at read time (thunks follow the + * active locale; owners projecting ledger rows call this instead of reading + * `options.label` raw). + * @param label - the stored label. + * @returns the display string, or undefined when the entry declared none. + */ +export function resolveSlotLabel(label: SlotLabel | undefined): string | undefined { + return typeof label === "function" ? label() : label; +} + +/** + * Type-erased options view the implementation works with. Optional members + * carry explicit `| undefined`: under exactOptionalPropertyTypes the public + * overloads (whose generics admit undefined) would otherwise fail + * overload-to-implementation compatibility. + */ +interface ErasedOptions { + name: string; + key?: string | undefined; + id?: string | undefined; + order?: number | undefined; + label?: SlotLabel | undefined; + select?: ((owner: never) => unknown) | undefined; + priority?: number | undefined; + children?: Record> | undefined; + store?: StoreDecl | undefined; + locale?: string | undefined; + /* oxlint-disable-next-line typescript/no-explicit-any -- + * implementation-signature position only (both public overloads type inject + * exactly); `never[]` would fail overload-to-implementation compatibility + * against the per-declaration InjectParams tuples. */ + inject?: ((...args: any) => Record) | undefined; + registrant?: string | undefined; +} + +/** + * Per-key registry record. Created on first touch; never removed (version + * stays monotonic across redeclarations). + */ +interface SlotRecord { + spec: SlotSpec | undefined; + /** Diagnostics: which slot's entry declared this key ('(built-in)' for root). */ + declaredBy: string | undefined; + /** Live parent declaration, absent for root slots. */ + parent: string | undefined; + /** Monotonic declaration lifetime, distinct from ordinary entry mutations. */ + declarationEpoch: number; + entries: readonly StoredEntry[]; + version: number; + listeners: Set<() => void>; + declarationListeners: Set<() => void>; +} + +interface FactoryRecord { + definition: StoredFactory | undefined; + version: number; + listeners: Set<() => void>; +} + +const NO_ENTRIES: readonly StoredEntry[] = Object.freeze([]); + +/** JSON-safe live occupant returned by slot inspection. */ +export interface LiveSlotOccupant { + /** Plugin or package that registered the entry, when known. */ + registrant?: string; + /** Keyed-slot cell. */ + key?: string; + /** List-slot cell. */ + id?: string; + /** List display order. */ + order?: number; + /** Shadowing or chain priority. */ + priority: number; + /** Whether the renderer currently selects this entry. */ + active: boolean; +} + +/** JSON-safe live Slot declaration tree. */ +export interface LiveSlotNode { + /** Discriminant for an ordinary Slot declaration. */ + type: "slot"; + /** Exact SlotMap key. */ + name: string; + /** Slot cardinality. */ + kind: SlotKind; + /** Runtime data scope. */ + scope: SlotScope; + /** Diagnostic owner of this declaration. */ + declaredBy?: string; + /** Current registrations in ledger order. */ + occupants: LiveSlotOccupant[]; + /** Slots declared by entries mounted in this slot. */ + children: LiveSlotNode[]; +} + +/** JSON-safe live Factory definition with its ordinary child Slot tree. */ +export interface LiveFactoryNode { + /** Discriminant for a reusable Factory definition. */ + type: "factory"; + /** Exact SlotFactoryMap key. */ + name: string; + /** Runtime data scope inherited by each occurrence. */ + scope: SlotScope; + /** Plugin or package that registered the definition, when known. */ + registrant?: string; + /** Ordinary Slots declared by the Factory definition. */ + children: LiveSlotNode[]; +} + +/** One root in the live Slot/Factory composition topology. */ +export type LiveCompositionNode = LiveSlotNode | LiveFactoryNode; + +/** + * Pure slot registry (no cordis; event emission and the renderer installation contract + * live in the runtime Service wrapper). + * + * The 'root' slot is the one a-priori declaration, seeded at construction + * (single/root, declared by the framework) — the render tree's root hole. + * + * Change propagation contract: versions bump and {@link SlotCore.onMutate} + * fires synchronously per mutation (registry state is consistent when they + * fire); {@link SlotCore.subscribeDeclaration} fires synchronously for each + * declaration lifetime boundary; {@link SlotCore.subscribe} notifications + * batch per microtask, so N same-tick mutations produce one notification per + * touched key. Entry crash reports ({@link SlotCore.reportEntryError}) ride + * the same mutation channel when they abdicate, then notify + * {@link SlotCore.onEntryError} synchronously. + */ +export class SlotCore { + private records = new Map(); + private factories = new Map(); + private mutateListeners = new Set<(key: string) => void>(); + /** Shared-handle scope ledger: handle → the scope it first mounted under + live mount count. */ + private handleScopes = new Map(); + // Dirty records, not keys: records are never removed, so holding the + // reference skips a lookup (and an unreachable missing-record branch) at flush. + private dirty = new Set(); + private flushScheduled = false; + /** + * Entries retired by an abdicating crash report + * ({@link SlotCore.reportEntryError}): excluded from + * {@link SlotCore.entriesOfSlot} projections for the rest of their + * registration's life, while the registration itself stays on the ledger + * (disposal authority remains with the registrant). + */ + private abdicated = new WeakSet(); + private entryErrorListeners = new Set< + ( + key: string, + registration: StoredEntry | StoredFactory, + error: unknown, + info: { abdicated: boolean }, + ) => void + >(); + + constructor() { + // The a-priori root hole. No markDirty: nothing can observe construction. + const root = this.record("root"); + root.spec = { kind: "single", scope: "root" }; + root.declaredBy = "(built-in)"; + root.declarationEpoch = 1; + } + + /** Register one reusable Factory definition. */ + readonly registerFactory: RegisterFactory = (( + rawOptions: object, + component: unknown, + ): (() => void) => { + const options = rawOptions as { + name: string; + scope: SlotScope; + children?: Record>; + store?: StoreDecl; + inject?: (...args: never[]) => Record; + locale?: string; + slots?: Record; + registrant?: string; + }; + const record = this.factoryRecord(options.name); + if (record.definition !== undefined) { + throw new Error(`slot factory "${options.name}" already has a definition`); + } + for (const childKey of Object.keys(options.children ?? {})) { + const childRecord = this.records.get(childKey); + if (childRecord?.spec !== undefined) { + throw new Error( + `slot "${childKey}" is already declared (by ${childRecord.declaredBy ?? "an unknown entry"})`, + ); + } + } + if (options.store !== undefined && typeof options.store !== "function") { + const pinned = this.handleScopes.get(options.store); + if (pinned !== undefined && pinned.scope !== options.scope) { + throw new Error( + `store handle mounted under factory "${options.name}" (scope "${options.scope}") is already mounted under scope "${pinned.scope}" — one handle, one scope`, + ); + } + if (pinned !== undefined) pinned.count += 1; + else this.handleScopes.set(options.store, { scope: options.scope, count: 1 }); + } + const definition: StoredFactory = { + name: options.name, + component, + scope: options.scope, + ...(options.children === undefined ? {} : { children: options.children }), + ...(options.store === undefined ? {} : { store: options.store }), + ...(options.inject === undefined ? {} : { inject: options.inject }), + ...(options.locale === undefined ? {} : { locale: options.locale }), + ...(options.slots === undefined ? {} : { slots: options.slots }), + ...(options.registrant === undefined ? {} : { registrant: options.registrant }), + }; + record.definition = definition; + this.markFactoryDirty(record); + const declarations: [string, SlotRecord][] = []; + for (const [childKey, childSpec] of Object.entries(options.children ?? {})) { + const childRecord = this.record(childKey); + childRecord.spec = childSpec; + childRecord.declaredBy = `factory "${options.name}"${options.registrant ? ` (${options.registrant})` : ""}`; + childRecord.parent = `factory:${options.name}`; + childRecord.declarationEpoch += 1; + declarations.push([childKey, childRecord]); + } + for (const [childKey, childRecord] of declarations) this.markDirty(childKey, childRecord); + for (const [, childRecord] of declarations) this.notifyDeclaration(childRecord); + return () => { + if (record.definition !== definition) return; + record.definition = undefined; + this.markFactoryDirty(record); + if (definition.store !== undefined && typeof definition.store !== "function") { + const pinned = this.handleScopes.get(definition.store); + if (pinned !== undefined && --pinned.count === 0) + this.handleScopes.delete(definition.store); + } + this.releaseChildren(definition.children); + }; + }) as RegisterFactory; + + /** + * Read one registered Factory definition. + * @param name - Factory name. + * @returns the live definition, or `undefined` when absent. + */ + factory(name: string): StoredFactory | undefined { + return this.factories.get(name)?.definition; + } + + /** + * Read the monotonic definition version for one Factory name. + * @param name - Factory name. + * @returns the current version. + */ + factoryVersion(name: string): number { + return this.factories.get(name)?.version ?? 0; + } + + /** + * Subscribe to one Factory definition's registration lifetime. + * @param name - Factory name. + * @param listener - callback notified after a definition change. + * @returns the unsubscribe function. + */ + subscribeFactory(name: string, listener: () => void): () => void { + const record = this.factoryRecord(name); + record.listeners.add(listener); + return () => { + record.listeners.delete(listener); + }; + } + + /** + * Return whether a retained Factory definition is still registered. + * @param definition - retained definition identity. + * @returns whether that exact definition remains live. + */ + isFactoryLive(definition: StoredFactory): boolean { + return this.factories.get(definition.name)?.definition === definition; + } + + /** + * Contribute a component to a declared slot and (optionally) declare child + * slots, a store seat, and the registrant's business face. + * + * Load-time validation (misconfiguration fails loud; the render hot path + * re-checks nothing): registering into an undeclared slot throws; declaring + * an already-declared child key throws (one declarer per slot — the message + * names the first declarer); mounting one shared store handle under slots + * of different scopes throws. Kind constraints: keyed — missing `key` + * throws; list — missing `id` throws; chain — missing `select` throws (the + * selector is the entry's routing seat, see {@link ChainSelect}). + * + * Shadowing (single/keyed/list): entries sharing one cell (single — the + * slot itself; keyed — same `key`; list — same `id`) coexist at distinct + * priorities, sorted ascending with ties keeping registration order; the + * cell's lowest live entry renders ({@link SlotCore.entriesOfSlot}). A + * second registration at an occupied cell's exact priority (default 0) + * throws naming the occupant, so priority-less composition keeps the + * historical one-occupant-per-cell fail-loud. + * + * Lifecycle: the disposer removes the contribution AND collapses every + * declared child slot (child entries clear recursively; their stale + * disposers become no-ops) — one lifecycle axis, no dangling state. + * + * @param options - registration options: target `name`, `children` + * declaration table, `store` seat, `inject` business-face factory, kind + * shape fields (keyed `key`; list `id`/`order`/`label`). + * @param component - component honoring the five-share composed props + * contract ({@link ComposedProps}); checked at this call site. + * @returns disposer removing the registration and its declarations + * (idempotent; stale disposers after a cascade are no-ops). + */ + /* jscpd:ignore-start -- the two register overloads are deliberately + * parallel declarations differing only in the inject share; folding them + * would lose the per-overload inference of I. */ + register< + K extends keyof SlotMap & string, + const EntryKey extends EntryKeyOf = EntryKeyOf, + const D extends ChildrenDecl = Record, + H extends StoreDecl | undefined = undefined, + M = never, + N extends (keyof LocaleNamespaceMap & string) | undefined = undefined, + C extends SlotComponent = SlotComponent, + >( + options: BaseOptions & { inject?: undefined }, + component: C & + SlotComponent< + ComposedProps< + K, + NoInfer, + keyof NoInfer & keyof SlotMap & string, + HandleOf>, + object, + NoInfer, + NoInfer + > + > & + RendersCheck, + ): () => void; + /** + * Inject-bearing overload: identical semantics to the overload above, plus + * the registrant's business face — `I` is inferred from the inject + * factory's return and joins the component's composed-props constraint + * (factory parameters derive from the declaration, {@link InjectParams}). + * @param options - registration options plus the `inject` business-face factory. + * @param component - component honoring the five-share composed props + * contract including the inject share `I`. + * @returns disposer removing the registration and its declarations. + */ + register< + K extends keyof SlotMap & string, + I extends object, + const EntryKey extends EntryKeyOf = EntryKeyOf, + const D extends ChildrenDecl = Record, + H extends StoreDecl | undefined = undefined, + M = never, + N extends (keyof LocaleNamespaceMap & string) | undefined = undefined, + C extends SlotComponent = SlotComponent, + >( + options: BaseOptions & { inject: (...args: InjectParams) => I }, + component: C & + SlotComponent< + ComposedProps< + K, + NoInfer, + keyof NoInfer & keyof SlotMap & string, + HandleOf>, + I, + NoInfer, + NoInfer + > + > & + RendersCheck, + ): () => void; + /* jscpd:ignore-end */ + register(options: ErasedOptions, component: unknown): () => void { + const rec = this.records.get(options.name); + if (!rec?.spec) { + throw new Error( + `slot "${options.name}" is not declared (a parent entry's children table must declare it)`, + ); + } + const spec = rec.spec; + // Kind constraints stay runtime checks for dynamically-composed callers; + // typed callers already satisfied KindOptions statically. Cell occupancy + // clashes only at the exact priority: a different priority shadows. + const priority = options.priority ?? 0; + const occupantHint = (occupant: StoredEntry) => + `at priority ${priority}${occupant.registrant !== undefined ? ` (registered by ${occupant.registrant})` : ""} — register at a different priority to shadow it (lowest renders)`; + switch (spec.kind) { + case "single": { + const occupant = rec.entries.find((e) => (e.options.priority ?? 0) === priority); + if (occupant) + throw new Error( + `single slot "${options.name}" already has a registration ${occupantHint(occupant)}`, + ); + break; + } + case "keyed": { + if (options.key === undefined) + throw new Error(`keyed slot "${options.name}" requires options.key`); + const occupant = rec.entries.find( + (e) => e.options.key === options.key && (e.options.priority ?? 0) === priority, + ); + if (occupant) { + throw new Error( + `keyed slot "${options.name}" already has an entry for key "${options.key}" ${occupantHint(occupant)}`, + ); + } + break; + } + case "list": { + if (options.id === undefined) + throw new Error(`list slot "${options.name}" requires options.id`); + const occupant = rec.entries.find( + (e) => e.options.id === options.id && (e.options.priority ?? 0) === priority, + ); + if (occupant) { + throw new Error( + `list slot "${options.name}" already has an entry with id "${options.id}" ${occupantHint(occupant)}`, + ); + } + break; + } + case "chain": + if (options.select === undefined) + throw new Error(`chain slot "${options.name}" requires options.select`); + break; + } + if (options.children) { + for (const childKey of Object.keys(options.children)) { + const childRec = this.records.get(childKey); + if (childRec?.spec) { + throw new Error( + `slot "${childKey}" is already declared (by ${childRec.declaredBy ?? "an unknown entry"})`, + ); + } + } + } + // Shared handles pin their scope on first mount; factories are exempt + // (the framework creates per-entry instances, no shared identity exists). + if (options.store !== undefined && typeof options.store !== "function") { + const pinned = this.handleScopes.get(options.store); + if (pinned && pinned.scope !== spec.scope) { + throw new Error( + `store handle mounted under "${options.name}" (scope "${spec.scope}") is already mounted under scope "${pinned.scope}" — one handle, one scope`, + ); + } + if (pinned) pinned.count += 1; + else this.handleScopes.set(options.store, { scope: spec.scope, count: 1 }); + } + + const entry: StoredEntry = { + component, + options: { + ...(options.key !== undefined ? { key: options.key } : {}), + ...(options.id !== undefined ? { id: options.id } : {}), + ...(options.order !== undefined ? { order: options.order } : {}), + ...(options.label !== undefined ? { label: options.label } : {}), + ...(options.priority !== undefined ? { priority: options.priority } : {}), + }, + ...(options.select !== undefined ? { select: options.select } : {}), + ...(options.inject !== undefined ? { inject: options.inject } : {}), + ...(options.children !== undefined ? { children: options.children } : {}), + ...(options.store !== undefined ? { store: options.store } : {}), + ...(options.locale !== undefined ? { locale: options.locale } : {}), + ...(options.registrant !== undefined ? { registrant: options.registrant } : {}), + }; + const next = [...rec.entries, entry]; + // Stable sorts: priority ascending for every kind, ties keep registration + // sequence — a cell's winner is its first occurrence, chain tries lower + // priority first. List refines equal priorities by explicit `order` so the + // raw ledger keeps its display sequence for priority-less compositions. + next.sort( + spec.kind === "list" + ? (a, b) => + (a.options.priority ?? 0) - (b.options.priority ?? 0) || + (a.options.order ?? 0) - (b.options.order ?? 0) + : (a, b) => (a.options.priority ?? 0) - (b.options.priority ?? 0), + ); + rec.entries = next; + this.markDirty(options.name, rec); + if (options.children) { + const declarations: [key: string, record: SlotRecord][] = []; + for (const [childKey, childSpec] of Object.entries(options.children)) { + const childRec = this.record(childKey); + childRec.spec = childSpec; + childRec.declaredBy = `an entry in "${options.name}"${options.registrant ? ` (${options.registrant})` : ""}`; + childRec.parent = options.name; + childRec.declarationEpoch += 1; + declarations.push([childKey, childRec]); + } + // Synchronous listeners may register into or try to redeclare a sibling; + // publish only after the whole children table owns its declarations. + for (const [childKey, childRec] of declarations) { + this.markDirty(childKey, childRec); + } + for (const [, childRec] of declarations) { + this.notifyDeclaration(childRec); + } + } + return () => { + if (!rec.entries.includes(entry)) return; + rec.entries = rec.entries.filter((e) => e !== entry); + this.markDirty(options.name, rec); + this.releaseEntry(entry); + }; + } + + /** + * Whether a previously obtained entry is still registered (the render + * machinery's stale-authorization probe: a retained renderSlot binding + * whose entry left the ledger must not render). + * @param entry - a previously read entry. + * @returns false once the entry's registration was disposed. + */ + isLive(entry: StoredEntry): boolean { + for (const rec of this.records.values()) { + if (rec.entries.includes(entry)) return true; + } + return false; + } + + /** + * Snapshot the registered entries for a key. Returns the cached array + * reference (stable between mutations — safe as a uSES getSnapshot source); + * empty for keys not (or no longer) declared, so renderers may probe ahead + * of plugin load order. + * @param key - slot key (dynamic: the render machinery holds keys as strings). + * @returns entries in registration (list: order) sequence. + */ + entries(key: string): readonly StoredEntry[] { + return this.records.get(key)?.entries ?? NO_ENTRIES; + } + + /** + * Project a key's entries to its shadowing winners: the first live + * (non-abdicated) entry of each cell in priority order — single: the slot + * is one cell; keyed: one cell per `key`; list: one cell per `id` (winners + * keep ledger sequence; list renderers still refine display by `order`). + * Chain keys return the raw entries unchanged: election consumes every + * entry, shadowing does not apply. The raw {@link SlotCore.entries} view + * stays the inspection surface. Builds a fresh array per call — a render + * body read, not a uSES getSnapshot source. + * @param key - slot key (dynamic: the render machinery holds keys as strings). + * @returns the winning entry per occupied cell (empty while undeclared). + */ + entriesOfSlot(key: string): readonly StoredEntry[] { + const rec = this.records.get(key); + if (!rec?.spec) return NO_ENTRIES; + const kind = rec.spec.kind; + if (kind === "chain") return rec.entries; + const heads: StoredEntry[] = []; + const seenCells = new Set(); + for (const entry of rec.entries) { + if (this.abdicated.has(entry)) continue; + // Single-kind entries all share the one undefined cell. + const cell = + kind === "keyed" ? entry.options.key : kind === "list" ? entry.options.id : undefined; + if (seenCells.has(cell)) continue; + seenCells.add(cell); + heads.push(entry); + } + return heads; + } + + /** + * Look up a slot's declared spec, narrowed by the SlotMap key. + * @param key - SlotMap key. + * @returns the spec, or undefined while undeclared. + */ + spec(key: K): SlotSpec | undefined { + return this.records.get(key)?.spec as SlotSpec | undefined; + } + + /** + * Dynamic-key escape hatch for spec lookup — renderers resolving keys they + * only hold as strings (generic dispatch) use this wide form; statically + * keyed callers use {@link SlotCore.spec}. + * @param key - candidate slot key. + * @returns the wide-typed spec, or undefined while undeclared. + */ + specDynamic(key: string): SlotSpec | undefined { + return this.records.get(key)?.spec; + } + + /** + * Export the current declaration topology without components or executable hooks. + * Factory definitions appear as `factory:` parents of their ordinary + * child Slots, matching the parent/child topology of ordinary registrations. + * @param root - exact Slot or `factory:` key to select; omitted returns every live root. + * @returns selected live Slot trees, or an empty array when `root` is unavailable. + */ + snapshot(root?: string): LiveCompositionNode[] { + const buildSlot = (name: string, seen: Set): LiveSlotNode | undefined => { + const record = this.records.get(name); + if (record?.spec === undefined || seen.has(name)) return undefined; + const branch = new Set(seen); + branch.add(name); + const active = new Set(this.entriesOfSlot(name)); + const children = [...this.records.entries()] + .filter(([, candidate]) => candidate.spec !== undefined && candidate.parent === name) + .flatMap(([child]) => { + const node = buildSlot(child, branch); + return node === undefined ? [] : [node]; + }); + return { + type: "slot", + name, + kind: record.spec.kind, + scope: record.spec.scope, + ...(record.declaredBy === undefined ? {} : { declaredBy: record.declaredBy }), + occupants: record.entries.map((entry) => ({ + ...(entry.registrant === undefined ? {} : { registrant: entry.registrant }), + ...(entry.options.key === undefined ? {} : { key: entry.options.key }), + ...(entry.options.id === undefined ? {} : { id: entry.options.id }), + ...(entry.options.order === undefined ? {} : { order: entry.options.order }), + priority: entry.options.priority ?? 0, + active: active.has(entry), + })), + children, + }; + }; + const buildFactory = (name: string): LiveFactoryNode | undefined => { + const definition = this.factories.get(name)?.definition; + if (definition === undefined) return undefined; + const nodeName = `factory:${name}`; + const children = [...this.records.entries()] + .filter(([, candidate]) => candidate.spec !== undefined && candidate.parent === nodeName) + .flatMap(([child]) => { + const node = buildSlot(child, new Set([nodeName])); + return node === undefined ? [] : [node]; + }); + return { + type: "factory", + name, + scope: definition.scope, + ...(definition.registrant === undefined ? {} : { registrant: definition.registrant }), + children, + }; + }; + if (root !== undefined) { + const node = root.startsWith("factory:") + ? buildFactory(root.slice("factory:".length)) + : buildSlot(root, new Set()); + return node === undefined ? [] : [node]; + } + const slots = [...this.records.entries()] + .filter(([, record]) => record.spec !== undefined && record.parent === undefined) + .flatMap(([name]) => { + const node = buildSlot(name, new Set()); + return node === undefined ? [] : [node]; + }); + const factories = [...this.factories.keys()].flatMap((name) => { + const node = buildFactory(name); + return node === undefined ? [] : [node]; + }); + return [...slots, ...factories]; + } + + /** + * Read the declaration lifetime of a key. Entry additions and removals do + * not change it; declaration creation and collapse each advance it. + * @param key - slot key. + * @returns monotonic epoch (0 before the first declaration). + */ + declarationEpoch(key: string): number { + return this.records.get(key)?.declarationEpoch ?? 0; + } + + /** + * Subscribe to registration changes for a key (microtask-batched). + * Subscribing ahead of declaration is allowed; the declaration notifies. + * @param key - slot key. + * @param fn - change callback. + * @returns unsubscribe. + */ + subscribe(key: string, fn: () => void): () => void { + const rec = this.record(key); + rec.listeners.add(fn); + return () => { + rec.listeners.delete(fn); + }; + } + + /** + * Subscribe to declaration lifetime boundaries for a key. Notifications + * are synchronous so declaration teardown finishes before a subsequent + * same-tick registration can observe stale resources. Ordinary entry + * mutations do not notify this surface. A children table commits every + * sibling declaration before its first notification. + * @param key - slot key. + * @param fn - declaration or collapse callback. + * @returns unsubscribe. + */ + subscribeDeclaration(key: string, fn: () => void): () => void { + const rec = this.record(key); + rec.declarationListeners.add(fn); + return () => { + rec.declarationListeners.delete(fn); + }; + } + + /** + * Monotonic version for a key, bumped synchronously per mutation so a + * uSES getSnapshot read is never stale when its batched notification lands. + * @param key - slot key. + * @returns current version (0 for untouched keys). + */ + getVersion(key: string): number { + return this.records.get(key)?.version ?? 0; + } + + /** + * Hook every mutation (the runtime Service wrapper bridges this to ctx.emit). + * Fires synchronously per mutation, unbatched — event semantics need one + * emission per change. + * @param fn - called with the mutated key. + * @returns unsubscribe. + */ + onMutate(fn: (key: string) => void): () => void { + this.mutateListeners.add(fn); + return () => { + this.mutateListeners.delete(fn); + }; + } + + /** + * Renderer crash report from an entry boundary. Always notifies + * {@link SlotCore.onEntryError} listeners; with `info.abdicate` set (the + * shadowing kinds — single/keyed/list) it first retires the entry from its + * cell, one-shot: the record's version bumps through the ordinary mutation + * channel so outlets re-project onto the cell's next survivor, and a + * repeat abdicating report no-ops entirely. Chain crashes report with + * `abdicate: false` — election alternatives resolve at select time, so the + * entry keeps its cell and only the notification fires. The registration + * itself stays on the ledger either way — raw {@link SlotCore.entries} + * still lists the entry and its disposer keeps working. + * @param key - slot key the entry rendered under. + * @param entry - the crashed entry. + * @param error - the crash cause, forwarded to listeners verbatim. + * @param info - `abdicate`: whether the crash retires the entry from its cell. + */ + reportEntryError( + key: string, + entry: StoredEntry, + error: unknown, + info: { abdicate: boolean }, + ): void { + if (info.abdicate) { + if (this.abdicated.has(entry)) return; + this.abdicated.add(entry); + const rec = this.records.get(key); + if (rec !== undefined) this.markDirty(key, rec); + } + for (const fn of [...this.entryErrorListeners]) + fn(key, entry, error, { abdicated: info.abdicate }); + } + + /** + * Report a contained Factory occurrence crash through the ordinary entry + * supervision channel without retiring the shared definition. + * @param name - Factory name whose occurrence crashed. + * @param registration - Factory definition or caller registration that owns the crashing Component. + * @param error - the crash cause, forwarded to listeners verbatim. + */ + reportFactoryError( + name: string, + registration: StoredEntry | StoredFactory, + error: unknown, + ): void { + for (const fn of [...this.entryErrorListeners]) + fn(`factory:${name}`, registration, error, { abdicated: false }); + } + + /** + * Observe ordinary entry and Factory occurrence crashes. Fires synchronously + * per report, after any ordinary-entry abdication mutation. Factory failures + * never retire their shared definition. + * @param fn - called with the Slot or `factory:` key, the crashed + * registration, the cause, and whether an ordinary entry was retired. + * @returns unsubscribe. + */ + onEntryError( + fn: ( + key: string, + registration: StoredEntry | StoredFactory, + error: unknown, + info: { abdicated: boolean }, + ) => void, + ): () => void { + this.entryErrorListeners.add(fn); + return () => { + this.entryErrorListeners.delete(fn); + }; + } + + /** + * Cascade for a removed entry: release its store mount and collapse every + * child slot it declared — specs clear, contributions empty (their stale + * disposers no-op), recursively down the declaration tree. One lifecycle + * axis: ledger rows, slots, contributions, and store mounts die together. + */ + private releaseEntry(entry: StoredEntry): void { + if (entry.store !== undefined && typeof entry.store !== "function") { + const pinned = this.handleScopes.get(entry.store); + if (pinned && --pinned.count === 0) this.handleScopes.delete(entry.store); + } + this.releaseChildren(entry.children); + } + + private releaseChildren( + children: Readonly>> | undefined, + ): void { + if (children === undefined) return; + for (const childKey of Object.keys(children)) { + const childRec = this.records.get(childKey); + /* v8 ignore next -- defensive: declaring always creates the record */ + if (!childRec) continue; + const doomed = childRec.entries; + childRec.spec = undefined; + childRec.declaredBy = undefined; + childRec.parent = undefined; + childRec.declarationEpoch += 1; + childRec.entries = NO_ENTRIES; + this.markDirty(childKey, childRec); + this.notifyDeclaration(childRec); + for (const dead of doomed) this.releaseEntry(dead); + } + } + + private record(key: string): SlotRecord { + let rec = this.records.get(key); + if (!rec) { + rec = { + spec: undefined, + declaredBy: undefined, + parent: undefined, + declarationEpoch: 0, + entries: NO_ENTRIES, + version: 0, + listeners: new Set(), + declarationListeners: new Set(), + }; + this.records.set(key, rec); + } + return rec; + } + + private factoryRecord(name: string): FactoryRecord { + let record = this.factories.get(name); + if (record === undefined) { + record = { definition: undefined, version: 0, listeners: new Set() }; + this.factories.set(name, record); + } + return record; + } + + private markFactoryDirty(record: FactoryRecord): void { + record.version += 1; + queueMicrotask(() => { + for (const listener of [...record.listeners]) listener(); + }); + } + + private markDirty(key: string, rec: SlotRecord): void { + rec.version += 1; + for (const fn of [...this.mutateListeners]) fn(key); + this.dirty.add(rec); + if (!this.flushScheduled) { + this.flushScheduled = true; + queueMicrotask(() => { + this.flush(); + }); + } + } + + private notifyDeclaration(rec: SlotRecord): void { + for (const fn of [...rec.declarationListeners]) fn(); + } + + private flush(): void { + // Reset before iterating so a mutation from inside a listener re-schedules. + this.flushScheduled = false; + const dirty = [...this.dirty]; + this.dirty.clear(); + for (const rec of dirty) { + for (const fn of [...rec.listeners]) fn(); + } + } +} diff --git a/apps/web-ui/packages/client/ui-slots/src/renderer.ts b/apps/web-ui/packages/client/ui-slots/src/renderer.ts new file mode 100644 index 000000000..2f9089221 --- /dev/null +++ b/apps/web-ui/packages/client/ui-slots/src/renderer.ts @@ -0,0 +1,292 @@ +/** React-free contracts between the slot host and an installed renderer. */ +import type { Context } from "@deepseek-ai/cordis"; +import type { ReactNode } from "react"; +import type { ObservableSnapshot } from "@deepseek-ai/dsh-client-store"; +import type { + SessionAreaProps, + SlotEntryDef, + SlotScope, + SlotSpec, + StoredEntry, + StoredFactory, + Translate, +} from "./index.ts"; + +/** + * The locale face the render machinery consumes: namespace binding plus an + * observable revision (getSnapshot/subscribe pair — the same HostObservable + * currency as every other standard-kit source). The revision moves on every + * active-locale or registry change; the renderer re-derives each entry's `t` + * from (namespace, revision), so a locale switch hands out NEW function + * references and memoized components re-render naturally. Implemented by the + * locale plugin, installed through the `ui-renderer` SlotRegistry. + * Install before the first render that needs the seat: outlets bind their + * revision subscription at mount, and a face appearing later has no channel + * to notify already-mounted outlets (the locale plugin is immediately-tier + * infrastructure, so normal compositions install during boot). + */ +export interface LocaleFace extends HostObservable<{ revision: number }> { + /** + * Bind a namespace to a translate function reading the active locale at + * call time. Identity may be stable per namespace — freshness of rendered + * text is carried by the renderer's (ns, revision) seat derivation, not by + * this binding. + * @param ns - dictionary namespace. + * @returns the namespace-bound translate function. + */ + bind(ns: string): Translate; +} + +/** Observable currency shared by domain sources, stores, and the renderer. */ +export type HostObservable = ObservableSnapshot; + +/** + * Convert one standard source name to its rendered Hook prop name. + * @param name - registered fixed or keyed source name. + * @returns the `use` prop exposed to Slot components. + */ +export function standardHookPropName(name: string): string { + return `use${name[0]?.toUpperCase() ?? ""}${name.slice(1)}`; +} + +/** + * Type-erased store instance face at the render boundary (the typed twin is + * {@link StoreInstance}): a bare snapshot source plus the draft-stripped + * action callbacks. No React hook crosses this boundary — the render machinery + * binds `useStore` from the source at its own side (cached per instance); + * typing lands at the component boundary via {@link PropsStore}. + */ +export interface StoreInstanceLike { + getSnapshot(): unknown; + /** + * Subscribe to state changes (uSES subscribe side). + * @param fn - change callback. + * @returns unsubscribe. + */ + subscribe(fn: () => void): () => void; + readonly actions: Record void>; +} + +/** Resolve one member of an open-key standard hook family. */ +export type KeyedStandardSource = (key: string) => HostObservable | undefined; + +/** + * Framework-neutral inputs from which the renderer materializes standard + * props. A scope adapter keeps member names present while its binding is + * absent so optional slots retain a stable Hook call order. + */ +export interface StandardSourceBinding { + /** Scope identity; absent for root data and an optional scope with no selection. */ + readonly key: string | undefined; + /** Fixed-name sources; each `name` becomes a `useName` selector Hook. */ + readonly hooks: Readonly | undefined>>; + /** Open-key source families; each `name` becomes a `useName(key, selector)` Hook. */ + readonly keyedHooks: Readonly>; + /** Stable plain values copied into standard props. */ + readonly props: Readonly>; +} + +/** Materialized binding for one live non-root scope. */ +export interface ScopedStandardSourceBinding extends StandardSourceBinding { + readonly key: string; + readonly ctx: Context; +} + +/** One installed source of bindings for a non-root Slot scope. */ +export interface SlotScopeAdapter { + /** Default binding inherited by scoped entries, including its absent projection. */ + readonly current: HostObservable; + /** + * Resolve a stable observable for an explicit scope target or explicit absence. + * @param target - domain-owned Provider target, or absence. + * @returns the target's current standard-source binding. + */ + bindingSource(target: SessionAreaProps["session"]): HostObservable; + /** + * Render the scope owner's area seat over the current binding. The renderer + * binds this function to the standard `SessionProvider` prop without owning + * Session selection semantics. + * @param binding - current scope binding, including its absent projection. + * @param props - render-prop body and empty branch. + * @returns rendered scope area. + */ + renderArea?(binding: StandardSourceBinding, props: SessionAreaProps): ReactNode; +} + +/** Root standard-source contribution installed by one domain UI package. */ +export interface RootStandardSourceContribution { + readonly hooks?: Readonly>>; + readonly keyedHooks?: Readonly>; + readonly props?: Readonly>; +} + +/** renderSlot dispatch options at the machinery level. */ +export interface RenderOpts { + entryKey?: string; + only?: string; + fallback?: ReactNode; + /** Opaque occurrence context consumed only by function-valued injected Hooks. */ + hookContext?: unknown; +} + +/** Host API the `ui-renderer` SlotRegistry presents to its React renderer. */ +export interface SlotRendererHost { + /** + * Subscribe to a key's registration changes (microtask-batched). + * @param key - slot key. + * @param fn - change callback. + * @returns unsubscribe. + */ + subscribe(key: string, fn: () => void): () => void; + /** + * Monotonic version for uSES pairing. + * @param key - slot key. + * @returns current version. + */ + getVersion(key: string): number; + /** + * Snapshot the registered entries for a key (stable reference between mutations). + * @param key - slot key. + * @returns entries in registration (list: order) sequence. + */ + entriesOf(key: string): readonly StoredEntry[]; + /** + * Shadowing winners per cell for a key — the render read for single/keyed/ + * list dispatch: the first live (non-abdicated) entry of each cell in + * priority order; chain keys pass through unchanged (election consumes + * every entry). Fresh array per call — a render-body read, not a uSES + * getSnapshot source. + * @param key - slot key. + * @returns the winning entry per occupied cell. + */ + entriesOfSlot(key: string): readonly StoredEntry[]; + /** + * Report an entry boundary crash. With `info.abdicate` (shadowing kinds) + * the entry retires from its cell, one-shot, so the next survivor renders; + * chain crashes report without abdicating. The registration stays on the + * ledger either way. + * @param key - slot key the entry rendered under. + * @param entry - the crashed entry. + * @param error - the crash cause. + * @param info - `abdicate`: whether the crash retires the entry from its cell. + */ + reportEntryError( + key: string, + entry: StoredEntry, + error: unknown, + info: { abdicate: boolean }, + ): void; + /** + * Report a contained Factory occurrence crash without retiring its shared definition. + * @param name - Factory name whose occurrence crashed. + * @param registration - Factory definition or caller registration that owns the crashing Component. + * @param error - the crash cause. + */ + reportFactoryError(name: string, registration: StoredEntry | StoredFactory, error: unknown): void; + /** + * Declared runtime spec from the declarations ledger. + * @param key - slot key. + * @returns the spec, or undefined while the key is undeclared (outlets render empty). + */ + specOf(key: string): SlotSpec | undefined; + /** + * Stale-authorization check: whether the entry is still in the ledger. + * @param entry - a previously rendered entry. + * @returns false once the entry's registration was disposed. + */ + isLive(entry: StoredEntry): boolean; + /** + * Resolve (create or return cached) the store instance for an entry's + * declared handle under a scope key; lifecycle rides the ledger axis. + * @param entry - entry whose declaration carries the handle. + * @param scopeBinding - exact Session binding for scoped slots, undefined for root scope. + * @returns the instance, or undefined when the entry declares no store. + */ + storeOf( + entry: StoredEntry, + scopeBinding: ScopedStandardSourceBinding | undefined, + ): StoreInstanceLike | undefined; + /** + * Resolve a Factory Store for one render occurrence and inherited scope. + * @param definition - live Factory definition. + * @param scopeBinding - exact Session binding for scoped Factories, undefined for root scope. + * @param occurrence - identity token owned by the render position. + * @returns the occurrence Store instance, or undefined without a Store declaration. + */ + factoryStoreOf( + definition: StoredFactory, + scopeBinding: ScopedStandardSourceBinding | undefined, + occurrence: object, + ): StoreInstanceLike | undefined; + /** + * Retain an exclusive Factory Store occurrence after React commits it. + * Repeated setup and cleanup preserve the occurrence's Store identity. + * @param definition - the live Factory definition. + * @param occurrence - identity token owned by the mounted render position. + * @returns an idempotent cleanup function. + */ + retainFactoryOccurrence(definition: StoredFactory, occurrence: object): () => void; + /** + * Subscribe to one Factory definition's registration lifetime. + * @param name - Factory name. + * @param fn - change callback. + * @returns the unsubscribe function. + */ + subscribeFactory(name: string, fn: () => void): () => void; + /** + * Read the monotonic version for one Factory definition. + * @param name - Factory name. + * @returns the current definition version. + */ + getFactoryVersion(name: string): number; + /** + * Read one live Factory definition. + * @param name - Factory name. + * @returns the definition, or undefined while unregistered. + */ + factoryOf(name: string): StoredFactory | undefined; + /** + * Check retained Factory render authority. + * @param definition - a previously resolved Factory definition. + * @returns whether that exact definition remains live. + */ + isFactoryLive(definition: StoredFactory): boolean; + /** Root standard data assembled from domain-owned contributions. */ + readonly root: HostObservable; + /** Monotonic source updated whenever the installed scope-adapter roster changes. */ + readonly scopeRevision: HostObservable; + /** + * Resolve the adapter installed for one non-root scope. + * `session` and `session-maybe` intentionally resolve the same adapter. + * @param scope - Slot scope. + * @returns adapter, or `undefined` when the composition omitted its owner. + */ + scope(scope: Exclude): SlotScopeAdapter | undefined; + /** + * Installed locale face backing the `t` standard seat (absent until the + * locale plugin installs one; rendering an entry that declared `locale:` + * without it is an assembly failure). + */ + locale?: LocaleFace | undefined; +} + +/** The installation contract between the `ui-renderer` SlotRegistry and its React renderer. */ +export interface SlotRenderer { + /** + * Render the root slot tree over the host API (the only ctx-level entry). + * @param host - the installing service's host API. + * @param ownerProps - owner props from the shell's renderSlot('root', ...) call. + * @returns the rendered tree. + */ + renderRoot(host: SlotRendererHost, ownerProps: object): ReactNode; +} + +/** Thrown when a retained renderSlot binding is invoked after its declaring entry was disposed. */ +export class StaleAuthorizationError extends Error {} + +/** + * Thrown when a renderSlot binding is invoked for a key outside its entry's + * children declaration (plain-JS backstop; typed callers are narrowed + * statically). + */ +export class SlotOwnershipError extends Error {} diff --git a/apps/web-ui/packages/client/ui-slots/src/store.ts b/apps/web-ui/packages/client/ui-slots/src/store.ts new file mode 100644 index 000000000..26e542c2f --- /dev/null +++ b/apps/web-ui/packages/client/ui-slots/src/store.ts @@ -0,0 +1,17 @@ +/** Slot-facing re-exports of the React-free store contracts. */ + +export type { + ActionsDecl, + BakedActions, + BoundActions, + DefineStore, + HandleOf, + MaybeSnapshotSelectorHook, + PropsStore, + SnapshotSelectorHook, + StoreDecl, + StoreFactory, + StoreHandle, + StoreInstance, + StoreSpec, +} from "@deepseek-ai/dsh-client-store"; diff --git a/apps/web-ui/packages/client/ui-slots/tsdown.config.ts b/apps/web-ui/packages/client/ui-slots/tsdown.config.ts new file mode 100644 index 000000000..d7331e6c3 --- /dev/null +++ b/apps/web-ui/packages/client/ui-slots/tsdown.config.ts @@ -0,0 +1,3 @@ +import { staticLinked } from "../tsdown.client.ts"; + +export default staticLinked("@deepseek-ai/dsh-client-ui-slots", ["lib/types/index.js"]); diff --git a/apps/web-ui/packages/client/ui-subagent/package.json b/apps/web-ui/packages/client/ui-subagent/package.json new file mode 100644 index 000000000..609321fcf --- /dev/null +++ b/apps/web-ui/packages/client/ui-subagent/package.json @@ -0,0 +1,40 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-subagent", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./client": { + "types": "./src/client/index.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "@deepseek-ai/dsh-client-ui-primitives": "*" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-session-controller", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-resources", + "@deepseek-ai/dsh-client-ui-chat", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-primitives", + "@deepseek-ai/dsh-client-ui-sidebar-right", + "@deepseek-ai/dsh-client-ui-input-trigger", + "@deepseek-ai/dsh-client-ui-workspace" + ], + "platform": "web" + } + } +} diff --git a/apps/web-ui/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css b/apps/web-ui/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css new file mode 100644 index 000000000..717712327 --- /dev/null +++ b/apps/web-ui/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css @@ -0,0 +1,367 @@ +.root { + position: relative; + display: inline-flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.switcherRoot { + min-width: 0; + margin-left: 6px; +} + +.trigger, +.switcherTrigger { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 3px 2px; + border: 0; + border-radius: var(--dsw-radius-sm); + background: transparent; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; + cursor: pointer; +} + +.trigger { + gap: 4px; +} + +.switcherTrigger { + gap: 4px; + min-width: 0; + max-width: 244px; + color: var(--dsw-alias-label-primary); + font-weight: 500; +} + +.ancestorSwitcherTrigger { + color: var(--dsw-alias-label-tertiary); + font-weight: 400; +} + +.switcherTitle { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.switcherTrigger svg { + flex: none; +} + +.activitySlot { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; +} + +.trigger:hover, +.trigger:focus-visible { + color: var(--dsw-alias-label-primary); +} + +.switcherTrigger:hover, +.switcherTrigger:focus-visible { + color: var(--dsw-alias-label-primary); +} + +.ancestorSwitcherTrigger:hover, +.ancestorSwitcherTrigger:focus-visible { + color: var(--dsw-alias-label-tertiary); +} + +.trigger svg, +.switcherTrigger svg { + transition: transform 120ms ease; +} + +.triggerOpen { + transform: rotate(180deg); +} + +.menu { + position: fixed; + z-index: 100; + box-sizing: border-box; + display: flex; + flex-direction: column; + width: 336px; + max-width: min(400px, calc(100vw - 32px)); + max-height: min(560px, calc(100vh - 140px)); + padding: 3px; + overflow: hidden; + border-radius: var(--dsw-radius-lg); + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); + --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); + box-shadow: var(--dsw-elevation-prominent); +} + +/* The menu skin lives on a pseudo-element: backdrop-filter on the menu itself + would turn it into the containing block for fixed descendants, so the + sidebar-button Tooltip bubbles would be re-anchored into the menu and + clipped by its hidden overflow. */ +.menu::before { + content: ""; + position: absolute; + z-index: -1; + inset: 0; + border-radius: inherit; + background: var(--dsw-specific-menu); + backdrop-filter: var(--dsw-menu-backdrop-filter); +} + +.menuBody { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + overflow: auto; +} + +.node { + position: relative; + min-width: 0; +} +.menuBody > .node { + margin-left: -2px; +} + +.row { + position: relative; + display: flex; + align-items: flex-start; + gap: 6px; + box-sizing: border-box; + width: 100%; + min-height: 44px; + padding: 6px 7px 6px 9px; + border: 0; + border-radius: var(--dsw-radius-lg); + background: transparent; + color: var(--dsw-alias-label-primary); + font-size: 12px; + line-height: 17px; + text-align: left; + cursor: pointer; + outline: none; +} + +.row:hover > .clickarea, +.row:focus-visible > .clickarea { + background: var(--dsw-alias-interactive-bg-hover); +} + +.clickarea { + box-sizing: border-box; + display: flex; + flex: 1; + align-self: stretch; + align-items: flex-start; + gap: 6px; + min-width: 0; + margin: -6px -7px -6px; + padding: 6px 7px; + border-radius: var(--dsw-radius-lg); +} + +.rowActivitySlot { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 14px; + height: 17px; +} + +.disclosure, +.disclosureSpace { + flex: none; + width: 14px; + height: 17px; +} + +.disclosure { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; + transition: transform 120ms ease; +} + +.disclosure svg { + width: 12px; + height: 12px; +} + +.disclosure:hover { + color: var(--dsw-alias-label-primary); +} + +.disclosureOpen { + transform: rotate(90deg); +} + +.content { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; +} + +.label, +.summary { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.label { + color: inherit; + font-weight: 400; +} + +.currentLabel { + font-weight: 600; +} + +.summary, +.metrics { + color: var(--dsw-alias-label-tertiary); + font-size: 10px; + line-height: 15px; +} + +.metrics { + display: grid; + grid-template-rows: 17px 15px; + flex: none; + font-variant-numeric: tabular-nums; + text-align: right; + white-space: nowrap; +} + +.metricToken { + grid-row: 1; + line-height: 17px; +} + +.metricDuration { + grid-row: 2; +} + +.sidebarButton { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + margin: 4px 0; + padding: 6px; + border: 0; + border-radius: var(--dsw-radius-sm); + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.sidebarButton:hover, +.sidebarButton:focus-visible { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} + +.children { + position: relative; + margin-left: 16px; + padding-left: 3px; +} + +.children::before, +.children > .node::before { + content: ""; + position: absolute; + left: 0; + border-left: 0.5px solid var(--dsw-alias-border-l2); +} + +.children::before { + top: -23px; + height: 23px; +} + +.children[aria-busy="true"]::before { + content: none; +} + +.children > .node::before { + top: 0; + bottom: 0; + left: -3px; +} + +.children > .node:last-child::before { + bottom: auto; + height: 15px; +} + +.children > .node > .row::before { + content: ""; + position: absolute; + top: 14px; + left: -3px; + width: 12px; + border-top: 0.5px solid var(--dsw-alias-border-l2); +} + +.notice, +.error { + padding: 8px 10px; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 16px; +} + +.error { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + color: var(--dsw-alias-state-error-primary); +} + +.refresh { + display: inline-flex; + flex: none; + align-items: center; + gap: 3px; + padding: 3px 5px; + border: 0; + border-radius: var(--dsw-radius-sm); + background: transparent; + color: inherit; + cursor: pointer; +} + +.refresh svg { + width: 12px; + height: 12px; +} + +.refresh:hover { + background: var(--dsw-alias-interactive-bg-hover); +} diff --git a/apps/web-ui/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx b/apps/web-ui/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx new file mode 100644 index 000000000..ffc13db74 --- /dev/null +++ b/apps/web-ui/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx @@ -0,0 +1,900 @@ +import { + useMemo, + useEffect, + useRef, + useState, + type CSSProperties, + type KeyboardEvent, + type MouseEvent, +} from "react"; +import { createPortal } from "react-dom"; +import { + type SessionProjectionMap, + type SessionSummary, + type SessionProjectionSnapshot, +} from "@deepseek-ai/dsh-api-session-controller/client"; +import type { SubagentAddress } from "@deepseek-ai/dsh-subagent/client"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import { + IconChevronDownOutlineRegular, + IconChevronRightOutlineRegular, + IconRefreshOutlineRegular, + StateDot, + Tooltip, +} from "@deepseek-ai/dsh-client-ui-primitives"; +import type { PropsLocale, PropsRuntime, TranslateNS } from "@deepseek-ai/dsh-client-ui-slots"; +import { NS } from "./locales.ts"; +import type {} from "@deepseek-ai/dsh-client-ui-conversation/client"; +import type {} from "@deepseek-ai/dsh-token-meter/client"; +import css from "./SubagentHeaderLineage.module.css"; + +type SubagentCatalogSnapshot = Omit & { + state: "loading" | "ready" | "error"; + entries: (SessionProjectionMap["subagentCatalog"][number] & { + activity: "running" | "inactive"; + })[]; +}; +type Catalogs = Readonly>; + +/** Business actions supplied by the slot registration. */ +export interface SubagentCatalogInjected { + openChild: (address: SubagentAddress) => void; + openChildAside: (address: SubagentAddress) => void; + refreshProjection: (parentSessionId: SessionId) => void; +} + +/** Full props for the session-header lineage renderer. */ +export type SubagentHeaderLineageProps = PropsRuntime<"conversation.session.header.lineage"> & + SubagentCatalogInjected & + PropsLocale; + +interface CatalogRowsProps { + parentSessionId: SessionId; + currentSessionId: SessionId | undefined; + catalog: SubagentCatalogSnapshot; + catalogs: Catalogs; + summaries: Readonly>; + expanded: ReadonlySet; + level: number; + openChild: (address: SubagentAddress) => void; + openChildAside: (address: SubagentAddress) => void; + refreshProjection: (parentSessionId: SessionId) => void; + toggleBranch: (childSessionId: SessionId) => void; + closeCatalog: () => void; +} + +function treeItems(root: HTMLDivElement | null): HTMLElement[] { + return root === null + ? [] + : Array.from( + root.querySelectorAll('[role="treeitem"]:not([aria-disabled="true"])'), + ); +} + +/** Compact token count shared in shape with the conversation stats strip. */ +function formatTokens(value: number, t: TranslateNS): string { + const scaled = (next: number): string => + next >= 100 ? String(Math.round(next)) : String(Math.round(next * 10) / 10); + if (value < 1_000) return String(value); + if (value < 1_000_000) return t("tokens.thousand", { value: scaled(value / 1_000) }); + return t("tokens.million", { value: scaled(value / 1_000_000) }); +} + +/** Sum the four disjoint durable provider-usage buckets. */ +function tokenTotal(usage: SessionProjectionMap["tokenUsage"] | undefined): number | undefined { + return usage === undefined + ? undefined + : usage.uncachedInputTokens + + usage.outputTokens + + usage.cacheReadTokens + + usage.cacheWriteTokens; +} + +/** Exact whole-second active-turn duration for one catalog row. */ +function activityDuration( + summary: SessionSummary | undefined, + activity: "running" | "inactive", + now: number, +): number | undefined { + if (summary === undefined) return undefined; + const timing: SessionProjectionMap["subagentTiming"] | undefined = + summary.projectionValues?.subagentTiming; + if (timing === undefined) return undefined; + if (timing.active === undefined) return timing.settledMs; + const end = activity === "running" ? now : timing.active.through; + return timing.settledMs + Math.max(0, end - timing.active.since); +} + +interface DurationParts { + seconds: number; + minutes: number; + hours: number; + days: number; + totalMinutes: number; + totalHours: number; +} + +function splitDuration(ms: number): DurationParts { + const totalSeconds = Math.floor(Math.max(0, ms) / 1_000); + const totalMinutes = Math.floor(totalSeconds / 60); + const totalHours = Math.floor(totalMinutes / 60); + return { + seconds: totalSeconds % 60, + minutes: totalMinutes % 60, + hours: totalHours % 24, + days: Math.floor(totalHours / 24), + totalMinutes, + totalHours, + }; +} + +/** Format a duration with decreasing visual precision at larger scales. */ +function formatDuration(ms: number, t: TranslateNS): string { + const { seconds, minutes, hours, days, totalMinutes, totalHours } = splitDuration(ms); + if (days >= 365) { + const years = Math.floor(days / 365); + const months = Math.floor((days % 365) / 30); + return months === 0 + ? t("duration.years", { years }) + : t("duration.yearsMonths", { years, months }); + } + if (days >= 30) { + const months = Math.floor(days / 30); + const remainingDays = days % 30; + return remainingDays === 0 + ? t("duration.months", { months }) + : t("duration.monthsDays", { months, days: remainingDays }); + } + if (days > 0) { + return hours === 0 ? t("duration.days", { days }) : t("duration.daysHours", { days, hours }); + } + if (totalHours > 0) { + return t("duration.hours", { + hours: totalHours, + minutes: String(minutes).padStart(2, "0"), + seconds: String(seconds).padStart(2, "0"), + }); + } + if (totalMinutes > 0) { + return t("duration.minutes", { + minutes: totalMinutes, + seconds: String(seconds).padStart(2, "0"), + }); + } + return t("duration.seconds", { seconds }); +} + +/** Preserve exact whole seconds for hover and accessible naming. */ +function formatExactDuration(ms: number, t: TranslateNS): string { + const { seconds, minutes, hours, days } = splitDuration(ms); + return days === 0 + ? formatDuration(ms, t) + : t("duration.exactDays", { + days, + hours: String(hours).padStart(2, "0"), + minutes: String(minutes).padStart(2, "0"), + seconds: String(seconds).padStart(2, "0"), + }); +} + +function SubagentSwitcherIcon() { + return ( + + ); +} + +/** Render catalog loading without inventing child membership. */ +function CatalogLoadingRows({ t }: { t: TranslateNS }) { + return

    {t("loading.label")}
    ; +} + +/** A child becomes a known leaf only after its own authoritative catalog loads empty. */ +function isKnownLeaf(catalog: SubagentCatalogSnapshot | undefined): boolean { + return catalog?.state === "ready" && catalog.entries.length === 0; +} + +/** Render one catalog level and recurse only through explicitly expanded rows. */ +function CatalogRows({ + parentSessionId, + currentSessionId, + catalog, + catalogs, + summaries, + expanded, + level, + openChild, + openChildAside, + refreshProjection, + toggleBranch, + closeCatalog, + t, +}: CatalogRowsProps & { t: TranslateNS }) { + const [now, setNow] = useState(() => Date.now()); + const running = catalog.entries.some((entry) => entry.activity === "running"); + useEffect(() => { + if (!running) return; + const timer = setInterval(() => { + setNow(Date.now()); + }, 1_000); + return () => { + clearInterval(timer); + }; + }, [running]); + const emptyLoading = catalog.state === "loading" && catalog.entries.length === 0; + const reserveDisclosure = catalog.entries.some((entry) => !isKnownLeaf(catalogs[entry.id])); + return ( + <> + {emptyLoading && } + {catalog.state === "error" && ( +
    + {catalog.error?.message ?? t("load.error")} + +
    + )} + {catalog.entries.map((entry) => { + const childCatalog = catalogs[entry.id]; + const isCurrent = entry.id === currentSessionId; + const isExpanded = expanded.has(entry.id); + const knownLeaf = isKnownLeaf(childCatalog); + const childLoading = + childCatalog === undefined || + (childCatalog.state === "loading" && childCatalog.entries.length === 0); + const summary = summaries[entry.id]; + const label = entry.label ?? entry.id; + const mode = + entry.mode === "unknown" + ? t("mode.unknown") + : entry.mode === "one-shot" + ? t("mode.oneShot") + : t("mode.continuable"); + const completed = + entry.activity === "inactive" && + summary?.projectionValues?.subagentTiming?.lastTurnCompleted === true; + const activity = + entry.activity === "running" + ? t("activity.running") + : completed + ? t("activity.completed") + : t("activity.inactive"); + const secondary = [summary?.title, mode, activity] + .filter((value) => value !== undefined) + .join(" · "); + const totalTokens = tokenTotal(summary?.projectionValues?.tokenUsage); + const durationMs = activityDuration(summary, entry.activity, now); + const tokenMetric = + totalTokens === undefined + ? undefined + : t("tokens.total", { value: formatTokens(totalTokens, t) }); + const durationMetric = + durationMs === undefined + ? undefined + : { + compact: formatDuration(durationMs, t), + exact: formatExactDuration(durationMs, t), + }; + const metrics = [tokenMetric, durationMetric?.exact] + .filter((value) => value !== undefined) + .join(" · "); + + const open = (): void => { + openChild({ + parentSessionId, + childSessionId: entry.id, + mode: entry.mode, + }); + closeCatalog(); + }; + const openAside = (event: MouseEvent): void => { + event.preventDefault(); + event.stopPropagation(); + openChildAside({ parentSessionId, childSessionId: entry.id, mode: entry.mode }); + closeCatalog(); + }; + const handleKey = (event: KeyboardEvent): void => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + event.stopPropagation(); + open(); + } else if ( + (event.key === "ArrowRight" && !knownLeaf && !isExpanded) || + (event.key === "ArrowLeft" && isExpanded) + ) { + event.preventDefault(); + event.stopPropagation(); + toggleBranch(entry.id); + } + }; + const toggle = (event: MouseEvent): void => { + event.preventDefault(); + event.stopPropagation(); + toggleBranch(entry.id); + }; + + return ( +
    +
    value !== "").join(" ")} + {...(knownLeaf ? {} : { "aria-expanded": isExpanded })} + className={css.row} + onClick={open} + onKeyDown={handleKey} + > + {knownLeaf ? ( + reserveDisclosure && + ) : ( + + )} +
    + + + + + + {label} + + {secondary} + + {metrics !== "" && ( + + {tokenMetric !== undefined && ( + {tokenMetric} + )} + {durationMetric !== undefined && ( + + {durationMetric.compact} + + )} + + )} + {!isCurrent && ( + + + + )} +
    +
    + {isExpanded && !knownLeaf && ( +
    + {childCatalog === undefined ? ( + + ) : ( + + )} +
    + )} +
    + ); + })} + + ); +} + +interface CatalogDropdownSharedProps extends SubagentCatalogInjected { + /** Session whose direct catalog roots the tree. */ + rootSessionId: SessionId; + useSessions: SubagentHeaderLineageProps["useSessions"]; + useSessionStatus: SubagentHeaderLineageProps["useSessionStatus"]; + t: TranslateNS; +} + +type CatalogDropdownProps = CatalogDropdownSharedProps & + ( + | { + /** Descendant-count control. */ + variant: "count"; + currentSessionId?: never; + displayTitle?: never; + openTitle?: never; + } + | { + /** Current-title sibling switcher. */ + variant: "switcher"; + /** Selected descendant highlighted in the catalog. */ + currentSessionId: SessionId; + /** Visible title included in the switcher's hover target. */ + displayTitle: string; + /** Optional ancestor navigation when the combined title is clicked. */ + openTitle?: () => void; + } + ); + +const MENU_VIEWPORT_MARGIN = 16; + +/** Place a portaled catalog below its trigger without crossing the viewport edge. */ +function catalogMenuPosition(trigger: HTMLButtonElement): CSSProperties { + const rect = trigger.getBoundingClientRect(); + const width = Math.min(336, window.innerWidth - MENU_VIEWPORT_MARGIN * 2); + return { + top: rect.bottom + 5, + left: Math.min( + Math.max(MENU_VIEWPORT_MARGIN, rect.left), + window.innerWidth - width - MENU_VIEWPORT_MARGIN, + ), + }; +} + +/** One trigger-plus-tree dropdown over the catalog rooted at `rootSessionId`. */ +function CatalogDropdown({ + rootSessionId, + currentSessionId, + displayTitle, + openTitle, + variant, + useSessions, + useSessionStatus, + openChild, + openChildAside, + refreshProjection, + t, +}: CatalogDropdownProps) { + const ancestorSwitcher = variant === "switcher" && openTitle !== undefined; + const projections = useSessions((state) => state.projectionsBySession); + const summaries = useSessions((state) => state.byId); + const statuses = useSessionStatus((value) => value); + const catalogs = useMemo( + () => + Object.fromEntries( + Object.entries(projections).map(([id, snapshot]) => [ + id, + { + state: + snapshot.state === "idle" + ? snapshot.values.subagentCatalog === undefined + ? "loading" + : "ready" + : snapshot.state, + error: snapshot.error, + entries: (snapshot.values.subagentCatalog ?? []).map((entry) => ({ + ...entry, + activity: + (statuses.get(entry.id)?.running ?? summaries[entry.id]?.running) === true + ? ("running" as const) + : ("inactive" as const), + })), + }, + ]), + ), + [projections, summaries, statuses], + ); + const catalog = catalogs[rootSessionId]; + const [open, setOpen] = useState(false); + const [menuPosition, setMenuPosition] = useState(); + const [expanded, setExpanded] = useState>(() => new Set()); + const rootRef = useRef(null); + const triggerRef = useRef(null); + const menuRef = useRef(null); + const hoverOpenTimer = useRef | undefined>(undefined); + const hoverCloseTimer = useRef | undefined>(undefined); + // A click-opened (pinned) menu ignores hover-out; only explicit dismissal closes it. + const pinnedRef = useRef(false); + const currentEntry = + currentSessionId === undefined + ? undefined + : catalog?.entries.find((entry) => entry.id === currentSessionId); + const switcherDisplayTitle = + currentEntry !== undefined ? (currentEntry.label ?? currentEntry.id) : displayTitle; + const directChildren = catalog?.entries ?? []; + const directCount = directChildren.length; + const runningCount = directChildren.filter((entry) => entry.activity === "running").length; + const totalCountKey = directCount === 1 ? "count.total.one" : "count.total.other"; + const runningCountKey = runningCount === 1 ? "count.running.one" : "count.running.other"; + const presentedCatalog: SubagentCatalogSnapshot | undefined = + catalog ?? + (variant === "switcher" ? { entries: [], state: "loading", error: null } : undefined); + + const cancelHoverClose = (): void => { + if (hoverCloseTimer.current === undefined) return; + clearTimeout(hoverCloseTimer.current); + hoverCloseTimer.current = undefined; + }; + + const cancelHoverOpen = (): void => { + if (hoverOpenTimer.current === undefined) return; + clearTimeout(hoverOpenTimer.current); + hoverOpenTimer.current = undefined; + }; + + const changeOpen = (next: boolean, restoreFocus = false): void => { + cancelHoverOpen(); + cancelHoverClose(); + if (next) { + const trigger = triggerRef.current; + /* v8 ignore next -- a queued callback can outlive the trigger */ + if (trigger === null) return; + setOpen(true); + setMenuPosition(catalogMenuPosition(trigger)); + } else { + pinnedRef.current = false; + setOpen(false); + setMenuPosition(undefined); + setExpanded(new Set()); + } + if (restoreFocus) + queueMicrotask(() => { + triggerRef.current?.focus(); + }); + }; + + const scheduleHoverOpen = (): void => { + cancelHoverOpen(); + cancelHoverClose(); + if (open) return; + hoverOpenTimer.current = setTimeout(() => { + hoverOpenTimer.current = undefined; + changeOpen(true); + }, 150); + }; + + const scheduleHoverClose = (): void => { + cancelHoverOpen(); + cancelHoverClose(); + if (pinnedRef.current) return; + hoverCloseTimer.current = setTimeout(() => { + hoverCloseTimer.current = undefined; + changeOpen(false); + }, 120); + }; + + const closeBranch = (root: SessionId): void => { + const closing = new Set(); + const visit = (parentSessionId: SessionId): void => { + if (closing.has(parentSessionId) || !expanded.has(parentSessionId)) return; + closing.add(parentSessionId); + const branch = catalogs[parentSessionId]; + for (const entry of branch?.entries ?? []) { + visit(entry.id); + } + }; + visit(root); + setExpanded((current) => new Set([...current].filter((id) => !closing.has(id)))); + }; + + const toggleBranch = (childSessionId: SessionId): void => { + if (expanded.has(childSessionId)) { + closeBranch(childSessionId); + return; + } + setExpanded((current) => new Set(current).add(childSessionId)); + refreshProjection(childSessionId); + }; + + useEffect(() => { + if (!open) return; + const closeOutside = (event: PointerEvent): void => { + if ( + event.target instanceof Node && + !rootRef.current?.contains(event.target) && + !menuRef.current?.contains(event.target) + ) { + changeOpen(false); + } + }; + document.addEventListener("pointerdown", closeOutside); + return () => { + document.removeEventListener("pointerdown", closeOutside); + }; + }, [open]); + + useEffect(() => { + if (!open) return; + const placeMenu = (): void => { + const trigger = triggerRef.current; + /* v8 ignore next -- native resize or scroll can outlive the trigger */ + if (trigger === null) return; + setMenuPosition(catalogMenuPosition(trigger)); + }; + window.addEventListener("resize", placeMenu); + document.addEventListener("scroll", placeMenu, true); + return () => { + window.removeEventListener("resize", placeMenu); + document.removeEventListener("scroll", placeMenu, true); + }; + }, [open]); + + useEffect( + () => () => { + cancelHoverOpen(); + cancelHoverClose(); + }, + [], + ); + + // Visibility needs catalog evidence of children or a failed load worth retrying. + // An empty loading catalog is not evidence of children. + const visible = + presentedCatalog !== undefined && + (variant === "switcher" || + presentedCatalog.state === "error" || + presentedCatalog.entries.length > 0); + useEffect(() => { + if (visible) return; + cancelHoverOpen(); + cancelHoverClose(); + if (!open) return; + pinnedRef.current = false; + setOpen(false); + setExpanded(new Set()); + }, [visible, open]); + + if (!visible) return null; + + const focusAt = (index: number): void => { + const items = treeItems(menuRef.current); + if (items.length === 0) return; + items[(index + items.length) % items.length]?.focus(); + }; + + const navigate = (event: KeyboardEvent): void => { + const items = treeItems(menuRef.current); + const index = items.indexOf(document.activeElement as HTMLElement); + if (event.key === "Escape") { + event.preventDefault(); + changeOpen(false, true); + } else if (event.key === "Home") { + event.preventDefault(); + focusAt(0); + } else if (event.key === "End") { + event.preventDefault(); + focusAt(items.length - 1); + } else if (event.key === "ArrowDown") { + event.preventDefault(); + focusAt(index + 1); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + focusAt(index < 0 ? items.length - 1 : index - 1); + } + }; + + return ( +
    + + {open && + createPortal( +
    +
    + { + changeOpen(false); + }} + t={t} + /> +
    +
    , + document.body, + )} +
    + ); +} + +/** Full props for the root-session catalog entry in the header actions band. */ +export type SubagentCatalogActionProps = PropsRuntime<"conversation.session.header.actions"> & + SubagentCatalogInjected & + PropsLocale; + +/** + * Session-header catalog action for root sessions: the descendant count and + * its dropdown at the start of the header actions band. Child sessions render nothing + * here — their breadcrumb switcher in the lineage slot owns the same + * navigation. + * @param props - Session standard props plus the catalog actions and translator. + * @returns The count dropdown, or null on a child session. + */ +export function SubagentCatalogAction({ + sessionId, + useSessions, + useSessionStatus, + openChild, + openChildAside, + refreshProjection, + t, +}: SubagentCatalogActionProps) { + const isChild = useSessions((state) => state.byId[sessionId]?.origin === "subagent"); + if (isChild) return null; + return ( + + ); +} + +/** + * Render one breadcrumb title together with its subagent navigation. + * @param props - Breadcrumb title, session standard props, and catalog actions. + * @returns A title-and-chevron sibling switcher, or nothing on a root session. + */ +export function SubagentHeaderLineage({ + lineageSessionId, + displayTitle, + openTitle, + useSessions, + useSession, + useSessionStatus, + openChild, + openChildAside, + refreshProjection, + t, +}: SubagentHeaderLineageProps) { + const address = useSession((session) => session.subagent?.address); + const parentId = useSessions((state) => { + if (address?.childSessionId === lineageSessionId) return address.parentSessionId; + for (const [parentId, snapshot] of Object.entries(state.projectionsBySession)) { + if (snapshot.values.subagentCatalog?.some((entry) => entry.id === lineageSessionId)) + return parentId as SessionId; + } + return undefined; + }); + const shared = { useSessions, useSessionStatus, openChild, openChildAside, refreshProjection, t }; + // Root sessions carry no breadcrumb; their descendant count lives in the + // header actions band (SubagentCatalogAction). + if (parentId === undefined) return null; + return ( + <> + + {openTitle === undefined && ( + + )} + + ); +} diff --git a/apps/web-ui/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.module.css b/apps/web-ui/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.module.css new file mode 100644 index 000000000..6056d0905 --- /dev/null +++ b/apps/web-ui/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.module.css @@ -0,0 +1,20 @@ +.frame { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin: 0 24px 20px; + min-height: 54px; + padding: 10px 16px; + border: 0.5px solid var(--dsw-alias-border-l4); + border-radius: var(--dsw-radius-lg); + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-tertiary); + font-size: 13px; + line-height: 20px; +} + +.frame strong { + color: var(--dsw-alias-label-primary); + font-weight: 510; +} diff --git a/apps/web-ui/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx b/apps/web-ui/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx new file mode 100644 index 000000000..1b04c10d5 --- /dev/null +++ b/apps/web-ui/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx @@ -0,0 +1,39 @@ +import type { PropsLocale, PropsRuntime } from "@deepseek-ai/dsh-client-ui-slots"; +import { NS } from "./locales.ts"; +import css from "./SubagentReadOnlyComposer.module.css"; + +/** Why a catalog-addressed conversation cannot accept human input. */ +export interface SubagentReadOnlyMatch { + reason: "one-shot" | "parent-unavailable" | "unknown"; +} + +/** Full chain props after the read-only subagent selector accepts the owner currency. */ +export type SubagentReadOnlyComposerProps = PropsRuntime<"conversation.composer"> & { + matched: SubagentReadOnlyMatch; +} & PropsLocale; + +/** + * Explain why the normal composer is unavailable for an addressed child. + * @param props - selector-owned read-only reason plus standard slot props. + * @returns A read-only composer replacement. + */ +export function SubagentReadOnlyComposer({ + matched, + t, +}: Pick) { + const oneShot = matched.reason === "one-shot"; + return ( +
    + {t(oneShot ? "readonly.oneShot.title" : "readonly.title")} + + {t( + matched.reason === "unknown" + ? "readonly.unknown.body" + : oneShot + ? "readonly.oneShot.body" + : "readonly.body", + )} + +
    + ); +} diff --git a/apps/web-ui/packages/client/ui-subagent/src/client/index.ts b/apps/web-ui/packages/client/ui-subagent/src/client/index.ts new file mode 100644 index 000000000..22cc6ea9e --- /dev/null +++ b/apps/web-ui/packages/client/ui-subagent/src/client/index.ts @@ -0,0 +1,118 @@ +/** Web subagent catalog, navigation, and addressed-session composer owner. */ +import type { Context as ClientContext } from "@deepseek-ai/cordis"; +import type { SubagentAddress } from "@deepseek-ai/dsh-subagent/client"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { ComposerChainProps } from "@deepseek-ai/dsh-client-ui-conversation/client"; +import { + SubagentCatalogAction, + SubagentHeaderLineage, + type SubagentCatalogInjected, +} from "./SubagentHeaderLineage.tsx"; +import { + SubagentReadOnlyComposer, + type SubagentReadOnlyMatch, +} from "./SubagentReadOnlyComposer.tsx"; +import { registerSidebarChat, subagentChatAddress } from "./sidebar-chat/index.tsx"; +import type {} from "@deepseek-ai/dsh-client-locale/client"; +import type {} from "@deepseek-ai/dsh-client-ui-chat/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-sidebar-right/client"; +import type {} from "@deepseek-ai/dsh-client-ui-workspace/client"; +import { en, NS, zh, type SubagentKey } from "./locales.ts"; + +declare module "@deepseek-ai/dsh-client-ui-slots" { + interface LocaleNamespaceMap { + /** Subagent catalog and read-only composer copy. */ + subagent: SubagentKey; + } +} + +export type { + SubagentCatalogActionProps, + SubagentCatalogInjected, + SubagentHeaderLineageProps, +} from "./SubagentHeaderLineage.tsx"; +export type { + SubagentReadOnlyComposerProps, + SubagentReadOnlyMatch, +} from "./SubagentReadOnlyComposer.tsx"; + +/** Required services for subagent presentation and navigation. */ +export const inject = ["sessions", "uiWorkspace", "slots", "locale", "sidebarRight"]; + +/** Claim the composer for one-shot history or an unavailable continuation owner. */ +function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatch | null { + const subagent = owner.session?.subagent; + if (subagent === undefined || subagent === null) return null; + if (subagent.address.mode === "unknown") return { reason: "unknown" }; + if (subagent.address.mode === "one-shot") return { reason: "one-shot" }; + // Until a Host summary establishes parent availability, keep the normal + // disabled composer instead of claiming that the parent is offline. + if (subagent.parentAvailable !== false) return null; + // A RUNNING parent-offline continuable child keeps the default composer: + // its input is disabled there, but the same primary Stop stays available so + // the child can be interrupted. Once it stops, this takeover returns. + return owner.session?.running === true ? null : { reason: "parent-unavailable" }; +} + +/** + * Client plugin body: register the subagent catalog and read-only composer seats. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), "ui-subagent: dictionaries"); + ctx.inject(["resources", "sidebarRightTabs"], (scope) => { + registerSidebarChat(scope, ctx.locale.bind(NS)); + }); + const catalogActions = (_parentSessionId: SessionId): SubagentCatalogInjected => ({ + openChild(address: SubagentAddress) { + ctx.uiWorkspace.openSession(address); + }, + openChildAside(address: SubagentAddress) { + ctx.sidebarRight.openResource(subagentChatAddress(address), { + kind: "subagentchat", + preferNewPane: true, + }); + }, + refreshProjection(parentSessionId: SessionId) { + void ctx.sessions.refreshProjections(parentSessionId); + }, + }); + ctx.slots.inject("conversation.session.header.lineage", () => + ctx.slots.register( + { + name: "conversation.session.header.lineage", + locale: NS, + inject: catalogActions, + }, + SubagentHeaderLineage, + ), + ); + ctx.slots.inject("conversation.session.header.actions", () => + ctx.slots.register( + { + name: "conversation.session.header.actions", + id: "subagent-catalog", + // Leads the band, directly after the title crumbs: subagent lineage is + // the title's own continuation, ahead of Team navigation (-20) and the + // preset label (-10). + order: -30, + locale: NS, + inject: catalogActions, + }, + SubagentCatalogAction, + ), + ); + ctx.slots.inject("conversation.composer", () => + ctx.slots.register( + { + name: "conversation.composer", + priority: -10, + locale: NS, + select: selectReadOnlySubagent, + }, + SubagentReadOnlyComposer, + ), + ); +} diff --git a/apps/web-ui/packages/client/ui-subagent/src/client/locales.ts b/apps/web-ui/packages/client/ui-subagent/src/client/locales.ts new file mode 100644 index 000000000..c910e584c --- /dev/null +++ b/apps/web-ui/packages/client/ui-subagent/src/client/locales.ts @@ -0,0 +1,94 @@ +/** `subagent` namespace dictionaries. */ + +/** Dictionary namespace owned by this plugin. */ +export const NS = "subagent"; + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + "duration.seconds": "{seconds}秒", + "duration.minutes": "{minutes}分{seconds}秒", + "duration.hours": "{hours}小时{minutes}分{seconds}秒", + "duration.days": "{days}天", + "duration.daysHours": "{days}天{hours}小时", + "duration.months": "约{months}个月", + "duration.monthsDays": "约{months}个月{days}天", + "duration.years": "约{years}年", + "duration.yearsMonths": "约{years}年{months}个月", + "duration.exactDays": "{days}天{hours}小时{minutes}分{seconds}秒", + "duration.exactTitle": "总活跃耗时:{duration}", + "tokens.thousand": "{value}K", + "tokens.million": "{value}M", + "tokens.total": "{value} tok", + "loading.label": "正在加载子智能体…", + "load.error": "无法加载子智能体", + retry: "重试", + "mode.oneShot": "一次性", + "mode.continuable": "可继续", + "mode.unknown": "模式未知", + "readonly.unknown.body": "读取子会话后才能确定是否可继续。", + "activity.running": "正在运行", + "activity.completed": "已完成", + "activity.inactive": "当前未运行", + "branch.collapse": "收起 {label} 的下级子智能体", + "branch.expand": "展开 {label} 的下级子智能体", + "count.total.one": "{count} 个子智能体", + "count.total.other": "{count} 个子智能体", + "count.running.one": "{count} 个子智能体,正在运行", + "count.running.other": "{count} 个子智能体,正在运行", + "switcher.aria": "切换子智能体:{title}", + "tree.aria": "子智能体会话", + "open.sidebar": "在侧边栏打开", + "open.sidebar.aria": "在侧边栏打开 {label}", + "sidebar.chat": "聊天", + "readonly.oneShot.title": "一次性子智能体记录", + "readonly.title": "此子智能体暂时只读", + "readonly.oneShot.body": "一次性任务不支持后续消息,可在这里查看完整执行记录。", + "readonly.body": "父会话当前不在线,重新打开父会话后即可继续发送消息。", +} as const; + +/** English dictionary, key-identical to the Chinese source of truth. */ +export const en: Record = { + "duration.seconds": "{seconds}s", + "duration.minutes": "{minutes}m {seconds}s", + "duration.hours": "{hours}h {minutes}m {seconds}s", + "duration.days": "{days}d", + "duration.daysHours": "{days}d {hours}h", + "duration.months": "~{months}mo", + "duration.monthsDays": "~{months}mo {days}d", + "duration.years": "~{years}y", + "duration.yearsMonths": "~{years}y {months}mo", + "duration.exactDays": "{days}d {hours}h {minutes}m {seconds}s", + "duration.exactTitle": "Total active duration: {duration}", + "tokens.thousand": "{value}K", + "tokens.million": "{value}M", + "tokens.total": "{value} tok", + "loading.label": "Loading subagents…", + "load.error": "Unable to load subagents", + retry: "Retry", + "mode.oneShot": "one-shot", + "mode.continuable": "continuable", + "mode.unknown": "unknown mode", + "readonly.unknown.body": "Read the child session to determine whether it can be continued.", + "activity.running": "running", + "activity.completed": "completed", + "activity.inactive": "not running", + "branch.collapse": "Collapse {label} descendants", + "branch.expand": "Expand {label} descendants", + "count.total.one": "{count} subagent", + "count.total.other": "{count} subagents", + "count.running.one": "{count} subagent running", + "count.running.other": "{count} subagents running", + "switcher.aria": "Switch subagent: {title}", + "tree.aria": "Subagent sessions", + "open.sidebar": "Open in sidebar", + "open.sidebar.aria": "Open {label} in sidebar", + "sidebar.chat": "Chat", + "readonly.oneShot.title": "One-shot subagent record", + "readonly.title": "This subagent is read-only for now", + "readonly.oneShot.body": + "One-shot tasks do not accept follow-ups; review the full execution record here.", + "readonly.body": "The parent session is offline; reopen it to continue sending messages.", +}; + +/** Key domain of the `subagent` namespace (zh is the source of truth). */ +export type SubagentKey = keyof typeof zh; diff --git a/apps/web-ui/packages/client/ui-subagent/src/client/sidebar-chat/SidebarChat.module.css b/apps/web-ui/packages/client/ui-subagent/src/client/sidebar-chat/SidebarChat.module.css new file mode 100644 index 000000000..267787e4e --- /dev/null +++ b/apps/web-ui/packages/client/ui-subagent/src/client/sidebar-chat/SidebarChat.module.css @@ -0,0 +1,7 @@ +.root { + display: flex; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; +} diff --git a/apps/web-ui/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx b/apps/web-ui/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx new file mode 100644 index 000000000..d552be733 --- /dev/null +++ b/apps/web-ui/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx @@ -0,0 +1,253 @@ +/** Right-Sidebar presentation of an existing subagent Conversation. */ +import type { Context } from "@deepseek-ai/cordis"; +import type { ISessions, SessionReference } from "@deepseek-ai/dsh-api-session-controller/client"; +import type { ResourceProvider } from "@deepseek-ai/dsh-client-resources/client"; +import type { ConversationViewsProps } from "@deepseek-ai/dsh-client-ui-conversation/client"; +import type { SidebarRightTabDefinition } from "@deepseek-ai/dsh-client-ui-sidebar-right/client"; +import type { + PropsRenderFactories, + PropsRenderSlots, + PropsRuntime, + TranslateNS, +} from "@deepseek-ai/dsh-client-ui-slots"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { SubagentAddress } from "@deepseek-ai/dsh-subagent/client"; +import type { NS } from "../locales.ts"; +import css from "./SidebarChat.module.css"; + +/** Stable implementation identity for the Sidebar tab body. */ +export const SUBAGENT_CHAT_ID = "@deepseek-ai/dsh-client-ui-subagent"; + +/** Resource-address prefix for an embedded Session chat. */ +export const SUBAGENT_CHAT_ADDRESS = "dsh-resource://subagentchat/session/"; + +/** Value retained by one live chat resource occurrence. */ +export interface SubagentChatResource { + readonly address: SubagentAddress; + readonly reference: SessionReference; +} + +declare module "@deepseek-ai/dsh-api-session-controller/client" { + interface SessionReferenceSourceMap { + sidebarChat: unknown; + } +} + +declare module "@deepseek-ai/dsh-client-ui-slots" { + interface ResourceProtocolMap { + subagentchat: SubagentChatResource; + } + + interface SlotMap { + /** Session-scoped Conversation occurrence hosted by one Sidebar chat tab. */ + "sidebar.chat.conversation": { kind: "single"; scope: "session" }; + } +} + +/** + * Address one subagent Session together with the routing facts needed to restore it. + * @param address - durable direct-parent subagent address. + * @returns canonical Sidebar resource address. + */ +export function subagentChatAddress(address: SubagentAddress): string { + const query = new URLSearchParams({ + parent: address.parentSessionId, + mode: address.mode, + }); + return `${SUBAGENT_CHAT_ADDRESS}${encodeURIComponent(address.childSessionId)}?${query}`; +} + +/** + * Parse one canonical Sidebar chat resource address. + * @param value - possible chat resource address. + * @returns the encoded direct-parent address, or undefined for another or malformed resource. + */ +export function parseSubagentChatAddress(value: string): SubagentAddress | undefined { + let url: URL; + try { + url = new URL(value); + } catch (_invalidUrl) { + return undefined; + } + if (url.protocol !== "dsh-resource:" || url.hostname.toLowerCase() !== "subagentchat") + return undefined; + const parts = url.pathname.split("/").filter(Boolean); + if (parts.length !== 2 || parts[0] !== "session") return undefined; + const parentSessionId = url.searchParams.get("parent"); + const mode = url.searchParams.get("mode"); + if ( + parentSessionId === null || + parentSessionId === "" || + (mode !== "one-shot" && mode !== "continuable" && mode !== "unknown") + ) { + return undefined; + } + try { + const childSessionId = decodeURIComponent(parts[1] as string); + return { + parentSessionId: parentSessionId as SessionId, + childSessionId: childSessionId as SessionId, + mode, + }; + } catch (_invalidEncoding) { + return undefined; + } +} + +function waitForAbort(signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve(); + return new Promise((resolve) => { + signal.addEventListener( + "abort", + () => { + resolve(); + }, + { once: true }, + ); + }); +} + +function isAbortRequested(signal: AbortSignal): boolean { + return signal.aborted; +} + +function subagentChatResourceProvider(sessions: ISessions): ResourceProvider<"subagentchat"> { + return { + protocol: "subagentchat", + async *open(resourceAddress, { signal }) { + const address = parseSubagentChatAddress(resourceAddress); + if (address === undefined) + throw new Error(`ui-subagent: invalid chat resource address "${resourceAddress}"`); + if (isAbortRequested(signal)) return; + const reference = sessions.retain(address, { source: "sidebarChat", signal }); + try { + yield { ok: true, value: { address, reference } }; + await waitForAbort(signal); + } finally { + reference.release(); + } + }, + }; +} + +/** Fixed Chat selection used by an embedded Conversation occurrence. */ +export function FixedChatConversationView(props: ConversationViewsProps) { + return <>{props.renderSlot("conversation.session", { view: "chat" })}; +} + +/** Props supplied to the child-Session Conversation host. */ +export type ConversationSlotPanelProps = PropsRuntime<"sidebar.chat.conversation"> & + PropsRenderFactories; + +/** Render the shared Conversation content for one explicitly provided child Session. */ +export function ConversationSlotPanel({ + sessionId, + useSession, + useConversation, + useSessions, + renderFactorySlot, +}: ConversationSlotPanelProps) { + const session = useSession((value) => value); + const conversation = useConversation((value) => value); + const active = + conversation.activeTargets.size > 0 || + (!session.blank && !session.awaitingFirstTurn) || + session.running; + const shellPhase = active ? "active" : session.promptAttempted ? "engaging" : "blank"; + const summaryBlank = useSessions((state) => state.byId[sessionId]?.blank); + const parentAvailabilityPending = + session.subagent?.address.mode === "continuable" && + session.subagent.parentAvailable === undefined; + const settling = + (shellPhase === "blank" && session.openState === "loading" && summaryBlank !== true) || + parentAvailabilityPending; + const hero = shellPhase === "blank" && (session.openState === "open" || summaryBlank === true); + const phase = settling ? "settling" : hero ? "hero" : "active"; + return renderFactorySlot( + "conversation.content", + { variant: "embedded", phase, hero }, + { + slots: { views: FixedChatConversationView }, + }, + ); +} + +/** Props supplied to the parent-Session Sidebar tab body. */ +export type SidebarChatTabProps = PropsRuntime<"sidebar.right.pane.tab"> & + PropsRenderSlots<"sidebar.chat.conversation">; + +/** Bind a chat resource's child reference around its Conversation slot. */ +export function SidebarChatTab({ + useResource, + useTabInfo, + SessionProvider, + renderSlot, +}: SidebarChatTabProps) { + const { tab } = useTabInfo(); + const resource = useResource<"subagentchat">(tab.contentId); + return ( +
    + {resource.value === undefined ? null : ( + + {renderSlot("sidebar.chat.conversation", {})} + + )} +
    + ); +} + +/** + * Register the chat resource owner and its right-Sidebar presentation. + * @param ctx - Client root carrying Sessions, resources, Slots, and Sidebar registries. + * @param t - Chat namespace translator used for fallback tab titles. + */ +export function registerSidebarChat(ctx: Context, t: TranslateNS): void { + ctx.effect( + () => ctx.resources.register(subagentChatResourceProvider(ctx.sessions)), + "ui-subagent: Sidebar chat resources", + ); + ctx.effect( + () => + ctx.sidebarRightTabs.register({ + id: SUBAGENT_CHAT_ID, + kind: "subagentchat", + patterns: [`${SUBAGENT_CHAT_ADDRESS}**`], + priority: "builtin", + canOpen: (address) => parseSubagentChatAddress(address) !== undefined, + title: (address) => { + const child = parseSubagentChatAddress(address)?.childSessionId; + return child === undefined + ? t("sidebar.chat") + : (ctx.sessions.list.getSnapshot().byId[child]?.projectionValues?.subagent?.label ?? + child); + }, + } satisfies SidebarRightTabDefinition), + "ui-subagent: Sidebar chat type", + ); + ctx.effect( + () => + ctx.slots.inject("sidebar.right.pane.tab", () => + ctx.slots.register( + { + name: "sidebar.right.pane.tab", + key: SUBAGENT_CHAT_ID, + children: { "sidebar.chat.conversation": { kind: "single", scope: "session" } }, + }, + SidebarChatTab, + ), + ), + "ui-subagent: Sidebar chat body", + ); + ctx.effect( + () => + ctx.slots.inject("sidebar.chat.conversation", () => + ctx.slots.register( + { + name: "sidebar.chat.conversation", + }, + ConversationSlotPanel, + ), + ), + "ui-subagent: Sidebar Conversation", + ); +} diff --git a/apps/web-ui/packages/client/ui-subagent/src/css-modules.d.ts b/apps/web-ui/packages/client/ui-subagent/src/css-modules.d.ts new file mode 100644 index 000000000..b284bf80b --- /dev/null +++ b/apps/web-ui/packages/client/ui-subagent/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module "*.module.css" { + const classes: Record; + export default classes; +} + +declare module "*.css"; diff --git a/apps/web-ui/packages/client/ui-subagent/src/index.ts b/apps/web-ui/packages/client/ui-subagent/src/index.ts new file mode 100644 index 000000000..bf3ea9967 --- /dev/null +++ b/apps/web-ui/packages/client/ui-subagent/src/index.ts @@ -0,0 +1,9 @@ +/** + * Subagent reference plugin, node half. Pure UI plugin: the empty apply + * exists so the plugin appears in the host cordis.yml / Loader; the browser + * half ships via exports["./client"], discovered through the package.json + * dsh.client declaration. + */ + +/** Host plugin body — no host-side behavior for this source plugin. */ +export function apply(): void {} diff --git a/apps/web-ui/packages/client/ui-subagent/tsdown.config.ts b/apps/web-ui/packages/client/ui-subagent/tsdown.config.ts new file mode 100644 index 000000000..b08702038 --- /dev/null +++ b/apps/web-ui/packages/client/ui-subagent/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from "../tsdown.client.ts"; + +export default clientBundle("@deepseek-ai/dsh-client-ui-subagent", ["lib/types/index.js"]); diff --git a/apps/web-ui/packages/client/ui-theme/package.json b/apps/web-ui/packages/client/ui-theme/package.json new file mode 100644 index 000000000..dd260c054 --- /dev/null +++ b/apps/web-ui/packages/client/ui-theme/package.json @@ -0,0 +1,40 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-theme", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./client": { + "types": "./src/client/index.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json", + "./brand-font.css": "./src/styles/brand-font.css" + }, + "dependencies": { + "@deepseek-ai/schemastery": "*", + "@deepseek-ai/dsh-client-store": "*", + "@deepseek-ai/dsh-client-ui-primitives": "*", + "react": "^18.2.0", + "clsx": "^2.0.0" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-api-remotes" + ], + "platform": "web", + "immediately": true + } + } +} diff --git a/apps/web-ui/packages/client/ui-theme/src/boot-theme.ts b/apps/web-ui/packages/client/ui-theme/src/boot-theme.ts new file mode 100644 index 000000000..6aaf1a6c4 --- /dev/null +++ b/apps/web-ui/packages/client/ui-theme/src/boot-theme.ts @@ -0,0 +1,53 @@ +/** + * Theme bootstrap row for the browser's pre-plugin interval. Each index + * render embeds the current durable built-in preference and content font size. + * Head CSS colors the document canvas before script execution; the body script + * installs the palette selector and font size that the client presenters adopt. + */ + +import type { IndexInjection } from "@deepseek-ai/dsh-host-webserver"; +import { DEFAULT_FONT_SIZE, DEFAULT_PREFERENCE, type ThemePreference } from "./theme-settings.ts"; + +const LIGHT_BACKGROUND = "#fff"; +const DARK_BACKGROUND = "#151517"; + +/** CSS that colors the document canvas before any script executes. */ +function bootThemeStyle(preference: ThemePreference): string { + const light = `:root{color-scheme:light}body{background-color:${LIGHT_BACKGROUND};--dsh-boot-bg:${LIGHT_BACKGROUND}}`; + const dark = `:root{color-scheme:dark}body{background-color:${DARK_BACKGROUND};--dsh-boot-bg:${DARK_BACKGROUND}}`; + if (preference === "light") return light; + if (preference === "dark") return dark; + return `${light}@media(prefers-color-scheme:dark){${dark}}`; +} + +/** Build the body script that installs the palette selector and content size. */ +function bootThemeBodyScript(preference: ThemePreference, fontSize: number): string { + return `(() => { + const preference = ${JSON.stringify(preference)} + const systemDark = preference === 'system' + && typeof matchMedia !== 'undefined' + && matchMedia('(prefers-color-scheme: dark)').matches + const dark = preference === 'dark' || systemDark + document.documentElement.dataset.dsThemeSource = preference + document.body.toggleAttribute('data-ds-dark-theme', dark) + document.body.style.setProperty('--dsh-content-font-size', ${JSON.stringify(`${fontSize}px`)}) +})()`; +} + +/** + * Theme bootstrap rows: head CSS colors the document canvas before + * first paint, then the body script installs the palette selector and font + * size before the shell mount and module script. + * @param preference - Current Host-backed built-in preference. + * @param fontSize - Current Host-backed content font size in px. + * @returns head and body script rows in execution order. + */ +export function bootThemeInjections( + preference: ThemePreference = DEFAULT_PREFERENCE, + fontSize: number = DEFAULT_FONT_SIZE, +): IndexInjection[] { + return [ + { kind: "style", text: bootThemeStyle(preference) }, + { kind: "script", placement: "body", text: bootThemeBodyScript(preference, fontSize) }, + ]; +} diff --git a/apps/web-ui/packages/client/ui-theme/src/client/AppearanceRow.module.css b/apps/web-ui/packages/client/ui-theme/src/client/AppearanceRow.module.css new file mode 100644 index 000000000..8f5da5985 --- /dev/null +++ b/apps/web-ui/packages/client/ui-theme/src/client/AppearanceRow.module.css @@ -0,0 +1,57 @@ +/* Appearance row (figma 'Frame 2117131228': title + cube row, column gap 8, + * pad 16/0, hairline separator; the section column strips it when last). */ + +.group { + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px 0; + border-bottom: 0.5px solid var(--dsw-alias-border-l2); +} + +.title { + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.cubeRow { + display: flex; + align-items: stretch; + gap: 8px; + flex-wrap: wrap; +} + +/* Appearance cube (figma '.Selector Cube' 276x82, pad 20/32, centered + * icon-over-label column, gap 4); flexed down from the figma width so all + * three sit on one row in the 800 panel, wrapping when narrower. */ +.themeCube { + box-sizing: border-box; + flex: 1 1 180px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + padding: 20px 32px; + border: 0.5px solid var(--dsw-alias-border-l4); + border-radius: var(--dsw-radius-xl); + background: transparent; + font: inherit; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +.themeCube:hover:not(.selected) { + background: var(--dsw-alias-interactive-bg-hover); +} + +/* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token — the bluish-400 + * step has no alias-layer name). */ +.selected { + background: var(--dsw-alias-bg-module-platform); + border-color: var(--dsw-static-neutral-bluish-400); +} diff --git a/apps/web-ui/packages/client/ui-theme/src/client/AppearanceRow.tsx b/apps/web-ui/packages/client/ui-theme/src/client/AppearanceRow.tsx new file mode 100644 index 000000000..99645932c --- /dev/null +++ b/apps/web-ui/packages/client/ui-theme/src/client/AppearanceRow.tsx @@ -0,0 +1,72 @@ +/** + * Appearance preference row registered into the General section item slot + * (figma 501:30012 'Frame 2117131228'): title + three preference cubes. + * Registered by this package — the theme feature owns its own settings + * surface. Selection follows the persisted preference, never the resolved + * active theme. + */ +import clsx from "clsx"; +import { + IconDarkOutlineMedium, + IconFollowsystemOutlineMedium, + IconLightOutlineMedium, +} from "@deepseek-ai/dsh-client-ui-primitives"; +import type { PropsLocale, PropsRuntime, PropsStore } from "@deepseek-ai/dsh-client-ui-slots"; +import type { ThemePreference } from "../theme-settings.ts"; +import type { ThemeKey } from "./locales.ts"; +import type {} from "@deepseek-ai/dsh-client-ui-settings/client"; +import type { createAppearanceRowStore } from "./settings-store.ts"; +import css from "./AppearanceRow.module.css"; + +/** Injected business face: the preference write (t rides the standard locale seat). */ +export interface AppearanceRowInjected { + /** Switch the theme preference. */ + setTheme: (id: ThemePreference) => void; +} + +/** Full component props: runtime share + store share + locale seat + injected face. */ +export type AppearanceRowComponentProps = PropsRuntime<"settings.general.item"> & + PropsStore> & + PropsLocale<"settings.theme"> & + AppearanceRowInjected; + +/** Cube order and icons (figma 501:30015-30017: Light, Dark, System). */ +const CUBES: readonly { + id: ThemePreference; + labelKey: ThemeKey; + Icon: typeof IconLightOutlineMedium; +}[] = [ + { id: "light", labelKey: "appearance.light", Icon: IconLightOutlineMedium }, + { id: "dark", labelKey: "appearance.dark", Icon: IconDarkOutlineMedium }, + { id: "system", labelKey: "appearance.system", Icon: IconFollowsystemOutlineMedium }, +]; + +/** + * Render the Appearance row. + * @param props - composed slot props. + * @returns the row element tree. + */ +export function AppearanceRow({ t, setTheme, useStore }: AppearanceRowComponentProps) { + const preference = useStore((s) => s.preference); + return ( +
    +
    {t("appearance.title")}
    +
    + {CUBES.map(({ id, labelKey, Icon }) => ( + + ))} +
    +
    + ); +} diff --git a/apps/web-ui/packages/client/ui-theme/src/client/FontSizeRow.module.css b/apps/web-ui/packages/client/ui-theme/src/client/FontSizeRow.module.css new file mode 100644 index 000000000..f3e66754e --- /dev/null +++ b/apps/web-ui/packages/client/ui-theme/src/client/FontSizeRow.module.css @@ -0,0 +1,111 @@ +/* Font-size row: title + stepper pill, same cell rhythm as the Language and + * Enter-behavior rows (gap 8, pad 16/0, hairline separator; the section + * column strips the separator on its last child). */ + +.row { + display: flex; + align-items: center; + gap: 8px; + padding: 16px 0; + border-bottom: 0.5px solid var(--dsw-alias-border-l2); +} + +.rowText { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + padding-right: 48px; +} + +.title { + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.desc { + font-size: 12px; + font-weight: 400; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); +} + +/* Standard-size stepper with an always-visible arrow column and an external unit label. */ +.control { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.stepper { + position: relative; /* the arrow column anchors to the pill's right edge */ + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 72px; + height: 36px; + border-radius: var(--dsw-radius-md); + background: var(--dsw-alias-bg-module-platform); +} + +.value { + min-width: 18px; + text-align: center; + font-size: 14px; + line-height: 22px; + font-variant-numeric: tabular-nums; + color: var(--dsw-alias-label-primary); +} + +.unit { + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-secondary); +} + +/* Arrow column: anchored right so the value stays pill-centered whether or + not the arrows are showing; revealed on hover/focus-within. Hidden with + opacity, not visibility: the buttons stay in tab order and in the + accessibility tree, so keyboard focus itself triggers the reveal. */ +.arrows { + position: absolute; + right: 8px; + display: flex; + flex-direction: column; + gap: 2px; + opacity: 0; +} + +.stepper:hover .arrows, +.stepper:focus-within .arrows { + opacity: 1; +} + +/* Arrow buttons: small chips carrying a translucent wash of the layer-1 + surface (white in light, the elevated layer in dark) over the module fill; + hover raises the full layer-1 chip. */ +.arrow { + display: inline-flex; + align-items: center; + justify-content: center; + width: 17px; + height: 12px; + padding: 0; + border: none; + border-radius: var(--dsw-radius-xs); + background: color-mix(in srgb, var(--dsw-alias-bg-layer-1) 75%, transparent); + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +.arrow:hover:not(:disabled) { + background: var(--dsw-alias-bg-layer-1); +} + +.arrow:disabled { + color: var(--dsw-alias-label-caption); + cursor: default; +} diff --git a/apps/web-ui/packages/client/ui-theme/src/client/FontSizeRow.tsx b/apps/web-ui/packages/client/ui-theme/src/client/FontSizeRow.tsx new file mode 100644 index 000000000..bba5a13bc --- /dev/null +++ b/apps/web-ui/packages/client/ui-theme/src/client/FontSizeRow.tsx @@ -0,0 +1,77 @@ +/** + * Font-size preference row registered into the General section item slot: + * title + body-text-only description + stepper pill (centered value; hover + * reveals the up/down arrow column anchored to the pill's right edge) + a px + * unit label after the pill. Registered by this package — the theme feature + * owns the content font-size setting the same way it owns the appearance + * preference. The displayed value follows the persisted setting, never the + * click echo. + */ +import { + IconChevronDownOutlineRegular, + IconChevronUpOutlineRegular, +} from "@deepseek-ai/dsh-client-ui-primitives"; +import type { PropsLocale, PropsRuntime, PropsStore } from "@deepseek-ai/dsh-client-ui-slots"; +import { FONT_SIZE_MAX, FONT_SIZE_MIN } from "../theme-settings.ts"; +import type {} from "@deepseek-ai/dsh-client-ui-settings/client"; +import type { createFontSizeRowStore } from "./settings-store.ts"; +import css from "./FontSizeRow.module.css"; + +/** Injected business face: the preference write (t rides the standard locale seat). */ +export interface FontSizeRowInjected { + /** Change the content font size (integer px within FONT_SIZE_MIN..FONT_SIZE_MAX). */ + setFontSize: (px: number) => void; +} + +/** Full component props: runtime share + store share + locale seat + injected face. */ +export type FontSizeRowComponentProps = PropsRuntime<"settings.general.item"> & + PropsStore> & + PropsLocale<"settings.theme"> & + FontSizeRowInjected; + +/** + * Render the font-size row. + * @param props - composed slot props. + * @returns the row element tree. + */ +export function FontSizeRow({ t, setFontSize, useStore }: FontSizeRowComponentProps) { + const fontSize = useStore((s) => s.fontSize); + return ( +
    +
    +
    {t("fontSize.title")}
    +
    {t("fontSize.description")}
    +
    +
    +
    + {fontSize} + + + + +
    + {t("fontSize.unit")} +
    +
    + ); +} diff --git a/apps/web-ui/packages/client/ui-theme/src/client/index.ts b/apps/web-ui/packages/client/ui-theme/src/client/index.ts new file mode 100644 index 000000000..1c5e314bf --- /dev/null +++ b/apps/web-ui/packages/client/ui-theme/src/client/index.ts @@ -0,0 +1,605 @@ +/** + * Browser theme registry over the `--dsw-*` token stylesheets. The service + * owns the live theme preference (light/dark/system), resolves `system` through + * `prefers-color-scheme`, and publishes immutable snapshots; it never touches + * the DOM — ui-layout's presenter consumes the resolved snapshot. The Host + * settings scope loads and stores the preference in the user-settings + * document. The plugin also registers the Appearance preference row into the + * settings General section — the theme feature owns its own settings surface. + */ +import type { Context as ClientContext } from "@deepseek-ai/cordis"; +import type { BoundActions } from "@deepseek-ai/dsh-client-ui-slots"; +// Type-only: the ctx.configForms Context merge. Cross-plugin collaboration +// goes through the service, never a value import (client bundle purity gate). +import type { ConfigForm } from "@deepseek-ai/dsh-client-ui-settings/client"; +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from "@deepseek-ai/dsh-client-locale/client"; +// Type-only: pulls the SlotRegistry service merge (ctx.slots). +import type {} from "@deepseek-ai/dsh-client-ui-renderer/client"; +import type { AppearanceRowInjected } from "./AppearanceRow.tsx"; +import { AppearanceRow } from "./AppearanceRow.tsx"; +import type { FontSizeRowInjected } from "./FontSizeRow.tsx"; +import { FontSizeRow } from "./FontSizeRow.tsx"; +import { createAppearanceRowStore, createFontSizeRowStore } from "./settings-store.ts"; +import { installThemeStyles } from "./styles.ts"; +import { en, zh, type ThemeKey } from "./locales.ts"; +import { + DEFAULT_FONT_SIZE, + DEFAULT_PREFERENCE, + FONT_SIZE_FIELD, + FONT_SIZE_MAX, + FONT_SIZE_MIN, + isThemePreference, + THEME_PREFERENCE_FIELD, + THEME_SETTINGS_NAMESPACE, + type ThemePreference, + type ThemeSettings, +} from "../theme-settings.ts"; + +export type { AppearanceRowComponentProps, AppearanceRowInjected } from "./AppearanceRow.tsx"; +export type { FontSizeRowComponentProps, FontSizeRowInjected } from "./FontSizeRow.tsx"; +export type { AppearanceRowState, FontSizeRowState } from "./settings-store.ts"; +export type { ThemeKey } from "./locales.ts"; +export type { ThemePreference, ThemeSettings } from "../theme-settings.ts"; + +/** Namespace owning this feature's settings-row copy. */ +export const SETTINGS_NS = "settings.theme"; + +declare module "@deepseek-ai/dsh-client-ui-slots" { + interface LocaleNamespaceMap { + /** The Appearance settings row's copy. */ + "settings.theme": ThemeKey; + } +} + +/** Theme token dictionary: --dsw-alias-* overrides keyed by variable name. */ +export type ThemeTokens = Record; + +/** + * One override-layer token value: both palette modes are mandatory (repeat + * the same value when the token is scheme-invariant) so an override never + * goes illegible when the user switches to the other scheme. + */ +export interface ThemeTokenModes { + /** Value applied while the light base palette is active. */ + light: string; + /** Value applied while the dark base palette is active. */ + dark: string; +} + +/** Override-layer dictionary: token names to per-mode value pairs. */ +export type ThemeTokenOverrides = Record; + +/** One selectable theme: id, dark/light semantics, and alias-token overrides. */ +export interface ThemeDefinition { + /** Theme id (the setTheme argument for concrete themes). */ + id: string; + /** + * Which base palette this theme builds on. The presenter switches + * `body[data-ds-dark-theme]` from this field — never from the id. + */ + colorScheme: "light" | "dark"; + /** Alias-layer overrides applied as inline CSS variables over the base palette. */ + tokens: ThemeTokens; +} + +/** Immutable theme state published on every change. */ +export interface ThemeSnapshot { + /** The persisted preference (may be `system`). */ + preference: ThemePreference; + /** Conversation content font size in px (integer within FONT_SIZE_MIN..FONT_SIZE_MAX). */ + fontSize: number; + /** + * The resolved active theme (`system` resolved via prefers-color-scheme) + * with override layers folded into its tokens (seq order, later layers win + * per-token; each value picked for the active color scheme). + */ + active: ThemeDefinition; + /** Registered themes in registration order. */ + themes: readonly ThemeDefinition[]; + /** Monotonic change counter (registry or active changes). */ + revision: number; +} + +/** One theme token exposed to pre-definition Cordis inspection. */ +export interface ThemeTokenInspection { + /** Token name accepted by {@link ThemeRuntime.overrideTokens}. */ + name: string; + /** Intended visual role. */ + description: string; + /** CSS value category. */ + valueType: string; + /** Whether override layers must supply both palette modes. */ + requiresLightAndDark: boolean; + /** CSS custom property consumed by UI styles. */ + cssVariable?: string; +} + +declare module "@deepseek-ai/cordis" { + interface Context { + theme: ThemeRuntime; + } + interface Events { + /** + * Theme state changed (preference switched, registry updated, or the OS + * color scheme changed while the preference is `system`). + * @param snapshot - Current immutable theme snapshot. + * @mode emit + */ + "theme/change"(snapshot: ThemeSnapshot): void; + } +} + +const BUILTIN_THEMES: readonly ThemeDefinition[] = Object.freeze([ + Object.freeze({ id: "light", colorScheme: "light" as const, tokens: Object.freeze({}) }), + Object.freeze({ id: "dark", colorScheme: "dark" as const, tokens: Object.freeze({}) }), +]); + +const BUILTIN_INSPECT_TOKENS: readonly ThemeTokenInspection[] = Object.freeze([ + { + name: "--dsw-alias-bg-base", + description: "Application base background.", + valueType: "CSS color", + requiresLightAndDark: true, + cssVariable: "--dsw-alias-bg-base", + }, + { + name: "--dsw-alias-bg-layer-1", + description: "Primary raised surface background.", + valueType: "CSS color", + requiresLightAndDark: true, + cssVariable: "--dsw-alias-bg-layer-1", + }, + { + name: "--dsw-alias-bg-layer-2", + description: "Secondary nested surface background.", + valueType: "CSS color", + requiresLightAndDark: true, + cssVariable: "--dsw-alias-bg-layer-2", + }, + { + name: "--dsw-alias-bg-overlay", + description: "Overlay and popover background.", + valueType: "CSS color", + requiresLightAndDark: true, + cssVariable: "--dsw-alias-bg-overlay", + }, + { + name: "--dsw-alias-border-l1", + description: "Primary subtle border.", + valueType: "CSS color", + requiresLightAndDark: true, + cssVariable: "--dsw-alias-border-l1", + }, + { + name: "--dsw-alias-border-l2", + description: "Secondary stronger border.", + valueType: "CSS color", + requiresLightAndDark: true, + cssVariable: "--dsw-alias-border-l2", + }, + { + name: "--dsw-alias-brand-primary", + description: "Primary brand accent.", + valueType: "CSS color", + requiresLightAndDark: true, + cssVariable: "--dsw-alias-brand-primary", + }, + { + name: "--dsw-alias-label-primary", + description: "Primary text color.", + valueType: "CSS color", + requiresLightAndDark: true, + cssVariable: "--dsw-alias-label-primary", + }, + { + name: "--dsw-alias-label-secondary", + description: "Secondary text color.", + valueType: "CSS color", + requiresLightAndDark: true, + cssVariable: "--dsw-alias-label-secondary", + }, + { + name: "--dsw-alias-state-error-primary", + description: "Primary error state color.", + valueType: "CSS color", + requiresLightAndDark: true, + cssVariable: "--dsw-alias-state-error-primary", + }, + { + name: "--dsw-alias-state-idle-primary", + description: "Primary inactive state color.", + valueType: "CSS color", + requiresLightAndDark: true, + cssVariable: "--dsw-alias-state-idle-primary", + }, + { + name: "--dsw-alias-state-success-primary", + description: "Primary success state color.", + valueType: "CSS color", + requiresLightAndDark: true, + cssVariable: "--dsw-alias-state-success-primary", + }, + { + name: "--dsw-alias-state-warn-primary", + description: "Primary warning state color.", + valueType: "CSS color", + requiresLightAndDark: true, + cssVariable: "--dsw-alias-state-warn-primary", + }, + { + name: "--dsw-specific-sidebar-fill", + description: "Sidebar column and title-row background.", + valueType: "CSS color", + requiresLightAndDark: true, + cssVariable: "--dsw-specific-sidebar-fill", + }, +]); + +/** + * Theme registry and preference owner. `light`/`dark` are built in (the base + * stylesheets carry both palettes); third-party themes register alias-layer + * overrides. Reads go through {@link getTheme}; preference writes only + * through {@link setTheme}; continuous sync only through the `theme/change` + * event. {@link overrideTokens} stacks partial token layers over the active + * theme without touching the registry. + * The service holds the `prefers-color-scheme` media query (environment + * sensing, not presentation) and re-emits when the OS scheme flips while the + * preference is `system`. + */ +export class ThemeRuntime { + private readonly ctx: ClientContext; + private readonly host: ConfigForm; + private themes: ThemeDefinition[] = [...BUILTIN_THEMES]; + private preference: ThemePreference; + private fontSize: number = bootstrapFontSize(); + private revision = 0; + private snapshot: ThemeSnapshot; + private readonly media: MediaQueryList | undefined; + /** Override layers by source; seq (monotonic) is the stacking order. */ + private readonly overrides = new Map(); + private overrideSeq = 0; + + /** + * @param ctx - owning context (change events are emitted on it; the + * media-query and scope listeners are released through ctx.effect on dispose). + * @param host - durable preference scope owned by the same plugin. + */ + constructor(ctx: ClientContext, host: ConfigForm) { + this.ctx = ctx; + this.host = host; + this.preference = DEFAULT_PREFERENCE; + // Non-browser runs (node e2e booting the client tree) have no matchMedia. + this.media = + typeof matchMedia === "undefined" ? undefined : matchMedia("(prefers-color-scheme: dark)"); + this.snapshot = this.buildSnapshot(); + if (this.media !== undefined) { + const media = this.media; + const onChange = (): void => { + if (this.preference !== "system") return; + this.publish(); + }; + ctx.effect(() => { + media.addEventListener("change", onChange); + return () => { + media.removeEventListener("change", onChange); + }; + }, "ui-theme: prefers-color-scheme listener"); + } + ctx.effect( + () => + host.subscribe(() => { + this.adopt(); + }), + "ui-theme: settings scope adoption", + ); + this.adopt(); + } + + /** + * Read the current immutable theme snapshot. + * @returns the current snapshot (stable reference until the next change). + */ + getTheme(): ThemeSnapshot { + return this.snapshot; + } + + /** + * Export the current token directory without reading DOM or computed styles. + * @returns stable JSON-safe token descriptions, including registered and override-only names. + */ + exportInspectTokens(): ThemeTokenInspection[] { + const tokens = new Map(BUILTIN_INSPECT_TOKENS.map((token) => [token.name, token])); + for (const theme of this.themes) { + for (const name of Object.keys(theme.tokens)) { + if (!tokens.has(name)) tokens.set(name, dynamicToken(name)); + } + } + for (const layer of this.overrides.values()) { + for (const name of Object.keys(layer.tokens)) { + if (!tokens.has(name)) tokens.set(name, dynamicToken(name)); + } + } + return [...tokens.values()] + .map((token) => ({ ...token })) + .sort((left, right) => left.name.localeCompare(right.name)); + } + + /** + * Switch the theme preference — the only user preference write entry. + * Built-in preferences are written through the settings scope and every + * accepted value emits `theme/change`. + * @param id - a registered theme id or `system`; unknown ids throw. + */ + setTheme(id: string): void { + if (id !== "system" && !this.themes.some((t) => t.id === id)) { + throw new Error(`theme "${id}" is not registered`); + } + if (this.preference === id) return; + this.preference = id as ThemePreference; + if (isThemePreference(id)) void this.host.set(THEME_PREFERENCE_FIELD, id); + this.publish(); + } + + /** + * Change the conversation content font size — the only font-size write + * entry. Accepted values are written through the settings scope and emit + * `theme/change`. + * @param px - integer px within FONT_SIZE_MIN..FONT_SIZE_MAX; out-of-range or fractional values throw. + */ + setFontSize(px: number): void { + if (!Number.isInteger(px) || px < FONT_SIZE_MIN || px > FONT_SIZE_MAX) { + throw new Error(`font size ${px} is outside ${FONT_SIZE_MIN}..${FONT_SIZE_MAX}`); + } + if (this.fontSize === px) return; + this.fontSize = px; + void this.host.set(FONT_SIZE_FIELD, px); + this.publish(); + } + + /** Adopt the scope's accepted durable preference without writing it back. */ + private adopt(): void { + const section = this.host.getSnapshot().value; + if (section === undefined) return; + if (this.preference === section.preference && this.fontSize === section.fontSize) return; + this.preference = section.preference; + this.fontSize = section.fontSize; + this.publish(); + } + + /** + * Register a theme. Duplicate id throws (single occupant per id; the + * built-in pair counts; `system` is a preference, not a registrable id). + * @param definition - theme id, colorScheme, and alias-token overrides. + * @returns disposer. Disposing the theme backing the active preference + * resets the preference to the default so the UI never keeps tokens of an + * unregistered theme. + */ + register(definition: ThemeDefinition): () => void { + if (definition.id === "system") + throw new Error('"system" is a preference, not a registrable theme id'); + if (this.themes.some((t) => t.id === definition.id)) { + throw new Error(`theme "${definition.id}" is already registered`); + } + this.themes = [...this.themes, definition]; + this.publish(); + return () => { + if (!this.themes.some((t) => t.id === definition.id)) return; + this.themes = this.themes.filter((t) => t.id !== definition.id); + if (this.preference === definition.id) { + this.preference = DEFAULT_PREFERENCE; + } + this.publish(); + }; + } + + /** + * Stack a token override layer on top of the active theme — the token-level + * analogue of slot shading: the base theme stays untouched, layers compose + * in seq order with later layers winning per-token, and removing a layer + * restores whatever it covered. Calling again with the same source replaces + * that source's whole layer and restacks it on top (effect re-registration + * semantics). Emits `theme/change` with the recomposed snapshot. + * @param source - layer identity; one layer per source (dynamic packages + * pass their package id — the façade pins it, so it also names the layer's + * origin for inspection). + * @param tokens - token-name → `{ light, dark }` value pairs. Validated at + * runtime (model-authored callers reach this boundary with untyped JS); + * a bare string value throws a teaching error. + * @returns disposer removing exactly the layer this call created; a no-op + * once the source has re-overridden (the newer layer is not torn down). + */ + overrideTokens(source: string, tokens: ThemeTokenOverrides): () => void { + const layer = { seq: this.overrideSeq++, tokens: validateOverrides(source, tokens) }; + this.overrides.set(source, layer); + this.publish(); + return () => { + if (this.overrides.get(source) !== layer) return; + this.overrides.delete(source); + this.publish(); + }; + } + + private buildSnapshot(): ThemeSnapshot { + const resolvedId = + this.preference === "system" + ? this.media?.matches === true + ? "dark" + : "light" + : this.preference; + // Both built-ins always exist; a registered preference id resolves or has + // been reset by its disposer, so the lookup cannot miss. + const active = this.themes.find((t) => t.id === resolvedId); + /* v8 ignore next 2 -- needs a registry without light/dark, which register()/dispose() cannot produce */ + if (active === undefined) throw new Error(`theme registry lost "${resolvedId}"`); + return Object.freeze({ + preference: this.preference, + fontSize: this.fontSize, + active: this.composeActive(active), + themes: Object.freeze([...this.themes]), + revision: this.revision, + }); + } + + /** + * Fold the override layers into the active definition: seq order, later + * layers win per-token, each value picked for the active color scheme (the + * presenter consumes the composed snapshot and needs no override awareness). + * Without layers the registered definition passes through by identity. + */ + private composeActive(active: ThemeDefinition): ThemeDefinition { + if (this.overrides.size === 0) return active; + const tokens: ThemeTokens = { ...active.tokens }; + for (const layer of [...this.overrides.values()].sort((a, b) => a.seq - b.seq)) { + for (const [name, modes] of Object.entries(layer.tokens)) { + tokens[name] = modes[active.colorScheme]; + } + } + return Object.freeze({ ...active, tokens: Object.freeze(tokens) }); + } + + private publish(): void { + this.revision += 1; + this.snapshot = this.buildSnapshot(); + this.ctx.emit("theme/change", this.snapshot); + } +} + +/** + * Read the font size the Host boot script wrote on `body` before any plugin + * ran, so the initial snapshot matches first paint and ui-layout's presenter + * does not flash the schema default while the settings read is in flight. + * Non-browser runs and mounts without the boot script fall back to the + * schema default; the durable settings adoption still lands afterwards. + */ +function bootstrapFontSize(): number { + /* v8 ignore next -- needs a documentless run (node e2e booting the client tree), not constructible under jsdom */ + if (typeof document === "undefined") return DEFAULT_FONT_SIZE; + const raw = document.body.style.getPropertyValue("--dsh-content-font-size"); + const parsed = Number.parseInt(raw, 10); + return Number.isInteger(parsed) && parsed >= FONT_SIZE_MIN && parsed <= FONT_SIZE_MAX + ? parsed + : DEFAULT_FONT_SIZE; +} + +/** + * Runtime shape check for one override layer (model-authored callers pass + * untyped JS through the dynamic-package façade, so the static type cannot + * enforce the pair shape there). Returns a defensive per-token copy so later + * caller mutation cannot reach the stored layer. + */ function validateOverrides(source: string, tokens: ThemeTokenOverrides): ThemeTokenOverrides { + const validated: ThemeTokenOverrides = {}; + for (const [name, value] of Object.entries(tokens)) { + if (typeof value === "string") { + throw new TypeError( + `theme override "${name}" from "${source}" is a bare string — pass { light: ${JSON.stringify(value)}, dark: ${JSON.stringify(value)} } ` + + "(repeat the value when it is the same in both palettes); a single value goes illegible when the user switches color scheme", + ); + } + if ( + typeof value !== "object" || + value === null || + typeof (value as { light?: unknown }).light !== "string" || + typeof (value as { dark?: unknown }).dark !== "string" + ) { + throw new TypeError( + `theme override "${name}" from "${source}" must map to a { light, dark } pair of strings — one value per color scheme`, + ); + } + const modes = value as ThemeTokenModes; + validated[name] = { light: modes.light, dark: modes.dark }; + } + return validated; +} + +function dynamicToken(name: string): ThemeTokenInspection { + return { + name, + description: "Theme token registered by the current Client composition.", + valueType: "CSS value", + requiresLightAndDark: true, + ...(name.startsWith("--") ? { cssVariable: name } : {}), + }; +} + +/** + * Required services: settings transport plus slots/locale for the Appearance + * row. `remote` carries the forwarded settings invalidation that + * `ctx.configForms.get(entryId)` subscribes to on this context. + */ +export const inject = ["slots", "locale", "remote", "configForms"]; + +/** + * Client plugin body: provide the theme service and register the + * feature-owned Appearance preference row into the General section's item + * slot (a feature owns its settings surface). + * @param ctx - client cordis context. + */ +export function apply(ctx: ClientContext): void { + installThemeStyles(ctx); + const host = ctx.configForms.get(THEME_SETTINGS_NAMESPACE); + const theme = new ThemeRuntime(ctx, host); + ctx.provide("theme", theme); + + ctx.effect( + () => ctx.locale.register(SETTINGS_NS, { zh, en }), + "ui-theme: settings row dictionaries", + ); + + const store = createAppearanceRowStore(); + let bound: BoundActions | undefined; + const fontSizeStore = createFontSizeRowStore(); + let fontSizeBound: BoundActions | undefined; + const sync = (snapshot: ThemeSnapshot): void => { + bound?.sync(snapshot.preference, snapshot.revision); + fontSizeBound?.sync(snapshot.fontSize, snapshot.revision); + }; + ctx.on("theme/change", sync); + const injected = (actions: BoundActions): AppearanceRowInjected => { + bound = actions; + // Re-sync from the getter so no event is lost between registration and + // first render (the store's revision guard drops stale duplicates). + sync(theme.getTheme()); + return { + setTheme: (id) => { + theme.setTheme(id); + }, + }; + }; + ctx.slots.inject("settings.general.item", () => + ctx.slots.register( + { + name: "settings.general.item", + id: "appearance", + order: 10, + store, + locale: SETTINGS_NS, + inject: injected, + }, + AppearanceRow, + ), + ); + + const fontSizeInjected = (actions: BoundActions): FontSizeRowInjected => { + fontSizeBound = actions; + sync(theme.getTheme()); + return { + setFontSize: (px) => { + theme.setFontSize(px); + }, + }; + }; + ctx.slots.inject("settings.general.item", () => + ctx.slots.register( + { + name: "settings.general.item", + id: "font-size", + order: 11, + store: fontSizeStore, + locale: SETTINGS_NS, + inject: fontSizeInjected, + }, + FontSizeRow, + ), + ); +} diff --git a/apps/web-ui/packages/client/ui-theme/src/client/locales.ts b/apps/web-ui/packages/client/ui-theme/src/client/locales.ts new file mode 100644 index 000000000..781ee295a --- /dev/null +++ b/apps/web-ui/packages/client/ui-theme/src/client/locales.ts @@ -0,0 +1,30 @@ +/** `settings.theme` namespace dictionaries (the Appearance and font-size rows' copy). */ + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + "appearance.title": "外观", + "appearance.light": "浅色", + "appearance.dark": "深色", + "appearance.system": "跟随系统", + "fontSize.title": "字号大小", + "fontSize.description": "仅影响会话内容的字号", + "fontSize.unit": "px", + "fontSize.increase": "增大字号", + "fontSize.decrease": "减小字号", +} satisfies Record; + +/** The settings.theme namespace key union. */ +export type ThemeKey = keyof typeof zh; + +/** English dictionary, checked complete against the zh key set. */ +export const en = { + "appearance.title": "Appearance", + "appearance.light": "Light", + "appearance.dark": "Dark", + "appearance.system": "System", + "fontSize.title": "Font size", + "fontSize.description": "Only affects conversation content", + "fontSize.unit": "px", + "fontSize.increase": "Increase font size", + "fontSize.decrease": "Decrease font size", +} satisfies Record; diff --git a/apps/web-ui/packages/client/ui-theme/src/client/settings-store.ts b/apps/web-ui/packages/client/ui-theme/src/client/settings-store.ts new file mode 100644 index 000000000..e6e1e2235 --- /dev/null +++ b/apps/web-ui/packages/client/ui-theme/src/client/settings-store.ts @@ -0,0 +1,70 @@ +/** + * Appearance and font-size row slot stores: mirrors of the theme service + * snapshot. The plugin's apply-world change listener is the only writer; the + * row components read via props.useStore. + */ +import { defineStore, type EngineStoreHandle } from "@deepseek-ai/dsh-client-store"; +import { DEFAULT_FONT_SIZE, type ThemePreference } from "../theme-settings.ts"; + +/** Store state mirrored from the theme snapshot. */ +export interface AppearanceRowState { + /** Persisted preference (selection state reads this, never the resolved active theme). */ + preference: ThemePreference; + /** Service revision; -1 until first sync so revision 0 lands as a change. */ + revision: number; +} + +/** Declared action shape giving the exported factory a stable return type. */ +type AppearanceRowActions = { + sync: (draft: AppearanceRowState, preference: ThemePreference, revision: number) => void; +}; + +/** + * Declares the Appearance row state and write surface. + * @returns the store handle. + */ +export function createAppearanceRowStore(): EngineStoreHandle< + AppearanceRowState, + AppearanceRowActions +> { + return defineStore({ + init: (): AppearanceRowState => ({ preference: "system", revision: -1 }), + actions: { + sync: (d, preference: ThemePreference, revision: number) => { + if (revision <= d.revision) return; + d.preference = preference; + d.revision = revision; + }, + }, + }); +} + +/** Store state mirrored from the theme snapshot's font size. */ +export interface FontSizeRowState { + /** Persisted content font size in px. */ + fontSize: number; + /** Service revision; -1 until first sync so revision 0 lands as a change. */ + revision: number; +} + +/** Declared action shape giving the exported factory a stable return type. */ +type FontSizeRowActions = { + sync: (draft: FontSizeRowState, fontSize: number, revision: number) => void; +}; + +/** + * Declares the font-size row state and write surface. + * @returns the store handle. + */ +export function createFontSizeRowStore(): EngineStoreHandle { + return defineStore({ + init: (): FontSizeRowState => ({ fontSize: DEFAULT_FONT_SIZE, revision: -1 }), + actions: { + sync: (d, fontSize: number, revision: number) => { + if (revision <= d.revision) return; + d.fontSize = fontSize; + d.revision = revision; + }, + }, + }); +} diff --git a/apps/web-ui/packages/client/ui-theme/src/client/styles.ts b/apps/web-ui/packages/client/ui-theme/src/client/styles.ts new file mode 100644 index 000000000..5cb4b60a0 --- /dev/null +++ b/apps/web-ui/packages/client/ui-theme/src/client/styles.ts @@ -0,0 +1,42 @@ +import type { Context } from "@deepseek-ai/cordis"; +import base from "../styles/base.css?inline"; +import cornerShape from "../styles/corner-shape.css?inline"; +import designPlatform from "../styles/design-platform.css?inline"; +import focus from "../styles/focus.css?inline"; +import onboarding from "../styles/onboarding.css?inline"; +import scrollbar from "../styles/scrollbar.css?inline"; +import gradientShadowText from "../styles/gradient-shadow-text.css?inline"; +import shiki from "../styles/shiki.css?inline"; + +const PLUGIN_ID = "@deepseek-ai/dsh-client-ui-theme"; + +const STYLES = [ + ["base.css", base], + ["corner-shape.css", cornerShape], + ["design-platform.css", designPlatform], + ["focus.css", focus], + ["onboarding.css", onboarding], + ["scrollbar.css", scrollbar], + ["gradient-shadow-text.css", gradientShadowText], + ["shiki.css", shiki], +] as const; + +/** + * Mount the global theme sheets for exactly the owning plugin lifetime. + * @param ctx - Owning plugin context. + */ +export function installThemeStyles(ctx: Context): void { + if (typeof document === "undefined") return; + for (const [name, css] of STYLES) { + ctx.effect(() => { + const tag = document.createElement("style"); + tag.dataset.plugin = PLUGIN_ID; + tag.dataset.pluginCss = `${PLUGIN_ID}/${name}`; + tag.textContent = css; + document.head.appendChild(tag); + return () => { + tag.remove(); + }; + }, `ui-theme: ${name} stylesheet`); + } +} diff --git a/apps/web-ui/packages/client/ui-theme/src/css-modules.d.ts b/apps/web-ui/packages/client/ui-theme/src/css-modules.d.ts new file mode 100644 index 000000000..0ae38c1df --- /dev/null +++ b/apps/web-ui/packages/client/ui-theme/src/css-modules.d.ts @@ -0,0 +1,11 @@ +declare module "*.module.css" { + const classes: Record; + export default classes; +} + +declare module "*.css"; + +declare module "*.css?inline" { + const css: string; + export default css; +} diff --git a/apps/web-ui/packages/client/ui-theme/src/index.ts b/apps/web-ui/packages/client/ui-theme/src/index.ts new file mode 100644 index 000000000..36d59917f --- /dev/null +++ b/apps/web-ui/packages/client/ui-theme/src/index.ts @@ -0,0 +1,70 @@ +/** Host registration for the browser theme preference and pre-plugin palette. */ +import type {} from "@deepseek-ai/dsh-settings"; + +import type { Volatile } from "@deepseek-ai/cordis"; +import type { ThemePreference } from "./theme-settings.ts"; +import z from "@deepseek-ai/schemastery"; + +import type { Context } from "@deepseek-ai/cordis"; +import type {} from "@deepseek-ai/dsh-host-webserver"; +import { bootThemeInjections } from "./boot-theme.ts"; +import { + DEFAULT_FONT_SIZE, + DEFAULT_PREFERENCE, + FONT_SIZE_MIN, + FONT_SIZE_MAX, + THEME_PREFERENCES, +} from "./theme-settings.ts"; + +export { + DEFAULT_FONT_SIZE, + DEFAULT_PREFERENCE, + FONT_SIZE_FIELD, + FONT_SIZE_MAX, + FONT_SIZE_MIN, + THEME_PREFERENCE_FIELD, + THEME_PREFERENCES, + THEME_SETTINGS_NAMESPACE, + type ThemePreference, + type ThemeSettings, +} from "./theme-settings.ts"; + +/** Runtime preferences projected to the browser. */ +export interface Config { + /** Browser palette preference. */ + preference: Volatile; + /** Browser font size in pixels. */ + fontSize: Volatile; +} + +/** Live theme and typography preferences. */ +export const Config = z.object({ + preference: z + .union([...THEME_PREFERENCES]) + .default(DEFAULT_PREFERENCE) + .volatile(), + fontSize: z + .number() + .step(1) + .min(FONT_SIZE_MIN) + .max(FONT_SIZE_MAX) + .default(DEFAULT_FONT_SIZE) + .volatile(), +}); + +/** Supply the current palette before browser plugins start. + * @param ctx Host plugin context. + * @param config Validated live theme preferences. + */ +export function apply(ctx: Context, config: Config): void { + ctx.inject(["settings"], (child) => { + child.effect(() => child.settings.configure({ auto: false }, ctx.fiber)); + }); + ctx.on( + "webserver/index-inject", + (table) => { + table.push(...bootThemeInjections(config.preference.get(), config.fontSize.get())); + }, + { prepend: true }, + ); +} diff --git a/apps/web-ui/packages/client/ui-theme/src/styles/Montserrat-OFL.txt b/apps/web-ui/packages/client/ui-theme/src/styles/Montserrat-OFL.txt new file mode 100644 index 000000000..3344bfd46 --- /dev/null +++ b/apps/web-ui/packages/client/ui-theme/src/styles/Montserrat-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2024 The Montserrat.Git Project Authors (https://github.com/JulietaUla/Montserrat.git) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/web-ui/packages/client/ui-theme/src/styles/base.css b/apps/web-ui/packages/client/ui-theme/src/styles/base.css new file mode 100644 index 000000000..8cbe29dd0 --- /dev/null +++ b/apps/web-ui/packages/client/ui-theme/src/styles/base.css @@ -0,0 +1,34 @@ +/* Base variables referenced by the token sheets and component CSS but defined + * upstream (deepsuite theme/global.css) — supplied here so the composite + * --dsw-font-* variables resolve and motion rides the upstream curve. Code + * font stack deliberately omits a bare `monospace` tail (Windows CJK falls + * back to SimSun otherwise). */ +:root { + --dsw-font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", + "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif; + --dsw-font-family-brand: "Montserrat", var(--dsw-font-family); + --ds-font-family-code: + "SF Mono", "JetBrains Mono", "Fira Code", Consolas, "Liberation Mono", Menlo, Courier, + "PingFang SC", "Microsoft YaHei"; + --ds-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + --ds-transition-duration: 0.2s; + --ds-transition-duration-fast: 0.1s; + --ds-transition-duration-slow: 0.3s; + --dsw-radius-xs: 4px; + --dsw-radius-sm: 8px; + --dsw-radius-md: 12px; + --dsw-radius-lg: 16px; + --dsw-radius-xl: 20px; + --dsw-radius-panel: 28px; +} + +body { + --dsw-alias-settings-card-fill: var(--dsw-alias-bg-layer-2); + --dsw-alias-settings-card-stroke: var(--dsw-alias-border-l4); +} + +[data-dsh-automatic-focus]:focus, +[data-dsh-automatic-focus]:focus:focus-visible { + outline: none; +} diff --git a/apps/web-ui/packages/client/ui-theme/src/styles/brand-font.css b/apps/web-ui/packages/client/ui-theme/src/styles/brand-font.css new file mode 100644 index 000000000..a89f448c5 --- /dev/null +++ b/apps/web-ui/packages/client/ui-theme/src/styles/brand-font.css @@ -0,0 +1,24 @@ +/* Montserrat; SIL Open Font License in Montserrat-OFL.txt. */ +@font-face { + font-family: "Montserrat"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(./montserrat-regular.woff2) format("woff2"); +} + +@font-face { + font-family: "Montserrat"; + font-style: normal; + font-weight: 300; + font-display: swap; + src: url(./montserrat-light.woff2) format("woff2"); +} + +@font-face { + font-family: "Montserrat"; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(./montserrat-medium.woff2) format("woff2"); +} diff --git a/apps/web-ui/packages/client/ui-theme/src/styles/corner-shape.css b/apps/web-ui/packages/client/ui-theme/src/styles/corner-shape.css new file mode 100644 index 000000000..70197aa6a --- /dev/null +++ b/apps/web-ui/packages/client/ui-theme/src/styles/corner-shape.css @@ -0,0 +1,26 @@ +/* Smooth corner curvature for every rounded surface. Inside the support + * guard, all corners curve along superellipse(1.5) — between a circular arc + * (round = superellipse(1)) and a squircle (superellipse(2)) — instead of the + * default arc; engines without corner-shape keep circular corners because the + * token and the rule exist only inside the guard. + * + * corner-shape does not inherit, so the universal selector applies the token + * to elements and their ::before/::after; other pseudo-elements (for example + * the ::-webkit-scrollbar parts) stay circular. + * + * Full-round shapes opt out where they are declared: a superellipse deforms a + * circle into a squircle and squares off capsule ends, so every effectively + * uncapped radius (50%, 100%, or a pill radius far above the box size) pairs + * `corner-shape: round` with its `border-radius` in the owning component + * sheet. The corner-shape stylesheet spec enforces that pairing. */ +@supports (corner-shape: superellipse(1.5)) { + :root { + --dsw-corner-shape: superellipse(1.5); + } + + *, + *::before, + *::after { + corner-shape: var(--dsw-corner-shape); + } +} diff --git a/apps/web-ui/packages/client/ui-theme/src/styles/design-platform.css b/apps/web-ui/packages/client/ui-theme/src/styles/design-platform.css new file mode 100644 index 000000000..d171493a1 --- /dev/null +++ b/apps/web-ui/packages/client/ui-theme/src/styles/design-platform.css @@ -0,0 +1,432 @@ +/* Figma font-weight 510 (an SF Pro variable-font weight) always renders as + font-weight: 500 in this UI — non-variable webfonts snap intermediate + weights unpredictably across platforms. */ +body { + --dsw-static-amber-100: rgb(254, 245, 231); + --dsw-static-amber-400: rgb(247, 173, 49); + --dsw-static-amber-500: rgb(245, 158, 11); + --dsw-static-amber-600: rgb(221, 134, 41); + --dsw-static-amber-900: rgb(39, 36, 31); + --dsw-static-blue-100: rgb(219, 234, 254); + --dsw-static-blue-300: rgb(147, 197, 253); + --dsw-static-blue-400: rgb(96, 165, 250); + --dsw-static-blue-450: rgb(77, 147, 248); + --dsw-static-blue-500: rgb(59, 130, 246); + --dsw-static-blue-50: rgb(239, 246, 255); + --dsw-static-blue-50p: rgb(234, 243, 255); + --dsw-static-blue-600: rgb(37, 99, 235); + --dsw-static-blue-75: rgb(229, 240, 255); + --dsw-static-blue-800: rgb(30, 64, 175); + --dsw-static-blue-900: rgb(14, 48, 116); + --dsw-static-blue-950: rgb(23, 37, 84); + --dsw-static-deepseek-100: rgb(228, 237, 253); + --dsw-static-deepseek-200: rgb(211, 226, 255); + --dsw-static-deepseek-300: rgb(183, 200, 254); + /* Brightened from the platform rgb(103, 158, 254) at the designer's request: legibility on the dark toast surface. */ + --dsw-static-deepseek-400: rgb(122, 170, 255); + --dsw-static-deepseek-450: rgb(86, 134, 254); + --dsw-static-deepseek-500: rgb(65, 118, 230); + --dsw-static-deepseek-50: rgb(237, 243, 254); + --dsw-static-deepseek-600: rgb(72, 104, 178); + --dsw-static-deepseek-700-delete: rgb(47, 76, 143); + --dsw-static-deepseek-800: rgb(52, 65, 91); + --dsw-static-deepseek-900: rgb(40, 49, 66); + --dsw-static-green-100: rgb(230, 250, 237); + --dsw-static-green-400: rgb(78, 209, 126); + --dsw-static-green-500: rgb(34, 197, 94); + --dsw-static-green-500-a08: rgb(34 197 94 / 8%); + --dsw-static-green-500-a12: rgb(34 197 94 / 12%); + --dsw-static-green-900: rgb(35, 60, 44); + --dsw-static-neutral-00: rgb(255, 255, 255); + --dsw-static-neutral-1000: rgb(0, 0, 0); + --dsw-static-neutral-100: rgb(245, 245, 245); + --dsw-static-neutral-150: rgb(237, 237, 237); + --dsw-static-neutral-200: rgb(229, 229, 229); + --dsw-static-neutral-250: rgb(220, 220, 220); + --dsw-static-neutral-300: rgb(212, 212, 212); + --dsw-static-neutral-400: rgb(162, 164, 166); + --dsw-static-neutral-500: rgb(127, 130, 135); + --dsw-static-neutral-50: rgb(250, 250, 250); + --dsw-static-neutral-550: rgb(101, 103, 107); + --dsw-static-neutral-600: rgb(84, 85, 87); + --dsw-static-neutral-700: rgb(60, 60, 61); + --dsw-static-neutral-800: rgb(41, 41, 41); + --dsw-static-neutral-850: rgb(33, 33, 35); + --dsw-static-neutral-900: rgb(15, 15, 15); + --dsw-static-neutral-bluish-00: rgb(255, 255, 255); + --dsw-static-neutral-bluish-1000: rgb(15, 17, 21); + --dsw-static-neutral-bluish-100: rgb(235, 238, 242); + --dsw-static-neutral-bluish-150: rgb(233, 236, 242); + --dsw-static-neutral-bluish-200: rgb(225, 229, 238); + --dsw-static-neutral-bluish-300: rgb(207, 211, 214); + --dsw-static-neutral-bluish-400: rgb(173, 178, 184); + --dsw-static-neutral-bluish-500: rgb(151, 157, 166); + --dsw-static-neutral-bluish-50: rgb(249, 250, 251); + --dsw-static-neutral-bluish-600: rgb(129, 133, 140); + --dsw-static-neutral-bluish-60: rgb(245, 246, 247); + --dsw-static-neutral-bluish-700: rgb(97, 102, 107); + --dsw-static-neutral-bluish-750: rgb(67, 69, 74); + --dsw-static-neutral-bluish-75: rgb(241, 243, 245); + --dsw-static-neutral-bluish-800: rgb(53, 54, 56); + --dsw-static-neutral-bluish-850: rgb(44, 44, 46); + --dsw-static-neutral-bluish-875: rgb(35, 35, 36); + --dsw-static-neutral-bluish-900: rgb(27, 27, 28); + --dsw-static-neutral-bluish-950: rgb(21, 21, 23); + --dsw-static-red-100: rgb(254, 226, 226); + --dsw-static-red-400: rgb(242, 90, 90); + --dsw-static-red-400-a12: rgb(242 90 90 / 12%); + --dsw-static-red-500: rgb(239, 68, 68); + --dsw-static-red-50: rgb(254, 242, 242); + --dsw-static-red-600: rgb(236, 19, 19); + --dsw-static-red-600-a08: rgb(236 19 19 / 8%); + --dsw-static-red-900: rgb(87, 12, 12); +} + +body[data-ds-dark-theme] { + --dsw-static-amber-100: rgb(254, 245, 231); + --dsw-static-amber-400: rgb(247, 173, 49); + --dsw-static-amber-500: rgb(245, 158, 11); + --dsw-static-amber-600: rgb(221, 134, 41); + --dsw-static-amber-900: rgb(39, 36, 31); + --dsw-static-blue-100: rgb(219, 234, 254); + --dsw-static-blue-300: rgb(147, 197, 253); + --dsw-static-blue-400: rgb(96, 165, 250); + --dsw-static-blue-450: rgb(77, 147, 248); + --dsw-static-blue-500: rgb(59, 130, 246); + --dsw-static-blue-50: rgb(239, 246, 255); + --dsw-static-blue-50p: rgb(234, 243, 255); + --dsw-static-blue-600: rgb(37, 99, 235); + --dsw-static-blue-75: rgb(229, 240, 255); + --dsw-static-blue-800: rgb(30, 64, 175); + --dsw-static-blue-900: rgb(14, 48, 116); + --dsw-static-blue-950: rgb(23, 37, 84); + --dsw-static-deepseek-100: rgb(228, 237, 253); + --dsw-static-deepseek-200: rgb(211, 226, 255); + --dsw-static-deepseek-300: rgb(183, 200, 254); + /* Brightened from the platform rgb(103, 158, 254) at the designer's request: legibility on the dark toast surface. */ + --dsw-static-deepseek-400: rgb(122, 170, 255); + --dsw-static-deepseek-450: rgb(86, 134, 254); + --dsw-static-deepseek-500: rgb(65, 118, 230); + --dsw-static-deepseek-50: rgb(237, 243, 254); + --dsw-static-deepseek-600: rgb(72, 104, 178); + --dsw-static-deepseek-700-delete: rgb(47, 76, 143); + --dsw-static-deepseek-800: rgb(52, 65, 91); + --dsw-static-deepseek-900: rgb(40, 49, 66); + --dsw-static-green-100: rgb(230, 250, 237); + --dsw-static-green-400: rgb(78, 209, 126); + --dsw-static-green-500: rgb(34, 197, 94); + --dsw-static-green-500-a08: rgb(34 197 94 / 8%); + --dsw-static-green-500-a12: rgb(34 197 94 / 12%); + --dsw-static-green-900: rgb(35, 60, 44); + --dsw-static-neutral-00: rgb(255, 255, 255); + --dsw-static-neutral-1000: rgb(0, 0, 0); + --dsw-static-neutral-100: rgb(245, 245, 245); + --dsw-static-neutral-150: rgb(237, 237, 237); + --dsw-static-neutral-200: rgb(229, 229, 229); + --dsw-static-neutral-250: rgb(220, 220, 220); + --dsw-static-neutral-300: rgb(212, 212, 212); + --dsw-static-neutral-400: rgb(162, 164, 166); + --dsw-static-neutral-500: rgb(127, 130, 135); + --dsw-static-neutral-50: rgb(250, 250, 250); + --dsw-static-neutral-550: rgb(101, 103, 107); + --dsw-static-neutral-600: rgb(84, 85, 87); + --dsw-static-neutral-700: rgb(60, 60, 61); + --dsw-static-neutral-800: rgb(41, 41, 41); + --dsw-static-neutral-850: rgb(33, 33, 35); + --dsw-static-neutral-900: rgb(15, 15, 15); + --dsw-static-neutral-bluish-00: rgb(255, 255, 255); + --dsw-static-neutral-bluish-1000: rgb(15, 17, 21); + --dsw-static-neutral-bluish-100: rgb(235, 238, 242); + --dsw-static-neutral-bluish-150: rgb(233, 236, 242); + --dsw-static-neutral-bluish-200: rgb(225, 229, 238); + --dsw-static-neutral-bluish-300: rgb(207, 211, 214); + --dsw-static-neutral-bluish-400: rgb(173, 178, 184); + --dsw-static-neutral-bluish-500: rgb(151, 157, 166); + --dsw-static-neutral-bluish-50: rgb(249, 250, 251); + --dsw-static-neutral-bluish-600: rgb(129, 133, 140); + --dsw-static-neutral-bluish-60: rgb(249, 250, 251); + --dsw-static-neutral-bluish-700: rgb(97, 102, 107); + --dsw-static-neutral-bluish-750: rgb(67, 69, 74); + --dsw-static-neutral-bluish-75: rgb(241, 243, 245); + --dsw-static-neutral-bluish-800: rgb(53, 54, 56); + --dsw-static-neutral-bluish-850: rgb(44, 44, 46); + --dsw-static-neutral-bluish-875: rgb(35, 35, 36); + --dsw-static-neutral-bluish-900: rgb(27, 27, 28); + --dsw-static-neutral-bluish-950: rgb(21, 21, 23); + --dsw-static-red-100: rgb(254, 226, 226); + --dsw-static-red-400: rgb(242, 90, 90); + --dsw-static-red-400-a12: rgb(242 90 90 / 12%); + --dsw-static-red-500: rgb(239, 68, 68); + --dsw-static-red-50: rgb(254, 242, 242); + --dsw-static-red-600: rgb(236, 19, 19); + --dsw-static-red-600-a08: rgb(236 19 19 / 8%); + --dsw-static-red-900: rgb(87, 12, 12); +} + +body { + --dsw-alias-bg-base: var(--dsw-static-neutral-bluish-00); + --dsw-alias-bg-document-preview: var(--dsw-static-neutral-bluish-100); + --dsw-alias-bg-document-selection: color-mix( + in srgb, + var(--dsw-static-blue-500) 40%, + transparent + ); + --dsw-alias-label-document-preview: var(--dsw-static-neutral-bluish-700); + --dsw-alias-bg-layer-1: var(--dsw-static-neutral-bluish-00); + --dsw-alias-bg-layer-2: var(--dsw-static-neutral-bluish-00); + --dsw-alias-bg-layer-3: var(--dsw-static-neutral-bluish-00); + --dsw-alias-bg-mask-1: rgba(0, 0, 0, 0.24); + --dsw-alias-bg-mask-2: rgba(0, 0, 0, 0.12); + --dsw-alias-bg-mask-3: rgba(0, 0, 0, 0.48); + --dsw-alias-bg-mask-photo: rgba(0, 0, 0, 0.88); + --dsw-alias-bg-mask-drop: rgba(255, 255, 255, 0.7); + --dsw-alias-bg-module-platform: var(--dsw-static-neutral-bluish-60); + --dsw-alias-bg-multi-select: var(--dsw-static-neutral-bluish-60); + --dsw-alias-bg-overlay: var(--dsw-static-neutral-bluish-150); + --dsw-alias-bg-skeleton: rgba(0, 0, 0, 0.04); + --dsw-alias-border-inverted2: rgba(0, 0, 0, 0); + --dsw-alias-border-inverted: rgba(0, 0, 0, 0); + --dsw-alias-border-l1: rgba(0, 0, 0, 0.04); + --dsw-alias-border-l2-darkmode-thin: rgba(0, 0, 0, 0.1); + --dsw-alias-border-l2: rgba(0, 0, 0, 0.1); + --dsw-alias-border-l3: rgba(0, 0, 0, 0.12); + --dsw-alias-border-l4: rgba(0, 0, 0, 0.16); + --dsw-alias-brand-primary-invert: var(--dsw-static-neutral-bluish-1000); + --dsw-alias-brand-primary-new-colorprimary-new-color: rgb(65, 118, 230); + --dsw-alias-brand-primary: var(--dsw-static-neutral-bluish-1000); + --dsw-alias-brand-text: var(--dsw-static-neutral-bluish-1000); + --dsw-alias-button-contrast-fill: var(--dsw-static-neutral-bluish-700); + --dsw-alias-button-elevated-fill: var(--dsw-static-neutral-bluish-00); + --dsw-alias-button-floating-fill: var(--dsw-static-neutral-bluish-00); + --dsw-alias-button-floating-hover: var(--dsw-static-neutral-bluish-75); + --dsw-alias-button-ghost-active-border: var(--dsw-static-neutral-bluish-500); + --dsw-alias-button-ghost-active-fill: var(--dsw-static-neutral-bluish-100); + --dsw-alias-button-ghost-active-hover: var(--dsw-static-neutral-bluish-150); + --dsw-alias-button-info-fill: var(--dsw-static-deepseek-500); + --dsw-alias-button-info-hover: var(--dsw-static-deepseek-400); + --dsw-alias-button-primary-dimmed: var(--dsw-static-neutral-bluish-100); + --dsw-alias-button-primary-fill: var(--dsw-alias-brand-primary); + --dsw-alias-button-primary-hover: var(--dsw-static-neutral-bluish-750); + --dsw-alias-button-tool-bar-fill-invisible: rgba(31, 31, 31, 0.36); + --dsw-alias-button-tool-bar-fill: rgba(84, 85, 87, 0.5); + --dsw-alias-button-tool-bar-hover: rgba(84, 85, 87, 0.6); + --dsw-alias-interactive-bg-active: rgba(38, 49, 72, 0.1); + --dsw-alias-interactive-bg-hover-accent: rgba(38, 49, 72, 0.14); + --dsw-alias-interactive-bg-hover-danger: rgba(236, 19, 19, 0.05); + --dsw-alias-interactive-bg-hover-solid: var(--dsw-static-neutral-bluish-75); + --dsw-alias-interactive-bg-hover: rgba(38, 49, 72, 0.06); + --dsw-alias-label-caption: var(--dsw-static-neutral-bluish-400); + --dsw-alias-label-deep-diving: color-mix( + in srgb, + var(--dsw-static-deepseek-500) 70%, + var(--dsw-static-blue-950) + ); + --dsw-alias-label-deep-diving-shimmer: color-mix( + in srgb, + var(--dsw-static-deepseek-500) 30%, + var(--dsw-static-blue-950) + ); + --dsw-alias-label-dimmed: var(--dsw-static-neutral-bluish-200); + --dsw-alias-label-primary-bluish: var(--dsw-static-blue-900); + --dsw-alias-label-primary-dimmed: var(--dsw-static-neutral-bluish-950); + --dsw-alias-label-primary-foreground: var(--dsw-static-neutral-bluish-00); + --dsw-alias-label-primary-inverted: var(--dsw-static-neutral-bluish-00); + --dsw-alias-label-primary: var(--dsw-static-neutral-bluish-1000); + --dsw-alias-label-secondary: var(--dsw-static-neutral-bluish-700); + --dsw-alias-label-shimmer: color-mix(in srgb, var(--dsw-static-neutral-1000) 30%, transparent); + --dsw-alias-label-tertiary: var(--dsw-static-neutral-bluish-600); + --dsw-alias-menu-icon: var(--dsw-static-neutral-bluish-800); + --dsw-alias-link: var(--dsw-static-deepseek-500); + --dsw-alias-markdown-citation: var(--dsw-static-neutral-bluish-100); + --dsw-alias-markdown-code-block-banner: var(--dsw-static-neutral-bluish-50); + --dsw-alias-markdown-code-block: var(--dsw-static-neutral-bluish-50); + --dsw-alias-markdown-code-segment-selected: var(--dsw-static-neutral-bluish-00); + --dsw-alias-markdown-code-segment-unselected: var(--dsw-static-neutral-bluish-75); + --dsw-alias-markdown-inline-code: var(--dsw-static-neutral-50); + --dsw-alias-markdown-placeholder: var(--dsw-static-neutral-bluish-60); + --dsw-alias-markdown-tag: var(--dsw-static-neutral-bluish-75); + --dsw-alias-scrollbar-bg-l1: var(--dsw-static-neutral-200); + --dsw-alias-scrollbar-bg-l2: var(--dsw-static-neutral-200); + --dsw-alias-scrollbar-hover-l1: var(--dsw-static-neutral-300); + --dsw-alias-scrollbar-hover-l2: var(--dsw-static-neutral-300); + --dsw-alias-state-business-primary: var(--dsw-static-deepseek-500); + --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); + --dsw-alias-state-success-primary: var(--dsw-static-green-500); + --dsw-alias-state-success-secondary: var(--dsw-static-green-400); + --dsw-alias-state-success-tertiary: var(--dsw-static-green-100); + --dsw-alias-state-warn-label: var(--dsw-static-amber-600); + --dsw-alias-state-warn-primary: var(--dsw-static-amber-500); + --dsw-alias-state-warn-secondary: var(--dsw-static-amber-400); + --dsw-alias-state-warn-tertiary: var(--dsw-static-amber-100); + --dsw-alias-switch-thumb: var(--dsw-static-neutral-bluish-00); + --dsw-alias-toast-bg: var(--dsw-static-neutral-bluish-800); + --dsw-alias-toast-label: var(--dsw-static-neutral-bluish-00); + --dsw-alias-tooltip-bg: var(--dsw-static-neutral-bluish-850); + --dsw-alias-tooltip-key-bg: color-mix(in srgb, var(--dsw-alias-tooltip-bg), white 18%); + --dsw-alias-turn-trigger-bg: var(--dsw-alias-markdown-code-block); + --dsw-alias-turn-trigger-bg-hover: var(--dsw-alias-interactive-bg-hover); + --dsw-specific-bubble-highlight: var(--dsw-static-deepseek-200); + --dsw-specific-bubble: var(--dsw-static-deepseek-50); + --dsw-specific-input-major: var(--dsw-static-neutral-bluish-00); + --dsw-specific-login-input: var(--dsw-static-neutral-bluish-50); + --dsw-menu-surface-fill: rgba(248, 249, 250, 0.58); + --dsw-alias-menu-group-header-fill: rgba(248, 249, 250, 0.94); + --dsw-specific-menu: var(--dsw-menu-surface-fill); + --dsw-specific-selector: var(--dsw-static-neutral-bluish-60); + --dsw-specific-sidebar-fill: var(--dsw-static-neutral-bluish-50); + --dsw-specific-sidebar-nav-item-active-accent: var(--dsw-static-deepseek-100); + --dsw-specific-sidebar-nav-item-active: var(--dsw-static-neutral-bluish-100); + --dsw-specific-sidebar-nav-item-hover: var(--dsw-static-neutral-bluish-75); + --dsw-specific-tip: var(--dsw-static-neutral-bluish-60); + + background-color: var(--dsw-alias-bg-base); +} + +body[data-ds-dark-theme] { + --dsw-alias-bg-base: var(--dsw-static-neutral-bluish-950); + --dsw-alias-bg-document-preview: var(--dsw-static-neutral-bluish-950); + --dsw-alias-bg-document-selection: color-mix( + in srgb, + var(--dsw-static-blue-500) 40%, + transparent + ); + --dsw-alias-label-document-preview: var(--dsw-static-neutral-bluish-300); + --dsw-alias-bg-layer-1: var(--dsw-static-neutral-bluish-875); + --dsw-alias-bg-layer-2: var(--dsw-static-neutral-bluish-850); + --dsw-alias-bg-layer-3: var(--dsw-static-neutral-bluish-800); + --dsw-alias-bg-mask-1: rgba(0, 0, 0, 0.5); + --dsw-alias-bg-mask-2: rgba(0, 0, 0, 0.2); + --dsw-alias-bg-mask-3: rgba(0, 0, 0, 0.48); + --dsw-alias-bg-mask-photo: rgba(0, 0, 0, 0.88); + --dsw-alias-bg-mask-drop: rgba(39, 39, 48, 0.7); + --dsw-alias-bg-module-platform: var(--dsw-static-neutral-bluish-800); + --dsw-alias-bg-multi-select: var(--dsw-static-neutral-850); + --dsw-alias-bg-overlay: var(--dsw-static-neutral-bluish-700); + --dsw-alias-bg-skeleton: rgba(255, 255, 255, 0.08); + --dsw-alias-border-inverted2: rgba(255, 255, 255, 0.08); + --dsw-alias-border-inverted: rgba(255, 255, 255, 0.06); + --dsw-alias-border-l1: rgba(255, 255, 255, 0.06); + --dsw-alias-border-l2-darkmode-thin: rgba(255, 255, 255, 0.06); + --dsw-alias-border-l2: rgba(255, 255, 255, 0.12); + --dsw-alias-border-l3: rgba(255, 255, 255, 0.16); + --dsw-alias-border-l4: rgba(255, 255, 255, 0.2); + --dsw-alias-brand-primary-invert: var(--dsw-static-neutral-bluish-50); + --dsw-alias-brand-primary-new-colorprimary-new-color: var(--dsw-static-deepseek-450); + --dsw-alias-brand-primary: var(--dsw-static-neutral-bluish-50); + --dsw-alias-brand-text: var(--dsw-static-neutral-bluish-50); + --dsw-alias-button-contrast-fill: var(--dsw-static-neutral-bluish-50); + --dsw-alias-button-elevated-fill: var(--dsw-static-neutral-bluish-750); + --dsw-alias-button-floating-fill: var(--dsw-static-neutral-bluish-850); + --dsw-alias-button-floating-hover: var(--dsw-static-neutral-bluish-800); + --dsw-alias-button-ghost-active-border: var(--dsw-static-neutral-bluish-600); + --dsw-alias-button-ghost-active-fill: var(--dsw-static-neutral-bluish-750); + --dsw-alias-button-ghost-active-hover: var(--dsw-static-neutral-bluish-700); + --dsw-alias-button-info-fill: var(--dsw-static-deepseek-400); + --dsw-alias-button-info-hover: var(--dsw-static-deepseek-500); + --dsw-alias-button-primary-dimmed: var(--dsw-static-neutral-bluish-750); + --dsw-alias-button-primary-fill: var(--dsw-alias-brand-primary); + --dsw-alias-button-primary-hover: var(--dsw-static-neutral-bluish-100); + --dsw-alias-button-tool-bar-fill-invisible: rgba(31, 31, 31, 0.36); + --dsw-alias-button-tool-bar-fill: rgba(84, 85, 87, 0.5); + --dsw-alias-button-tool-bar-hover: rgba(84, 85, 87, 0.6); + --dsw-alias-interactive-bg-active: rgba(255, 255, 255, 0.14); + --dsw-alias-interactive-bg-hover-accent: rgba(255, 255, 255, 0.24); + --dsw-alias-interactive-bg-hover-danger: rgba(242, 90, 90, 0.15); + --dsw-alias-interactive-bg-hover-solid: var(--dsw-static-neutral-bluish-800); + --dsw-alias-interactive-bg-hover: rgba(255, 255, 255, 0.08); + --dsw-alias-label-caption: var(--dsw-static-neutral-bluish-600); + --dsw-alias-label-deep-diving: color-mix( + in srgb, + var(--dsw-static-deepseek-450) 55%, + var(--dsw-static-neutral-bluish-400) + ); + --dsw-alias-label-deep-diving-shimmer: color-mix( + in srgb, + var(--dsw-static-blue-300) 65%, + var(--dsw-static-deepseek-400) + ); + --dsw-alias-label-dimmed: var(--dsw-static-neutral-bluish-750); + --dsw-alias-label-primary-bluish: var(--dsw-static-neutral-bluish-50); + --dsw-alias-label-primary-dimmed: var(--dsw-static-neutral-bluish-100); + --dsw-alias-label-primary-foreground: var(--dsw-static-neutral-bluish-1000); + --dsw-alias-label-primary-inverted: var(--dsw-static-neutral-bluish-800); + --dsw-alias-label-primary: var(--dsw-static-neutral-bluish-50); + --dsw-alias-label-secondary: var(--dsw-static-neutral-bluish-300); + --dsw-alias-label-shimmer: color-mix(in srgb, var(--dsw-static-neutral-00) 45%, transparent); + --dsw-alias-label-tertiary: var(--dsw-static-neutral-bluish-400); + --dsw-alias-menu-icon: var(--dsw-alias-label-primary-dimmed); + --dsw-alias-link: var(--dsw-static-deepseek-400); + --dsw-alias-markdown-citation: var(--dsw-static-neutral-bluish-800); + --dsw-alias-markdown-code-block-banner: var(--dsw-static-neutral-bluish-850); + --dsw-alias-markdown-code-block: var(--dsw-static-neutral-bluish-900); + --dsw-alias-markdown-code-segment-selected: var(--dsw-static-neutral-bluish-800); + --dsw-alias-markdown-code-segment-unselected: var(--dsw-static-neutral-bluish-900); + --dsw-alias-markdown-inline-code: var(--dsw-static-neutral-800); + --dsw-alias-markdown-placeholder: var(--dsw-static-neutral-bluish-850); + --dsw-alias-markdown-tag: var(--dsw-static-neutral-bluish-850); + --dsw-alias-scrollbar-bg-l1: var(--dsw-static-neutral-700); + --dsw-alias-scrollbar-bg-l2: var(--dsw-static-neutral-600); + --dsw-alias-scrollbar-hover-l1: var(--dsw-static-neutral-600); + --dsw-alias-scrollbar-hover-l2: var(--dsw-static-neutral-550); + --dsw-alias-state-business-primary: var(--dsw-static-deepseek-400); + --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); + --dsw-alias-state-success-primary: var(--dsw-static-green-500); + --dsw-alias-state-success-secondary: var(--dsw-static-green-400); + --dsw-alias-state-success-tertiary: var(--dsw-static-green-900); + --dsw-alias-state-warn-label: var(--dsw-static-amber-600); + --dsw-alias-state-warn-primary: var(--dsw-static-amber-500); + --dsw-alias-state-warn-secondary: var(--dsw-static-amber-400); + --dsw-alias-state-warn-tertiary: var(--dsw-static-amber-900); + --dsw-alias-switch-thumb: var(--dsw-static-neutral-bluish-400); + --dsw-alias-toast-bg: var(--dsw-static-neutral-bluish-750); + --dsw-alias-toast-label: var(--dsw-static-neutral-bluish-00); + --dsw-alias-turn-trigger-bg: var(--dsw-alias-interactive-bg-hover); + --dsw-alias-turn-trigger-bg-hover: var(--dsw-alias-interactive-bg-active); + --dsw-alias-tooltip-bg: var(--dsw-static-neutral-bluish-750); + --dsw-alias-tooltip-key-bg: color-mix(in srgb, var(--dsw-alias-tooltip-bg), white 18%); + --dsw-specific-bubble-highlight: var(--dsw-static-neutral-bluish-750); + --dsw-specific-bubble: var(--dsw-static-neutral-bluish-850); + --dsw-specific-input-major: var(--dsw-static-neutral-bluish-850); + --dsw-specific-login-input: var(--dsw-static-neutral-bluish-900); + --dsw-menu-surface-fill: rgba(67, 69, 74, 0.45); + --dsw-alias-menu-group-header-fill: rgba(48, 49, 54, 0.94); + --dsw-specific-menu: var(--dsw-menu-surface-fill); + --dsw-specific-selector: var(--dsw-static-neutral-bluish-800); + --dsw-specific-sidebar-fill: var(--dsw-static-neutral-bluish-900); + --dsw-specific-sidebar-nav-item-active-accent: var(--dsw-static-neutral-bluish-800); + --dsw-specific-sidebar-nav-item-active: var(--dsw-static-neutral-bluish-750); + --dsw-specific-sidebar-nav-item-hover: var(--dsw-static-neutral-bluish-850); + --dsw-specific-tip: var(--dsw-static-neutral-bluish-800); +} + +/* Overlays without MenuSurface's opaque backing cannot blur page content over + * native vibrancy reliably, so their fill stays nearly opaque on macOS. */ +html[data-platform="darwin"] body { + --dsw-specific-menu: rgba(248, 249, 250, 0.94); +} + +html[data-platform="darwin"] body[data-ds-dark-theme] { + --dsw-specific-menu: rgba(48, 49, 54, 0.94); +} diff --git a/apps/web-ui/packages/client/ui-theme/src/styles/focus.css b/apps/web-ui/packages/client/ui-theme/src/styles/focus.css new file mode 100644 index 000000000..8e870e5c2 --- /dev/null +++ b/apps/web-ui/packages/client/ui-theme/src/styles/focus.css @@ -0,0 +1,21 @@ +/* Components own offsets; dense tables and toolbars may keep a 1px ring. */ +:root { + --dsw-focus-ring-width: 2px; +} + +/* The fallback names the colour and the standard width, never the style: a control that + * disables its outline keeps `none` and paints nothing. Without the width, Chromium's + * unnamed ring stayed at `auto 1px` while every declared ring used the standard geometry. + * Declared rings also read this color, including rings on descendants and pseudo-elements. */ +:focus-visible { + outline-color: var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-width: var(--dsw-focus-ring-width); +} + +/* ui-primitives/input-modality publishes the attribute. Editable controls keep + * their focus feedback on click; other controls reveal rings on keyboard + * navigation. Only ring paint is suppressed, not elevation or selected borders. */ +html[data-input-modality="pointer"] body :focus-visible:not(:read-write) { + --dsw-focus-ring-color: transparent; + outline-color: transparent; +} diff --git a/apps/web-ui/packages/client/ui-theme/src/styles/gradient-shadow-text.css b/apps/web-ui/packages/client/ui-theme/src/styles/gradient-shadow-text.css new file mode 100644 index 000000000..bda11dd27 --- /dev/null +++ b/apps/web-ui/packages/client/ui-theme/src/styles/gradient-shadow-text.css @@ -0,0 +1,291 @@ +body { + --dsw-linear-gradient-think: linear-gradient(180deg, #fff 20.19%, rgba(255, 255, 255, 0) 100%); + --dsw-linear-think-select: linear-gradient(180deg, #f5f6f7 20.19%, rgba(245, 246, 247, 0) 100%); + /* 阴影 */ + --dsw-shadow-lv1: 0 2px 4px 0 rgba(0, 0, 0, 0.05); + --dsw-shadow-lv1-blur: 0 4px 12px 0 rgba(0, 0, 0, 0.02); + --dsw-shadow-lv2: 0 4px 12px 0 rgba(0, 0, 0, 0.02), 0 2px 8px 0 rgba(0, 0, 0, 0.04); + --dsw-shadow-lv3: + 0 0 1px 0 rgba(0, 0, 0, 0.2), 0 0 4px 0 rgba(0, 0, 0, 0.02), 0 12px 32px 0 rgba(0, 0, 0, 0.08); + /* Elevation:浮层/面板/面型按钮的描边+投影三件套。0.5px 发丝描边画在 + box-shadow 里(组件配 border: 0,不占布局),叠两层极淡柔光;深色主题下 + 柔光几乎不可见,由描边承担分离。描边颜色经 --dsw-elevation-stroke-color + 间接,组件可重绑(菜单面重绑最浅的 l1、输入框重绑 l2,输入框的 + workspace-trigger 态置 transparent 只保留柔光)。soft 供输入框:更大 + 模糊、更低透明度的柔光。默认色只声明在 body 上,让表面的重绑沿继承 + 传给真正消费投影的后代。 */ + --dsw-elevation-stroke-color: var(--dsw-alias-border-l4); + /* Blocking masks dim the page; menu backgrounds retain their blur. */ + --dsw-mask-blur: none; + --dsw-menu-backdrop-filter: blur(40px) saturate(150%); +} + +body[data-ds-dark-theme] [data-menu-material] { + --dsw-elevation-stroke-color: var(--dsw-alias-border-l3); +} + +/* 派生 elevation 值逐元素声明而非从 body 继承:继承传下来的是在 body 上就 + 已替换完 var() 的值,组件重绑 --dsw-elevation-stroke-color 将无法进入 + var(--dsw-elevation-*);逐元素声明让每个元素按自己看到的描边色重新替换 + (同 scrollbar.css 对 --dsh-scrollbar-thumb 的处理)。 */ +body, +body * { + --dsw-elevation-stroke: 0 0 0 0.5px var(--dsw-elevation-stroke-color); + --dsw-elevation-panel: + var(--dsw-elevation-stroke), 0 3px 8px 0 rgba(0, 0, 0, 0.03), 0 0 16px 0 rgba(0, 0, 0, 0.02); + --dsw-elevation-prominent: + var(--dsw-elevation-stroke), 0 3px 8px 0 rgba(0, 0, 0, 0.04), 0 0 20px 0 rgba(0, 0, 0, 0.05); + --dsw-elevation-soft: + var(--dsw-elevation-stroke), 0 4px 16px 0 rgba(0, 0, 0, 0.03), 0 0 24px 0 rgba(0, 0, 0, 0.03); +} + +body[data-ds-dark-theme] { + --dsw-linear-gradient-think: linear-gradient(180deg, #151517 20.19%, rgba(21, 21, 23, 0) 100%); + --dsw-linear-think-select: linear-gradient(180deg, #232325 20.19%, rgba(35, 35, 37, 0) 100%); +} + +/* ---------- 字体 ---------- */ +/* 由 @deepseek-figma-plugin/custom-variable-name 插件导出 */ +/* Markdown 阶梯整体按 0.875(正文 16 → 14)等比缩放,四舍五入到整数像素; + 重新从 Figma 导出时需要保留这一缩放。 */ +/* Bootstrap and ThemePresenter set the integer content size on body (10–22px, + default 14px). Headings and body variants share its pixel delta. Secondary + text uses size − 1 through 14px, then size − 2: 10→9, 14→13, 15→13, 22→20. + max(13px, ...) selects the delta, not a font-size floor. Small and code + typography keep their fixed sizes. */ +body { + --dsh-content-font-delta: calc(var(--dsh-content-font-size, 14px) - 14px); + --dsh-content-font-size-secondary: min( + calc(var(--dsh-content-font-size, 14px) - 1px), + max(13px, calc(var(--dsh-content-font-size, 14px) - 2px)) + ); + --dsh-content-font-delta-secondary: calc(var(--dsh-content-font-size-secondary) - 13px); + + --dsw-font-markdown-h1: 700 calc(21px + var(--dsh-content-font-delta)) / + calc(30px + var(--dsh-content-font-delta)) var(--dsw-font-family); + --dsw-font-markdown-h1-font-family: var(--dsw-font-family); + --dsw-font-markdown-h1-font-weight: 700; + --dsw-font-markdown-h1-line-height: calc(30px + var(--dsh-content-font-delta)); + --dsw-font-markdown-h1-font-size: calc(21px + var(--dsh-content-font-delta)); + --dsw-font-markdown-h1-font-style: normal; + + --dsw-font-markdown-h2: 700 calc(19px + var(--dsh-content-font-delta)) / + calc(28px + var(--dsh-content-font-delta)) var(--dsw-font-family); + --dsw-font-markdown-h2-font-family: var(--dsw-font-family); + --dsw-font-markdown-h2-font-weight: 700; + --dsw-font-markdown-h2-line-height: calc(28px + var(--dsh-content-font-delta)); + --dsw-font-markdown-h2-font-size: calc(19px + var(--dsh-content-font-delta)); + --dsw-font-markdown-h2-font-style: normal; + + --dsw-font-markdown-h3: 700 calc(18px + var(--dsh-content-font-delta)) / + calc(26px + var(--dsh-content-font-delta)) var(--dsw-font-family); + --dsw-font-markdown-h3-font-family: var(--dsw-font-family); + --dsw-font-markdown-h3-font-weight: 700; + --dsw-font-markdown-h3-line-height: calc(26px + var(--dsh-content-font-delta)); + --dsw-font-markdown-h3-font-size: calc(18px + var(--dsh-content-font-delta)); + --dsw-font-markdown-h3-font-style: normal; + + --dsw-font-markdown-h4: 600 var(--dsh-content-font-size, 14px) / + calc(24px + var(--dsh-content-font-delta)) var(--dsw-font-family); + --dsw-font-markdown-h4-font-family: var(--dsw-font-family); + --dsw-font-markdown-h4-font-weight: 600; + --dsw-font-markdown-h4-line-height: calc(24px + var(--dsh-content-font-delta)); + --dsw-font-markdown-h4-font-size: var(--dsh-content-font-size, 14px); + --dsw-font-markdown-h4-font-style: normal; + + --dsw-font-markdown-base: var(--dsh-content-font-size, 14px) / + calc(24px + var(--dsh-content-font-delta)) var(--dsw-font-family); + --dsw-font-markdown-base-font-family: var(--dsw-font-family); + --dsw-font-markdown-base-font-weight: 400; + --dsw-font-markdown-base-line-height: calc(24px + var(--dsh-content-font-delta)); + --dsw-font-markdown-base-font-size: var(--dsh-content-font-size, 14px); + --dsw-font-markdown-base-font-style: normal; + + --dsw-font-markdown-base-strong: 600 var(--dsh-content-font-size, 14px) / + calc(24px + var(--dsh-content-font-delta)) var(--dsw-font-family); + --dsw-font-markdown-base-strong-font-family: var(--dsw-font-family); + --dsw-font-markdown-base-strong-font-weight: 600; + --dsw-font-markdown-base-strong-line-height: calc(24px + var(--dsh-content-font-delta)); + --dsw-font-markdown-base-strong-font-size: var(--dsh-content-font-size, 14px); + --dsw-font-markdown-base-strong-font-style: normal; + + --dsw-font-markdown-base-italic: italic var(--dsh-content-font-size, 14px) / + calc(24px + var(--dsh-content-font-delta)) var(--dsw-font-family); + --dsw-font-markdown-base-italic-font-family: var(--dsw-font-family); + --dsw-font-markdown-base-italic-font-weight: 400; + --dsw-font-markdown-base-italic-line-height: calc(24px + var(--dsh-content-font-delta)); + --dsw-font-markdown-base-italic-font-size: var(--dsh-content-font-size, 14px); + --dsw-font-markdown-base-italic-font-style: italic; + + --dsw-font-markdown-base-strong-italic: italic 600 var(--dsh-content-font-size, 14px) / + calc(24px + var(--dsh-content-font-delta)) var(--dsw-font-family); + --dsw-font-markdown-base-strong-italic-font-family: var(--dsw-font-family); + --dsw-font-markdown-base-strong-italic-font-weight: 600; + --dsw-font-markdown-base-strong-italic-line-height: calc(24px + var(--dsh-content-font-delta)); + --dsw-font-markdown-base-strong-italic-font-size: var(--dsh-content-font-size, 14px); + --dsw-font-markdown-base-strong-italic-font-style: italic; + + --dsw-font-markdown-table: var(--dsh-content-font-size-secondary, 13px)/calc( + 22px + var(--dsh-content-font-delta-secondary, 0px) + ) + var(--dsw-font-family); + --dsw-font-markdown-table-font-family: var(--dsw-font-family); + --dsw-font-markdown-table-font-weight: 400; + --dsw-font-markdown-table-line-height: calc(22px + var(--dsh-content-font-delta-secondary, 0px)); + --dsw-font-markdown-table-font-size: var(--dsh-content-font-size-secondary, 13px); + --dsw-font-markdown-table-font-style: normal; + + --dsw-font-markdown-table-head: 500 + var(--dsh-content-font-size-secondary, 13px)/calc( + 22px + var(--dsh-content-font-delta-secondary, 0px) + ) + var(--dsw-font-family); + --dsw-font-markdown-table-head-font-family: var(--dsw-font-family); + --dsw-font-markdown-table-head-font-weight: 500; + --dsw-font-markdown-table-head-line-height: calc( + 22px + var(--dsh-content-font-delta-secondary, 0px) + ); + --dsw-font-markdown-table-head-font-size: var(--dsh-content-font-size-secondary, 13px); + --dsw-font-markdown-table-head-font-style: normal; + + --dsw-font-markdown-small: 12px/20px var(--dsw-font-family); + --dsw-font-markdown-small-font-family: var(--dsw-font-family); + --dsw-font-markdown-small-font-weight: 400; + --dsw-font-markdown-small-line-height: 20px; + --dsw-font-markdown-small-font-size: 12px; + --dsw-font-markdown-small-font-style: normal; + + --dsw-font-markdown-small-strong: 600 12px/20px var(--dsw-font-family); + --dsw-font-markdown-small-strong-font-family: var(--dsw-font-family); + --dsw-font-markdown-small-strong-font-weight: 600; + --dsw-font-markdown-small-strong-line-height: 20px; + --dsw-font-markdown-small-strong-font-size: 12px; + --dsw-font-markdown-small-strong-font-style: normal; + + --dsw-font-markdown-small-italic: italic 12px/20px var(--dsw-font-family); + --dsw-font-markdown-small-italic-font-family: var(--dsw-font-family); + --dsw-font-markdown-small-italic-font-weight: 400; + --dsw-font-markdown-small-italic-line-height: 20px; + --dsw-font-markdown-small-italic-font-size: 12px; + --dsw-font-markdown-small-italic-font-style: italic; + + --dsw-font-markdown-small-strong-italic: italic 600 12px/20px var(--dsw-font-family); + --dsw-font-markdown-small-strong-italic-font-family: var(--dsw-font-family); + --dsw-font-markdown-small-strong-italic-font-weight: 600; + --dsw-font-markdown-small-strong-italic-line-height: 20px; + --dsw-font-markdown-small-strong-italic-font-size: 12px; + --dsw-font-markdown-small-strong-italic-font-style: italic; + + --dsw-font-markdown-code: 12px/19px var(--ds-font-family-code); + --dsw-font-markdown-code-font-family: var(--ds-font-family-code); + --dsw-font-markdown-code-font-weight: 400; + --dsw-font-markdown-code-line-height: 19px; + --dsw-font-markdown-code-font-size: 12px; + --dsw-font-markdown-code-font-style: normal; + + --dsw-font-markdown-code-block: 11px/19px var(--ds-font-family-code); + --dsw-font-markdown-code-block-font-family: var(--ds-font-family-code); + --dsw-font-markdown-code-block-font-weight: 400; + --dsw-font-markdown-code-block-line-height: 19px; + --dsw-font-markdown-code-block-font-size: 11px; + --dsw-font-markdown-code-block-font-style: normal; + + /* 手工补充(非插件导出):tool row 展开卡片内的小号 code 字体。 */ + --dsw-font-markdown-code-block-small: 11px/16px var(--ds-font-family-code); + --dsw-font-markdown-code-block-small-font-family: var(--ds-font-family-code); + --dsw-font-markdown-code-block-small-font-weight: 400; + --dsw-font-markdown-code-block-small-line-height: 16px; + --dsw-font-markdown-code-block-small-font-size: 11px; + --dsw-font-markdown-code-block-small-font-style: normal; + + --dsw-font-xl-24: 600 24px/32px var(--dsw-font-family); + --dsw-font-xl-24-font-family: var(--dsw-font-family); + --dsw-font-xl-24-font-weight: 600; + --dsw-font-xl-24-line-height: 32px; + --dsw-font-xl-24-font-size: 24px; + --dsw-font-xl-24-font-style: normal; + + --dsw-font-l-20: 500 20px/28px var(--dsw-font-family); + --dsw-font-l-20-font-family: var(--dsw-font-family); + --dsw-font-l-20-font-weight: 500; + --dsw-font-l-20-line-height: 28px; + --dsw-font-l-20-font-size: 20px; + --dsw-font-l-20-font-style: normal; + + --dsw-font-m-18: 500 16px/28px var(--dsw-font-family); + --dsw-font-m-18-font-family: var(--dsw-font-family); + --dsw-font-m-18-font-weight: 500; + --dsw-font-m-18-line-height: 28px; + --dsw-font-m-18-font-size: 16px; + --dsw-font-m-18-font-style: normal; + + --dsw-font-base-16: 16px/24px var(--dsw-font-family); + --dsw-font-base-16-font-family: var(--dsw-font-family); + --dsw-font-base-16-font-weight: 400; + --dsw-font-base-16-line-height: 24px; + --dsw-font-base-16-font-size: 16px; + --dsw-font-base-16-font-style: normal; + + --dsw-font-base-strong-16: 500 16px/24px var(--dsw-font-family); + --dsw-font-base-strong-16-font-family: var(--dsw-font-family); + --dsw-font-base-strong-16-font-weight: 500; + --dsw-font-base-strong-16-line-height: 24px; + --dsw-font-base-strong-16-font-size: 16px; + --dsw-font-base-strong-16-font-style: normal; + + --dsw-font-s-14: 14px/22px var(--dsw-font-family); + --dsw-font-s-14-font-family: var(--dsw-font-family); + --dsw-font-s-14-font-weight: 400; + --dsw-font-s-14-line-height: 22px; + --dsw-font-s-14-font-size: 14px; + --dsw-font-s-14-font-style: normal; + + --dsw-font-s-strong-14: 500 14px/22px var(--dsw-font-family); + --dsw-font-s-strong-14-font-family: var(--dsw-font-family); + --dsw-font-s-strong-14-font-weight: 500; + --dsw-font-s-strong-14-line-height: 22px; + --dsw-font-s-strong-14-font-size: 14px; + --dsw-font-s-strong-14-font-style: normal; + + --dsw-font-xs-13: 13px/20px var(--dsw-font-family); + --dsw-font-xs-13-font-family: var(--dsw-font-family); + --dsw-font-xs-13-font-weight: 400; + --dsw-font-xs-13-line-height: 20px; + --dsw-font-xs-13-font-size: 13px; + --dsw-font-xs-13-font-style: normal; + + --dsw-font-xs-strong-13: 500 13px/20px var(--dsw-font-family); + --dsw-font-xs-strong-13-font-family: var(--dsw-font-family); + --dsw-font-xs-strong-13-font-weight: 500; + --dsw-font-xs-strong-13-line-height: 20px; + --dsw-font-xs-strong-13-font-size: 13px; + --dsw-font-xs-strong-13-font-style: normal; + + --dsw-font-xxs-12: 12px/18px var(--dsw-font-family); + --dsw-font-xxs-12-font-family: var(--dsw-font-family); + --dsw-font-xxs-12-font-weight: 400; + --dsw-font-xxs-12-line-height: 18px; + --dsw-font-xxs-12-font-size: 12px; + --dsw-font-xxs-12-font-style: normal; + + --dsw-font-xxs-strong-12: 500 12px/18px var(--dsw-font-family); + --dsw-font-xxs-strong-12-font-family: var(--dsw-font-family); + --dsw-font-xxs-strong-12-font-weight: 500; + --dsw-font-xxs-strong-12-line-height: 18px; + --dsw-font-xxs-strong-12-font-size: 12px; + --dsw-font-xxs-strong-12-font-style: normal; + + --dsw-font-xxxs-11: 11px/14px var(--dsw-font-family); + --dsw-font-xxxs-11-font-family: var(--dsw-font-family); + --dsw-font-xxxs-11-font-weight: 400; + --dsw-font-xxxs-11-line-height: 14px; + --dsw-font-xxxs-11-font-size: 11px; + --dsw-font-xxxs-11-font-style: normal; + + --dsw-font-xxxs-strong-11: 500 11px/14px var(--dsw-font-family); + --dsw-font-xxxs-strong-11-font-family: var(--dsw-font-family); + --dsw-font-xxxs-strong-11-font-weight: 500; + --dsw-font-xxxs-strong-11-line-height: 14px; + --dsw-font-xxxs-strong-11-font-size: 11px; + --dsw-font-xxxs-strong-11-font-style: normal; +} diff --git a/apps/web-ui/packages/client/ui-theme/src/styles/montserrat-light.woff2 b/apps/web-ui/packages/client/ui-theme/src/styles/montserrat-light.woff2 new file mode 100644 index 000000000..dfc6af5c0 Binary files /dev/null and b/apps/web-ui/packages/client/ui-theme/src/styles/montserrat-light.woff2 differ diff --git a/apps/web-ui/packages/client/ui-theme/src/styles/montserrat-medium.woff2 b/apps/web-ui/packages/client/ui-theme/src/styles/montserrat-medium.woff2 new file mode 100644 index 000000000..7906d1768 Binary files /dev/null and b/apps/web-ui/packages/client/ui-theme/src/styles/montserrat-medium.woff2 differ diff --git a/apps/web-ui/packages/client/ui-theme/src/styles/montserrat-regular.woff2 b/apps/web-ui/packages/client/ui-theme/src/styles/montserrat-regular.woff2 new file mode 100644 index 000000000..9ca86f6d1 Binary files /dev/null and b/apps/web-ui/packages/client/ui-theme/src/styles/montserrat-regular.woff2 differ diff --git a/apps/web-ui/packages/client/ui-theme/src/styles/onboarding.css b/apps/web-ui/packages/client/ui-theme/src/styles/onboarding.css new file mode 100644 index 000000000..6c6e40b7d --- /dev/null +++ b/apps/web-ui/packages/client/ui-theme/src/styles/onboarding.css @@ -0,0 +1,29 @@ +/* Onboarding palette and themed materials; card geometry and animation belong to the feature. */ +body { + --dsw-alias-onboarding-accent: rgb(57, 100, 254); + --dsw-gradient-onboarding-violet-stops: + #2a2fb6 33.102%, #8b76f6 50.954%, #c5c0ff 85.326%, #2a2fb6; + --dsw-gradient-onboarding-blue-stops: #3964fe 18.75%, #398efe 51.78%, #6dccff 86.252%, #3964fe; + --dsw-gradient-onboarding-cyan-stops: #0293b4 21.154%, #2dc8eb 50.954%, #aff0ff 85.326%, #0293b4; + --dsw-alias-onboarding-card-fill: color-mix( + in srgb, + var(--dsw-static-neutral-bluish-00) 80%, + transparent + ); + --dsw-alias-onboarding-secondary-fill: var(--dsw-static-neutral-bluish-00); + --dsw-alias-onboarding-checkbox-border: color-mix( + in srgb, + var(--dsw-static-neutral-bluish-1000) 20%, + transparent + ); +} + +body[data-ds-dark-theme] { + --dsw-alias-onboarding-card-fill: color-mix( + in srgb, + var(--dsw-static-neutral-bluish-850) 80%, + transparent + ); + --dsw-alias-onboarding-secondary-fill: rgb(72, 73, 76); + --dsw-alias-onboarding-checkbox-border: var(--dsw-alias-border-l2); +} diff --git a/apps/web-ui/packages/client/ui-theme/src/styles/scrollbar.css b/apps/web-ui/packages/client/ui-theme/src/styles/scrollbar.css new file mode 100644 index 000000000..035d31513 --- /dev/null +++ b/apps/web-ui/packages/client/ui-theme/src/styles/scrollbar.css @@ -0,0 +1,95 @@ +/* Scrollbar skin: the sole consumer of the four --dsw-alias-scrollbar-* + * tokens. Without it every scrolling region renders the UA scrollbar, which + * ignores the theme — a light native bar over the dark palette. + * + * The rules sit on `body`, not `html`: design-platform.css declares the + * --dsw-alias-* tokens on `body` (and the dark overrides on + * `body[data-ds-dark-theme]`), and custom properties only inherit downward, + * so an `html` rule resolves them to the guaranteed-invalid value and + * `scrollbar-color` falls back to `auto`. + * + * Surfaces pick their elevation by rebinding --dsh-scrollbar-thumb{,-hover}: + * the l1 pair here is the base-surface default, and an elevated surface + * (menu, popover, dialog) rebinds to the l2 pair on its own container. A + * scroll container may also rebind the three geometry variables below. */ + +body { + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l1); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l1); + --dsh-scrollbar-thumb-border: 0px; + --dsh-scrollbar-track-margin: 0px; + /* The WebKit bar's layout width, mirrored by the ::-webkit-scrollbar rule + below. A surface that must align itself beside a space-consuming bar + (ConversationRoot's overlay composer seat) reads this instead of + hardcoding the number. */ + --dsh-scrollbar-width: 5px; +} + +/* The two paths are mutually exclusive, and the gate is load-bearing rather + than defensive. A non-`auto` `scrollbar-width` or `scrollbar-color` makes + Chromium and Safari drop every `::-webkit-scrollbar*` rule for that + element, including `::-webkit-scrollbar-thumb:hover` — measured in chromium + as a `::-webkit-scrollbar` width taking effect on its own and being + ignored as soon as `scrollbar-width: thin` is added. Declaring both + unconditionally therefore leaves the hover tokens with no rendering at all, + because the engines that implement the hover pseudo-element are exactly the + ones the standard properties silence, and Firefox has no hover + pseudo-element to fall back on. + + `not selector(::-webkit-scrollbar)` is true only where the pseudo-element + is unimplemented, so Firefox takes the standard path and WebKit-based + engines take the pseudo-element path. An engine too old for the + `selector()` function makes the condition invalid, which evaluates false + and selects the pseudo-element path — the correct side for the pre-16.4 + Safari that is the realistic case. */ +@supports not selector(::-webkit-scrollbar) { + /* Declared on every element rather than inherited from `body`. Inheriting + would pass down the COLOUR already substituted at `body`, so a descendant + rebinding --dsh-scrollbar-thumb could not change it; re-declaring makes + each element substitute the variable as it sees it, which is what gives + an elevated surface a working rebind. `scrollbar-width` is not an + inherited property at all, so it needs the per-element declaration + regardless. + + No hover counterpart exists on this path: `scrollbar-color` states one + thumb colour and the engine derives its own hover treatment. */ + body, + body * { + scrollbar-width: thin; + scrollbar-color: var(--dsh-scrollbar-thumb) transparent; + } +} + +/* Not gated in turn: an engine that does not implement these pseudo-elements + drops the rules as unknown selectors, so the gate would only restate what + selector matching already does. Not inherited either, hence the unscoped + selectors. */ +::-webkit-scrollbar { + width: var(--dsh-scrollbar-width); + height: var(--dsh-scrollbar-width); +} + +/* Track stays transparent so the thumb reads against whatever surface scrolls + under it; only the thumb carries a token colour. */ +::-webkit-scrollbar-track { + margin-block: var(--dsh-scrollbar-track-margin); + background: transparent; +} + +::-webkit-scrollbar-thumb { + border: var(--dsh-scrollbar-thumb-border) solid transparent; + border-radius: 999px; + corner-shape: round; + background: var(--dsh-scrollbar-thumb); + background-clip: content-box; +} + +::-webkit-scrollbar-thumb:hover { + background-color: var(--dsh-scrollbar-thumb-hover); +} + +/* Both scrollbars meeting in a corner: no separate token, so the corner + matches the transparent track rather than the UA's opaque default. */ +::-webkit-scrollbar-corner { + background: transparent; +} diff --git a/apps/web-ui/packages/client/ui-theme/src/styles/shiki.css b/apps/web-ui/packages/client/ui-theme/src/styles/shiki.css new file mode 100644 index 000000000..c7a3c5d27 --- /dev/null +++ b/apps/web-ui/packages/client/ui-theme/src/styles/shiki.css @@ -0,0 +1,31 @@ +/* Syntax-highlight token palette: the values behind shiki's css-variables + theme (--shiki-* custom properties emitted by the ui-primitives CodeBlock). + Light values on :root, dark overrides on the body attribute — the same + cascade as every other token sheet. Background/foreground deliberately + alias the markdown code-block tokens so highlighted and plain blocks agree. */ + +:root { + --shiki-foreground: var(--dsw-alias-label-primary); + --shiki-background: var(--dsw-alias-markdown-code-block); + --shiki-token-constant: #1c7ed6; + --shiki-token-string: #2f9e44; + --shiki-token-comment: #868e96; + --shiki-token-keyword: #d6336c; + --shiki-token-parameter: #e8590c; + --shiki-token-function: #6741d9; + --shiki-token-string-expression: #2b8a3e; + --shiki-token-punctuation: #495057; + --shiki-token-link: #1971c2; +} + +body[data-ds-dark-theme] { + --shiki-token-constant: #4dabf7; + --shiki-token-string: #69db7c; + --shiki-token-comment: #adb5bd; + --shiki-token-keyword: #faa2c1; + --shiki-token-parameter: #ffa94d; + --shiki-token-function: #b197fc; + --shiki-token-string-expression: #8ce99a; + --shiki-token-punctuation: #ced4da; + --shiki-token-link: #74c0fc; +} diff --git a/apps/web-ui/packages/client/ui-theme/src/theme-settings.ts b/apps/web-ui/packages/client/ui-theme/src/theme-settings.ts new file mode 100644 index 000000000..82c4f19be --- /dev/null +++ b/apps/web-ui/packages/client/ui-theme/src/theme-settings.ts @@ -0,0 +1,58 @@ +/** Theme preferences stored in the Host user-settings document. */ + +import z from "@deepseek-ai/schemastery"; + +/** Built-in preferences accepted at the registry and settings boundaries. */ +export const THEME_PREFERENCES = ["light", "dark", "system"] as const; + +/** Settings namespace owned by the theme plugin. */ +export const THEME_SETTINGS_NAMESPACE = "ui-theme"; + +/** Field carrying the selected built-in theme preference. */ +export const THEME_PREFERENCE_FIELD = "preference"; + +/** Field carrying the conversation content font size. */ +export const FONT_SIZE_FIELD = "fontSize"; + +/** Theme preference persisted by the product Appearance row. */ +export type ThemePreference = (typeof THEME_PREFERENCES)[number]; + +/** Default preference when the user-settings document has no override. */ +export const DEFAULT_PREFERENCE: ThemePreference = "system"; + +/** Smallest accepted content font size (px). */ +export const FONT_SIZE_MIN = 10; + +/** Largest accepted content font size (px). */ +export const FONT_SIZE_MAX = 22; + +/** Content font size when the user-settings document has no override (px). */ +export const DEFAULT_FONT_SIZE = 14; + +/** Durable theme section shared by the Host schema and the browser scope. */ +export interface ThemeSettings { + /** Selected built-in preference. */ + preference: ThemePreference; + /** Conversation content font size in px (integer within {@link FONT_SIZE_MIN}..{@link FONT_SIZE_MAX}). */ + fontSize: number; +} + +/** Durable theme schema; also the wire envelope the browser scope validates against. */ +export const ThemeSettingsSchema: z = z.object({ + [THEME_PREFERENCE_FIELD]: z.union([...THEME_PREFERENCES]).default(DEFAULT_PREFERENCE), + [FONT_SIZE_FIELD]: z + .number() + .step(1) + .min(FONT_SIZE_MIN) + .max(FONT_SIZE_MAX) + .default(DEFAULT_FONT_SIZE), +}); + +/** + * Narrow one wire or registry value to a persistable preference. + * @param value - value crossing the settings or registry boundary. + * @returns whether the value is a built-in preference. + */ +export function isThemePreference(value: unknown): value is ThemePreference { + return THEME_PREFERENCES.some((preference) => preference === value); +} diff --git a/apps/web-ui/packages/client/ui-theme/tsdown.config.ts b/apps/web-ui/packages/client/ui-theme/tsdown.config.ts new file mode 100644 index 000000000..7a2fd73bd --- /dev/null +++ b/apps/web-ui/packages/client/ui-theme/tsdown.config.ts @@ -0,0 +1,12 @@ +import { clientBundle } from "../tsdown.client.ts"; + +export default clientBundle("@deepseek-ai/dsh-client-ui-theme", ["lib/types/index.js"], { + lib: { + copy: [ + { + from: "src/styles/{brand-font.css,montserrat-*.woff2,Montserrat-OFL.txt}", + to: "lib/styles", + }, + ], + }, +}); diff --git a/apps/web-ui/packages/client/ui-tool/package.json b/apps/web-ui/packages/client/ui-tool/package.json new file mode 100644 index 000000000..6ecb1a969 --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/package.json @@ -0,0 +1,37 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-tool", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./client": { + "types": "./src/client/index.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dependencies": { + "@deepseek-ai/dsh-client-ui-primitives": "*", + "react": "^18.2.0", + "clsx": "^2.0.0", + "@deepseek-ai/dsh-util-workspace-path": "*", + "@deepseek-ai/dsh-spill-policy": "*" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-workspace-controller", + "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-conversation" + ], + "platform": "web" + } + } +} diff --git a/apps/web-ui/packages/client/ui-tool/src/client/apply.ts b/apps/web-ui/packages/client/ui-tool/src/client/apply.ts new file mode 100644 index 000000000..32565d603 --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/apply.ts @@ -0,0 +1,60 @@ +/** Register the Tool call tree, details renderer, and built-in atomic views. */ +import type { Context as ClientContext } from "@deepseek-ai/cordis"; +import type { RemoteHostFacts } from "@deepseek-ai/dsh-api-remotes/client"; +import type { HostObservable } from "@deepseek-ai/dsh-client-ui-slots"; +import type {} from "@deepseek-ai/dsh-api-remotes/client"; +import type {} from "@deepseek-ai/dsh-client-ui-conversation/client"; +import type {} from "@deepseek-ai/dsh-client-ui-renderer/client"; +import type {} from "@deepseek-ai/dsh-client-ui-session/client"; +import { ToolCallTree } from "./tool/ToolCallTree.tsx"; +import { CONVERSATION_NS as NS } from "./locale.ts"; +import { askQuestionToolview } from "./tool/toolviews/ask-question-row.tsx"; +import { bashToolviewSample } from "./tool/toolviews/bash-sample.tsx"; +import { fileMutationToolview } from "./tool/toolviews/file-mutation-row.tsx"; +import { readToolview } from "./tool/toolviews/read-row.tsx"; +import { readImageToolview } from "./tool/toolviews/read-image-row.tsx"; +import { searchToolview } from "./tool/toolviews/search-row.tsx"; +import { contextCompactionToolview } from "./tool/toolviews/context-compaction-row.tsx"; +import { detailsToolview } from "./tool/toolviews/details-row.tsx"; +import { todoToolview } from "./tool/toolviews/todo-row.tsx"; +import { webToolview } from "./tool/toolviews/web-row.tsx"; + +/** Required services: the slot registry and the Remote face carrying the Host home used for POSIX `~`. */ +export const inject = ["slots", "remote"]; + +/** + * Mount the whole-Tool renderers and built-in atomic Tool registrations. + * @param ctx - Client root context. + */ +export function apply(ctx: ClientContext): void { + const hostInfo: HostObservable = { + getSnapshot: () => ctx.remote.$host, + subscribe: (listener) => ctx.on("connection/reset", listener), + }; + const toolInject = () => ({ hooks: { hostInfo } }); + ctx.slots.inject("conversation.chat.node", () => + ctx.slots.register( + { + name: "conversation.chat.node", + key: "tool-call", + locale: NS, + children: { + "tool.call.toolview": { kind: "keyed", scope: "session" }, + }, + inject: toolInject, + }, + ToolCallTree, + ), + ); + + ctx.plugin(bashToolviewSample); + ctx.plugin(readToolview); + ctx.plugin(readImageToolview); + ctx.plugin(fileMutationToolview); + ctx.plugin(searchToolview); + ctx.plugin(webToolview); + ctx.plugin(contextCompactionToolview); + ctx.plugin(todoToolview); + ctx.plugin(detailsToolview); + ctx.plugin(askQuestionToolview); +} diff --git a/apps/web-ui/packages/client/ui-tool/src/client/contract/slots.ts b/apps/web-ui/packages/client/ui-tool/src/client/contract/slots.ts new file mode 100644 index 000000000..0efebaadb --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/contract/slots.ts @@ -0,0 +1,171 @@ +/** Tool UI slot declarations and their composed component props. */ +import type { + HostObservable, + InjectFace, + PropsLocale, + PropsRenderSlots, + PropsRuntime, + SessionIdOf, +} from "@deepseek-ai/dsh-client-ui-slots"; +import type { RemoteHostFacts } from "@deepseek-ai/dsh-api-remotes/client"; +import type { + OpenFileOptions, + PreparingToolCall, + StartedToolCall, + ToolResultNode, + UseDisclosure, +} from "@deepseek-ai/dsh-client-ui-chat/client"; +import type { + MessageImageLoader, + MessageImageSource, +} from "@deepseek-ai/dsh-client-ui-conversation/client"; +import type { + AskUserQuestionAnswerItem, + AskUserQuestionItem, +} from "@deepseek-ai/dsh-user-questions/types"; +import type {} from "@deepseek-ai/dsh-client-locale/client"; + +declare module "@deepseek-ai/dsh-client-ui-slots" { + interface SlotMap { + /** + * Keyed Tool call view dispatched by wire Tool name. Any name is allowed, + * including tools registered by your package. Register with + * `key: ''`; a typo never renders. + * + * Registering an occupied key replaces its view; unclaimed keys use the + * generic row. The owner supplies the call identity and running + * or settled node through explicit phase props. Every stage supplies + * `name` and a lazy `args` view; preparing arguments may be incomplete. + */ + "tool.call.toolview": { + kind: "keyed"; + scope: "session"; + owner: ToolCallOwnerProps; + }; + /** + * Durable images of a settled image-bearing Tool call, rendered through + * the attachment presentation plugin. The Tool layer never imports an + * attachment implementation: a toolview declares this slot as a child and + * renders it with the image card's references plus the session-authorized + * loader it received in its owner, and the attachment plugin fills the + * gallery. Composing no attachment presentation plugin renders nothing, + * which is why the image card keeps its own envelope text beside the + * gallery. A child slot is declared by exactly one entry: registering a + * second toolview that declares the same child throws at load, so a + * future image-bearing tool must reuse this entry or own a distinct + * slot. + */ + "tool.call.images": { kind: "single"; scope: "session"; owner: ToolImagesOwnerProps }; + } +} + +/** Owner currency of the Tool image gallery slot: references plus the loader. */ +export interface ToolImagesOwnerProps { + /** Durable references or submission-echo previews in result order. */ + images: readonly MessageImageSource[]; + /** Session-authorized image URL loader for the durable arm. */ + loadImage: MessageImageLoader; + /** Horizontal placement inside the owning record. */ + align: "start" | "end"; +} + +/** Standard owner currency supplied to every atomic Tool view. */ +export interface ToolCallCommonProps { + /** Stable Hook; each invocation owns its open state and subscribes to enclosing-Turn resets. */ + useDisclosure: UseDisclosure; + /** Call identity, stable across all stages. */ + callId: string; + /** Wire Tool name and keyed dispatch value. */ + toolName: string; + /** Session workspace root for relative summaries. */ + cwd?: string | undefined; + /** Host account home; POSIX home-rooted summaries display as `~`. */ + home?: string | undefined; + /** Open an argument path at its optional requested line. */ + openFile: (path: string, options?: OpenFileOptions) => void; + /** Chat-supplied, session-authorized loader for durable images; Tool views do not manage attachment URLs. */ + loadImage: MessageImageLoader; + /** Inspect this call in the trajectory view when available. */ + inspect?: (() => void) | undefined; +} + +/** Stage-specific tool data; only start/result expose the dispatched call material. */ +export type ToolCallPhaseProps = + | { readonly phase: "preparing"; readonly block: PreparingToolCall } + | { readonly phase: "start"; readonly block: StartedToolCall } + | { readonly phase: "result"; readonly block: ToolResultNode }; + +/** Common owner callbacks and the data admitted at the current tool stage. */ +export type ToolCallOwnerProps = ToolCallCommonProps & ToolCallPhaseProps; + +/** Full props of a registered atomic Tool view. */ +export type ToolCallViewProps = PropsRuntime<"tool.call.toolview">; + +/** Existing argument/result business components exclude the preparation stage. */ +export type StartedToolCallViewProps = Exclude; + +/** + * One settled `ask_user_question` call as its transcript row read it. The row + * reads the questions from the recorded call JSON, and the answers from + * whichever recorded them: its own result when the answer arrived in time, or + * the `userQuestions` projection when a late reply settled the call. + */ +export interface UserQuestionRecord { + /** The call's questions, with the option lists the panel renders. */ + readonly questions: readonly AskUserQuestionItem[]; + /** The recorded answer batch; an item with no selection and no custom text was skipped. */ + readonly answers: readonly AskUserQuestionAnswerItem[]; +} + +/** + * Optional answer-panel provider consumed by the ask-question row. A timed + * `ask_user_question` call stays answerable after its result is recorded, and + * its panel can be closed, so the transcript row is the way back to it. A + * composition without a question UI has no panel to show. + */ +export interface UserQuestionPanels { + /** + * Put one call's answer panel back in the composer, ahead of any other + * pending question. + * @param sessionId - Session the call belongs to. + * @param callId - `ask_user_question` call whose panel to show. + * @returns whether a panel for that call was there to show. + */ + reveal(sessionId: SessionIdOf, callId: string): boolean; + /** + * Show one settled call's recorded answers as a read-only panel, ahead of + * any other pending question. Closing that panel drops it for good; the row + * builds it again from the same record. + * @param sessionId - Session the call belongs to. + * @param callId - `ask_user_question` call whose record to show. + * @param record - the call's questions and recorded answers. + * @returns whether the panel was shown. + */ + review(sessionId: SessionIdOf, callId: string, record: UserQuestionRecord): boolean; +} + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Optional user-question answer-panel provider. */ + userQuestionPanels: UserQuestionPanels; + } +} + +/** Injected Host description for POSIX home-path display. */ +export type ToolHostInfoInjected = { + hooks: { + /** + * Fixed Host facts, reached through a hook rather than injected as values: + * the renderer memoizes an entry's inject result for the registration's + * lifetime, so facts read there would freeze at whatever the first render + * saw. Select the field the view needs (`info => info.home`). + */ + hostInfo: HostObservable; + }; +}; + +/** Full props of the Tool call-tree renderer registered as a tool-call Chat Node. */ +export type ToolTreeProps = PropsRuntime<"conversation.chat.node", "tool-call"> & + PropsRenderSlots<"tool.call.toolview"> & + PropsLocale<"conversation"> & + InjectFace; diff --git a/apps/web-ui/packages/client/ui-tool/src/client/index.ts b/apps/web-ui/packages/client/ui-tool/src/client/index.ts new file mode 100644 index 000000000..7635a2db5 --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/index.ts @@ -0,0 +1,13 @@ +/** Browser Tool plugin: whole-call composition and keyed atomic Tool views. */ +export { apply, inject } from "./apply.ts"; +export type { + StartedToolCallViewProps, + ToolCallCommonProps, + ToolCallOwnerProps, + ToolCallPhaseProps, + ToolCallViewProps, + ToolHostInfoInjected, + ToolTreeProps, + UserQuestionPanels, + UserQuestionRecord, +} from "./contract/slots.ts"; diff --git a/apps/web-ui/packages/client/ui-tool/src/client/locale.ts b/apps/web-ui/packages/client/ui-tool/src/client/locale.ts new file mode 100644 index 000000000..e08a4fd17 --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/locale.ts @@ -0,0 +1,2 @@ +/** Locale namespace supplied by the conversation owner to Tool renderers. */ +export const CONVERSATION_NS = "conversation"; diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/ToolCallTree.module.css b/apps/web-ui/packages/client/ui-tool/src/client/tool/ToolCallTree.module.css new file mode 100644 index 000000000..7d3ba6759 --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/ToolCallTree.module.css @@ -0,0 +1,12 @@ +.callRow { + border-radius: var(--dsw-radius-sm); +} + +.subCalls { + display: flex; + flex-direction: column; + gap: 4px; + margin: 4px 0 2px 22px; + padding-left: 8px; + border-left: 0.5px solid var(--dsw-alias-border-l2); +} diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx b/apps/web-ui/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx new file mode 100644 index 000000000..e4f1539b2 --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx @@ -0,0 +1,166 @@ +/** Root/subcall Tool composition with one keyed atomic dispatch path. */ +import { memo, useMemo, type ReactNode } from "react"; +import type { ToolCallBlock } from "@deepseek-ai/dsh-client-ui-chat/client"; +import type { ToolCallOwnerProps, ToolCallPhaseProps, ToolTreeProps } from "../contract/slots.ts"; +import { toolRowModel } from "./models/tool-call-model.ts"; +import { GenericToolCard } from "./toolviews/GenericToolCard.tsx"; +import css from "./ToolCallTree.module.css"; + +function toolCallPhase(block: ToolCallBlock): ToolCallPhaseProps { + if ("kind" in block) return { phase: "result", block }; + return block.phase === "preparing" ? { phase: "preparing", block } : { phase: "start", block }; +} + +/** Resolve a Tool call's wire name from its current stage. */ +function callName(call: ToolCallPhaseProps): string { + return call.phase === "result" ? (call.block.call?.name ?? "") : call.block.name; +} + +/** One atomic call dispatched through the Tool-owned keyed slot. */ +const ToolCall = memo(function ToolCall({ + renderSlot, + callId, + toolName, + call, + openFile, + cwd, + home, + inspectCall, + loadImage, + useDisclosure, + t, + children, +}: Pick< + ToolTreeProps, + "renderSlot" | "openFile" | "cwd" | "inspectCall" | "loadImage" | "useDisclosure" | "t" +> & { + callId: string; + toolName: string; + call: ToolCallPhaseProps; + home?: string | undefined; + children?: ReactNode; +}) { + const owner: ToolCallOwnerProps = useMemo( + () => ({ + callId, + toolName, + ...call, + openFile, + cwd, + home, + loadImage, + useDisclosure, + inspect: + inspectCall === undefined + ? undefined + : () => { + inspectCall(callId); + }, + }), + [callId, toolName, call, openFile, cwd, home, loadImage, inspectCall, useDisclosure], + ); + const autoReviewDenied = useMemo( + () => call.phase === "result" && toolRowModel(toolName, call.block).autoReviewDenial !== null, + [toolName, call], + ); + return ( +
    + {autoReviewDenied ? ( + + ) : ( + renderSlot("tool.call.toolview", owner, { + entryKey: toolName, + fallback: , + }) + )} + {children} +
    + ); +}); + +const ToolCallBranch = memo(function ToolCallBranch({ + renderSlot, + block, + cwd, + home, + openFile, + inspectCall, + loadImage, + useDisclosure, + t, +}: Pick< + ToolTreeProps, + "renderSlot" | "cwd" | "openFile" | "inspectCall" | "loadImage" | "useDisclosure" | "t" +> & { + block: ToolCallBlock; + home?: string | undefined; +}) { + const call = useMemo(() => toolCallPhase(block), [block]); + return ( + + {call.phase !== "preparing" && call.block.subCalls.length > 0 ? ( +
    + {call.block.subCalls.map((child) => ( + + ))} +
    + ) : null} +
    + ); +}); + +/** + * Render one root Tool call and its recursive children through the same + * atomic keyed dispatch. + * @param props - whole-Tool owner data and the Tool-owned child-slot share. + * @returns the Tool call tree. + */ +export function ToolCallTree({ + renderSlot, + node, + cwd, + openFile, + inspectCall, + loadImage, + useDisclosure, + useHostInfo, + t, +}: ToolTreeProps) { + const home = useHostInfo((info) => info.home); + return ( + + ); +} diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/components/AskQuestionCard.module.css b/apps/web-ui/packages/client/ui-tool/src/client/tool/components/AskQuestionCard.module.css new file mode 100644 index 000000000..ae969921c --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/components/AskQuestionCard.module.css @@ -0,0 +1,67 @@ +.card { + display: flex; + flex-direction: column; + gap: 16px; + max-height: 360px; + margin: 4px 0 4px 4px; + padding: 16px 20px; + overflow-y: auto; + border: 0.5px solid var(--dsw-alias-border-l1); + border-radius: var(--dsw-radius-lg); + background: var(--dsw-alias-bg-base); +} + +.item { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.question, +.answer { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + font-size: var(--dsh-content-font-size, 14px); + line-height: calc(24px + var(--dsh-content-font-delta, 0px)); +} + +.question { + color: var(--dsw-alias-label-tertiary); +} + +.answer { + color: var(--dsw-alias-label-primary); +} + +.answerLine { + display: block; +} + +.skipped { + color: var(--dsw-alias-label-tertiary); +} + +.verdict { + margin: 0; + color: var(--dsw-alias-label-primary); + font-size: var(--dsh-content-font-size, 14px); + line-height: calc(24px + var(--dsh-content-font-delta, 0px)); +} + +.questionList { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding-left: 20px; +} + +.unansweredQuestion { + color: var(--dsw-alias-label-tertiary); + white-space: pre-wrap; + overflow-wrap: anywhere; + font-size: var(--dsh-content-font-size, 14px); + line-height: calc(24px + var(--dsh-content-font-delta, 0px)); +} diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/components/AskQuestionCard.tsx b/apps/web-ui/packages/client/ui-tool/src/client/tool/components/AskQuestionCard.tsx new file mode 100644 index 000000000..6c790f452 --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/components/AskQuestionCard.tsx @@ -0,0 +1,46 @@ +/** Ask-user transcript rendering from validated plain card data. @module */ + +import type { AskQuestionCardModel } from "../models/ask-question-card-model.ts"; +import css from "./AskQuestionCard.module.css"; + +/** + * Render a validated ask-user transcript from plain card data. + * @param props - Localized transcript card data. + * @returns the readable answered or unanswered question list. + */ +export function AskQuestionCard({ card }: { card: AskQuestionCardModel }) { + if (card.kind === "unanswered") { + return ( +
    +

    {card.verdict}

    +
      + {card.questions.map((question) => ( +
    • + {question.question} +
    • + ))} +
    +
    + ); + } + return ( +
    + {card.questions.map((question) => ( +
    +
    {question.question}
    +
    + {question.answers.length === 0 ? ( + {card.skippedLabel} + ) : ( + question.answers.map((answer, index) => ( + + {answer} + + )) + )} +
    +
    + ))} +
    + ); +} diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/components/QuestionToolRow.module.css b/apps/web-ui/packages/client/ui-tool/src/client/tool/components/QuestionToolRow.module.css new file mode 100644 index 000000000..20c82c56a --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/components/QuestionToolRow.module.css @@ -0,0 +1,29 @@ +.panelButton { + flex: none; + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: 8px; + padding: 0 10px; + height: calc(22px + var(--dsh-content-font-delta, 0px)); + border: none; + border-radius: 999px; + corner-shape: round; + background: var(--dsw-alias-button-floating-fill); + color: var(--dsw-alias-label-primary); + font-family: inherit; + font-size: calc(var(--dsh-content-font-size-secondary, 13px) - 1px); + line-height: 1; + white-space: nowrap; + cursor: pointer; + transition: background 100ms ease; +} + +.panelButton svg { + width: 13px; + height: 13px; +} + +.panelButton:hover { + background: var(--dsw-alias-button-floating-hover); +} diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/components/QuestionToolRow.tsx b/apps/web-ui/packages/client/ui-tool/src/client/tool/components/QuestionToolRow.tsx new file mode 100644 index 000000000..311aeea26 --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/components/QuestionToolRow.tsx @@ -0,0 +1,123 @@ +/** Question-owned summary action and transcript disclosure. */ +import { + DisclosureRow, + IconInspectOutlineRegular, + IconQuestionOutlineRegular, + TextShimmer, +} from "@deepseek-ai/dsh-client-ui-primitives"; +import type { ToolRowProps } from "./ToolRow.tsx"; +import { formatToolBody } from "../models/tool-call-model.ts"; +import { AskQuestionCard } from "./AskQuestionCard.tsx"; +import css from "./ToolRow.module.css"; +import questionCss from "./QuestionToolRow.module.css"; + +type QuestionToolRowProps = Pick< + ToolRowProps, + "useDisclosure" | "t" | "summary" | "bodyRaw" | "output" | "askQuestion" | "state" | "inspect" +> & { + readonly openPanel: () => boolean; + readonly panelLabel: string; +}; + +/** + * Render a question with its answer-panel entry point. + * @param props - Question transcript, panel action, and disclosure state. + * @returns The question's summary and optional expanded record. + */ +export function QuestionToolRow({ + useDisclosure, + t, + summary, + bodyRaw, + output, + askQuestion, + state, + inspect, + openPanel, + panelLabel, +}: QuestionToolRowProps) { + const { expanded, toggle } = useDisclosure(); + const expandable = bodyRaw != null || output != null || askQuestion != null; + const open = expanded && expandable; + return ( +
    + {state === "running" && {t("row.running")}} + } + title={t("ask.rowTitle")} + running={state === "running"} + open={open} + expandable={expandable} + keepContentWhenOpen + onToggle={toggle} + collapsedContent={ + <> + {summary !== "" && ( + <> + + + {summary} + + + )} + + + } + > + {open && ( +
    + {askQuestion != null ? ( + + ) : ( +
    + {bodyRaw != null && ( +
    + {t("row.input")} + {formatToolBody("others", bodyRaw)} +
    + )} + {bodyRaw != null && output != null && ( + + )} + {output != null && ( +
    + {t("row.output")} + {output} +
    + )} +
    + )} + {inspect !== undefined && ( + + )} +
    + )} +
    +
    + ); +} diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css b/apps/web-ui/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css new file mode 100644 index 000000000..0e95c9a52 --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css @@ -0,0 +1,289 @@ +.root { + margin: 4px 0 4px 4px; + border: 0.5px solid var(--dsw-alias-border-l1); + border-radius: var(--dsw-radius-lg); + background: var(--dsw-alias-markdown-code-block); + color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-xs-13); + overflow: hidden; +} + +.list { + list-style: none; + margin: 0; + padding: 0; + max-height: 320px; + overflow-y: auto; +} + +.item, +.empty { + margin: 0; + padding: 10px 14px; +} + +.item + .item { + border-top: 0.5px solid var(--dsw-alias-border-l2); +} + +.root[data-inspect]:not([data-caption]) > .list > .item, +.root[data-inspect]:not([data-caption]) .empty, +.root[data-inspect] .caption { + padding-inline-end: 88px; +} + +.caption { + padding: 10px 14px 6px; + color: var(--dsw-alias-label-caption); + font-size: 12px; +} + +.heading { + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; +} + +.text { + flex: 1; + min-width: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.status { + flex: none; + align-self: flex-start; + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 18px; + font-size: 16px; + line-height: 18px; +} + +.pending { + box-sizing: border-box; + width: 10px; + height: 10px; + border: 1px solid var(--dsw-alias-label-tertiary); + border-radius: 2px; +} + +.heading:has(.statusText) .text { + flex-basis: 12em; +} + +.statusText { + margin-inline-start: auto; + color: var(--dsw-alias-label-caption); + font-size: 12px; + white-space: nowrap; +} + +.previous, +.unchanged { + color: var(--dsw-alias-label-tertiary); +} + +.item[data-change="added"] .status { + color: var(--dsw-alias-state-success-primary); +} + +.item[data-change="removed"] .status { + color: var(--dsw-alias-state-error-primary); +} + +.item[data-change="removed"] .text { + color: var(--dsw-alias-label-tertiary); + text-decoration: line-through; +} + +.unchanged { + border-top: 0.5px solid var(--dsw-alias-border-l2); +} + +.unchanged summary { + display: flex; + align-items: center; + gap: 6px; + padding: 9px 14px; + list-style: none; + cursor: pointer; +} + +.unchanged summary::-webkit-details-marker { + display: none; +} + +.unchanged[open] summary svg { + transform: rotate(90deg); +} + +.fields { + margin: 0; +} + +.heading + .fields { + margin-top: 6px; +} + +.field { + display: grid; + grid-template-columns: 5.5em minmax(0, 1fr); + gap: 12px; +} + +.field + .field { + margin-top: 4px; +} + +.field dt { + color: var(--dsw-alias-label-caption); +} + +.field dd { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.badge { + flex: none; + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--dsw-alias-label-caption); + font-size: 12px; +} + +.badge::before { + content: ""; + width: 5px; + height: 5px; + border-radius: 50%; + corner-shape: round; + background: currentColor; +} + +.badge[data-tone="info"] { + color: var(--dsw-alias-state-business-primary); +} +.badge[data-tone="success"] { + color: var(--dsw-alias-state-success-primary); +} +.badge[data-tone="warning"] { + color: var(--dsw-alias-state-warn-primary); +} +.badge[data-tone="error"] { + color: var(--dsw-alias-state-error-primary); +} + +.subtitle { + margin-top: 3px; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + overflow-wrap: anywhere; +} + +.description { + margin: 6px 0; + color: var(--dsw-alias-label-caption); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.lines { + margin: 6px 0 0; + padding-inline-start: 18px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.lines li + li { + margin-top: 4px; +} + +.group { + margin-top: 8px; + border-top: 0.5px solid var(--dsw-alias-border-l2); +} + +.group > summary { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 0 0; + color: var(--dsw-alias-label-caption); + list-style: none; + cursor: pointer; + overflow-wrap: anywhere; +} + +.group > summary::-webkit-details-marker { + display: none; +} +.group[open] > summary svg { + transform: rotate(90deg); +} +.group > summary svg { + flex: none; +} +.group .list { + max-height: none; + overflow: visible; +} +.group .item { + padding: 9px 0 2px 20px; +} + +.prose, +.code { + max-width: 100%; + font-size: 13px; +} + +.prose { + margin-top: 8px; +} + +.root .item > .code { + margin: 8px 0 0; + max-height: 240px; + overflow: auto; +} + +.root .item:has(> .code:only-child) { + padding: 0; +} +.root .item > .code:only-child { + margin: 0; +} + +.root .group .item:has(> .code:only-child) { + padding: 4px 0 0 20px; +} + +.path { + flex: 1; + min-width: 0; + padding: 0; + border: none; + background: none; + color: inherit; + font: inherit; + text-align: start; + overflow-wrap: anywhere; + cursor: pointer; +} + +.path:hover { + text-decoration: underline; +} +.path:focus-visible { + outline: var(--dsw-focus-ring-width) solid + var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: 3px; +} diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/components/ToolDetails.tsx b/apps/web-ui/packages/client/ui-tool/src/client/tool/components/ToolDetails.tsx new file mode 100644 index 000000000..a4b409b89 --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/components/ToolDetails.tsx @@ -0,0 +1,206 @@ +/** Compact, read-only fields and lists for recorded Tool results. */ +import { + CodeBlock, + MarkdownText, + IconCheckOutlineRegular, + IconChevronRightOutlineRegular, + IconPlayOutlineRegular, +} from "@deepseek-ai/dsh-client-ui-primitives"; +import type { TranslateNS } from "@deepseek-ai/dsh-client-ui-slots"; +import type { OpenFileOptions } from "@deepseek-ai/dsh-client-ui-chat/client"; +import { codeToolbarLabels, markdownLabels } from "../models/primitive-labels.ts"; +import css from "./ToolDetails.module.css"; + +/** One recorded entity, receipt, or collapsible group of related values. */ +export interface ToolDetailItem { + title?: string; + subtitle?: string; + description?: string; + badge?: { label: string; tone: "neutral" | "info" | "success" | "warning" | "error" }; + status?: { value: "completed" | "in_progress" | "pending"; label: string }; + previousStatus?: string; + change?: { value: "added" | "removed" | "updated"; label: string }; + fields: readonly { label: string; value: string }[]; + lines?: readonly string[]; + markdown?: string; + code?: { text: string; language?: string }; + location?: { path: string; line?: number }; + groups?: readonly { label: string; items: readonly ToolDetailItem[] }[]; +} + +/** Localized display data shared by the built-in detail cards. */ +export interface ToolDetailsModel { + items: readonly ToolDetailItem[]; + summary?: string; + /** Header text while expanded, omitting a receipt status shown in the body. */ + expandedSummary?: string; + empty?: string; + caption?: string; + unchanged?: { label: string; items: ToolDetailsModel["items"] }; +} + +interface DetailContentProps { + t: TranslateNS<"conversation">; + onOpenFile?: ((path: string, options?: OpenFileOptions) => void) | undefined; +} + +function DetailItem({ item, t, onOpenFile }: { item: ToolDetailItem } & DetailContentProps) { + const status = item.status; + return ( +
  • + {item.title !== undefined && ( +
    + {status !== undefined && ( + + {item.change?.value === "added" ? ( + "+" + ) : item.change?.value === "removed" ? ( + "−" + ) : status.value === "completed" ? ( + + ) : status.value === "in_progress" ? ( + + ) : ( + + )} + + )} + {item.location !== undefined && onOpenFile !== undefined ? ( + + ) : ( + {item.title} + )} + {item.badge !== undefined && ( + + {item.badge.label} + + )} + {status !== undefined && ( + + {item.previousStatus !== undefined && ( + <> + {item.previousStatus} + → + + )} + {status.label} + {item.change?.value === "updated" && item.previousStatus === undefined && ( + · {item.change.label} + )} + + )} +
    + )} + {item.subtitle !== undefined &&
    {item.subtitle}
    } + {item.description !== undefined &&

    {item.description}

    } + {item.fields.length > 0 && ( +
    + {item.fields.map((field) => ( +
    +
    {field.label}
    +
    {field.value}
    +
    + ))} +
    + )} + {item.lines !== undefined && ( +
      + {item.lines.map((line, index) => ( +
    • {line}
    • + ))} +
    + )} + {item.markdown !== undefined && ( +
    + +
    + )} + {item.code !== undefined && ( + + )} + {item.groups?.map((group, index) => ( +
    + + + {group.label} + +
      + {group.items.map((child, childIndex) => ( + + ))} +
    +
    + ))} +
  • + ); +} + +/** + * Render recorded values with local disclosures, copy controls, and file navigation. + * @param props.model - Localized fields or list items; an empty list uses its empty label. + * @param props.hasInspect - Reserve space for the row's upper-right Inspect button. + * @param props.t - Conversation dictionary for shared Markdown and code controls. + * @param props.onOpenFile - Open a recorded file location in the session workspace. + * @returns The expanded detail body. + */ +export function ToolDetails({ + model, + hasInspect = false, + t, + onOpenFile, +}: { + model: ToolDetailsModel; + hasInspect?: boolean; +} & DetailContentProps) { + return ( +
    + {model.caption !== undefined &&
    {model.caption}
    } + {model.items.length === 0 ? ( +

    {model.empty}

    + ) : ( +
      + {model.items.map((item, index) => ( + + ))} +
    + )} + {model.unchanged !== undefined && ( +
    + + + {model.unchanged.label} + +
      + {model.unchanged.items.map((item, index) => ( + + ))} +
    +
    + )} +
    + ); +} diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css b/apps/web-ui/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css new file mode 100644 index 000000000..72174a1e9 --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css @@ -0,0 +1,351 @@ +/* Tool summary row (figma 122:9479): 24px single line at the default size — + [16 leading] gap6 [title 13/24] gap8 [2x2 dot] gap8 [summary FILL truncate]. + The Settings font-size preference rides the body-published axis: the shared + DisclosureRow chrome scales the row height and leading glyph by the body px + delta; the title and the summary texts here read the secondary tier + (--dsh-content-font-size-secondary: one step under the body — setting −1 at + ≤14, setting −2 above), matching think text. */ + +.root { + display: flex; + flex-direction: column; +} + +.leading { + flex-shrink: 0; +} + +.root[data-tool^="cordis_"] .title { + font-weight: 500; +} + +.title { + font-weight: 400; +} + +.sep { + flex: none; + width: 2px; + height: 2px; + border-radius: 1px; + margin: 0 8px; + background: var(--dsw-alias-label-caption); +} + +.summary { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(24px + var(--dsh-content-font-delta, 0px)); +} + +/* Trailing summary fragment kept out of .summary's ellipsis, for a count whose + whole value is that it survives a narrow row (the todo row's parallel-active + `+n`). Repeats .summary's type because it sits beside that text, and its + `nowrap` too: `flex: none` stops the box shrinking but not the text wrapping, + which would break the one-line row in the narrow case the slot exists for. */ +.summarySuffix { + flex: none; + margin-left: 4px; + white-space: nowrap; + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(24px + var(--dsh-content-font-delta, 0px)); +} + +/* Diff-row +/- totals: the code font, matching the diff body the numbers + summarize; a wider gap keeps the digits from reading as part of the path. + Two px under the secondary tier (still riding the axis): mono digits read + optically larger than the sans path at the same size. The + half-pixel nudge closes the baseline gap the size difference leaves under + the row's box-centering (a transform, so flex layout is untouched). */ +.diffStat { + margin-left: 10px; + font-family: var(--ds-font-family-code); + font-size: calc(var(--dsh-content-font-size-secondary, 13px) - 2px); + transform: translateY(0.5px); +} + +.row:is(:hover, [aria-expanded="true"]) .diffAdded { + color: var(--dsw-alias-state-success-primary); +} + +.row:is(:hover, [aria-expanded="true"]) .diffRemoved { + color: var(--dsw-alias-state-error-primary); +} + +.fileStats { + margin: 0 0 8px; + padding: 0; + list-style: none; +} + +.fileStat { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 28px; + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(24px + var(--dsh-content-font-delta, 0px)); +} + +.fileStatPath { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fileStat .diffAdded { + color: var(--dsw-alias-state-success-primary); +} + +.fileStat .diffRemoved { + color: var(--dsw-alias-state-error-primary); +} + +/* File-tool path: same type as .summary, with a persistent link affordance. + Dotted dimmed underline: visible enough to say "clickable", light enough + that a long path doesn't read as one heavy rule under the row. Shrink-to-fit + (flex 0): the click target ends where the path text ends — the row's empty + remainder stays the expand/collapse toggle. */ +.fileLink { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin: 0; + padding: 0; + border: none; + background: none; + font: inherit; + text-align: left; + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(24px + var(--dsh-content-font-delta, 0px)); + color: inherit; + text-decoration: underline dotted; + text-decoration-color: currentColor; + text-decoration-thickness: 1px; + text-underline-offset: 3px; + cursor: pointer; +} + +/* Error row's collapsed summary: the failure's first line in the error color. */ +.errorSummary { + color: var(--dsw-alias-state-error-primary); +} + +/* Interrupted rows retain their business glyph and replace the summary with a visible warning. */ +.stoppedSummary { + color: var(--dsw-alias-state-warn-label); +} + +/* Expanded body + Inspect pill wrapper (sibling of .row: clicks never toggle). */ +.bodyWrap { + display: flex; + flex-direction: column; +} + +/* Hover-revealed jump to the trajectory record: a small pill in real flow + under the expanded body's bottom-left corner (it reserves its line, so + revealing never shifts layout); revealed by hovering anywhere on the tool + call — title row included — or by keyboard focus. */ +.inspectButton { + display: inline-flex; + align-self: flex-start; + align-items: center; + gap: 4px; + margin: 4px 0 2px 4px; + padding: 2px 8px; + border: 0.5px solid var(--dsw-alias-border-l3); + border-radius: 999px; + corner-shape: round; + /* Base background, not bg-overlay: the overlay token is a raised dark + surface and reads too heavy for a quiet in-flow affordance. */ + background: var(--dsw-alias-bg-base); + color: var(--dsw-alias-label-secondary); + font-size: 11px; + line-height: 16px; + cursor: pointer; + opacity: 0; + transition: opacity 100ms ease; +} + +.root:hover .inspectButton, +.inspectButton:focus-visible { + opacity: 1; +} + +.detailsBodyWrap { + position: relative; +} + +.detailsBodyWrap .inspectButton { + position: absolute; + top: 12px; + right: 12px; + margin: 0; +} + +/* Solid hover fill (a translucent token would let content bleed through). */ +.inspectButton:hover { + background: var(--dsw-alias-interactive-bg-hover-solid); + color: var(--dsw-alias-label-primary); +} + +/* Expanded-body scroll wrapper for the run_code CodeBlock; the IN/OUT card + and the terminal card scroll INSIDE their own surface instead, so the + scrollbar sits within the rounded card. */ +.bodyScroll { + max-height: 260px; + overflow-y: auto; +} + +/* Expanded input/output card (figma 1249:35657): the code-block surface and + radius from the TerminalBlock/CodeBlock family. The card itself is a plain + column — the padding and the IN/OUT gutter-label grid live on each section + so the divider spans the full card width and each section scrolls alone. */ +.ioCard { + display: flex; + flex-direction: column; + margin: 4px 0 4px 4px; + border: 0.5px solid var(--dsw-alias-border-l1); + border-radius: var(--dsw-radius-lg); + background: var(--dsw-alias-markdown-code-block); + font: var(--dsw-font-markdown-code-block-small); +} + +/* One card section (IN or OUT): the gutter-label grid, capped and scrolling + independently so a long input never buries a short output (and vice versa). */ +.ioSection { + display: grid; + grid-template-columns: max-content 1fr; + column-gap: 14px; + align-items: baseline; + padding: 12px 16px; + max-height: 150px; + overflow-y: auto; +} + +/* Card-internal scrollbar: a 2px transparent border clips the thumb inward so + it floats off the rounded card edge instead of hugging it (the terminal + card's own output scroller carries the same treatment in TerminalBlock). */ +.ioSection::-webkit-scrollbar-thumb { + border: 2px solid transparent; + background-clip: padding-box; + border-radius: var(--dsw-radius-sm); +} + +/* Track end-margins keep the thumb's travel out of the rounded corners. */ +.ioSection::-webkit-scrollbar-track { + margin: 6px 0; +} + +/* Caption (not tertiary): one step dimmer than the payload text so the + gutter labels read as labels, not as part of the content. Sticky against + the section's own scroll so the label stays readable while its payload + scrolls underneath (top 0 = the section's padding edge inside the + scrollport; start-aligned because sticky needs a block-start anchor). */ +.ioLabel { + position: sticky; + top: 0; + align-self: start; + color: var(--dsw-alias-label-caption); +} + +/* l2 hairline between the IN and OUT sections, spanning the full card width + (it sits between the padded sections, not inside their grid). */ +.ioDivider { + flex: none; + height: 0.5px; + background: var(--dsw-alias-border-l2); +} + +.ioText { + min-width: 0; + white-space: pre-wrap; + word-break: break-word; + color: var(--dsw-alias-label-secondary); +} + +/* A failed call's OUT text shares the collapsed summary's error color. */ +.ioText[data-error] { + color: var(--dsw-alias-state-error-primary); +} + +/* The block-shaped expanded bodies: the code variant's run_code program + through CodeBlock (shiki-highlighted TypeScript), a terminal card's command + output through TerminalBlock, a diff card through DiffBlock, a read card's + line-numbered window through ReadBlock, a search card's grouped matches or + path list through SearchBlock, and a web card's citation/source list through + WebBlock. All are drawn by the shared primitive, so only the row's + indentation is this file's concern — the margin also replaces each + primitive's own standalone vertical spacing with the flow's row rhythm. */ +.codeBody, +.terminalBody, +.diffBody, +.readBody, +.imageBody, +.searchBody, +.webBody { + margin: 4px 0 4px 4px; +} + +/* The recovery footer for a capped search: the result text (its `Full … stored + at …` locator) below the card in the muted tone, since the card holds only the + retained rows. Same column indent as the card body. */ +.searchRecovery { + margin: 4px 0 4px 4px; + white-space: pre-wrap; + overflow-wrap: anywhere; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-label-tertiary); +} + +/* The image card's in-card label: the view's replacement title, else the + shortened path — the same role ReadBlock's own label plays, so the presentation + contract's replacement-title rule is honoured here too. */ +.imageLabel { + margin-bottom: 4px; + overflow-wrap: anywhere; + font: var(--dsw-font-sm-13); + color: var(--dsw-alias-label-secondary); +} + +/* The image card's own result text (media type, dimensions, byte size) under the + gallery, in the same muted tone the search recovery footer uses. Always + rendered: the attachment presentation slot is optional, so when nothing + occupies it the gallery is empty and this line is the only evidence an image + was returned. The enclosing .imageBody already carries the row indent. */ +.imageMeta { + white-space: pre-wrap; + overflow-wrap: anywhere; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-label-tertiary); +} + +/* The terminal card scrolls its OUTPUT inside its own surface (same l1 + hairline as the IN/OUT card): the banner stays pinned and the scrollbar + never rides over it. 224px = the 260px card cap minus the ~36px banner. */ +.terminalBody { + --dsl-terminal-font: var(--dsw-font-markdown-code-block-small); + --dsl-terminal-line-height: 18px; + --dsl-terminal-output-max-height: 224px; + border: 0.5px solid var(--dsw-alias-border-l1); +} + +/* Visually hidden run-state label for assistive technology: the business icon + and animated text treatment do not name the state, so this text carries the + running/failed/interrupted state to a screen reader. */ +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/apps/web-ui/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx new file mode 100644 index 000000000..1c2226a8e --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -0,0 +1,486 @@ +import { + memo, + useCallback, + useMemo, + type KeyboardEvent, + type MouseEvent, + type ReactNode, +} from "react"; +import clsx from "clsx"; +import { + CodeBlock, + DiffBlock, + DisclosureRow, + IconInspectOutlineRegular, + ReadBlock, + SearchBlock, + TerminalBlock, + TextShimmer, + WebBlock, + diffTotals, +} from "@deepseek-ai/dsh-client-ui-primitives"; +import type { PropsRenderSlots, TranslateNS } from "@deepseek-ai/dsh-client-ui-slots"; +import type { OpenFileOptions, UseDisclosure } from "@deepseek-ai/dsh-client-ui-chat/client"; +import type { MessageImageLoader } from "@deepseek-ai/dsh-client-ui-conversation/client"; +import { CHAT_DIFF_MAX_LINES, type DiffCardModel } from "../models/diff-card-model.ts"; +import { CHAT_READ_MAX_LINES, type ReadCardModel } from "../models/read-card-model.ts"; +import type { ImageCardModel } from "../models/image-card-model.ts"; +import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from "../models/search-card-model.ts"; +import { + localizeTerminalCardModel, + terminalBlockLabels, + type TerminalCardModel, +} from "../models/terminal-card-model.ts"; +import { + codeToolbarLabels, + diffBlockLabels, + readBlockLabels, + searchBlockLabels, + webBlockLabels, +} from "../models/primitive-labels.ts"; +import type { AskQuestionCardModel } from "../models/ask-question-card-model.ts"; +import { + formatToolBody, + type ToolRowState, + type ToolRowVariant, +} from "../models/tool-call-model.ts"; +import type { WebCardModelProps } from "../models/web-card-model.ts"; +import { AskQuestionCard } from "./AskQuestionCard.tsx"; +import { ToolDetails, type ToolDetailsModel } from "./ToolDetails.tsx"; +import css from "./ToolRow.module.css"; + +export interface ToolRowProps { + /** Subscribe here, where the row owns its expanded body. */ + useDisclosure: UseDisclosure; + t: TranslateNS<"conversation">; + variant: ToolRowVariant; + /** Wire tool name for tool-owned styling layered over the generic variant. */ + toolName?: string | undefined; + icon: ReactNode; + title: string; + summary: string; + /** + * Trailing summary fragment rendered outside the ellipsized summary text, so + * a narrow row clips the summary before this. Appears before diff totals + * when both are present. null/absent omits this fragment. Error and stopped + * rows omit both the suffix and diff totals. + */ + summarySuffix?: string | null | undefined; + /** Original argument JSON formatted only while the row is expanded. */ + bodyRaw?: string | null | undefined; + /** Flattened result text for the expanded Output section; null/absent = no output section. */ + output?: string | null | undefined; + /** Ask-user transcript card; card fields are mutually exclusive and replace text sections. */ + askQuestion?: AskQuestionCardModel | null | undefined; + /** Error first line shown as the collapsed summary on an error row; null/absent = keep `summary`. */ + errorSummary?: string | null | undefined; + /** Terminal card; card fields are mutually exclusive and replace text sections. */ + terminal?: TerminalCardModel | null | undefined; + /** Diff card with inline totals colored on header hover and while expanded. */ + diff?: DiffCardModel | null | undefined; + /** Per-file +/- rows shown above the diff when more than one path changed. */ + fileStats?: readonly { path: string; added: number; removed: number }[] | undefined; + read?: ReadCardModel | null | undefined; + /** + * Image-card material for a call whose result is an image (derived by + * `imageCardModel`). Rendered through the `tool.call.images` slot, so the + * tool layer never imports an attachment implementation nor handles URL + * authorization. + */ + image?: ImageCardModel | null | undefined; + /** + * Dispatch the image gallery through the tool-owned `tool.call.images` + * slot, supplied by the toolview that owns this row together with the + * session-authorized loader. + */ + renderSlot?: PropsRenderSlots<"tool.call.images">["renderSlot"] | undefined; + /** Session-authorized image URL loader for the gallery slot. */ + loadImage?: MessageImageLoader | undefined; + search?: SearchCardModel | null | undefined; + web?: WebCardModelProps | null | undefined; + /** Read-only fields/list card derived from a successful recorded result. */ + details?: ToolDetailsModel | null | undefined; + state: ToolRowState; + /** + * Filesystem path from tool args; when set with onOpenFile, the summary + * renders as a hover-underline link that opens the host default app. + */ + filePath?: string | undefined; + /** 1-based line the call was about; absent = open the file at its beginning. */ + filePathLine?: number | undefined; + /** Open the path (already cwd-resolved), landing on `filePathLine` when given. */ + onOpenFile?: ((path: string, options?: OpenFileOptions) => void) | undefined; + /** Safe http(s) URL from tool args; when set, the summary renders as a link that opens it in a new tab. */ + href?: string | undefined; + /** + * Jump to this call in the trajectory view: a hover-revealed Inspect pill + * over the expanded body. Absent = no affordance. + */ + inspect?: (() => void) | undefined; +} + +/** Keep a summary link click from toggling the row; the browser still follows the link. */ +function stopLinkClick(event: MouseEvent): void { + event.stopPropagation(); +} + +/** Visually hidden run-state label for color-only running and settlement cues. */ +function stateStatus(state: ToolRowState, t: TranslateNS<"conversation">): string | null { + switch (state) { + case "preparing": + return t("row.preparing"); + case "running": + return t("row.running"); + case "error": + return t("row.failed"); + case "stopped": + return t("row.stopped"); + default: + return null; + } +} + +/** + * Render one localized tool summary and lazily mounted result card. + * Preparation retains the icon, title, and optional tool-name summary without disclosure. + * @param props - tool state, summary, output, and navigation callbacks. + * @returns the tool disclosure. + */ +export const ToolRow = memo(function ToolRow({ + t, + variant, + toolName, + icon, + title, + summary, + summarySuffix, + bodyRaw, + output, + askQuestion, + errorSummary, + terminal, + diff, + fileStats, + read, + image, + renderSlot, + loadImage, + search, + web, + details, + state, + filePath, + filePathLine, + onOpenFile, + href, + inspect, + useDisclosure, +}: ToolRowProps) { + const { expanded, toggle: toggleExpand } = useDisclosure(); + const terminalLabels = useMemo(() => terminalBlockLabels(t), [t]); + const diffLabels = useMemo(() => diffBlockLabels(t), [t]); + const readLabels = useMemo(() => readBlockLabels(t), [t]); + const searchLabels = useMemo(() => searchBlockLabels(t), [t]); + const webLabels = useMemo(() => webBlockLabels(t), [t]); + const terminalBody = useMemo( + () => + terminal === undefined || terminal === null ? null : localizeTerminalCardModel(terminal, t), + [terminal, t], + ); + const diffBody = diff ?? null; + const readBody = read ?? null; + const imageBody = + image !== undefined && image !== null && renderSlot !== undefined && loadImage !== undefined + ? image + : null; + const searchBody = search ?? null; + const webBody = web ?? null; + const askQuestionBody = askQuestion ?? null; + const detailsBody = details ?? null; + const inputRaw = bodyRaw ?? null; + const outputText = output ?? null; + const card = + askQuestionBody ?? + terminalBody ?? + diffBody ?? + readBody ?? + imageBody ?? + searchBody ?? + webBody ?? + detailsBody; + const expandable = + state !== "preparing" && (inputRaw !== null || outputText !== null || card !== null); + const open = expanded && expandable; + const bodyText = useMemo( + () => (open && card === null && inputRaw !== null ? formatToolBody(variant, inputRaw) : null), + [card, inputRaw, open, variant], + ); + const status = stateStatus(state, t); + const running = state === "running" || state === "preparing"; + const normalSummary = + terminalBody?.description ?? (open ? (detailsBody?.expandedSummary ?? summary) : summary); + // A failure keeps its first result line when available and otherwise turns + // the ordinary summary red. An interruption turns the tool-owned summary + // amber while retaining the business icon and hidden state announcement. + const failureLine = state === "error" ? (errorSummary ?? normalSummary) : null; + const summaryText = failureLine ?? normalSummary; + // Diff totals remain visible after the optional summary suffix. + const diffStat = useMemo( + () => (diffBody === null ? null : diffTotals(diffBody.card.diffs)), + [diffBody], + ); + const settledWithCue = state === "error" || state === "stopped"; + const suffix = settledWithCue ? null : (summarySuffix ?? null); + const totals = settledWithCue ? null : diffStat; + const openFile = useMemo( + () => + filePath !== undefined && onOpenFile !== undefined && !settledWithCue + ? (event: MouseEvent) => { + event.stopPropagation(); + if (filePathLine === undefined) onOpenFile(filePath); + else onOpenFile(filePath, { line: filePathLine }); + } + : undefined, + [filePath, filePathLine, onOpenFile, settledWithCue], + ); + const linkHref = settledWithCue ? undefined : href; + // Keep Enter/Space on the focused path or URL link from bubbling to the row's + // keydown handler, which would preventDefault() the key and toggle expand + // instead of activating the link — the keyboard analogue of the click + // handlers' stopPropagation. Enter activates both links and Space activates + // the path button; Space on a URL link does nothing. + const summaryLinkKeyDown = useCallback((event: KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") event.stopPropagation(); + }, []); + // The code variant's program renders through CodeBlock (shiki), so only its + // output joins the IN/OUT card; every other variant's input does too. + const cardBody = variant === "code" ? null : bodyText; + const collapsedContent = useMemo( + () => + summaryText !== "" && ( + /* An empty summary drops the separator with it (a row that is only + its title shows no trailing dot). */ + <> + + {openFile !== undefined ? ( + + ) : linkHref !== undefined ? ( + + {summaryText} + + ) : ( + + {summaryText} + + )} + {suffix !== null && {suffix}} + {totals !== null && ( + + {`+${totals.added}`}{" "} + {`-${totals.removed}`} + + )} + + ), + [summaryLinkKeyDown, linkHref, openFile, state, suffix, totals, summaryText], + ); + const expandedContent = useMemo( + () => + open ? ( +
    + {askQuestionBody !== null ? ( + + ) : terminalBody !== null ? ( + + ) : diffBody !== null ? ( + <> + {fileStats !== undefined && fileStats.length > 1 && ( +
      + {fileStats.map((file) => ( +
    • + {file.path} + + {`+${file.added}`}{" "} + {`-${file.removed}`} + +
    • + ))} +
    + )} + + + ) : readBody !== null ? ( + + ) : imageBody !== null ? ( + /* Label, gallery, then the result's OWN envelope text. The text + comes from the image card model (which reads the result's text + block), never from the row's flattened output: an image read's + content is [text envelope, image block] and flattening + JSON.stringifies the image block, printing the raw attachment + object under the picture. It is not redundant either — the + attachment slot can render nothing, and then this line is the + only evidence an image was returned. */ +
    +
    {imageBody.label}
    + {renderSlot !== undefined && + loadImage !== undefined && + renderSlot("tool.call.images", { + images: imageBody.images, + loadImage, + align: "start", + })} +
    {imageBody.text}
    +
    + ) : searchBody !== null ? ( + <> + + {/* A capped search's recovery locator lives only in the result + text; show it below the card so the dropped rows survive. */} + {searchBody.recovery !== undefined && ( +
    {searchBody.recovery}
    + )} + + ) : webBody !== null ? ( + + ) : detailsBody !== null ? ( + + ) : ( + <> + {variant === "code" && bodyText !== null && ( +
    + +
    + )} + {(cardBody !== null || outputText !== null) && ( +
    + {cardBody !== null && ( +
    + {t("row.input")} + {cardBody} +
    + )} + {cardBody !== null && outputText !== null && ( + + )} + {outputText !== null && ( +
    + {t("row.output")} + + {outputText} + +
    + )} +
    + )} + + )} + {inspect !== undefined && ( + + )} +
    + ) : undefined, + [ + open, + detailsBody, + askQuestionBody, + terminalBody, + terminalLabels, + diffBody, + fileStats, + diffLabels, + readBody, + readLabels, + imageBody, + renderSlot, + loadImage, + searchBody, + searchLabels, + webBody, + webLabels, + inspect, + t, + onOpenFile, + variant, + bodyText, + cardBody, + outputText, + state, + ], + ); + return ( +
    + {status !== null && {status}} + + {expandedContent} + +
    + ); +}); diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/models/ask-question-card-model.ts b/apps/web-ui/packages/client/ui-tool/src/client/tool/models/ask-question-card-model.ts new file mode 100644 index 000000000..f931b6b7b --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/models/ask-question-card-model.ts @@ -0,0 +1,25 @@ +/** Pure ask-user transcript card data shared by its presenter and renderer. @module */ + +interface AnsweredQuestionCardItem { + id: string; + question: string; + answers: readonly string[]; +} + +interface UnansweredQuestionCardItem { + id: string; + question: string; +} + +/** Validated, localized data rendered by the ask-user transcript card. */ +export type AskQuestionCardModel = + | { + kind: "answered"; + questions: readonly AnsweredQuestionCardItem[]; + skippedLabel: string; + } + | { + kind: "unanswered"; + questions: readonly UnansweredQuestionCardItem[]; + verdict: string; + }; diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/models/auto-review-denial.ts b/apps/web-ui/packages/client/ui-tool/src/client/tool/models/auto-review-denial.ts new file mode 100644 index 000000000..9227e3bd8 --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/models/auto-review-denial.ts @@ -0,0 +1,36 @@ +import type { TranslateNS } from "@deepseek-ai/dsh-client-ui-slots"; +import type { AutoReviewDenial } from "./tool-call-model.ts"; + +/** Localized copy that replaces ordinary failed-call output for an Auto denial. */ +export interface AutoReviewDenialPresentation { + summary: string; + output: string; +} + +/** + * Normalize only the user-visible copy; the durable error keeps the raw reason. + * @param reason - raw persisted reviewer reason, or null when none was recorded. + * @returns one display line, or null when the reason has no displayable text. + */ +export function normalizeAutoReviewReason(reason: string | null): string | null { + if (reason === null) return null; + const normalized = reason.trim().replace(/[\r\n\u2028\u2029]+/gu, " "); + return normalized === "" ? null : normalized; +} + +/** + * Resolve the collapsed identity and the single expanded OUT line. + * @param denial - locale-neutral persisted denial facts. + * @param t - conversation-namespace translator. + * @returns localized summary and output text for the Tool row. + */ +export function localizeAutoReviewDenial( + denial: AutoReviewDenial, + t: TranslateNS<"conversation">, +): AutoReviewDenialPresentation { + const reason = normalizeAutoReviewReason(denial.reason) ?? t("tool.autoReviewReasonFallback"); + return { + summary: t("tool.autoReviewRejected"), + output: t("tool.autoReviewNotExecuted", { reason }), + }; +} diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/models/control-details-model.ts b/apps/web-ui/packages/client/ui-tool/src/client/tool/models/control-details-model.ts new file mode 100644 index 000000000..ba79da039 --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/models/control-details-model.ts @@ -0,0 +1,404 @@ +/** Entity lists and receipts for agent, job, terminal, and language-server tools. */ +import type { ToolDetailsModel } from "../components/ToolDetails.tsx"; +import { + detailBadge, + detailList, + detailRecord, + inspectionItems, + type DetailItem, + type DetailTranslate, +} from "./detail-model-shared.ts"; + +const OUTPUT_TRUNCATED = "\n[output truncated]"; + +function arg(args: Record, key: string): string { + const value = args[key]; + return typeof value === "string" ? value : ""; +} + +function receipt( + title: string, + badge: NonNullable, + t: DetailTranslate, + fields: DetailItem["fields"] = [], + description?: string, +): ToolDetailsModel { + return { + ...detailList( + [{ title, badge, fields, ...(description === undefined ? {} : { description }) }], + `${title} · ${badge.label}`, + t, + ), + expandedSummary: title, + }; +} + +function agentList(text: string, json: unknown, t: DetailTranslate): ToolDetailsModel | null { + if (Array.isArray(json)) + return detailList( + inspectionItems(json, t), + t("detail.agents.count", { count: json.length }), + t, + ); + if (text === "(no subagents)") return detailList([], t("detail.agents.count", { count: 0 }), t); + const items: DetailItem[] = []; + for (const line of text.split("\n")) { + const match = /^(\S+) \[([^\]]+)\](?: parent=(\S+) depth=(\d+))?(?: — (.*))?$/u.exec(line); + if (match === null) return null; + const [, id, state, parent, depth, title] = match; + if (id === undefined || state === undefined) return null; + items.push({ + title: title ?? id, + ...(title === undefined ? {} : { subtitle: id }), + badge: detailBadge(state, t), + fields: + parent === undefined + ? [] + : [ + { label: t("detail.field.parent"), value: parent }, + { label: t("detail.field.depth"), value: depth ?? "" }, + ], + }); + } + return detailList(items, t("detail.agents.count", { count: items.length }), t); +} + +function jobList(text: string, t: DetailTranslate): ToolDetailsModel | null { + if (text === "(no background jobs)") + return detailList([], t("detail.jobs.count", { count: 0 }), t); + const items: DetailItem[] = []; + for (const line of text.split("\n")) { + const match = /^(\S+) \[([^\]]+)\] (\S+) — (.*)$/u.exec(line); + if (match === null) return null; + const [, id, kind, state, title] = match; + if (id === undefined || kind === undefined || state === undefined || title === undefined) + return null; + items.push({ + title, + subtitle: id, + badge: detailBadge(state, t), + fields: [{ label: t("detail.field.type"), value: kind }], + }); + } + return detailList(items, t("detail.jobs.count", { count: items.length }), t); +} + +function terminalList(text: string, t: DetailTranslate): ToolDetailsModel | null { + if (text === "(no terminal sessions)") + return detailList([], t("detail.terminals.count", { count: 0 }), t); + const items: DetailItem[] = []; + for (const line of text.split("\n")) { + const match = + /^(\S+)(?: \((.*?)\))? \[([^\]]+)\] (running|exited code=(\S+) signal=(\S+))(?: pid=(\d+))?$/u.exec( + line, + ); + if (match === null) return null; + const [, id, name, type, state, exitCode, signal, pid] = match; + if (id === undefined || type === undefined || state === undefined) return null; + const fields = [{ label: t("detail.field.type"), value: type }]; + if (pid !== undefined) fields.push({ label: t("detail.field.pid"), value: pid }); + if (exitCode !== undefined) fields.push({ label: t("detail.field.exitCode"), value: exitCode }); + if (signal !== undefined && signal !== "null") + fields.push({ label: t("detail.field.signal"), value: signal }); + items.push({ + title: name ?? id, + ...(name === undefined ? {} : { subtitle: id }), + badge: + state === "running" + ? detailBadge("running", t) + : { label: t("detail.status.exited"), tone: exitCode === "0" ? "success" : "neutral" }, + fields, + }); + } + return detailList(items, t("detail.terminals.count", { count: items.length }), t); +} + +function lspDetails( + args: Record, + text: string, + t: DetailTranslate, +): ToolDetailsModel | null { + const file = arg(args, "file_path"); + const operation = arg(args, "operation"); + if (file === "" || typeof args.line !== "number" || typeof args.character !== "number") + return null; + const source = `${file}:${args.line}:${args.character}`; + if (operation === "hover") + return detailList( + [{ title: source, location: { path: file, line: args.line }, markdown: text, fields: [] }], + source, + t, + ); + if (text === "No results.") return detailList([], t("detail.locations.count", { count: 0 }), t); + const items: DetailItem[] = []; + for (const line of text.split("\n")) { + if (line.startsWith("… ")) { + items.push({ description: line, fields: [] }); + continue; + } + const match = /^(.*):(\d+):(\d+)$/u.exec(line); + if (match === null) return null; + const [, path, row, column] = match; + if (path === undefined || row === undefined || column === undefined) return null; + const isUri = /^[a-z][a-z\d+.-]*:/iu.test(path) && !/^[a-z]:[\\/]/iu.test(path); + items.push({ + title: path, + subtitle: t("detail.location", { line: row, column }), + fields: [], + ...(isUri ? {} : { location: { path, line: Number(row) } }), + }); + } + const count = items.filter((item) => item.title !== undefined).length; + return detailList(items, `${file} · ${t("detail.locations.count", { count })}`, t); +} + +/** + * Derive entity lists and operation receipts from supported recorded output formats. + * @param name - Wire tool name, including Team-scoped aliases. + * @param args - Parsed recorded arguments. + * @param text - Successful recorded result text. + * @param json - Parsed whole-result JSON, or undefined for non-JSON text. + * @param t - Conversation translator. + * @returns Compact details, or null when the output format is not recognized. + */ +export function controlDetails( + name: string, + args: Record, + text: string, + json: unknown, + t: DetailTranslate, +): ToolDetailsModel | null { + const target = + arg(args, "target") || arg(args, "agent_id") || arg(args, "sessionId") || arg(args, "job_id"); + switch (name) { + case "list_agents": + return agentList(text, json, t); + case "job_list": + return jobList(text, t); + case "terminal_list": + return terminalList(text, t); + case "lsp": + return lspDetails(args, text, t); + case "spawn_teammate": + if (!detailRecord(json) || !detailRecord(json.member)) return null; + return detailList(inspectionItems(json.member, t), arg(args, "name"), t); + case "team_task_create": + case "team_task_get": + case "team_task_update": + if (!detailRecord(json) || typeof json.subject !== "string") return null; + return detailList(inspectionItems(json, t), json.subject, t); + case "team_task_list": + if (!detailRecord(json) || !Array.isArray(json.tasks)) return null; + if (json.nextCursor !== undefined && typeof json.nextCursor !== "number") return null; + return { + ...detailList( + inspectionItems(json.tasks, t), + t("detail.tasks.count", { count: json.tasks.length }), + t, + ), + ...(json.nextCursor === undefined + ? {} + : { caption: t("detail.tasks.nextPage", { cursor: String(json.nextCursor) }) }), + }; + case "send_message": { + const status = detailRecord(json) ? json.status : undefined; + if (status === "accepted" || status === "queued") + return receipt( + target, + { + label: t(status === "queued" ? "detail.status.queued" : "detail.receipt.delivered"), + tone: status === "queued" ? "warning" : "success", + }, + t, + [], + arg(args, "message"), + ); + return text === `message delivered to agent ${target}` + ? receipt( + target, + { label: t("detail.receipt.delivered"), tone: "success" }, + t, + [], + arg(args, "message"), + ) + : null; + } + case "interrupt_agent": + if (detailRecord(json) && typeof json.previousStatus === "string") + return receipt(target, { label: t("detail.receipt.interrupt"), tone: "warning" }, t, [ + { + label: t("detail.field.previousStatus"), + value: detailBadge(json.previousStatus, t).label, + }, + ]); + return text === `interrupt requested for agent ${target}` + ? receipt(target, { label: t("detail.receipt.interrupt"), tone: "warning" }, t) + : null; + case "wait_agent": + if (!detailRecord(json) || typeof json.timedOut !== "boolean") return null; + if (detailRecord(json.noProgress) && typeof json.noProgress.message === "string") + return detailList( + [ + { + title: t("detail.wait.noProgress"), + description: json.noProgress.message, + fields: [], + }, + ], + t("detail.wait.noProgress"), + t, + ); + return receipt( + t("detail.wait.title"), + { + label: t(json.timedOut ? "detail.wait.timeout" : "detail.wait.changed"), + tone: "neutral", + }, + t, + ); + case "subagent": { + const started = /^started (background subagent job|subagent) (\S+)$/u.exec(text); + if (started !== null) + return receipt( + arg(args, "prompt"), + { label: t("detail.receipt.started"), tone: "info" }, + t, + [ + { + label: t(started[1] === "subagent" ? "detail.field.agent" : "detail.field.job"), + value: started[2] ?? "", + }, + ], + ); + return detailList( + [ + { + title: t("detail.agent.reply"), + markdown: text, + fields: [], + groups: [ + { + label: t("detail.field.task"), + items: [{ description: arg(args, "prompt"), fields: [] }], + }, + ], + }, + ], + t("detail.agent.reply"), + t, + ); + } + case "list_subagent_models": { + const items = text.split("\n").map((line) => { + const split = line.indexOf(" — "); + return split < 0 + ? { description: line, fields: [] } + : { title: line.slice(0, split), description: line.slice(split + 3), fields: [] }; + }); + return detailList( + items, + arg(args, "model") || arg(args, "provider") || t("detail.models.title"), + t, + ); + } + case "job_output": { + const match = /\n\[status: ([^,\]\n]+)(?:, ([^\]\n]+))?\]$/u.exec(text); + if (match === null || match[1] === undefined) return null; + const output = text.slice(0, match.index); + const truncated = output.endsWith(OUTPUT_TRUNCATED); + const code = truncated ? output.slice(0, -OUTPUT_TRUNCATED.length) : output; + const description = [match[2], truncated ? t("detail.output.truncated") : undefined] + .filter((value): value is string => value !== undefined) + .join(" · "); + return { + ...detailList( + [ + { + title: target, + badge: detailBadge(match[1], t), + fields: [], + ...(description === "" ? {} : { description }), + code: { text: code }, + }, + ], + `${target} · ${detailBadge(match[1], t).label}`, + t, + ), + expandedSummary: target, + }; + } + case "job_kill": + if (text === `requested cancellation of job ${target}`) + return receipt( + target, + { label: t("detail.receipt.cancel"), tone: "warning" }, + t, + [], + arg(args, "reason"), + ); + if (text.startsWith(`job ${target} had already finished `)) + return receipt(target, { label: t("detail.receipt.alreadyFinished"), tone: "neutral" }, t); + return null; + case "terminal_open": { + const match = + /^started terminal session (\S+)(?: \((.*?)\))? \[type: ([^\]]+)\]\n([\s\S]*)$/u.exec(text); + if ( + match === null || + match[1] === undefined || + match[3] === undefined || + match[4] === undefined + ) + return null; + return detailList( + [ + { + title: match[2] ?? match[1], + ...(match[2] === undefined ? {} : { subtitle: match[1] }), + badge: { label: t("detail.receipt.started"), tone: "info" }, + fields: [{ label: t("detail.field.type"), value: match[3] }], + code: { text: match[4] }, + }, + ], + match[2] ?? match[1], + t, + ); + } + case "terminal_read": { + const match = /\n\[lines: (\d+)-(\d+) of (\d+)\](\n\[output truncated\])?$/u.exec(text); + if (match === null) return null; + return detailList( + [ + { + title: target, + subtitle: t("detail.output.lines", { + begin: match[1] ?? "", + end: match[2] ?? "", + total: match[3] ?? "", + }), + fields: [], + code: { text: text.slice(0, match.index) }, + ...(match[4] === undefined ? {} : { description: t("detail.output.truncated") }), + }, + ], + target, + t, + ); + } + case "terminal_signal": { + const match = /^delivered (\S+) to foreground process group (\d+)$/u.exec(text); + if (match === null || match[1] === undefined || match[2] === undefined) return null; + return receipt(target, { label: t("detail.receipt.signal"), tone: "success" }, t, [ + { label: t("detail.field.signal"), value: match[1] }, + { label: t("detail.field.processGroup"), value: match[2] }, + ]); + } + case "terminal_close": + if (text === `closed terminal session ${target}`) + return receipt(target, { label: t("detail.receipt.closed"), tone: "neutral" }, t); + if (text === `terminal session ${target} was already closing`) + return receipt(target, { label: t("detail.receipt.closing"), tone: "neutral" }, t); + return null; + default: + return null; + } +} diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/models/detail-model-shared.ts b/apps/web-ui/packages/client/ui-tool/src/client/tool/models/detail-model-shared.ts new file mode 100644 index 000000000..d00e8412b --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/models/detail-model-shared.ts @@ -0,0 +1,242 @@ +/** Recorded-value formatting shared by entity lists and inspection results. */ +import type { TranslateNS } from "@deepseek-ai/dsh-client-ui-slots"; +import type { ToolDetailItem, ToolDetailsModel } from "../components/ToolDetails.tsx"; + +/** Translator supplied by the conversation slot. */ +export type DetailTranslate = TranslateNS<"conversation">; +/** A compact detail row derived from durable tool output. */ +export type DetailItem = ToolDetailItem; + +/** + * Narrow parsed JSON to an object record. + * @param value - Parsed result or argument value. + * @returns Whether named fields can be read. + */ +export function detailRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Check a recorded string field that must contain visible text. + * @param value - Parsed field value. + * @returns Whether the field is a non-empty string after trimming. + */ +export function nonempty(value: unknown): value is string { + return typeof value === "string" && value.trim() !== ""; +} + +/** + * Decode an entire JSON result without accepting a partial prefix. + * @param text - Recorded text. + * @returns Parsed JSON, or undefined for non-JSON output. + */ +export function detailJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + // Text receipts and reports are decoded by their tool-specific adapters. + return undefined; + } +} + +const STATUS_KEYS = { + running: "detail.status.running", + idle: "detail.status.idle", + ready: "detail.status.ready", + inactive: "detail.status.inactive", + provisioning: "detail.status.provisioning", + failed: "detail.status.failed", + error: "detail.status.failed", + completed: "detail.status.completed", + complete: "detail.status.completed", + done: "detail.status.completed", + pending: "detail.todo.pending", + in_progress: "detail.todo.in_progress", + deleted: "detail.status.deleted", + killed: "detail.status.killed", + blocked: "detail.goal.blocked", + accepted: "detail.status.accepted", + queued: "detail.status.queued", +} as const; + +/** + * Give a recorded status its localized name and a static semantic color. + * @param status - Result status, retained verbatim when the vocabulary is unknown. + * @param t - Conversation translator. + * @returns A badge that does not imply a live subscription. + */ +export function detailBadge(status: string, t: DetailTranslate): NonNullable { + const key = Object.hasOwn(STATUS_KEYS, status) + ? STATUS_KEYS[status as keyof typeof STATUS_KEYS] + : undefined; + const tone = ["completed", "complete", "done", "accepted"].includes(status) + ? "success" + : ["failed", "error"].includes(status) + ? "error" + : ["blocked", "killed", "pending", "queued", "inactive"].includes(status) + ? "warning" + : ["running", "in_progress", "provisioning"].includes(status) + ? "info" + : "neutral"; + return { label: key === undefined ? status : t(key), tone }; +} + +const FIELD_KEYS = { + id: "detail.field.id", + revision: "detail.field.revision", + platform: "detail.field.platform", + provider: "detail.field.provider", + model: "detail.field.model", + role: "detail.field.role", + context: "detail.field.context", + ownerName: "detail.field.owner", + ready: "detail.field.ready", + blockedBy: "detail.field.dependencies", + writeScopes: "detail.field.writeScopes", + writeScopeWarnings: "detail.field.warnings", + diagnostics: "detail.field.diagnostics", + methods: "detail.field.methods", + inputSchema: "detail.field.inputSchema", + outputSchema: "detail.field.outputSchema", + currentPackageId: "detail.field.currentPackage", + nextPackageId: "detail.field.nextPackage", + latestRun: "detail.field.latestRun", + packages: "detail.field.packages", + registrations: "detail.field.registrations", + props: "detail.field.props", + data: "detail.field.data", + source: "detail.field.source", + arguments: "row.input", + content: "detail.field.content", + message: "detail.field.message", + messageId: "detail.field.messageId", + status: "detail.state", + root: "detail.field.root", + pid: "detail.field.pid", + type: "detail.field.type", + time: "detail.field.time", + seq: "detail.field.seq", + turn: "detail.field.turn", + step: "detail.field.step", + callId: "detail.field.callId", + agentsStarted: "detail.field.agents", + output: "row.output", + result: "detail.field.result", +} as const; + +/** + * Name a known tool field while preserving extension-owned field names. + * @param key - Recorded JSON property name. + * @param t - Conversation translator. + * @returns Localized known label or the original property name. + */ +export function detailLabel(key: string, t: DetailTranslate): string { + const label = Object.hasOwn(FIELD_KEYS, key) + ? FIELD_KEYS[key as keyof typeof FIELD_KEYS] + : undefined; + return label === undefined ? key : t(label); +} + +function scalar(value: unknown, t: DetailTranslate): string { + if (value === null) return t("detail.none"); + if (typeof value === "boolean") return t(value ? "detail.yes" : "detail.no"); + return typeof value === "string" ? value : JSON.stringify(value); +} + +const INSPECTION_KEY_ORDER = [ + "subject", + "title", + "name", + "pluginId", + "packageId", + "id", + "summary", +] as const; +const INSPECTION_DETAIL_KEYS = ["description", "purpose"] as const; +const MAX_INSPECTION_ITEMS = 40; + +/** + * Project open inspection records into readable fields and named disclosures. + * @param value - Parsed JSON, including provider-owned extension fields. + * @param t - Conversation translator. + * @param depth - Current disclosure depth; deeper records remain available as code. + * @returns Entity rows preserving the order of visible values. + */ +export function inspectionItems(value: unknown, t: DetailTranslate, depth = 0): DetailItem[] { + if (depth > 4 && value !== null && typeof value === "object") { + return [{ code: { text: JSON.stringify(value, null, 2), language: "json" }, fields: [] }]; + } + if (Array.isArray(value)) { + const items = value + .slice(0, MAX_INSPECTION_ITEMS) + .flatMap((entry) => inspectionItems(entry, t, depth + 1)); + if (value.length > MAX_INSPECTION_ITEMS) + items.push({ + description: t("detail.moreInInspect", { count: value.length - MAX_INSPECTION_ITEMS }), + fields: [], + }); + return items.length === 0 ? [{ description: t("detail.empty"), fields: [] }] : items; + } + if (!detailRecord(value)) return [{ description: scalar(value, t), fields: [] }]; + const titleKey = INSPECTION_KEY_ORDER.find( + (key) => typeof value[key] === "string" && value[key] !== "", + ); + const descriptionKey = INSPECTION_DETAIL_KEYS.find( + (key) => typeof value[key] === "string" && value[key] !== "", + ); + const title = titleKey === undefined ? undefined : String(value[titleKey]); + const fields: DetailItem["fields"][number][] = []; + const groups: NonNullable[number][] = []; + for (const [key, field] of Object.entries(value)) { + if ( + key === titleKey || + key === descriptionKey || + (key === "status" && typeof field === "string") + ) + continue; + if (key === "inputSchema" || key === "outputSchema") { + groups.push({ + label: detailLabel(key, t), + items: [{ fields: [], code: { text: JSON.stringify(field, null, 2), language: "json" } }], + }); + continue; + } + if (Array.isArray(field) && field.length === 0) continue; + if (key === "arguments" && typeof field === "string") { + const args = detailJson(field); + if (detailRecord(args)) { + groups.push({ label: detailLabel(key, t), items: inspectionItems(args, t, depth + 1) }); + continue; + } + } + if (field !== null && typeof field === "object") + groups.push({ label: detailLabel(key, t), items: inspectionItems(field, t, depth + 1) }); + else fields.push({ label: detailLabel(key, t), value: scalar(field, t) }); + } + return [ + { + ...(title === undefined && typeof value.status !== "string" + ? {} + : { title: title ?? t("detail.field.result") }), + ...(descriptionKey === undefined ? {} : { description: String(value[descriptionKey]) }), + ...(typeof value.status === "string" ? { badge: detailBadge(value.status, t) } : {}), + fields, + ...(groups.length === 0 ? {} : { groups }), + }, + ]; +} + +/** + * Give a result list consistent historical context and empty-state copy. + * @param items - Recorded result rows. + * @param summary - Collapsed-row summary. + * @param t - Conversation translator. + * @returns A complete compact detail model. + */ +export function detailList( + items: readonly DetailItem[], + summary: string, + t: DetailTranslate, +): ToolDetailsModel { + return { items, summary, caption: t("detail.recordedResult"), empty: t("detail.empty") }; +} diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/models/details-card-model.ts b/apps/web-ui/packages/client/ui-tool/src/client/tool/models/details-card-model.ts new file mode 100644 index 000000000..e24ae5e8a --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/models/details-card-model.ts @@ -0,0 +1,252 @@ +/** Recorded todo, goal, and schedule values for the compact detail body. */ +import type { TranslateNS } from "@deepseek-ai/dsh-client-ui-slots"; +import type { ToolCallBlock } from "./tool-call-model.ts"; +import type { ToolDetailsModel } from "../components/ToolDetails.tsx"; +import { parsedToolCall, singleResultText } from "./raw-tool-call.ts"; +import { controlDetails } from "./control-details-model.ts"; +import { inspectionDetails } from "./inspection-details-model.ts"; +import { detailJson, detailRecord, nonempty } from "./detail-model-shared.ts"; + +type Translate = TranslateNS<"conversation">; +type DetailItem = ToolDetailsModel["items"][number]; + +function count(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +function formatDate(date: Date, locale: string, fallback: string): string { + try { + return new Intl.DateTimeFormat(locale, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + timeZoneName: "short", + }).format(date); + } catch { + return fallback; + } +} + +/** + * Derive the compact todo list from a todo_write call. + * @param args - Parsed todo_write arguments. + * @param t - Conversation dictionary translator. + * @returns Localized todo details, or null for unsupported input. + */ +export function todosDetail(args: Record, t: Translate): ToolDetailsModel | null { + if (!Array.isArray(args.todos)) return null; + const items: DetailItem[] = []; + const seen = new Set(); + for (const todo of args.todos) { + if (!detailRecord(todo) || !nonempty(todo.content)) return null; + const status = todo.status; + if (status !== "completed" && status !== "in_progress" && status !== "pending") return null; + const title = todo.content.trim(); + if (seen.has(title)) return null; + seen.add(title); + items.push({ title, status: { value: status, label: t(`detail.todo.${status}`) }, fields: [] }); + } + return { items, empty: t("detail.todo.empty") }; +} + +function goalDetail(value: unknown, t: Translate): ToolDetailsModel | null { + if (!detailRecord(value)) return null; + if (value.goal === null) return { items: [], empty: t("detail.goal.empty") }; + const goal = value.goal; + if ( + !detailRecord(goal) || + !nonempty(goal.id) || + !nonempty(goal.objective) || + !count(goal.revision) || + !count(goal.roundsStarted) || + !count(goal.maxGoalRounds) + ) + return null; + const phase = goal.phase; + if (phase !== "active" && phase !== "paused" && phase !== "blocked" && phase !== "complete") + return null; + if (value.activation !== "armed" && value.activation !== "disarmed") return null; + const fields = [ + { + label: t("detail.state"), + value: t( + phase === "active" && value.activation === "disarmed" + ? "detail.goal.disarmed" + : `detail.goal.${phase}`, + ), + }, + { label: t("detail.goal.rounds"), value: `${goal.roundsStarted} / ${goal.maxGoalRounds}` }, + ]; + if (goal.blockedReason !== undefined) { + if ( + !detailRecord(goal.blockedReason) || + !nonempty(goal.blockedReason.code) || + !nonempty(goal.blockedReason.message) + ) + return null; + fields.push({ label: t("detail.goal.reason"), value: goal.blockedReason.message }); + } + return { items: [{ title: goal.objective, fields }] }; +} + +/** ISO-weekday keys, Monday first: a stored weekly rule names days 1 through 7. */ +const WEEKDAY_KEYS = [ + "detail.weekday.1", + "detail.weekday.2", + "detail.weekday.3", + "detail.weekday.4", + "detail.weekday.5", + "detail.weekday.6", + "detail.weekday.7", +] as const; + +function interval(seconds: number, t: Translate): string { + if (seconds % 86400 === 0) return t("detail.days", { count: seconds / 86400 }); + if (seconds % 3600 === 0) return t("detail.hours", { count: seconds / 3600 }); + if (seconds % 60 === 0) return t("detail.minutes", { count: seconds / 60 }); + return t("detail.seconds", { count: seconds }); +} + +/** A stored wall clock without its zero seconds, the figure the schedule surfaces state. */ +function shortTime(time: string): string { + return time.replace(/:00\.000$/, "").replace(/\.000$/, ""); +} + +/** Localized weekday list of a stored weekly rule, or undefined when the set is empty or out of range. */ +function weekdayText(days: unknown, t: Translate): string | undefined { + if (!Array.isArray(days) || days.length === 0) return undefined; + const labels: string[] = []; + for (const day of days) { + if (typeof day !== "number" || !Number.isInteger(day)) return undefined; + const key = WEEKDAY_KEYS[day - 1]; + if (key === undefined) return undefined; + labels.push(t(key)); + } + // The reader's own enumeration separator, the one the Web detail states. + return labels.join(t("detail.weekday.join")); +} + +function scheduleItem(value: unknown, t: Translate, locale: string): DetailItem | null { + if ( + !detailRecord(value) || + !nonempty(value.id) || + !nonempty(value.prompt) || + typeof value.scheduledAt !== "string" || + // `host` is the current Host-delivered view; `session-local` stays readable + // for results recorded before the Host storage domain owned delivery. + (value.deliveryMode !== "host" && value.deliveryMode !== "session-local") || + (value.state !== "scheduled" && value.state !== "overdue") + ) + return null; + const date = new Date(value.scheduledAt); + if (!Number.isFinite(date.getTime()) || date.toISOString() !== value.scheduledAt) return null; + let frequency: string; + switch (value.kind) { + case "at": + frequency = t("detail.schedule.once"); + break; + case "after": + if (!count(value.afterSeconds) || value.afterSeconds === 0) return null; + frequency = t("detail.schedule.once"); + break; + case "every": + if (!count(value.everySeconds) || value.everySeconds === 0) return null; + frequency = t("detail.schedule.every", { interval: interval(value.everySeconds, t) }); + break; + case "daily": + if (!nonempty(value.time) || !nonempty(value.timeZone)) return null; + frequency = t("detail.schedule.daily", { time: shortTime(value.time), zone: value.timeZone }); + break; + case "weekly": { + const days = weekdayText(value.weekdays, t); + if (!nonempty(value.time) || !nonempty(value.timeZone) || days === undefined) return null; + frequency = t("detail.schedule.weekly", { + days, + time: shortTime(value.time), + zone: value.timeZone, + }); + break; + } + case "cron": + if (!nonempty(value.expression) || !nonempty(value.timeZone)) return null; + frequency = t("detail.schedule.cron", { expression: value.expression, zone: value.timeZone }); + break; + default: + return null; + } + const dateText = formatDate(date, locale, value.scheduledAt); + return { + // Every Host view carries a title; a recorded result without one keeps its instruction. + title: nonempty(value.title) ? value.title : value.prompt, + fields: [ + { label: t("detail.schedule.when"), value: dateText }, + { label: t("detail.schedule.frequency"), value: frequency }, + { label: t("detail.state"), value: t(`detail.schedule.${value.state}`) }, + ], + }; +} + +/** + * Derive a supported successful result, retaining generic output on unknown or malformed data. + * @param block - Logged root or nested Tool call and its optional result. + * @param t - Conversation dictionary translator. + * @param locale - Display locale for absolute dates in the viewer's time zone. + * @returns Localized detail data, or null for raw input/output. + */ +export function detailsCardModel( + block: ToolCallBlock, + t: Translate, + locale: string, +): ToolDetailsModel | null { + if (!("kind" in block) || block.isError) return null; + const call = parsedToolCall(block); + if (call === null) return null; + const text = singleResultText(block); + if (text === undefined) return null; + const value = detailJson(text); + const details = + controlDetails(call.name, call.args, text, value, t) ?? + inspectionDetails(call.name, call.args, text, value, t, locale); + if (details !== null) return details; + if (value === undefined) return null; + switch (call.name) { + case "create_goal": + case "get_goal": + case "update_goal": + return goalDetail(value, t); + case "schedule_create": + case "schedule_update": { + const item = scheduleItem(value, t, locale); + return item === null ? null : { items: [item] }; + } + case "schedule_list": { + if (!Array.isArray(value)) return null; + const items: DetailItem[] = []; + for (const entry of value) { + const item = scheduleItem(entry, t, locale); + if (item === null) return null; + items.push(item); + } + return { + items, + summary: t("detail.schedule.count", { count: items.length }), + empty: t("detail.schedule.empty"), + }; + } + case "schedule_delete": + if (!detailRecord(value) || !nonempty(value.id) || value.deleted !== true) return null; + return { + items: [ + { + title: value.id, + fields: [{ label: t("detail.state"), value: t("detail.schedule.deleted") }], + }, + ], + }; + default: + return null; + } +} diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/models/diff-card-model.ts b/apps/web-ui/packages/client/ui-tool/src/client/tool/models/diff-card-model.ts new file mode 100644 index 000000000..8bd4e7168 --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/models/diff-card-model.ts @@ -0,0 +1,161 @@ +/** Pure diff-card derivation from raw file-mutation calls and result metadata. @module */ +import type { DiffBlockProps, DiffHunk } from "@deepseek-ai/dsh-client-ui-primitives"; +import type { ToolCallBlock } from "./tool-call-model.ts"; +import { parsedToolCall } from "./raw-tool-call.ts"; + +/** Room for a path, one removed/added pair, and three context lines on each side. */ +export const CHAT_DIFF_MAX_LINES = 9; + +/** + * The {@link DiffBlock} props this derivation owns. Picked off the primitive's + * props so the two stay in step; `maxLines`/`className` belong to each render + * site. + */ +export interface DiffCardModel { + /** + * The props {@link DiffBlock} draws. Held as a nested object so a render site + * spreads exactly the primitive's own surface and can never leak a + * neighbouring field into it. + */ + card: Pick; +} + +/** + * Narrow opaque result metadata's `diffs` to well-formed hunks. + * @param diffs - the metadata field to validate. + * @returns the validated hunks, or null when the payload is not usable. + */ +function narrowDiffs(diffs: unknown): DiffHunk[] | null { + if (!Array.isArray(diffs) || diffs.length === 0) return null; + const out: DiffHunk[] = []; + for (const hunk of diffs) { + if (typeof hunk !== "object" || hunk === null) return null; + const { path, oldText, newText } = hunk as Record; + if (typeof path !== "string") return null; + if (oldText !== null && typeof oldText !== "string") return null; + if (typeof newText !== "string") return null; + out.push({ path, oldText, newText }); + } + return out; +} + +type IntendedDiff = { tool: "write" | "edit" | "str_replace_editor"; diff: DiffHunk }; + +function intendedDiff(block: ToolCallBlock): IntendedDiff | null { + const parsed = parsedToolCall(block); + if (parsed === null) return null; + if (parsed.name === "str_replace_editor") { + const { command, path, file_text: fileText, old_str: oldText, new_str: newText } = parsed.args; + if (typeof path !== "string" || path.trim() === "") return null; + if (command === "create") { + if (fileText !== undefined && typeof fileText !== "string") return null; + return { + tool: "str_replace_editor", + diff: { path, oldText: null, newText: fileText ?? "" }, + }; + } + if (command === "str_replace") { + if (oldText !== undefined && typeof oldText !== "string") return null; + if (newText !== undefined && typeof newText !== "string") return null; + return { + tool: "str_replace_editor", + diff: { path, oldText: oldText ?? null, newText: newText ?? "" }, + }; + } + return null; + } + const path = typeof parsed.args.file_path === "string" ? parsed.args.file_path : parsed.args.path; + if (typeof path !== "string" || path.trim() === "") return null; + if (parsed.name === "write") { + const { content } = parsed.args; + return typeof content === "string" + ? { tool: "write", diff: { path, oldText: null, newText: content } } + : null; + } + if (parsed.name !== "edit") return null; + const { old_string: oldText, new_string: newText, replace_all: replaceAll } = parsed.args; + if (typeof oldText !== "string" || typeof newText !== "string") return null; + if (replaceAll !== undefined && typeof replaceAll !== "boolean") return null; + return { tool: "edit", diff: { path, oldText: oldText || null, newText } }; +} + +function hunkFromFileArg(value: unknown): DiffHunk | null { + if (typeof value !== "object" || value === null) return null; + const record = value as Record; + const path = + typeof record.path === "string" + ? record.path + : typeof record.file_path === "string" + ? record.file_path + : ""; + if (path.trim() === "") return null; + if ( + record.kind === "add" || + (typeof record.content === "string" && record.old_string === undefined) + ) { + const newText = + typeof record.content === "string" + ? record.content + : typeof record.new_string === "string" + ? record.new_string + : ""; + return { path, oldText: null, newText }; + } + if (typeof record.old_string === "string" && typeof record.new_string === "string") { + return { path, oldText: record.old_string || null, newText: record.new_string }; + } + return null; +} + +function intendedHunks( + block: ToolCallBlock, +): { tool: "write" | "edit" | "str_replace_editor"; diffs: DiffHunk[] } | null { + const parsed = parsedToolCall(block); + if (parsed === null) return null; + if (parsed.name === "str_replace_editor") { + const intended = intendedDiff(block); + return intended === null ? null : { tool: intended.tool, diffs: [intended.diff] }; + } + if (parsed.name !== "edit" && parsed.name !== "write") return null; + if (Array.isArray(parsed.args.files) && parsed.args.files.length > 0) { + const diffs: DiffHunk[] = []; + for (const file of parsed.args.files) { + const hunk = hunkFromFileArg(file); + if (hunk === null) return null; + diffs.push(hunk); + } + return { tool: parsed.name, diffs }; + } + const intended = intendedDiff(block); + return intended === null ? null : { tool: intended.tool, diffs: [intended.diff] }; +} + +function appliedDiffs(meta: unknown): DiffHunk[] | "empty" | null { + if (typeof meta !== "object" || meta === null || Array.isArray(meta)) return null; + const diffs = (meta as Record).diffs; + if (!Array.isArray(diffs)) return null; + if (diffs.length === 0) return "empty"; + return narrowDiffs(diffs); +} + +/** + * Derive running diffs for root write/edit and `str_replace_editor` + * create/replace calls, plus applied settled diffs from result metadata. + * Settled `meta.diffs` render even when the call arguments were incomplete. + * A successful write with valid empty metadata uses its argument-derived + * whole-file diff, matching create and identical-overwrite presentation; + * `str_replace_editor` settles through Generic because it has no result view. + * @param block - running or settled Tool block. + * @returns the diff-card props, or null for the generic path. + */ +export function diffCardModel(block: ToolCallBlock): DiffCardModel | null { + if (block.parentCallId !== undefined) return null; + const intended = intendedHunks(block); + if (!("kind" in block)) return intended === null ? null : { card: { diffs: intended.diffs } }; + if (intended?.tool === "str_replace_editor") return null; + if (block.isError) return null; + const applied = appliedDiffs(block.meta); + if (applied !== null && applied !== "empty") return { card: { diffs: applied } }; + if (intended === null) return null; + return intended.tool === "write" ? { card: { diffs: intended.diffs } } : null; +} diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/models/image-card-model.ts b/apps/web-ui/packages/client/ui-tool/src/client/tool/models/image-card-model.ts new file mode 100644 index 000000000..998a6b865 --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/models/image-card-model.ts @@ -0,0 +1,253 @@ +/** Pure image-card derivation from raw result content and metadata. @module */ +import type { AttachmentId, ImageAttachmentRef, ImageMediaType } from "@deepseek-ai/dsh-attachment"; +import { abbreviateHomePath, relativizeToCwd } from "@deepseek-ai/dsh-util-workspace-path"; +import type { ToolCallBlock } from "./tool-call-model.ts"; +import { parsedToolCall } from "./raw-tool-call.ts"; + +/** + * The image-card material one settled call contributes: the display label plus + * the durable references the attachment slot renders as a gallery. + * + * The bytes are not here. `attachmentId` is opaque and provider-owned, so a UI + * resolves it to a session-authorized URL at render time; this model never parses + * it nor derives a path from it. + */ +export interface ImageCardModel { + /** Card label: the read path, shortened the way every other card's is. */ + label: string; + /** The durable images this result returned, in result order. */ + images: readonly { readonly attachment: ImageAttachmentRef }[]; + /** + * The model-facing envelope text, for the line under the gallery. + * + * Taken from the result's own text block rather than the row's flattened + * result text: an image read's content is `[text envelope, image block]`, and + * flattening JSON.stringifies the image block, which would print the raw + * attachment object under the picture — the symptom this card exists to remove. + */ + text: string; +} + +/** + * The persisted `presentationMeta` this card reads: the resolved display path only + * (the value `read_image` persisted from its target's display path, not the + * author-typed `file_path` argument). + * + * Root calls persist it; a nested call (a read_image dispatched from inside + * run_code) settles without `meta`, so the card falls back to the call's own + * `file_path` argument for the label. + * + * The attachment reference deliberately does NOT come from here. The settled + * `content` already carries the image block with its complete reference, so + * reading the reference from `meta` too would keep two copies of one fact — and a + * `tools/post-execute` hook that legitimately replaces the result content would + * leave the stale `meta` copy behind, showing an image the result no longer + * returns. `path` is the one fact `content` does not carry. + */ +interface ImageMeta { + path: string; +} + +/** + * Whether a wire value is a usable pixel or byte measure. + * @param value - unvalidated wire value. + * @returns true when it is a positive integer. + */ +function positiveInteger(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value > 0; +} + +/** The envelope `formatImageReadOutput` writes, matched by shape. */ +const IMAGE_ENVELOPE = + /^[^\n]*<\/path>\nimage<\/type>\n\n[\s\S]*\n<\/content>$/u; + +/** The media types a durable image block may claim; anything else declines. + * Hand-written mirror of `ImageMediaType` from dsh-attachment — the wire + * boundary needs a runtime check and feature plugins must not import values + * from each other; a new member added there must be added here too, or the + * decline point below silently degrades that image to the generic card. */ +const IMAGE_MEDIA_TYPES: ReadonlySet = new Set([ + "image/png", + "image/jpeg", + "image/webp", + "image/gif", +]); + +/** Runtime membership check; the cast is safe because the set holds exactly the four members. */ +function isImageMediaType(value: string): value is ImageMediaType { + return IMAGE_MEDIA_TYPES.has(value as ImageMediaType); +} + +/** + * Narrow the persisted metadata, defensively. Every field arrives unvalidated on + * replay (an obsolete or hand-edited log reaches here), so any mismatch declines + * to the generic card rather than throwing. An absent `meta` (a nested call + * persists none) leaves the path to the call's own `file_path` argument. + * + * The attachment id is checked for existence only: it is opaque and + * provider-owned, and consumers must not parse that representation, so + * pattern-matching the local content-address form would reject a legitimate id + * minted by an alternative store. + * @param meta - persisted presentation metadata of unknown shape. + * @returns the narrowed path, or null when it does not match. + */ +function imageMeta(meta: unknown): ImageMeta | null { + if (typeof meta !== "object" || meta === null || Array.isArray(meta)) return null; + const { path } = meta as Record; + if (typeof path !== "string" || path === "") return null; + return { path }; +} + +/** + * Narrow every attachment reference carried by the result's image blocks, in + * order. + * + * The content is the single source of truth for the references: it is what the + * tool actually returned and what a post-execute hook would replace together + * with the rest of the content. Every field arrives unvalidated over the wire, + * so any malformed image block declines to the generic card rather than + * rendering a partial gallery. + * + * The attachment id is checked for existence only — it is opaque and + * provider-owned, so pattern-matching the local content-address form would reject + * a legitimate id minted by an alternative store. + * @param content - the settled result's content blocks. + * @returns the narrowed references, or null when no valid image block is present. + */ +function imageReferences(content: readonly unknown[]): ImageAttachmentRef[] | null { + const refs: ImageAttachmentRef[] = []; + for (const part of content) { + if (typeof part !== "object" || part === null) continue; + const { type, attachment } = part as { type?: unknown; attachment?: unknown }; + if (type !== "image") continue; + if (typeof attachment !== "object" || attachment === null || Array.isArray(attachment)) + return null; + const { attachmentId, mediaType, bytes, width, height, name, originalDimensions } = + attachment as Record; + if (typeof attachmentId !== "string" || attachmentId === "") return null; + if (typeof mediaType !== "string" || !isImageMediaType(mediaType)) return null; + // Narrowed one at a time: a loop over the three does validate them, but the + // control-flow analysis does not carry that back to the object below. + if (!positiveInteger(bytes) || !positiveInteger(width) || !positiveInteger(height)) return null; + if (name !== undefined && typeof name !== "string") return null; + // The store records input dimensions when normalization downsampled the + // image; rebuild the pair so a renderer that needs the original size gets + // it, and decline on a malformed pair like any other field. + let inputDimensions: ImageAttachmentRef["originalDimensions"] | undefined; + if (originalDimensions !== undefined) { + if ( + typeof originalDimensions !== "object" || + originalDimensions === null || + Array.isArray(originalDimensions) + ) + return null; + const { width: inputWidth, height: inputHeight } = originalDimensions as Record< + string, + unknown + >; + if (!positiveInteger(inputWidth) || !positiveInteger(inputHeight)) return null; + inputDimensions = { width: inputWidth, height: inputHeight }; + } + refs.push({ + attachmentId: attachmentId as AttachmentId, + mediaType, + bytes, + width, + height, + ...(name === undefined ? {} : { name }), + ...(inputDimensions === undefined ? {} : { originalDimensions: inputDimensions }), + }); + } + return refs.length > 0 ? refs : null; +} + +/** + * Read the text of every text block of a settled image result, joined in order. + * + * The envelope is one of them; a post-execute hook that appends further text + * blocks keeps them visible under the gallery instead of being dropped. The + * envelope shape is still the recognition gate: a result without it is not a + * well-formed image read and declines. + * @param content - the settled result's content blocks. + * @returns the joined text, or null when no envelope-shaped block is present. + */ +function imageTexts(content: readonly { type: string; text?: string }[]): string | null { + const parts: string[] = []; + let sawEnvelope = false; + for (const part of content) { + if (part.type !== "text" || typeof part.text !== "string") continue; + if (IMAGE_ENVELOPE.test(part.text)) sawEnvelope = true; + parts.push(part.text); + } + return sawEnvelope && parts.length > 0 ? parts.join("\n") : null; +} + +/** + * Whether the content carries only blocks the card consumes. + * + * `ContentBlock` is a merge-extensible union, so a post-execute hook could + * append a block of a type this card does not render (reasoning, an extension + * type, or a non-object). Rendering the card anyway would silently hide that + * block, so anything beyond a well-formed text or image object declines to the + * generic card, which shows the flattened content. + * @param content - the settled result's content blocks. + * @returns true when every block is a text or image object with usable fields. + */ +function fullyRendered(content: readonly unknown[]): boolean { + return content.every((part) => { + if (typeof part !== "object" || part === null) return false; + const { type, text } = part as { type?: unknown; text?: unknown }; + return type === "image" || (type === "text" && typeof text === "string"); + }); +} + +/** + * Derive a settled image card after validating the call head, persisted + * metadata (or its argument fallback), and the model-facing image envelope. + * + * The card is result-side only: a call carries no content until `execute` + * returns, so a running `read_image` has none and this returns null for it. + * Both root and nested calls settle as ToolResultNode; the nested one (a + * read_image dispatched from inside run_code) persists no presentationMeta, so + * its label falls back to the call's own `file_path` argument. + * @param block - running or settled Tool block. + * @param sessionCwd - the session workspace root; a workspace-rooted absolute + * path label displays relative to it. Absent leaves the path as authored. + * @param home - host account home; a leftover POSIX home path displays as `~`. + * @returns the image-card props, or null for the generic path. + */ +export function imageCardModel( + block: ToolCallBlock, + sessionCwd?: string, + home?: string, +): ImageCardModel | null { + // Result-side only: a running call is not a ToolResultNode and carries no + // content, so it has no card here. + if (!("kind" in block) || block.isError) return null; + const call = parsedToolCall(block); + if (call?.name !== "read_image") return null; + const { file_path: filePath } = call.args; + if (typeof filePath !== "string" || filePath.trim() === "") return null; + // The label path: root calls persist it in presentationMeta; a nested call + // (dispatched from inside run_code) persists none, so its own file_path + // argument fills the label. A root call with missing or malformed meta + // declines — malformed tool data falls back to the generic card, which shows + // the flattened content rather than an author-typed path. + const metaPath = imageMeta(block.meta)?.path; + const path = metaPath ?? (block.parentCallId !== undefined ? filePath : null); + if (path === null) return null; + // The card renders only text and image blocks; a block of any other type must + // not be silently hidden, so the whole card declines to the generic form. + if (!fullyRendered(block.content)) return null; + // The references come from the result's own image blocks, the single source of + // truth; `meta` contributes only the path, which the content does not carry. + const refs = imageReferences(block.content); + if (refs === null) return null; + const text = imageTexts(block.content); + if (text === null) return null; + return { + label: abbreviateHomePath(relativizeToCwd(path, sessionCwd), home), + images: refs.map((ref) => ({ attachment: ref })), + text, + }; +} diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/models/inspection-details-model.ts b/apps/web-ui/packages/client/ui-tool/src/client/tool/models/inspection-details-model.ts new file mode 100644 index 000000000..1e7d0a216 --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/models/inspection-details-model.ts @@ -0,0 +1,351 @@ +/** Runtime inspection, session queries, and workflow reports from recorded text. */ +import type { ToolDetailsModel } from "../components/ToolDetails.tsx"; +import { hasSpillNotice } from "@deepseek-ai/dsh-spill-policy/notice"; +import { + detailJson, + detailList, + detailRecord, + inspectionItems, + type DetailItem, + type DetailTranslate, +} from "./detail-model-shared.ts"; + +function dateText(value: string | number, locale: string): string { + const date = new Date(value); + if (!Number.isFinite(date.getTime())) return String(value); + try { + return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format( + date, + ); + } catch { + return String(value); + } +} + +function cordisDetails( + name: string, + args: Record, + value: unknown, + t: DetailTranslate, +): ToolDetailsModel | null { + if (!detailRecord(value)) return null; + if (name === "cordis_inspect_list") { + if (!Array.isArray(value.providers)) return null; + return detailList( + inspectionItems(value.providers, t), + t("detail.providers.count", { count: value.providers.length }), + t, + ); + } + if (name === "cordis_inspect_query") { + if ( + !("data" in value) || + typeof value.provider !== "string" || + typeof value.method !== "string" + ) + return null; + return detailList(inspectionItems(value.data, t), `${value.provider}.${value.method}`, t); + } + if (name === "cordis_inspect_self") { + if (Array.isArray(value.plugins)) + return detailList( + inspectionItems(value.plugins, t), + t("detail.plugins.count", { count: value.plugins.length }), + t, + ); + return detailList( + inspectionItems(value, t), + String(args.pluginId ?? args.packageId ?? value.mode), + t, + ); + } + return null; +} + +function workflowDetails( + name: string, + args: Record, + text: string, + t: DetailTranslate, +): ToolDetailsModel | null { + if (name === "workflow") { + const match = + /^workflow "([\s\S]*?)" completed \((\d+) agents?\)\.\nReturn value:\n([\s\S]*)$/u.exec(text); + if ( + match === null || + match[1] === undefined || + match[2] === undefined || + match[3] === undefined + ) + return null; + const value = detailJson(match[3]); + if (value === undefined) return null; + const header: DetailItem = { + title: match[1], + badge: { label: t("detail.status.completed"), tone: "success" }, + fields: [{ label: t("detail.field.agents"), value: match[2] }], + ...(typeof args.code === "string" + ? { + groups: [ + { + label: t("detail.workflow.script"), + items: [{ fields: [], code: { text: args.code, language: "javascript" } }], + }, + ], + } + : {}), + }; + return detailList([header, ...inspectionItems(value, t)], match[1], t); + } + const separator = "\nFinal report:\n"; + const split = text.indexOf(separator); + if (split < 0) return null; + const header = text.slice(0, split); + const rounds = /\b(\d+) rounds?\b/u.exec(header)?.[1]; + const report = detailJson(text.slice(split + separator.length)); + if ( + !detailRecord(report) || + typeof report.summary !== "string" || + !Array.isArray(report.evidence) || + !report.evidence.every((value) => typeof value === "string") || + !Array.isArray(report.nextSteps) || + !report.nextSteps.every((value) => typeof value === "string") || + typeof report.blocker !== "string" + ) + return null; + const badge = header.startsWith("Ralph worker reported completion ") + ? { label: t("detail.ralph.reportedComplete"), tone: "success" as const } + : header.startsWith("Ralph worker reported a blocker ") + ? { label: t("detail.ralph.reportedBlocker"), tone: "warning" as const } + : header.startsWith("Ralph reached its ") + ? { label: t("detail.ralph.limit"), tone: "warning" as const } + : undefined; + if (badge === undefined) return null; + const groups: NonNullable[number][] = []; + if (report.nextSteps.length > 0) + groups.push({ + label: t("detail.report.nextSteps"), + items: [{ fields: [], lines: report.nextSteps }], + }); + if (typeof args.objective === "string") + groups.push({ + label: t("detail.field.task"), + items: [{ fields: [], description: args.objective }], + }); + const fields = rounds === undefined ? [] : [{ label: t("detail.goal.rounds"), value: rounds }]; + if (report.blocker !== "") fields.push({ label: t("detail.goal.reason"), value: report.blocker }); + const item = { + title: report.summary, + badge, + fields, + ...(report.evidence.length === 0 ? {} : { lines: report.evidence }), + groups, + }; + return detailList([item], report.summary, t); +} + +const TRACE_FIELDS = { + Created: "detail.field.time", + Availability: "detail.field.availability", + Parent: "detail.field.parent", + "Best match": "detail.field.bestMatch", + Target: "detail.field.target", + "Replaced by": "detail.trace.replacedBy", + "Replacement chain": "detail.trace.replacementChain", + "Events replaced by target": "detail.trace.replaces", + "Events cited directly as sources": "detail.trace.sources", + "Direct derived events": "detail.trace.derived", +} as const; + +function textFields(text: string, t: DetailTranslate, locale: string): DetailItem["fields"] { + return text.split("\n").flatMap((line) => { + const match = /^\s*([^:]+): (.+)$/u.exec(line); + if (match === null || match[1] === undefined || match[2] === undefined) return []; + const key = Object.hasOwn(TRACE_FIELDS, match[1]) + ? TRACE_FIELDS[match[1] as keyof typeof TRACE_FIELDS] + : undefined; + if (key === undefined) return []; + return [ + { + label: t(key), + value: + match[1] === "Created" + ? dateText(match[2], locale) + : match[2] === "none" + ? t("detail.none") + : match[2], + }, + ]; + }); +} + +function searchDetails( + name: string, + text: string, + t: DetailTranslate, + locale: string, +): ToolDetailsModel | null { + if ( + text === "No prior session matches found." || + text.endsWith("\n\nNo prior event matches found.") + ) + return detailList([], t("detail.matches.count", { count: 0 }), t); + const blocks = text.split(/\n(?=\d+\. )/u).filter((block) => /^\d+\. /u.test(block)); + if (blocks.length === 0) return null; + const items: DetailItem[] = []; + for (const block of blocks) { + const snippet = /\n\s*Snippet: ([\s\S]*?)(?:\n\nResult cap reached\.|$)/u.exec(block)?.[1]; + if (name === "session_search") { + const match = /^\d+\. Session (\S+) — (.*)/u.exec(block); + if (match === null || match[1] === undefined || match[2] === undefined) return null; + items.push({ + title: match[2], + subtitle: match[1], + ...(snippet === undefined ? {} : { description: snippet.trimEnd() }), + fields: textFields(block, t, locale), + }); + } else { + const match = /^\d+\. seq (\d+) \| ([^|]+) \| ([^|]+) \| ([^\n]+)/u.exec(block); + if ( + match === null || + match[1] === undefined || + match[2] === undefined || + match[3] === undefined || + match[4] === undefined + ) + return null; + items.push({ + title: snippet?.trimEnd() ?? match[2], + subtitle: `${match[2].trim()} · #${match[1]}`, + fields: [ + { label: t("detail.field.time"), value: dateText(match[4], locale) }, + { label: t("detail.field.surface"), value: match[3].trim() }, + ], + }); + } + } + return { + ...detailList(items, t("detail.matches.count", { count: items.length }), t), + ...(text.includes("Result cap reached.") ? { caption: t("detail.matches.capped") } : {}), + }; +} + +function eventReadDetails( + text: string, + t: DetailTranslate, + locale: string, +): ToolDetailsModel | null { + const match = + /^Session (\S+) — ([^\n]*)\nTarget event seq (\d+):\n```json\n([\s\S]*?)\n```([\s\S]*)$/u.exec( + text, + ); + if ( + match === null || + match[1] === undefined || + match[2] === undefined || + match[3] === undefined || + match[4] === undefined + ) + return null; + const event = detailJson(match[4]); + if (!detailRecord(event) || typeof event.type !== "string" || !detailRecord(event.data)) + return null; + const fields = [{ label: t("detail.field.seq"), value: match[3] }]; + if (typeof event.time === "number") + fields.push({ label: t("detail.field.time"), value: dateText(event.time, locale) }); + const groups: NonNullable[number][] = []; + const adjacent = match[5]?.trim(); + if (adjacent) + groups.push({ + label: t("detail.event.neighbors"), + items: [{ fields: [], description: adjacent }], + }); + return detailList( + [ + { title: event.type, subtitle: `${match[2]} · ${match[1]}`, fields }, + ...inspectionItems(event.data, t), + ...(groups.length === 0 ? [] : [{ fields: [], groups }]), + ], + `${event.type} · #${match[3]}`, + t, + ); +} + +function traceDetails( + name: string, + text: string, + t: DetailTranslate, + locale: string, +): ToolDetailsModel | null { + const match = /^Session (\S+) — ([^\n]*)\n([\s\S]*)$/u.exec(text); + if (match === null || match[1] === undefined || match[2] === undefined || match[3] === undefined) + return null; + if (name === "session_event_trace") + return detailList( + [{ title: match[2], subtitle: match[1], fields: textFields(match[3], t, locale) }], + match[2], + t, + ); + const sections = match[3].split("\n\n"); + const items: DetailItem[] = [ + { title: match[2], subtitle: match[1], fields: textFields(sections[0] ?? "", t, locale) }, + ]; + for (const section of sections.slice(1)) { + const firstBreak = section.indexOf("\n"); + const label = section.startsWith("Ancestors (nearest first):") + ? t("detail.trace.ancestors") + : section.startsWith("Descendants:") + ? t("detail.trace.descendants") + : undefined; + if (label === undefined || firstBreak < 0) return null; + const body = section.slice(firstBreak + 1); + items.push({ + title: label, + fields: [], + ...(body === "- none" || body === "- none (target is a root session)" + ? { description: t("detail.none") } + : { lines: body.split("\n").map((line) => line.replace(/^(\s*)- /u, "$1")) }), + }); + } + return detailList(items, match[2], t); +} + +/** + * Present successful inspection, query, and workflow text as named records. + * @param name - Wire tool name. + * @param args - Recorded argument object. + * @param text - Recorded result text. + * @param json - Parsed whole-result JSON, or undefined for non-JSON text. + * @param t - Conversation translator. + * @param locale - Date display locale. + * @returns Structured details, or null when an output format is unknown. + */ +export function inspectionDetails( + name: string, + args: Record, + text: string, + json: unknown, + t: DetailTranslate, + locale: string, +): ToolDetailsModel | null { + if (hasSpillNotice(text)) return null; + switch (name) { + case "cordis_inspect_list": + case "cordis_inspect_query": + case "cordis_inspect_self": + return cordisDetails(name, args, json, t); + case "workflow": + case "ralph": + return workflowDetails(name, args, text, t); + case "session_search": + case "session_event_search": + return searchDetails(name, text, t, locale); + case "session_event_read": + return eventReadDetails(text, t, locale); + case "session_trace": + case "session_event_trace": + return traceDetails(name, text, t, locale); + default: + return null; + } +} diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/models/primitive-labels.ts b/apps/web-ui/packages/client/ui-tool/src/client/tool/models/primitive-labels.ts new file mode 100644 index 000000000..6bf7ebe68 --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/models/primitive-labels.ts @@ -0,0 +1,109 @@ +/** Localized copy adapters for Cordis-free UI primitives used by Tool cards. */ + +import type { + CodeToolbarLabels, + DiffBlockLabels, + MarkdownLabels, + ReadBlockLabels, + SearchBlockLabels, + WebBlockLabels, +} from "@deepseek-ai/dsh-client-ui-primitives"; +import type { TranslateNS } from "@deepseek-ai/dsh-client-ui-slots"; + +type T = TranslateNS<"conversation">; + +/** + * Localize the shared code-card toolbar. + * @param t - Conversation locale seat. + * @returns Language fallback and wrapping actions. + */ +export function codeToolbarLabels(t: T): CodeToolbarLabels { + return { + codeLabel: t("codeBlock.title"), + wrapLabel: t("codeBlock.wrap"), + unwrapLabel: t("codeBlock.unwrap"), + }; +} + +/** + * Build localized Markdown chrome labels. + * @param t - Conversation locale seat. + * @returns Markdown chrome labels. + */ +export function markdownLabels(t: T): MarkdownLabels { + return { + code: { copyLabel: t("copy"), copiedLabel: t("copied"), toolbarLabels: codeToolbarLabels(t) }, + footnotes: t("markdown.footnotes"), + }; +} + +/** + * Build localized diff-card chrome labels. + * @param t - Conversation locale seat. + * @returns Diff-card chrome labels. + */ +export function diffBlockLabels(t: T): DiffBlockLabels { + return { + ...codeToolbarLabels(t), + copy: t("copy"), + copied: t("copied"), + collapseAria: t("diff.collapseAria"), + expandAria: (count) => t("diff.expandAria", { count }), + collapse: t("collapse"), + expand: (count) => t("diff.expandRest", { count }), + }; +} + +/** + * Build localized read-card chrome labels. + * @param t - Conversation locale seat. + * @returns Read-card chrome labels. + */ +export function readBlockLabels(t: T): ReadBlockLabels { + return { + ...codeToolbarLabels(t), + window: (shown, total) => t("read.window", { shown, total }), + copy: t("copy"), + copied: t("copied"), + collapseAria: t("read.collapseAria"), + expandAria: (count) => t("read.expandAria", { count }), + collapse: t("collapse"), + expand: (count) => t("read.expandRest", { count }), + }; +} + +/** + * Build localized search-card chrome labels. + * @param t - Conversation locale seat. + * @returns Search-card chrome labels. + */ +export function searchBlockLabels(t: T): SearchBlockLabels { + return { + pathsSummary: (shown, total, truncated) => + t(truncated ? "search.paths.truncated" : "search.paths", { shown, total }), + matchesSummary: (shown, total, files, truncated) => + t(truncated ? "search.matches.truncated" : "search.matches", { shown, total, files }), + copy: t("copy"), + copied: t("copied"), + noResults: t("search.noResults"), + collapseAria: t("search.collapseAria"), + expandAria: (count) => t("search.expandAria", { count }), + collapse: t("collapse"), + expand: (count) => t("search.expandRest", { count }), + }; +} + +/** + * Build localized web-card chrome labels. + * @param t - Conversation locale seat. + * @returns Web-card chrome labels. + */ +export function webBlockLabels(t: T): WebBlockLabels { + return { + noResults: t("web.noResults"), + sourcesTruncated: t("web.sourcesTruncated"), + http: t("web.http"), + contentTruncated: t("web.contentTruncated"), + markdown: markdownLabels(t), + }; +} diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/models/raw-tool-call.ts b/apps/web-ui/packages/client/ui-tool/src/client/tool/models/raw-tool-call.ts new file mode 100644 index 000000000..79ee9aa3d --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/models/raw-tool-call.ts @@ -0,0 +1,52 @@ +/** Shared narrowing for raw Tool call and result fields consumed by card models. */ +import type { ToolResultNode } from "@deepseek-ai/dsh-client-ui-chat/client"; +import type { ToolCallBlock } from "./tool-call-model.ts"; + +/** A parsed, in-window Tool call whose arguments are a JSON object. */ +export interface ParsedToolCall { + name: string; + args: Record; +} + +const parsedCalls = new WeakMap(); + +/** + * Parse the call head paired with one immutable Tool block. + * @param block - preparing, dispatched, or settled Tool block. + * @returns the Tool name and object arguments, or null during preparation or when valid arguments are unavailable. + */ +export function parsedToolCall(block: ToolCallBlock): ParsedToolCall | null { + if (!("kind" in block) && block.phase === "preparing") return null; + const cached = parsedCalls.get(block); + if (cached !== undefined || parsedCalls.has(block)) return cached ?? null; + const call = "kind" in block ? block.call : block; + if (call === null) { + parsedCalls.set(block, null); + return null; + } + let value: unknown; + try { + value = JSON.parse(call.argsRaw); + } catch { + parsedCalls.set(block, null); + return null; + } + if (typeof value !== "object" || value === null || Array.isArray(value)) { + parsedCalls.set(block, null); + return null; + } + const parsed = { name: call.name, args: value as Record }; + parsedCalls.set(block, parsed); + return parsed; +} + +/** + * Read the exact single text block consumed by first-party card derivations. + * @param block - settled Tool result. + * @returns its text, or undefined for any other content layout. + */ +export function singleResultText(block: ToolResultNode): string | undefined { + if (block.content.length !== 1) return undefined; + const only = block.content[0]; + return only?.type === "text" ? only.text : undefined; +} diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/models/read-card-model.ts b/apps/web-ui/packages/client/ui-tool/src/client/tool/models/read-card-model.ts new file mode 100644 index 000000000..ea3f22bad --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/models/read-card-model.ts @@ -0,0 +1,135 @@ +/** Pure read-card derivation from raw result content and metadata. @module */ +import type { ReadBlockLine, ReadBlockProps } from "@deepseek-ai/dsh-client-ui-primitives"; +import { abbreviateHomePath, relativizeToCwd } from "@deepseek-ai/dsh-util-workspace-path"; +import type { ToolCallBlock } from "./tool-call-model.ts"; +import { parsedToolCall, singleResultText } from "./raw-tool-call.ts"; + +/** + * Content lines the chat row's resident read body shows before collapsing the + * middle — half the primitive's own default, which the details panel keeps. A + * chat row is a summary surface inside the message flow: the flow must stay + * scannable across many calls, while the details panel is the single-call + * reading surface. A design constant of this UI's row geometry, not a + * deployment choice, so it is fixed here rather than a plugin Config field. The + * same split [`CHAT_TERMINAL_MAX_LINES`](./terminal-card-model.ts) draws for + * terminal output. + */ +export const CHAT_READ_MAX_LINES = 8; + +/** + * The {@link ReadBlock} props this derivation owns. Picked off the primitive's + * props so the two stay in step; `maxLines`/`className` belong to each render + * site. + */ +export type ReadCardModel = Pick; + +interface ReadMeta { + path: string; + offset: number; + lines: ReadBlockLine[]; + totalLines: number; + lang?: string; +} + +/** Whether a model-supplied argument is a 1-based line position or count: an integer of at least 1. */ +function positiveInteger(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 1; +} + +function validReadCall(block: ToolCallBlock): boolean { + const call = parsedToolCall(block); + if (call?.name !== "read") return false; + const { file_path: path, offset, limit } = call.args; + if (typeof path !== "string" || path.trim() === "") return false; + if (offset !== undefined && !positiveInteger(offset)) return false; + if (limit !== undefined && !positiveInteger(limit)) return false; + return true; +} + +function readMeta(meta: unknown): ReadMeta | null { + if (typeof meta !== "object" || meta === null || Array.isArray(meta)) return null; + const { path, offset, lines, totalLines, lang } = meta as Record; + if ( + typeof path !== "string" || + typeof offset !== "number" || + !Number.isInteger(offset) || + offset < 1 + ) + return null; + if ( + typeof totalLines !== "number" || + !Number.isInteger(totalLines) || + totalLines < 0 || + !Array.isArray(lines) + ) + return null; + if (lang !== undefined && typeof lang !== "string") return null; + const narrowed: ReadBlockLine[] = []; + let previous = offset - 1; + for (const line of lines) { + if (typeof line !== "object" || line === null || Array.isArray(line)) return null; + const { number, text } = line as Record; + if (typeof number !== "number" || !Number.isInteger(number) || number < 1 || number <= previous) + return null; + if (number > totalLines || typeof text !== "string") return null; + previous = number; + narrowed.push({ number, text }); + } + return { + path, + offset, + lines: narrowed, + totalLines, + ...(lang === undefined ? {} : { lang }), + }; +} + +/** + * The line one `read` call was about, from its arguments. + * + * `offset` is the read tool's own 1-based start line, so opening the path can + * land where the model looked. Available while the call is still running, + * unlike the persisted metadata, because the arguments carry it. The arguments + * are model-produced JSON: only an integer of at least 1 is a line, and a call + * whose `offset` is anything else names none. + * @param block - running or settled Tool block. + * @returns the 1-based line, or undefined when the call named none. + */ +export function readCallLine(block: ToolCallBlock): number | undefined { + if (!validReadCall(block)) return undefined; + const { offset } = parsedToolCall(block)?.args ?? {}; + return positiveInteger(offset) ? offset : undefined; +} + +/** + * Derive a settled root read card after validating its persisted metadata and + * model-facing read envelope. + * @param block - running or settled Tool block. + * @param sessionCwd - the session workspace root; a workspace-rooted absolute + * path label displays relative to it. Absent leaves the path as authored. + * @param home - host account home; a leftover POSIX home path displays as `~`. + * @returns the read-card props, or null for the generic path. + */ +export function readCardModel( + block: ToolCallBlock, + sessionCwd?: string, + home?: string, +): ReadCardModel | null { + if (block.parentCallId !== undefined || !("kind" in block) || block.isError) return null; + if (!validReadCall(block)) return null; + const meta = readMeta(block.meta); + if (meta === null) return null; + const text = singleResultText(block); + if (text === undefined) return null; + const body = + /^[^\n]*<\/path>\nfile<\/type>\n\n([\s\S]*)\n<\/content>$/u.exec( + text, + )?.[1]; + if (body === undefined) return null; + return { + label: abbreviateHomePath(relativizeToCwd(meta.path, sessionCwd), home), + lines: meta.lines, + totalLines: meta.totalLines, + lang: meta.lang, + }; +} diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/models/search-card-model.ts b/apps/web-ui/packages/client/ui-tool/src/client/tool/models/search-card-model.ts new file mode 100644 index 000000000..0dd57676a --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/models/search-card-model.ts @@ -0,0 +1,110 @@ +/** Pure search-card derivation from raw grep/glob result metadata. @module */ +import type { SearchBlockProps, SearchFileGroup } from "@deepseek-ai/dsh-client-ui-primitives"; +import type { ToolCallBlock } from "./tool-call-model.ts"; +import { parsedToolCall } from "./raw-tool-call.ts"; + +type DistributiveOmit = T extends unknown ? Omit : never; + +/** The {@link SearchBlockProps} union minus each render site's own fields. */ +type SearchBlockModelProps = DistributiveOmit< + SearchBlockProps, + "labels" | "maxLines" | "className" +>; + +/** Result rows retained in a Chat card before its middle collapses. */ +export const CHAT_SEARCH_MAX_LINES = 8; + +/** Search-card props plus an optional locator for a capped full result. */ +export interface SearchCardModel { + /** Props consumed by {@link SearchBlock}. */ + card: SearchBlockModelProps; + /** Raw result text containing the full-result locator for a capped search. */ + recovery: string | undefined; +} + +function validSearchCall(block: ToolCallBlock): "grep" | "glob" | null { + const call = parsedToolCall(block); + if (call === null) return null; + const { pattern, path } = call.args; + if (typeof pattern !== "string") return null; + if (call.name === "grep" && pattern === "") return null; + if (call.name === "glob" && pattern.trim() === "") return null; + if (call.name !== "grep" && call.name !== "glob") return null; + if (path !== undefined && (typeof path !== "string" || path.trim() === "")) return null; + if (call.name === "grep") { + const { include } = call.args; + if (include !== undefined && (typeof include !== "string" || !validInclude(include))) + return null; + } + return call.name; +} + +function validInclude(include: string): boolean { + if (include.trim() === "" || include.startsWith("!")) return false; + let braceDepth = 0; + for (const character of include) { + if (character === "{") braceDepth += 1; + else if (character === "}") braceDepth = Math.max(0, braceDepth - 1); + else if (character === "," && braceDepth === 0) return false; + } + return true; +} + +function searchFiles(value: unknown): SearchFileGroup[] | null { + if (!Array.isArray(value)) return null; + const files: SearchFileGroup[] = []; + for (const file of value) { + if (typeof file !== "object" || file === null || Array.isArray(file)) return null; + const { path, matches } = file as Record; + if (typeof path !== "string" || !Array.isArray(matches)) return null; + const narrowed: { lineNumber: number; line: string }[] = []; + for (const match of matches) { + if (typeof match !== "object" || match === null || Array.isArray(match)) return null; + const { lineNumber, line } = match as Record; + if (typeof lineNumber !== "number" || !Number.isInteger(lineNumber) || lineNumber < 1) + return null; + if (typeof line !== "string") return null; + narrowed.push({ lineNumber, line }); + } + files.push({ path, matches: narrowed }); + } + return files; +} + +function flattenContent(content: readonly { type: string; text?: string }[]): string | undefined { + const text = content + .filter( + (block): block is { type: "text"; text: string } => + block.type === "text" && typeof block.text === "string", + ) + .map((block) => block.text) + .join("\n"); + return text === "" ? undefined : text; +} + +/** + * Derive a settled root grep/glob card from persisted metadata. + * @param block - running or settled Tool block. + * @returns search-card props, or null for the generic path. + */ +export function searchCardModel(block: ToolCallBlock): SearchCardModel | null { + if (block.parentCallId !== undefined || !("kind" in block) || block.isError) return null; + const tool = validSearchCall(block); + if (tool === null) return null; + if (typeof block.meta !== "object" || block.meta === null || Array.isArray(block.meta)) + return null; + const meta = block.meta as Record; + if (typeof meta.truncated !== "boolean") return null; + if (typeof meta.total !== "number" || !Number.isInteger(meta.total) || meta.total < 0) + return null; + const common = { truncated: meta.truncated, total: meta.total }; + const recovery = meta.truncated ? flattenContent(block.content) : undefined; + if (tool === "grep") { + if (meta.shape !== "matches") return null; + const files = searchFiles(meta.files); + return files === null ? null : { recovery, card: { kind: "matches", files, ...common } }; + } + if (meta.shape !== "paths" || !Array.isArray(meta.paths)) return null; + if (!meta.paths.every((path): path is string => typeof path === "string")) return null; + return { recovery, card: { kind: "paths", paths: [...meta.paths], ...common } }; +} diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts b/apps/web-ui/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts new file mode 100644 index 000000000..0e37115da --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts @@ -0,0 +1,348 @@ +/** Pure terminal-card derivation from raw Tool call and result fields. @module */ +import type { + TerminalBlockLabels, + TerminalBlockProps, +} from "@deepseek-ai/dsh-client-ui-primitives"; +import type { TranslateNS } from "@deepseek-ai/dsh-client-ui-slots"; +import { resolveWorkspacePath } from "@deepseek-ai/dsh-util-workspace-path"; +import { hasSpillNotice } from "@deepseek-ai/dsh-spill-policy/notice"; +import type { ToolCallBlock } from "./tool-call-model.ts"; +import { parsedToolCall, singleResultText } from "./raw-tool-call.ts"; + +/** + * Build the TerminalBlock display copy from the conversation locale seat — + * the one place the primitive's label surface pairs with this package's + * dictionary, shared by every terminal render site (chat row, bash row, + * details panel). + * @param t - the render site's conversation locale seat. + * @returns the full label set for {@link TerminalBlockProps}'s `labels`. + */ +export function terminalBlockLabels(t: TranslateNS<"conversation">): TerminalBlockLabels { + return { + signal: (signal) => t("terminal.signal", { signal }), + exitCode: (code) => t("terminal.exitCode", { code }), + noExitCode: t("terminal.noExitCode"), + running: t("terminal.running"), + failed: t("terminal.failed"), + done: t("terminal.done"), + copy: t("copy"), + copied: t("copied"), + noOutput: t("terminal.noOutput"), + collapseAria: t("terminal.collapseAria"), + collapse: t("collapse"), + expandAria: (hidden) => t("terminal.expandAria", { n: hidden }), + expand: (hidden) => t("terminal.expandRest", { n: hidden }), + }; +} + +/** + * The {@link TerminalBlock} props this derivation owns. Picked off the + * primitive's props so the two stay in step; `maxLines`/`className` belong to + * each render site. + */ +export interface TerminalCardModel { + /** + * The locale-neutral props {@link TerminalBlock} draws. The render site adds + * `command` after resolving {@link copy} through its locale seat. + */ + card: Pick; + /** + * Verbatim Tool data or semantic `terminal_send` data. Product copy stays + * unresolved until a render site supplies its locale seat. + */ + copy: + | { readonly kind: "shell"; readonly command: string; readonly description: string | undefined } + | { readonly kind: "terminal-send"; readonly text: string; readonly sessionId: string }; +} + +interface LocalizedTerminalCardModel { + readonly card: Pick< + TerminalBlockProps, + "command" | "cwd" | "output" | "exitCode" | "signal" | "running" + >; + readonly description: string | undefined; +} + +/** + * Resolve locale-owned `terminal_send` copy while preserving Tool-authored + * shell commands and descriptions verbatim. + * @param model - locale-neutral terminal card data. + * @param t - the render site's conversation locale seat. + * @returns terminal props and description ready for rendering. + */ +export function localizeTerminalCardModel( + model: TerminalCardModel, + t: TranslateNS<"conversation">, +): LocalizedTerminalCardModel { + if (model.copy.kind === "shell") { + return { + card: { command: model.copy.command, ...model.card }, + description: model.copy.description, + }; + } + return { + card: { + command: model.copy.text === "" ? t("terminal.sendInput") : model.copy.text, + ...model.card, + }, + description: t("terminal.session", { sessionId: model.copy.sessionId }), + }; +} + +/** + * True when a settled terminal card reports a failing exit — a non-zero code + * or a terminating signal. The bash tool settles a failing command as a + * completed call (`isError` stays false: the exit status is result data), so + * this is the collapsed row's only failure signal; without it the red exit + * pill would be visible only after expanding the card. + * @param model - a derived terminal card. + * @returns whether the card's exit status is a failure. + */ +export function terminalFailed(model: TerminalCardModel): boolean { + const { exitCode, signal, running } = model.card; + return running !== true && ((exitCode !== undefined && exitCode !== 0) || signal !== undefined); +} + +/** + * Resolve a shell call's workdir for display: an absolute path is used as-is, + * a relative one joins under the session workspace, and an omitted one is the + * session workspace. Without a session cwd, a relative path stays as authored + * and an omitted one stays absent. + * @param workdir - the raw call's workdir, if any. + * @param sessionCwd - the session workspace root, if the caller knows it. + * @returns the working directory for the prompt label, or undefined. + */ +function resolveTerminalCwd( + workdir: string | undefined, + sessionCwd: string | undefined, +): string | undefined { + if (workdir === undefined || workdir === "") return sessionCwd; + if (sessionCwd === undefined || sessionCwd === "") return normalizeSegments(workdir); + return normalizeSegments(resolveWorkspacePath(sessionCwd, workdir)); +} + +/** + * Collapse `.` and `..` segments so the prompt label names the directory the + * command actually ran in. The bash executor resolves the workdir before + * running, so a joined `/w/app/..` must display as `w`, not as `..`. Separators + * are preserved as authored (a Windows path keeps its backslashes) because this + * value is only ever displayed; a `..` that would climb past the root is + * dropped, which is what a filesystem does with it. A UNC path's `server` and + * `share` are part of its root, not poppable segments: Windows cannot climb + * above a share, so `\\\\server\\share` with a `..` stays there. + * @param path - a joined or absolute path, possibly carrying `.`/`..` segments. + * @returns the same path with those segments resolved. + */ +function normalizeSegments(path: string): string { + if (!/(?:^|[/\\])\.\.?(?:[/\\]|$)/.test(path)) return path; + // A UNC path is `\\\\server\\share\\...`: the server and share form the root, + // so they are split off here and neither is a segment `..` may pop. Its + // separator is fixed to a backslash, since a joined relative part may have + // introduced a forward slash that UNC syntax does not use. + const unc = /^[/\\]{2}([^/\\]+)[/\\]+([^/\\]+)/.exec(path); + if (unc !== null) { + // Both groups are mandatory in the pattern, so destructuring types them as + // strings without an assertion. + const [matched, server, share] = unc; + const root = `\\\\${String(server)}\\${String(share)}`; + // Rooted: what follows the share hangs off it, so a `..` at the top is + // dropped rather than kept — Windows cannot climb above a share. + const rest = collapse(path.slice(matched.length), true); + return rest === "" ? root : `${root}\\${rest}`; + } + const backslashed = path.includes("\\") && !path.includes("/"); + const separator = backslashed ? "\\" : "/"; + const rooted = /^[/\\]/.test(path); + const drive = /^[A-Za-z]:/.exec(path)?.[0] ?? ""; + const body = collapse(path.slice(drive.length), rooted || drive !== "", separator); + const leading = rooted ? separator : ""; + return drive === "" ? `${leading}${body}` : `${drive}${rooted ? leading : separator}${body}`; +} + +/** + * Collapse the `.`/`..` segments of a path body against a known root state. + * @param body - the path after any drive letter or UNC root. + * @param rooted - the body hangs off a root, so a `..` at its top is dropped + * the way a filesystem drops one; without a root the `..` is kept, since it + * stays meaningful against a cwd this function cannot see. + * @param separator - separator to rejoin with (default `/`). + * @returns the collapsed body, without leading or trailing separators. + */ +function collapse(body: string, rooted: boolean, separator = "/"): string { + const kept: string[] = []; + for (const segment of body.split(/[/\\]/)) { + if (segment === "" || segment === ".") continue; + if (segment === "..") { + if (kept.length > 0 && kept[kept.length - 1] !== "..") kept.pop(); + else if (!rooted) kept.push(segment); + continue; + } + kept.push(segment); + } + return kept.join(separator); +} + +interface ShellCall { + kind: "shell"; + command: string; + description: string | undefined; + workdir: string | undefined; + persistent: boolean; + background: boolean; +} + +function shellCall(name: string, args: Record): ShellCall | null { + if (name !== "bash" && name !== "pwsh") return null; + const { command, description, timeoutMs, workdir, run_in_background: background } = args; + if (typeof command !== "string" || command.trim() === "") return null; + if ( + timeoutMs !== undefined && + (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0) + ) + return null; + if (workdir !== undefined && typeof workdir !== "string") return null; + if (background !== undefined && typeof background !== "boolean") return null; + // Escalation fields stay unchecked: their validity depends on the Session's + // sandbox mode, which only the Host knows, and a rejected call settles as an + // error result on the generic body. + if (description === undefined) { + // Standard dsh-tool-bash and dsh-tool-pwsh schemas require `description`; + // persistent shell providers omit it. Their parameter roots stay open, so + // unrelated fields do not change their running-card behavior. + return { + kind: "shell", + command, + description: undefined, + workdir: undefined, + persistent: true, + background: false, + }; + } + if (typeof description !== "string" || description.trim() === "") return null; + return { + kind: "shell", + command, + description, + workdir, + persistent: false, + background: background === true, + }; +} + +/** + * Identify a settled root call from the persistent Bash or PowerShell tool. + * Its result stays on the generic input/output path because the persistent + * shell can report resets and partial output without one process exit status. + * @param block - running or settled Tool block. + * @returns whether the block is a settled persistent-shell call. + */ +export function isSettledPersistentShellCall(block: ToolCallBlock): boolean { + if (!("kind" in block) || block.parentCallId !== undefined) return false; + const parsed = parsedToolCall(block); + if (parsed === null) return false; + return shellCall(parsed.name, parsed.args)?.persistent === true; +} + +/** + * Identify a settled foreground shell preview whose spill footer can hide the exit marker. + * @param block - running or settled Tool block. + * @returns whether the shell output must remain generic without an inferred exit status. + */ +export function isSpilledShellCall(block: ToolCallBlock): boolean { + if (!("kind" in block)) return false; + const parsed = parsedToolCall(block); + if (parsed === null) return false; + const call = shellCall(parsed.name, parsed.args); + if (call === null || call.background) return false; + const output = singleResultText(block); + return output !== undefined && hasSpillNotice(output); +} + +interface TerminalSendCall { + kind: "terminal-send"; + text: string; + sessionId: string; + background: boolean; +} + +function terminalSendCall(name: string, args: Record): TerminalSendCall | null { + if (name !== "terminal_send") return null; + const { sessionId, text, submit, run_in_background: background } = args; + if (typeof sessionId !== "string" || sessionId === "" || typeof text !== "string") return null; + if (submit !== undefined && typeof submit !== "boolean") return null; + if (background !== undefined && typeof background !== "boolean") return null; + return { + kind: "terminal-send", + text, + sessionId, + background: background === true, + }; +} + +/** + * Parse the marker literals owned by `@deepseek-ai/dsh-shell/render` without + * importing that Host-only package into the Client dependency graph. + * @param text - rendered shell result text. + * @returns output with a trailing exit-code or signal marker extracted. + */ +function parseExitStatus(text: string): { output: string; exitCode?: number; signal?: string } { + const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text); + if (signal?.[1] !== undefined) return { output: text.slice(0, signal.index), signal: signal[1] }; + const exit = /\n\[exit code: (\d+)\]$/.exec(text); + if (exit?.[1] !== undefined) + return { output: text.slice(0, exit.index), exitCode: Number(exit[1]) }; + return { output: text, exitCode: 0 }; +} + +/** + * Derive terminal props for supported shell and terminal-send calls, including + * nested PTC dispatch calls. Standard shell results parse their final status + * marker; persistent shell results, spill previews, background calls, errors, + * and malformed input use the generic path. {@link isSettledPersistentShellCall} lets that generic + * persistent result remain expandable without inventing one process status. + * @param block - running or settled Tool block. + * @param sessionCwd - session workspace root used to resolve workdir. + * @returns locale-neutral terminal-card data, or null for the generic path. + */ +export function terminalCardModel( + block: ToolCallBlock, + sessionCwd?: string, +): TerminalCardModel | null { + const parsed = parsedToolCall(block); + if (parsed === null) return null; + const call = shellCall(parsed.name, parsed.args) ?? terminalSendCall(parsed.name, parsed.args); + if (call === null || call.background) return null; + + const copy: TerminalCardModel["copy"] = + call.kind === "shell" + ? { kind: "shell", command: call.command, description: call.description } + : { kind: "terminal-send", text: call.text, sessionId: call.sessionId }; + const cwd = resolveTerminalCwd(call.kind === "shell" ? call.workdir : undefined, sessionCwd); + if (!("kind" in block)) { + return { + copy, + card: { + cwd, + output: undefined, + exitCode: undefined, + signal: undefined, + running: true, + }, + }; + } + if (block.isError || (call.kind === "shell" && call.persistent) || isSpilledShellCall(block)) + return null; + const output = singleResultText(block); + if (output === undefined) return null; + const status = call.kind === "terminal-send" ? { output } : parseExitStatus(output); + return { + copy, + card: { + cwd, + output: status.output, + exitCode: status.exitCode, + signal: status.signal, + running: false, + }, + }; +} diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/models/todo-diff-model.ts b/apps/web-ui/packages/client/ui-tool/src/client/tool/models/todo-diff-model.ts new file mode 100644 index 000000000..1de504f5b --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/models/todo-diff-model.ts @@ -0,0 +1,107 @@ +/** Todo changes relative to the preceding recorded write. */ +import type { ToolCallBlock } from "./tool-call-model.ts"; +import type { TranslateNS } from "@deepseek-ai/dsh-client-ui-slots"; +import type { ToolDetailsModel } from "../components/ToolDetails.tsx"; +import type { TodoBaseline } from "./todo-history.ts"; +import { todosDetail } from "./details-card-model.ts"; +import { parsedToolCall } from "./raw-tool-call.ts"; + +/** + * Compare this write with its predecessor in the loaded call history. + * @param block - The write being displayed. + * @param baseline - List recorded before this call, or undefined when its start is unavailable. + * @param hasMore - Whether older unloaded history may contain a preceding list. + * @param t - Conversation dictionary translator. + * @returns Details and a change summary, or null for generic Tool output. + */ +export function todoDiffModel( + block: ToolCallBlock, + baseline: TodoBaseline | undefined, + hasMore: boolean, + t: TranslateNS<"conversation">, +): { details: ToolDetailsModel; summary: string | null } | null { + if (!("kind" in block) || block.isError) return null; + const call = parsedToolCall(block); + const current = call?.name === "todo_write" ? todosDetail(call.args, t) : null; + if (current === null) return null; + const previous: ToolDetailsModel | null = + baseline?.todos === undefined + ? null + : { + items: baseline.todos.map((todo) => ({ + title: todo.content, + status: { value: todo.status, label: t(`detail.todo.${todo.status}`) }, + fields: [], + })), + }; + if (baseline === undefined || (previous === null && hasMore)) { + return { details: { ...current, caption: t("todo.diff.unavailable") }, summary: null }; + } + const previousByTitle = new Map(previous?.items.map((item) => [item.title, item])); + const currentTitles = new Set(current.items.map((item) => item.title)); + const retainedPositions = new Map( + previous?.items + .filter((item) => currentTitles.has(item.title)) + .map((item, index) => [item.title, index]), + ); + let retainedIndex = 0; + const items: ToolDetailsModel["items"][number][] = []; + const unchanged: ToolDetailsModel["items"][number][] = []; + let added = 0; + let updated = 0; + for (const item of current.items) { + const before = previousByTitle.get(item.title); + previousByTitle.delete(item.title); + if (before === undefined) { + added++; + items.push({ ...item, change: { value: "added", label: t("todo.diff.addedItem") } }); + } else { + const moved = retainedPositions.get(item.title) !== retainedIndex++; + const statusChanged = before.status?.value !== item.status?.value; + if (statusChanged || moved) { + updated++; + items.push({ + ...item, + ...(statusChanged && before.status !== undefined + ? { previousStatus: before.status.label } + : {}), + change: { + value: "updated", + label: t(statusChanged ? "todo.diff.updatedItem" : "todo.diff.movedItem"), + }, + }); + } else { + unchanged.push(item); + } + } + } + for (const item of previousByTitle.values()) { + items.push({ ...item, change: { value: "removed", label: t("todo.diff.removedItem") } }); + } + const summary = [ + added > 0 ? t("todo.diff.added", { count: added }) : null, + updated > 0 ? t("todo.diff.updated", { count: updated }) : null, + previousByTitle.size > 0 ? t("todo.diff.removed", { count: previousByTitle.size }) : null, + ] + .filter((part) => part !== null) + .join(" · "); + return { + summary: summary || t("todo.diff.noChanges"), + details: { + items, + caption: t(previous === null ? "todo.diff.initial" : "todo.diff.compare"), + empty: + current.items.length === 0 && previous === null + ? t("detail.todo.empty") + : t("todo.diff.noChanges"), + ...(unchanged.length === 0 + ? {} + : { + unchanged: { + label: t("todo.diff.unchanged", { count: unchanged.length }), + items: unchanged, + }, + }), + }, + }; +} diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/models/todo-history.ts b/apps/web-ui/packages/client/ui-tool/src/client/tool/models/todo-history.ts new file mode 100644 index 000000000..8aaef9172 --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/models/todo-history.ts @@ -0,0 +1,101 @@ +/** Indexed recorded todo predecessors for root and nested Tool calls. */ +import type {} from "@deepseek-ai/dsh-tools/types"; +import type { Context } from "@deepseek-ai/cordis"; +import type { + ConversationNodeDefinition, + ConversationViewDefinition, + ConversationViewNode, + TodoItem, +} from "@deepseek-ai/dsh-client-ui-conversation/client"; + +/** The durable list preceding one Tool invocation; absent before the first loaded write. */ +export interface TodoBaseline { + readonly todos: readonly TodoItem[] | undefined; +} + +/** Session-owned call lookup, read only through the target's observable snapshot. */ +export interface TodoHistory { + /** @param callId - Root or nested call identity. @returns its recorded predecessor, when the start is loaded. */ + get(callId: string): TodoBaseline | undefined; +} + +declare module "@deepseek-ai/dsh-client-ui-conversation/client" { + interface ConversationViewSnapshotMap { + /** Recorded todo lists preceding each loaded todo call. */ + "tool-todo-history": TodoHistory; + } +} + +/** Durable writes are indexed independently of Tool success receipts. */ +export const todoWriteDefinition: ConversationNodeDefinition = { + kind: "tool-todo-write", + match: (event) => (event.type === "todo/write" ? { id: String(event.seq), role: "start" } : null), + start: (_context, match) => { + if (match.event.type !== "todo/write") throw new Error("tool-todo-write requires todo/write"); + return match.event.data.todos; + }, + update: (context) => context.state, + publication: () => "none", +}; + +/** Invocation predecessors are repaired by the assembler when older history arrives. */ +export const todoCallDefinition: ConversationNodeDefinition = { + kind: "tool-todo-call", + target: "tool-todo-history", + match: (event) => { + if (event.type === "tool/call" && event.data.name === "todo_write") { + return { id: String(event.data.callId), role: "start" }; + } + if (event.type === "tool/ptc-dispatch-start" && event.data.name === "todo_write") { + return { id: String(event.data.subCallId), role: "start" }; + } + return null; + }, + start: (_context, _match, reader) => ({ + todos: reader.previous("tool-todo-write")?.state, + }), + update: (context) => context.state, + buildViewNode: (context) => + context.state === undefined + ? null + : { + key: context.key, + kind: context.kind, + id: context.id, + target: "tool-todo-history", + data: context.state, + }, +}; + +interface TodoHistoryNode extends ConversationViewNode { + readonly data: TodoBaseline; +} + +/** Incremental lookup snapshots preserve earlier call baselines across later writes. */ +export const todoHistoryView: ConversationViewDefinition = { + target: "tool-todo-history", + create: () => { + let calls = new Map(); + return { + empty: calls, + replace: ({ nodes }) => (calls = new Map(nodes.map((node) => [node.id, node.data]))), + apply: ({ upserts }) => { + if (upserts.length > 0) { + calls = new Map(calls); + for (const node of upserts) calls.set(node.id, node.data); + } + return calls; + }, + }; + }, +}; + +/** + * Install the recorded-write index and call predecessor target. + * @param ctx - Tool presentation plugin context. + */ +export function registerTodoHistory(ctx: Context): void { + ctx.uiConversation.events.register(todoWriteDefinition); + ctx.uiConversation.events.register(todoCallDefinition); + ctx.uiConversation.views.register(todoHistoryView); +} diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts b/apps/web-ui/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts new file mode 100644 index 000000000..e1d69c2de --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts @@ -0,0 +1,373 @@ +/** + * Pure row-model derivation for tool summary rows: variant classification, + * one-line summary, expansion-time body input, and flattened result output + * from the frozen call slice. Input material comes from the call ARGUMENTS; + * output and error material from the settled result node. A supported terminal + * call gets its expanded body from `terminalCardModel` instead. + */ +import type { + ToolArgs, + ToolCallBlock, + ToolResultNode, +} from "@deepseek-ai/dsh-client-ui-chat/client"; +import type { LocaleKeysOf } from "@deepseek-ai/dsh-client-ui-slots"; +import { abbreviateHomePath, relativizeToCwd } from "@deepseek-ai/dsh-util-workspace-path"; + +export type { ToolCallBlock } from "@deepseek-ai/dsh-client-ui-chat/client"; + +/** Tool-call row variants selected by the generic atomic renderer. */ +export type ToolRowVariant = "search" | "read" | "bash" | "write" | "edit" | "code" | "others"; + +/** Row lifecycle state used by summary styling and accessible status text. */ +export type ToolRowState = "preparing" | "running" | "ok" | "error" | "stopped"; + +/** Locale-neutral structured fact consumed only by the user-facing Tool row. */ +export interface AutoReviewDenial { + /** Raw persisted reviewer reason; display normalization happens at render time. */ + reason: string | null; +} + +type ToolTitleKey = Extract< + LocaleKeysOf<"conversation">, + `tool.title.${string}` | "ask.rowTitle" | "todo.rowTitle" +>; + +/** Locale key per generic row variant. */ +export const VARIANT_TITLE_KEYS = { + search: "tool.title.search", + read: "tool.title.read", + bash: "tool.title.bash", + write: "tool.title.write", + edit: "tool.title.edit", + code: "tool.title.code", + others: "tool.title.generic", +} as const satisfies Record; + +/** + * Known tool name -> variant. + * + * `cordis_define` is deliberately absent: ui-cordis registers a keyed + * `tool.call.toolview` entry for it, and a keyed hit REPLACES the generic row + * (this table is only reached through GenericToolCard, the dispatch fallback in + * ToolCallTree). An entry here would be unreachable, and a second title for the + * same call would be a second answer to a question the card already owns. + */ +const TOOL_VARIANTS: Record = { + bash: "bash", + // The PowerShell twin is a shell tool: the bash row family (icon, colors) + // with its own title from TOOL_TITLE_KEYS, not the generic `others` row. + pwsh: "bash", + read: "read", + // read_image is a single-file read: the same browse icon and the same openable + // path summary (FILE_PATH_VARIANTS covers `read`), with its own title key below. + // Left unclassified it falls to `others`, which titles the row generically and + // derives no filePath — so the path the row advertises as openable never is. + read_image: "read", + web_fetch: "read", + web_search: "search", + grep: "search", + glob: "search", + write: "write", + edit: "edit", + run_code: "code", + cordis_package_inspect: "read", + cordis_runtime_inspect: "read", + // The three run-control verbs take one package id and produce a receipt, so + // the generic row is the decided intent, not an unclassified default: there is + // no program to show (that is `cordis_define`'s card) and no file to open. The + // id lands in the summary slot, and the titles below name the act. + cordis_run: "others", + cordis_stop: "others", + cordis_undefine: "others", +}; + +/** Tool-owned titles that refine a generic row variant without replacing it. */ +const TOOL_TITLE_KEYS: Record = { + cordis_package_inspect: "tool.title.inspect", + cordis_runtime_inspect: "tool.title.inspect", + cordis_run: "tool.title.runCordis", + cordis_stop: "tool.title.stopCordis", + cordis_undefine: "tool.title.removeCordis", + pwsh: "tool.title.pwsh", + read_image: "tool.title.readImage", + todo_write: "todo.rowTitle", + ask_user_question: "ask.rowTitle", + create_goal: "tool.title.createGoal", + get_goal: "tool.title.getGoal", + update_goal: "tool.title.updateGoal", + schedule_create: "tool.title.createSchedule", + schedule_list: "tool.title.listSchedules", + schedule_delete: "tool.title.deleteSchedule", + schedule_update: "tool.title.updateSchedule", + cordis_inspect_list: "tool.title.inspectProviders", + cordis_inspect_query: "tool.title.queryRuntime", + cordis_inspect_self: "tool.title.inspectPlugins", + workflow: "tool.title.workflow", + ralph: "tool.title.ralph", + session_event_read: "tool.title.readEvent", + session_event_search: "tool.title.searchEvents", + session_event_trace: "tool.title.traceEvent", + session_search: "tool.title.searchSessions", + session_trace: "tool.title.traceSession", + list_subagent_models: "tool.title.listModels", + subagent: "tool.title.subagent", + list_agents: "tool.title.listAgents", + send_message: "tool.title.sendMessage", + interrupt_agent: "tool.title.interruptAgent", + job_list: "tool.title.listJobs", + job_output: "tool.title.readJob", + job_kill: "tool.title.killJob", + terminal_open: "tool.title.openTerminal", + terminal_read: "tool.title.readTerminal", + terminal_list: "tool.title.listTerminals", + terminal_signal: "tool.title.signalTerminal", + terminal_close: "tool.title.closeTerminal", + lsp: "tool.title.lsp", + spawn_teammate: "tool.title.spawnTeammate", + team_task_create: "tool.title.createTeamTask", + team_task_get: "tool.title.getTeamTask", + team_task_update: "tool.title.updateTeamTask", + team_task_list: "tool.title.listTeamTasks", + wait_agent: "tool.title.waitAgent", +}; + +/** + * Classify a tool name into its row variant. + * @param toolName - wire tool name. + * @returns matching variant, others when unknown. + */ +export function classifyTool(toolName: string): ToolRowVariant { + return TOOL_VARIANTS[toolName] ?? "others"; +} + +/** + * Select a tool-owned or generic title without reading arguments. + * @param toolName - wire tool name. + * @returns the localized title key. + */ +export function toolTitleKey(toolName: string): ToolTitleKey { + return TOOL_TITLE_KEYS[toolName] ?? VARIANT_TITLE_KEYS[classifyTool(toolName)]; +} + +/** Everything ToolRow needs, derived once from the frozen slice. */ +export interface ToolRowModel { + variant: ToolRowVariant; + titleKey: ToolTitleKey; + /** Generic rows retain the wire tool name; available arguments append their summary. */ + summary: string; + /** + * Filesystem path from args (`path` / `file_path`) when the row is a file + * tool; absent for URL reads and non-file tools. A `file_path` argument + * supplies it at every stage once its string is complete. The chat view + * resolves relative values against the session cwd before opening. + */ + filePath: string | undefined; + /** Original argument JSON retained for expansion-time body formatting. */ + bodyRaw: string | null; + /** Flattened result text ({@link resultText}); null while running or when the result carries no text. */ + output: string | null; + /** First line of the result text on an error row; null for every other state. */ + errorSummary: string | null; + /** Structured Auto-review denial identity; null for every ordinary result. */ + autoReviewDenial: AutoReviewDenial | null; + state: ToolRowState; +} + +function deriveAutoReviewDenial(block: ToolCallBlock): AutoReviewDenial | null { + if (!("kind" in block) || !block.isError) return null; + const error = block.error; + if (error?.name !== "AutoReviewDeniedError" || error.code !== "AUTO_REVIEW_DENIED") return null; + // A durable record reaches this renderer without a type check on `reason`, so + // a non-string value degrades to the no-reason copy exactly as a missing one. + return { reason: typeof error.reason === "string" ? error.reason : null }; +} + +/** + * Flatten a settled result's content blocks to display text: text blocks + * verbatim, other block shapes as pretty JSON. Empty content on a failed call + * falls back to the structured error's `name: code` line. + * @param node - the settled result node. + * @returns the flattened result text (may be empty). + */ +export function resultText(node: ToolResultNode): string { + const parts: string[] = []; + for (const block of node.content) { + if (block.type === "text") parts.push(block.text); + else parts.push(JSON.stringify(block, null, 2)); + } + if (parts.length === 0 && node.error !== undefined) { + parts.push(`${node.error.name}: ${node.error.code}`); + } + return parts.join("\n"); +} + +function parseArgs(argsRaw: string): unknown { + try { + return JSON.parse(argsRaw); + } catch { + // Non-JSON args (mid-stream truncation or a rejected call kept verbatim): summary/body fall back to the raw string. + return undefined; + } +} + +function firstLine(text: string): string { + const nl = text.indexOf("\n"); + return nl === -1 ? text : text.slice(0, nl); +} + +function pickString(args: Record, keys: readonly string[]): string | undefined { + for (const key of keys) { + const v = args[key]; + if (typeof v === "string" && v !== "") return v; + } + return undefined; +} + +/** Summary key preference per variant (args-derived; result-derived summaries are a ledger item). */ +const SUMMARY_KEYS: Record = { + bash: ["description", "command"], + read: ["path", "file_path", "url"], + search: ["query", "pattern", "url"], + write: ["path", "file_path"], + edit: ["path", "file_path"], + code: ["description"], + others: [], +}; + +function deriveSummary(variant: ToolRowVariant, argsRaw: string): string { + const parsed = parseArgs(argsRaw); + if (typeof parsed !== "object" || parsed === null) return firstLine(argsRaw); + const args = parsed as Record; + if (variant === "search" && Array.isArray(args.queries)) { + const queries = args.queries.filter( + (query: unknown): query is string => typeof query === "string" && query !== "", + ); + if (queries.length > 0) return queries.map(firstLine).join(", "); + } + const picked = pickString(args, SUMMARY_KEYS[variant]); + if (picked !== undefined) return firstLine(picked); + for (const v of Object.values(args)) { + if (typeof v === "string" && v !== "") return firstLine(v); + } + return firstLine(argsRaw); +} + +/** Path keys only — never `url` (web_fetch lands on the read variant). */ +const FILE_PATH_KEYS = ["path", "file_path"] as const; + +/** File-tool variants whose summary may be an openable workspace path. */ +const FILE_PATH_VARIANTS: ReadonlySet = new Set(["read", "write", "edit"]); + +/** + * Summary material read the same way at every stage from the argument view: a + * complete `file_path` for file tools, otherwise the `description` text so far. + * Empty when the view carries neither, so the caller falls back to the raw text. + */ +function argumentSummary( + variant: ToolRowVariant, + args: ToolArgs, + cwd?: string, + home?: string, +): { summary: string; filePath: string | undefined } { + if (FILE_PATH_VARIANTS.has(variant)) { + const path = args.complete("file_path") ? args.text("file_path") : undefined; + // Decoding text can discover an invalid escape and make complete() false. + const filePath = + path !== undefined && path !== "" && args.complete("file_path") ? firstLine(path) : undefined; + return { + summary: + filePath === undefined ? "" : abbreviateHomePath(relativizeToCwd(filePath, cwd), home), + filePath, + }; + } + const description = args.text("description"); + return { summary: description === undefined ? "" : firstLine(description), filePath: undefined }; +} + +function deriveFilePath(variant: ToolRowVariant, argsRaw: string): string | undefined { + if (!FILE_PATH_VARIANTS.has(variant)) return undefined; + const parsed = parseArgs(argsRaw); + if (typeof parsed !== "object" || parsed === null) return undefined; + const picked = pickString(parsed as Record, FILE_PATH_KEYS); + return picked === undefined ? undefined : firstLine(picked); +} + +/** + * Format one argument payload when its generic input body becomes visible. + * @param variant - row presentation selected for the Tool name. + * @param argsRaw - original argument JSON or incomplete raw text. + * @returns display body, or null for empty input. + */ +export function formatToolBody(variant: ToolRowVariant, argsRaw: string): string | null { + if (argsRaw === "") return null; + const parsed = parseArgs(argsRaw); + if (parsed === undefined) return argsRaw; + // The code row's expanded body IS the program (monospace via the row's + // variant styling), not the args JSON envelope around it. + if (variant === "code" && typeof parsed === "object" && parsed !== null) { + const code = (parsed as Record).code; + if (typeof code === "string" && code !== "") return code; + } + return JSON.stringify(parsed, null, 2); +} + +/** + * Derive the full row model from a frozen call slice. + * @param toolName - wire tool name (dispatch-supplied; survives windowless results). + * @param block - preparing call, dispatched call, or result from the snapshot. + * @param cwd - session workspace root; workspace-rooted path summaries display relative to it. + * @param home - host account home; a leftover POSIX home path displays as `~`. + * @returns the row model. + */ +export function toolRowModel( + toolName: string, + block: ToolCallBlock, + cwd?: string, + home?: string, +): ToolRowModel { + const variant = classifyTool(toolName); + const titleKey = toolTitleKey(toolName); + const done = "kind" in block; + const argsRaw = done + ? (block.call?.argsRaw ?? "") + : block.phase === "start" + ? block.argsRaw + : null; + const state: ToolRowState = !done + ? block.phase === "preparing" + ? "preparing" + : "running" + : block.error?.code === "interrupted" + ? "stopped" + : block.isError + ? "error" + : "ok"; + const primary = argumentSummary(variant, block.args, cwd, home); + // The argument view serves every stage; the raw text is the fallback when it carries nothing useful. + const base = + primary.summary !== "" || argsRaw === null + ? primary.summary + : argsRaw === "" + ? block.callId + : abbreviateHomePath(relativizeToCwd(deriveSummary(variant, argsRaw), cwd), home); + const summary = [titleKey === "tool.title.generic" ? toolName : "", base] + .filter(Boolean) + .join(" · "); + // The empty string is "no text" for both derived result fields: a settled + // call with blank content has nothing to expand, and a blank first line + // would erase the collapsed error row's summary slot. + const output = done ? resultText(block) || null : null; + const errorSummary = state === "error" && output !== null ? firstLine(output) : null; + const bodyRaw = argsRaw === "" ? null : argsRaw; + return { + variant, + titleKey, + summary, + filePath: primary.filePath ?? (argsRaw === null ? undefined : deriveFilePath(variant, argsRaw)), + bodyRaw, + output, + errorSummary, + autoReviewDenial: deriveAutoReviewDenial(block), + state, + }; +} diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/models/web-card-model.ts b/apps/web-ui/packages/client/ui-tool/src/client/tool/models/web-card-model.ts new file mode 100644 index 000000000..ffab710d8 --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/models/web-card-model.ts @@ -0,0 +1,109 @@ +/** Pure web-card derivation from raw web result metadata. @module */ +import type { WebBlockProps } from "@deepseek-ai/dsh-client-ui-primitives"; +import type { ToolCallBlock } from "./tool-call-model.ts"; +import { parsedToolCall } from "./raw-tool-call.ts"; + +type DistributiveOmit = T extends unknown ? Omit : never; + +/** Web-card data owned by the presenter; render sites add localized labels and classes. */ +export type WebCardModelProps = DistributiveOmit; + +function validWebCall(block: ToolCallBlock): "web_search" | "web_fetch" | null { + const call = parsedToolCall(block); + if (call === null) return null; + if (call.name === "web_search") { + const { queries } = call.args; + if (!Array.isArray(queries) || queries.length === 0) return null; + return queries.every((query) => typeof query === "string" && query.trim() !== "") + ? call.name + : null; + } + if (call.name === "web_fetch") { + const { url } = call.args; + return typeof url === "string" && url.trim() !== "" ? call.name : null; + } + return null; +} + +interface WebSource { + url: string; + title?: string; + snippet?: string; + publishedAt?: string; +} + +function webSources(value: unknown): WebSource[] | null { + if (!Array.isArray(value)) return null; + const sources: WebSource[] = []; + for (const source of value) { + if (typeof source !== "object" || source === null || Array.isArray(source)) return null; + const { url, title, snippet, publishedAt } = source as Record; + if (typeof url !== "string") return null; + if (title !== undefined && typeof title !== "string") return null; + if (snippet !== undefined && typeof snippet !== "string") return null; + if (publishedAt !== undefined && typeof publishedAt !== "string") return null; + sources.push({ + url, + ...(title === undefined ? {} : { title }), + ...(snippet === undefined ? {} : { snippet }), + ...(publishedAt === undefined ? {} : { publishedAt }), + }); + } + return sources; +} + +/** + * Derive a settled root web-search or web-fetch card from persisted metadata. + * @param block - running or settled Tool block. + * @returns web-card props, or null for the generic path. + */ +export function webCardModel(block: ToolCallBlock): WebCardModelProps | null { + if (block.parentCallId !== undefined || !("kind" in block) || block.isError) return null; + const tool = validWebCall(block); + if ( + tool === null || + typeof block.meta !== "object" || + block.meta === null || + Array.isArray(block.meta) + ) + return null; + const meta = block.meta as Record; + if (typeof meta.truncated !== "boolean") return null; + if (tool === "web_search") { + const sources = webSources(meta.sources); + if (sources === null || (meta.answer !== undefined && typeof meta.answer !== "string")) + return null; + return { + kind: "search", + answer: meta.answer, + sources, + truncated: meta.truncated, + }; + } + if (typeof meta.url !== "string") return null; + if (typeof meta.statusCode !== "number" || !Number.isInteger(meta.statusCode)) return null; + return { + kind: "fetch", + url: meta.url, + statusCode: meta.statusCode, + truncated: meta.truncated, + }; +} + +/** + * Read the openable URL of a web_fetch call from its arguments. + * @param block - running or settled Tool block. + * @returns the http(s) URL, or undefined for another tool, protocol, or unparsable argument. + */ +export function webFetchHref(block: ToolCallBlock): string | undefined { + const call = parsedToolCall(block); + if (call?.name !== "web_fetch" || typeof call.args.url !== "string") return undefined; + const { url } = call.args; + try { + const { protocol } = new URL(url); + return protocol === "http:" || protocol === "https:" ? url : undefined; + } catch { + // An unparsable URL stays plain summary text. + return undefined; + } +} diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx b/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx new file mode 100644 index 000000000..071b899ab --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx @@ -0,0 +1,86 @@ +import type { ReactNode } from "react"; +import { + IconApiOutlineRegular, + IconBrowseOutlineRegular, + IconCodeOutlineRegular, + IconEditOutlineRegular, + IconSearchOutlineRegular, + IconSparkleRegular, +} from "@deepseek-ai/dsh-client-ui-primitives"; +import type { ToolCallOwnerProps, ToolTreeProps } from "../../contract/slots.ts"; +import { readCardModel } from "../models/read-card-model.ts"; +import { diffCardModel } from "../models/diff-card-model.ts"; +import { searchCardModel } from "../models/search-card-model.ts"; +import { terminalCardModel, terminalFailed } from "../models/terminal-card-model.ts"; +import { webCardModel } from "../models/web-card-model.ts"; +import { toolRowModel, type ToolRowVariant } from "../models/tool-call-model.ts"; +import { localizeAutoReviewDenial } from "../models/auto-review-denial.ts"; +import { ToolRow } from "../components/ToolRow.tsx"; + +/** Variant leading icons (figma table); all glyphs render at 14 inside the 16px leading box. */ +const VARIANT_ICONS: Record = { + search: , + read: , + bash: , + write: , + edit: , + code: , + others: , +}; + +/** Card props: the owner payload plus the render site's locale seat (plain prop). */ +export type GenericToolCardProps = ToolCallOwnerProps & { + t: ToolTreeProps["t"]; +}; + +/** @param props - current tool stage and locale. @returns its preparation or dispatched card. */ +export function GenericToolCard({ + toolName, + block, + cwd, + home, + openFile, + inspect, + useDisclosure, + t, +}: GenericToolCardProps) { + const model = toolRowModel(toolName, block, cwd, home); + const autoReview = + model.autoReviewDenial === null ? null : localizeAutoReviewDenial(model.autoReviewDenial, t); + const terminal = terminalCardModel(block, cwd); + const read = readCardModel(block, cwd, home); + const diff = diffCardModel(block); + const search = searchCardModel(block); + const web = webCardModel(block); + // A failing exit status is the terminal card's own error signal (the call + // itself settles isError:false), surfaced through the row's error summary. + const state = + model.state === "ok" && terminal !== null && terminalFailed(terminal) ? "error" : model.state; + const singleFile = model.filePath !== undefined; + return ( + + ); +} diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx b/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx new file mode 100644 index 000000000..440f7b0d2 --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx @@ -0,0 +1,437 @@ +import { useCallback } from "react"; +import { IconQuestionOutlineRegular } from "@deepseek-ai/dsh-client-ui-primitives"; +import type { Context } from "@deepseek-ai/cordis"; +import type {} from "@deepseek-ai/dsh-agent/types"; +import type { InjectFace, PropsLocale } from "@deepseek-ai/dsh-client-ui-slots"; +// Also merges the userQuestions key into SessionProjectionMap for useProjection. +import type { + AskUserQuestionAnswerItem, + AskUserQuestionItem, + AskUserQuestionOption, +} from "@deepseek-ai/dsh-user-questions/types"; +import type { ToolCallViewProps, UserQuestionRecord } from "../../contract/slots.ts"; +import type { AskQuestionCardModel } from "../models/ask-question-card-model.ts"; +import { singleResultText } from "../models/raw-tool-call.ts"; +import { toolRowModel } from "../models/tool-call-model.ts"; +import { ToolRow } from "../components/ToolRow.tsx"; +import { QuestionToolRow } from "../components/QuestionToolRow.tsx"; +import { CONVERSATION_NS as NS } from "../../locale.ts"; + +/** One paired question and its visible answer lines. */ +interface AnsweredQuestion { + id: string; + question: string; + answers: string[]; +} + +/** Everything a recorded answer batch puts on its row. */ +interface AnswerPresentation { + summary: string; + /** The paired transcript card; absent when pairing the batch would be ambiguous. */ + transcript: AskQuestionCardModel | null; + /** Material for the read-only panel; absent whenever the transcript card is, since both render the same pairing. */ + record: UserQuestionRecord | undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +/** Answer records from the result JSON; null when the result is malformed. */ +function answerEntries(text: string): AskUserQuestionAnswerItem[] | null { + const parsed = parseJson(text); + if (!isRecord(parsed)) return null; + const answers = parsed.answers; + if (!Array.isArray(answers) || !answers.every(isRecord)) return null; + const entries: AskUserQuestionAnswerItem[] = []; + for (const answer of answers) { + if ( + typeof answer.id !== "string" || + !Array.isArray(answer.selected) || + !answer.selected.every((item) => typeof item === "string") || + (answer.custom !== undefined && typeof answer.custom !== "string") + ) + return null; + entries.push({ + id: answer.id, + selected: answer.selected, + ...(answer.custom === undefined ? {} : { custom: answer.custom }), + }); + } + return entries; +} + +/** Answers from a submitted steer that still awaits admission to a step. */ +function queuedAnswerEntries(message: unknown, callId: string): AskUserQuestionAnswerItem[] | null { + if (!isRecord(message) || !Array.isArray(message.content)) return null; + const content: unknown = message.content[0]; + if (!isRecord(content) || content.type !== "text" || typeof content.text !== "string") + return null; + const parsed = parseJson(content.text); + if (!isRecord(parsed) || parsed.kind !== "answer_to_pending_question" || parsed.callId !== callId) + return null; + return answerEntries(content.text); +} + +/** One question's choices from call JSON; absent when any entry is malformed. */ +function optionEntries(value: unknown): AskUserQuestionOption[] | undefined { + if (!Array.isArray(value)) return undefined; + const options: AskUserQuestionOption[] = []; + for (const option of value) { + if (!isRecord(option) || typeof option.label !== "string") return undefined; + options.push({ + label: option.label, + ...(typeof option.description === "string" ? { description: option.description } : {}), + }); + } + return options; +} + +/** + * Questions from call JSON; null when pairing with answers would be ambiguous. + * The optional presentation fields are carried for the read-only answer panel, + * which renders the same option lists the user chose from; a malformed one is + * dropped rather than failing the whole call, because the transcript card needs + * only the id and the question text. + */ +function questionEntries(argsRaw: string): AskUserQuestionItem[] | null { + const parsed = parseJson(argsRaw); + if (!isRecord(parsed) || !Array.isArray(parsed.questions) || parsed.questions.length === 0) + return null; + const questions: AskUserQuestionItem[] = []; + const ids = new Set(); + for (const question of parsed.questions) { + if ( + !isRecord(question) || + typeof question.id !== "string" || + typeof question.question !== "string" || + ids.has(question.id) + ) + return null; + ids.add(question.id); + const options = optionEntries(question.options); + questions.push({ + id: question.id, + question: question.question, + ...(typeof question.header === "string" ? { header: question.header } : {}), + ...(typeof question.detail === "string" ? { detail: question.detail } : {}), + ...(options === undefined ? {} : { options }), + // The tool schema spells the multi-select flag in snake case. + ...(typeof question.multi_select === "boolean" ? { multiSelect: question.multi_select } : {}), + }); + } + return questions; +} + +/** Pair questions with result entries by their echoed stable ids. */ +function pairAnswers( + questions: readonly AskUserQuestionItem[], + answers: readonly AskUserQuestionAnswerItem[], +): AnsweredQuestion[] | null { + if (questions.length !== answers.length) return null; + const byId = new Map(); + for (const answer of answers) { + if (byId.has(answer.id)) return null; + byId.set(answer.id, answer); + } + const paired: AnsweredQuestion[] = []; + for (const question of questions) { + const answer = byId.get(question.id); + if (answer === undefined) return null; + paired.push({ + id: question.id, + question: question.question, + answers: [ + ...answer.selected, + ...(answer.custom === undefined || answer.custom === "" ? [] : [answer.custom]), + ], + }); + } + return paired; +} + +/** + * Answer summary, transcript content, and panel material from one recorded + * answer batch, whether the result carried it in time or a late reply did. + * @param questions - The call's questions, or null when they failed validation. + * @param answers - The recorded answer batch. + * @param t - Row translator. + * @returns What the answered row shows. + */ +function answeredPresentation( + questions: readonly AskUserQuestionItem[] | null, + answers: readonly AskUserQuestionAnswerItem[], + t: AskQuestionRowProps["t"], +): AnswerPresentation { + const answered = answers.filter( + (answer) => answer.selected.length > 0 || (answer.custom ?? "") !== "", + ).length; + const paired = questions === null ? null : pairAnswers(questions, answers); + return { + summary: t("ask.answered", { answered, total: answers.length }), + transcript: + paired === null + ? null + : { kind: "answered", questions: paired, skippedLabel: t("ask.skipped") }, + // The read-only panel renders the option lists the user chose from and + // looks each answer up by question id, so a batch this row cannot pair + // unambiguously gets no panel either: blank or dropped answers would read + // as choices the user made. + record: paired === null || questions === null ? undefined : { questions, answers }, + }; +} + +/** Best-effort answered-count summary when strict transcript pairing fails. */ +function answeredSummary(text: string, t: AskQuestionRowProps["t"]): string | null { + const parsed = parseJson(text); + if (!isRecord(parsed)) return null; + const answers = parsed.answers; + if (!Array.isArray(answers) || !answers.every(isRecord)) return null; + const answered = answers.filter( + (a) => + (Array.isArray(a.selected) && a.selected.length > 0) || + (typeof a.custom === "string" && a.custom !== ""), + ).length; + return t("ask.answered", { answered, total: answers.length }); +} + +/** Whether a successful tool result records a still-answerable timed question. */ +function isPendingResult(text: string): boolean { + const parsed = parseJson(text); + return isRecord(parsed) && parsed.pending === true && typeof parsed.callId === "string"; +} + +/** Injected panel verbs for the row's own Session, filled by the optional panel provider. */ +interface AskQuestionPanelInjected { + /** + * Show one call's answer panel in the composer. + * @param callId - the row's own `ask_user_question` call. + * @returns whether a panel for that call was there to show. + */ + revealPanel: (callId: string) => boolean; + /** + * Show one settled call's recorded answers as a read-only panel. + * @param callId - the row's own `ask_user_question` call. + * @param record - the call's questions and recorded answers, read from this row. + * @returns whether a panel provider was there to show it. + */ + reviewPanel: (callId: string, record: UserQuestionRecord) => boolean; +} + +type AskQuestionRowProps = ToolCallViewProps & + PropsLocale<"conversation"> & + InjectFace; + +/** Summarizes a pending, answered, cancelled, or interrupted question set. */ +export function AskQuestionRow({ + callId, + toolName, + block, + inspect, + useDisclosure, + useProjection, + revealPanel, + reviewPanel, + t, +}: AskQuestionRowProps) { + const model = toolRowModel(toolName, block); + // The `userQuestions` projection lists exactly the timed calls that can still + // take an answer — the running ones and the ones the agent continued past — + // so it, not the recorded result, decides whether this row offers its panel. + // A recorded pending result stays recorded after the reply, the skip, or the + // timeout lands, and a legacy blocking call is never listed: its row reads + // exactly as it did before timed questions existed. + const answerable = useProjection( + "userQuestions", + (view) => view?.active.some((row) => row.callId === callId) ?? false, + ); + // The same projection settles every answered timed call with its batch: the + // one its own result carried, or the one a late reply carried after the + // result recorded the timeout. A legacy call is never settled there, so its + // answers come from its result text alone, without a panel to read back. + const settled = useProjection("userQuestions", (view) => + view?.settled.find((row) => row.callId === callId), + ); + // A continued call stays answerable until its steered reply is admitted. + // Select the durable Inbox entry so its submitted answers can be reviewed + // read-only during that interval, including after a browser reconnect. + const queuedReply = useProjection("inbox", (view) => { + const matches = (message: unknown): boolean => { + const source = isRecord(message) ? message.source : undefined; + return isRecord(source) && source.kind === "user-question-reply" && source.callId === callId; + }; + return view?.["next-step"].find(matches) ?? view?.["next-turn"].find(matches); + }); + const reopen = useCallback(() => revealPanel(callId), [callId, revealPanel]); + // Composer verdicts settle the call as specific UserQuestionErrors + // (ask_user_question handler): 'ASK_CANCELLED' is the user's own + // dismissal of the set, 'ASK_ABORTED' is a turn interrupt landing while the + // question was pending. Both name their verdict instead of the generic + // failed shape, and the abort keeps the shared stopped (amber) semantics of + // any other interrupted tool call. + const code = "kind" in block ? block.error?.code : undefined; + const argsRaw = model.bodyRaw ?? ""; + let summary = model.summary; + let state = model.state; + let transcript: AskQuestionCardModel | null = null; + // An answerable call reopens its panel, a queued reply reveals the row's + // read-only transcript, and a settled call opens a read-only panel. + let rowAction: (() => boolean) | undefined; + let rowActionLabel = t("ask.reopen"); + if (code === "ASK_CANCELLED") { + summary = t("ask.cancelled"); + state = "ok"; + const questions = questionEntries(argsRaw); + if (questions !== null) { + transcript = { kind: "unanswered", questions, verdict: t("ask.cancelledDetail") }; + } + } else if (code === "ASK_ABORTED") { + summary = t("ask.interrupted"); + state = "stopped"; + const questions = questionEntries(argsRaw); + if (questions !== null) { + transcript = { kind: "unanswered", questions, verdict: t("ask.interruptedDetail") }; + } + } else if (model.state === "running") { + summary = t("ask.waiting"); + if (answerable) rowAction = reopen; + } else if (code === "TOOL_OUTCOME_UNKNOWN" && (answerable || settled !== undefined)) { + // Crash repair has no pending result text; the durable question projection + // still knows whether the interrupted call can take a late answer. + state = "ok"; + const questions = questionEntries(argsRaw); + if (answerable) { + summary = t("ask.pending"); + if (questions !== null) + transcript = { kind: "unanswered", questions, verdict: t("ask.pendingDetail") }; + rowAction = reopen; + } else if (settled?.answers.length === 0) { + summary = t("ask.closed"); + if (questions !== null) + transcript = { kind: "unanswered", questions, verdict: t("ask.closedDetail") }; + } else if (settled !== undefined) { + const presentation = answeredPresentation(questions, settled.answers, t); + summary = presentation.summary; + transcript = presentation.transcript; + const record = presentation.record; + if (record !== undefined) { + rowAction = () => reviewPanel(callId, record); + rowActionLabel = t("ask.review"); + } + } + } else if ("kind" in block && model.state === "ok") { + const text = singleResultText(block); + if (text !== undefined) { + const questions = questionEntries(argsRaw); + // A timed question's recorded result may be the timeout; whether it is + // still answerable, and what the user finally answered, is the + // projection's to say. + const pending = isPendingResult(text); + if (pending && answerable) { + summary = t("ask.pending"); + if (questions !== null) { + transcript = { kind: "unanswered", questions, verdict: t("ask.pendingDetail") }; + } + rowAction = reopen; + } else if (pending && (settled === undefined || settled.answers.length === 0)) { + summary = t("ask.closed"); + if (questions !== null) { + transcript = { kind: "unanswered", questions, verdict: t("ask.closedDetail") }; + } + } else { + // Answered either way: whether the answer beat the timeout is the + // agent's pacing, not something a reader of the row has to reason + // about, so a late-answered call reads exactly like an in-time one. + const answers = settled?.answers ?? answerEntries(text); + const presentation = answers === null ? null : answeredPresentation(questions, answers, t); + // Full transcripts require stable ids and valid visible fields; retain the + // legacy best-effort count when only strict pairing is unsafe. + summary = presentation?.summary ?? answeredSummary(text, t) ?? model.summary; + transcript = presentation?.transcript ?? null; + // Only a timed call has a panel to read its answers back in. + const record = settled === undefined ? undefined : presentation?.record; + if (record !== undefined) { + rowAction = () => reviewPanel(callId, record); + rowActionLabel = t("ask.review"); + } + } + } + } + if (answerable && queuedReply !== undefined && rowAction === reopen) { + const answers = queuedAnswerEntries(queuedReply, callId); + const presentation = + answers === null ? null : answeredPresentation(questionEntries(argsRaw), answers, t); + if (presentation?.record !== undefined) { + summary = presentation.summary; + transcript = presentation.transcript; + // The queued reply is reviewed in this row while its editable card is absent. + rowAction = () => false; + rowActionLabel = t("ask.review"); + } + } + if (rowAction !== undefined) + return ( + + ); + return ( + } + title={t(model.titleKey)} + summary={summary} + bodyRaw={transcript === null ? model.bodyRaw : null} + output={transcript === null ? model.output : null} + askQuestion={transcript} + state={state} + inspect={inspect} + /> + ); +} + +/** Registers the ask-user-question conversation row. */ +export const askQuestionToolview = { + name: "ask-question-toolview", + inject: ["slots"], + apply(ctx: Context): void { + ctx.slots.inject("tool.call.toolview", () => + ctx.slots.register( + { + name: "tool.call.toolview", + key: "ask_user_question", + locale: NS, + // Read per click, not per render: a composition with no question UI + // provides no panels, and the row then falls back to its own record. + inject: (sessionId): AskQuestionPanelInjected => ({ + revealPanel: (callId) => + ctx.get("userQuestionPanels")?.reveal(sessionId, callId) ?? false, + reviewPanel: (callId, record) => + ctx.get("userQuestionPanels")?.review(sessionId, callId, record) ?? false, + }), + }, + AskQuestionRow, + ), + ); + }, +}; diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css b/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css new file mode 100644 index 000000000..240eca829 --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css @@ -0,0 +1,239 @@ +/* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description), + plus the expand-gated terminal card under the summary line. */ + +/* Summary line over the terminal card; the summary row keeps its own 24px + height, so the card is a column around it rather than a change to it. */ +.card { + display: flex; + flex-direction: column; +} + +/* Expanded terminal card, matching ToolRow's terminalBody: 4px indent, l1 + hairline, and the max-height scroll on the card's own OUTPUT (banner stays + pinned; 224px = the 260px card cap minus the ~36px banner); the margin + replaces the primitive's standalone vertical margin with the flow's. */ +.terminal { + --dsl-terminal-font: var(--dsw-font-markdown-code-block-small); + --dsl-terminal-line-height: 18px; + --dsl-terminal-output-max-height: 224px; + margin: 4px 0 4px 4px; + border: 0.5px solid var(--dsw-alias-border-l1); +} + +/* A bash execution error can settle without terminal-card material (for + example, command cancellation). Preserve ToolRow's bounded IN/OUT fallback + so the original command and full error remain available from this keyed row. */ +.ioCard { + display: flex; + flex-direction: column; + margin: 4px 0 4px 4px; + border: 0.5px solid var(--dsw-alias-border-l1); + border-radius: var(--dsw-radius-lg); + background: var(--dsw-alias-markdown-code-block); + font: var(--dsw-font-markdown-code-block-small); +} + +.ioSection { + display: grid; + grid-template-columns: max-content 1fr; + column-gap: 14px; + align-items: baseline; + padding: 12px 16px; + max-height: 150px; + overflow-y: auto; +} + +.ioSection::-webkit-scrollbar-thumb { + border: 2px solid transparent; + background-clip: padding-box; + border-radius: var(--dsw-radius-sm); +} + +.ioSection::-webkit-scrollbar-track { + margin: 6px 0; +} + +.ioLabel { + position: sticky; + top: 0; + align-self: start; + color: var(--dsw-alias-label-caption); +} + +.ioDivider { + flex: none; + height: 0.5px; + background: var(--dsw-alias-border-l2); +} + +.ioText { + min-width: 0; + white-space: pre-wrap; + word-break: break-word; + color: var(--dsw-alias-label-secondary); +} + +.ioText[data-error] { + color: var(--dsw-alias-state-error-primary); +} + +/* ToolRow's unified expand interaction, replicated per the registrant + posture: pointer on the expandable row (the icon→chevron hover preview is + the affordance, no row fill). */ +.root[data-expandable] { + cursor: pointer; +} + +.root { + display: flex; + align-items: center; + height: calc(24px + var(--dsh-content-font-delta, 0px)); + min-width: 0; + color: var(--dsw-alias-label-tertiary); + transition: color 100ms ease; +} + +.root:hover { + color: var(--dsw-alias-label-secondary); +} + +@media (prefers-reduced-motion: reduce) { + .root { + transition: none; + } +} + +.leading { + position: relative; /* .chevronHover overlay anchor */ + flex: none; + width: calc(16px + var(--dsh-content-font-delta, 0px)); + height: calc(16px + var(--dsh-content-font-delta, 0px)); + display: inline-flex; + align-items: center; + justify-content: center; + margin-right: 6px; + color: inherit; +} + +/* Same glyph-scaling rule as DisclosureRow's leading: the CSS edge overrides + the svg attributes so the 14px business glyph follows the font-size delta. + TODO: this bash header duplicates the DisclosureRow structure (and this is + the fourth copy of the glyph rule, with DisclosureRow, MessageItem + .compactionLeading, and MessageIconActions); migrating the bash row onto + the shared DisclosureRow would collapse them to one. */ +.leading svg:not([data-state]) { + width: calc(14px + var(--dsh-content-font-delta, 0px)); + height: calc(14px + var(--dsh-content-font-delta, 0px)); +} + +.chevron { + color: inherit; +} + +/* Hover preview on the expandable row: the idle icon crossfades (100ms) into + a down chevron before the row is opened — same overlay as ToolRow. */ +.iconIdle { + display: inline-flex; + opacity: 1; + transition: opacity 100ms ease; +} + +.chevronHover { + position: absolute; + inset: 0; + margin: auto; + opacity: 0; + transition: opacity 100ms ease; +} + +.root:hover .iconIdle { + opacity: 0; +} + +.root:hover .chevronHover { + opacity: 1; +} + +.title { + flex: none; + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(24px + var(--dsh-content-font-delta, 0px)); +} + +.sep { + flex: none; + width: 2px; + height: 2px; + border-radius: 1px; + margin: 0 8px; + background: var(--dsw-alias-label-caption); +} + +.summary { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(24px + var(--dsh-content-font-delta, 0px)); +} + +/* Error row's collapsed summary: the failure's first line in the error color. */ +.errorSummary { + color: var(--dsw-alias-state-error-primary); +} + +.stoppedSummary { + color: var(--dsw-alias-state-warn-label); +} + +/* Hover-revealed Inspect pill under the expanded terminal's bottom-left — + ToolRow's .bodyWrap/.inspectButton treatment, replicated per the registrant + posture: real flow (it reserves its line), revealed by hovering anywhere on + the tool call — title row included — or by keyboard focus. */ +.bodyWrap { + display: flex; + flex-direction: column; +} + +.inspectButton { + display: inline-flex; + align-self: flex-start; + align-items: center; + gap: 4px; + margin: 4px 0 2px 4px; + padding: 2px 8px; + border: 0.5px solid var(--dsw-alias-border-l4); + border-radius: 999px; + corner-shape: round; + /* Base background, not bg-overlay: the overlay token reads too heavy. */ + background: var(--dsw-alias-bg-base); + color: var(--dsw-alias-label-secondary); + font-size: 11px; + line-height: 16px; + cursor: pointer; + opacity: 0; + transition: opacity 100ms ease; +} + +.card:hover .inspectButton, +.inspectButton:focus-visible { + opacity: 1; +} + +/* Solid hover fill: the pill floats over terminal output, so a translucent + hover token would let the text underneath bleed through. */ +.inspectButton:hover { + background: var(--dsw-alias-interactive-bg-hover-solid); + color: var(--dsw-alias-label-primary); +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx b/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx new file mode 100644 index 000000000..33a26134b --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx @@ -0,0 +1,209 @@ +import { memo, useCallback, useMemo, type KeyboardEvent } from "react"; +import type { Context } from "@deepseek-ai/cordis"; +import clsx from "clsx"; +import { + IconApiOutlineRegular, + IconChevronDownOutlineRegular, + IconChevronUpOutlineRegular, + IconInspectOutlineRegular, + TerminalBlock, + TextShimmer, +} from "@deepseek-ai/dsh-client-ui-primitives"; +import type { PropsLocale } from "@deepseek-ai/dsh-client-ui-slots"; +import type { ToolCallViewProps } from "../../contract/slots.ts"; +import { + isSettledPersistentShellCall, + isSpilledShellCall, + localizeTerminalCardModel, + terminalBlockLabels, + terminalCardModel, + terminalFailed, +} from "../models/terminal-card-model.ts"; +import { formatToolBody, toolRowModel, type ToolRowState } from "../models/tool-call-model.ts"; +import { CONVERSATION_NS as NS } from "../../locale.ts"; +import css from "./bash-sample.module.css"; + +type BashRowProps = ToolCallViewProps & PropsLocale<"conversation">; + +const BASH_ICON = ; + +/** Visually hidden status for the color-only running sweep and error tone. */ +function stateStatus(state: ToolRowState, t: BashRowProps["t"]): string | null { + switch (state) { + case "preparing": + return t("row.preparing"); + case "running": + return t("bash.running"); + case "error": + return t("bash.failed"); + case "stopped": + return t("bash.stopped"); + default: + return null; + } +} + +/** + * Render expandable Bash output with an accessible lifecycle label. While the + * call is preparing the row shows the description streamed so far and cannot expand. + * @param props - tool call, Session sources, locale, and inspection callback. + * @returns the Bash output row. + */ +export const BashRow = memo(function BashRow({ + callId, + toolName, + block, + sessionId, + useSessions, + useProjection, + inspect, + useDisclosure, + t, +}: BashRowProps) { + const model = useMemo(() => toolRowModel(toolName, block), [toolName, block]); + // An omitted shell workdir is the session workspace; relative values resolve + // against it before reaching the terminal primitive. + const cwd = useSessions((list) => list.byId[sessionId]?.cwd); + const terminalModel = useMemo(() => terminalCardModel(block, cwd), [block, cwd]); + const terminal = useMemo( + () => (terminalModel === null ? null : localizeTerminalCardModel(terminalModel, t)), + [terminalModel, t], + ); + const labels = useMemo(() => terminalBlockLabels(t), [t]); + // A failing exit status is the terminal card's own error signal (the call + // itself settles isError:false), surfaced through the row's error summary. + const state = + model.state === "ok" && terminalModel !== null && terminalFailed(terminalModel) + ? "error" + : model.state; + const status = stateStatus(state, t); + const { expanded, toggle: toggleExpand } = useDisclosure(); + // Failures, persistent-shell results, and spill previews use a generic body; + // background acknowledgements and malformed calls remain collapsed. + const genericBody = + terminal === null && + (model.state === "error" || isSettledPersistentShellCall(block) || isSpilledShellCall(block)) && + (model.bodyRaw !== null || model.output !== null); + const expandable = terminal !== null || genericBody; + const open = expanded && expandable; + const body = useMemo( + () => + open && genericBody && model.bodyRaw !== null + ? formatToolBody(model.variant, model.bodyRaw) + : null, + [genericBody, model.bodyRaw, model.variant, open], + ); + const normalSummary = terminal?.description ?? model.summary; + const settlementLine = + state === "error" + ? (model.errorSummary ?? normalSummary) + : state === "stopped" + ? t("bash.stopped") + : null; + const running = state === "running" || state === "preparing"; + // CodexHost: running commands stream their output tail through a projection. + const liveOutputs = (useProjection as unknown as (key: string) => unknown)( + "codexhostToolOutput", + ) as Readonly> | undefined; + const liveOutput = running ? liveOutputs?.[callId] : undefined; + const toggleFromKeyboard = useCallback( + (event: KeyboardEvent) => { + if (!expandable || (event.key !== "Enter" && event.key !== " ")) return; + event.preventDefault(); + toggleExpand(); + }, + [expandable, toggleExpand], + ); + const leading = open ? ( + + ) : expandable ? ( + <> + {BASH_ICON} + + + ) : ( + BASH_ICON + ); + return ( +
    +
    + {leading} + {status !== null && {status}} + + {t(model.titleKey)} + + + {settlementLine ?? normalSummary} + + +
    + {open && ( +
    + {terminal !== null ? ( + + ) : ( +
    + {body !== null && ( +
    + {t("row.input")} + {body} +
    + )} + {body !== null && model.output !== null && ( + + )} + {model.output !== null && ( +
    + {t("row.output")} + + {model.output} + +
    + )} +
    + )} + {inspect !== undefined && ( + + )} +
    + )} +
    + ); +}); + +/** Registers the standalone Bash conversation-row sample. */ +export const bashToolviewSample = { + name: "bash-toolview-sample", + inject: ["slots"], + apply(ctx: Context): void { + ctx.slots.inject("tool.call.toolview", () => + ctx.slots.register({ name: "tool.call.toolview", key: "bash", locale: NS }, BashRow), + ); + }, +}; diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/context-compaction-row.tsx b/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/context-compaction-row.tsx new file mode 100644 index 000000000..4a6ed183c --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/context-compaction-row.tsx @@ -0,0 +1,54 @@ +import type { Context } from "@deepseek-ai/cordis"; +import type { PropsLocale } from "@deepseek-ai/dsh-client-ui-slots"; +import { IconApiOutlineRegular } from "@deepseek-ai/dsh-client-ui-primitives"; +import type { ToolCallViewProps } from "../../contract/slots.ts"; +import { CONVERSATION_NS as NS } from "../../locale.ts"; +import { ToolRow } from "../components/ToolRow.tsx"; +import { toolRowModel } from "../models/tool-call-model.ts"; + +type ContextCompactionRowProps = ToolCallViewProps & PropsLocale<"conversation">; + +/** Render the standard Host compaction Item as a first-class lifecycle row. */ +export function ContextCompactionRow({ + toolName, + block, + useDisclosure, + t, +}: ContextCompactionRowProps) { + const model = toolRowModel(toolName, block); + const title = + model.state === "running" || model.state === "preparing" + ? t("tool.contextCompaction.running") + : model.state === "ok" + ? t("tool.contextCompaction.completed") + : t("tool.contextCompaction.action"); + return ( + } + title={title} + summary="" + bodyRaw={null} + output={model.state === "ok" ? null : model.output} + errorSummary={model.errorSummary} + state={model.state} + /> + ); +} + +/** Register the canonical Web projection used by manual and automatic compaction. */ +export const contextCompactionToolview = { + name: "context-compaction-toolview", + inject: ["slots"], + apply(ctx: Context): void { + ctx.slots.inject("tool.call.toolview", () => + ctx.slots.register( + { name: "tool.call.toolview", key: "compact_context", locale: NS }, + ContextCompactionRow, + ), + ); + }, +}; diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/details-row.tsx b/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/details-row.tsx new file mode 100644 index 000000000..6f31cd5e0 --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/details-row.tsx @@ -0,0 +1,252 @@ +/** Keyed recorded-result rows sharing the compact detail body. */ +import { useMemo } from "react"; +import type { Context } from "@deepseek-ai/cordis"; +import { + IconAgentPresetOutlineRegular, + IconBranchOutlineRegular, + IconChecklistOutlineRegular, + IconClockOutlineRegular, + IconCodeOutlineRegular, + IconCordisPluginOutlineRegular, + IconGoalOutlineRegular, + IconSearchOutlineRegular, + IconUsersOutlineRegular, +} from "@deepseek-ai/dsh-client-ui-primitives"; +import type { PropsLocale } from "@deepseek-ai/dsh-client-ui-slots"; +import type { ToolCallViewProps } from "../../contract/slots.ts"; +import { CONVERSATION_NS as NS } from "../../locale.ts"; +import { ToolRow } from "../components/ToolRow.tsx"; +import { detailsCardModel } from "../models/details-card-model.ts"; +import { parsedToolCall } from "../models/raw-tool-call.ts"; +import { toolRowModel } from "../models/tool-call-model.ts"; + +type DetailsRowProps = ToolCallViewProps & PropsLocale<"conversation">; + +const LSP_TITLE_KEYS = { + goToDefinition: "tool.title.findDefinition", + findReferences: "tool.title.findReferences", + goToImplementation: "tool.title.findImplementation", + hover: "tool.title.hoverSymbol", +} as const; + +/** Teammate-coordination tools presented with the two-person team icon. */ +const TEAMMATE_TOOLS = new Set([ + "spawn_teammate", + "list_agents", + "send_message", + "interrupt_agent", + "wait_agent", +]); + +function detailIcon(toolName: string) { + if (TEAMMATE_TOOLS.has(toolName)) return ; + if (toolName.startsWith("schedule_")) return ; + if (toolName.endsWith("_goal")) return ; + if (toolName.startsWith("cordis_")) return ; + if (toolName.startsWith("terminal_")) return ; + if (toolName.startsWith("session_") || toolName === "lsp") + return ; + if (toolName.startsWith("job_") || toolName.startsWith("team_task_")) + return ; + if (toolName === "workflow" || toolName === "ralph") + return ; + return ; +} + +/** + * Present recorded entities, receipts, and report fields in the existing expandable row. + * @param props - Tool call, row actions, and locale supplied by the keyed slot. + * @returns A Tool row with structured details or generic input/output. + */ +export function DetailsRow({ + toolName, + block, + cwd, + home, + openFile, + inspect, + useDisclosure, + t, +}: DetailsRowProps) { + const model = toolRowModel(toolName, block, cwd, home); + const locale = document.documentElement.lang; + const details = useMemo(() => detailsCardModel(block, t, locale), [block, t, locale]); + const operation = toolName === "lsp" ? parsedToolCall(block)?.args.operation : undefined; + const titleKey = + typeof operation === "string" && Object.hasOwn(LSP_TITLE_KEYS, operation) + ? LSP_TITLE_KEYS[operation as keyof typeof LSP_TITLE_KEYS] + : model.titleKey; + return ( + + ); +} + +/** Register recorded-result details through the standard atomic Tool slot. */ +export const detailsToolview = { + name: "details-toolview", + inject: ["slots"], + apply(ctx: Context): void { + ctx.slots.inject("tool.call.toolview", function* () { + yield ctx.slots.register( + { name: "tool.call.toolview", key: "create_goal", locale: NS }, + DetailsRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "get_goal", locale: NS }, + DetailsRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "update_goal", locale: NS }, + DetailsRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "schedule_create", locale: NS }, + DetailsRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "schedule_list", locale: NS }, + DetailsRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "schedule_delete", locale: NS }, + DetailsRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "schedule_update", locale: NS }, + DetailsRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "cordis_inspect_list", locale: NS }, + DetailsRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "cordis_inspect_query", locale: NS }, + DetailsRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "cordis_inspect_self", locale: NS }, + DetailsRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "workflow", locale: NS }, + DetailsRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "ralph", locale: NS }, + DetailsRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "session_event_read", locale: NS }, + DetailsRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "session_event_search", locale: NS }, + DetailsRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "session_event_trace", locale: NS }, + DetailsRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "session_search", locale: NS }, + DetailsRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "session_trace", locale: NS }, + DetailsRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "list_subagent_models", locale: NS }, + DetailsRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "subagent", locale: NS }, + DetailsRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "list_agents", locale: NS }, + DetailsRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "send_message", locale: NS }, + DetailsRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "interrupt_agent", locale: NS }, + DetailsRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "job_list", locale: NS }, + DetailsRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "job_output", locale: NS }, + DetailsRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "job_kill", locale: NS }, + DetailsRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "terminal_open", locale: NS }, + DetailsRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "terminal_read", locale: NS }, + DetailsRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "terminal_list", locale: NS }, + DetailsRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "terminal_signal", locale: NS }, + DetailsRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "terminal_close", locale: NS }, + DetailsRow, + ); + yield ctx.slots.register({ name: "tool.call.toolview", key: "lsp", locale: NS }, DetailsRow); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "spawn_teammate", locale: NS }, + DetailsRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "team_task_create", locale: NS }, + DetailsRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "team_task_get", locale: NS }, + DetailsRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "team_task_update", locale: NS }, + DetailsRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "team_task_list", locale: NS }, + DetailsRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "wait_agent", locale: NS }, + DetailsRow, + ); + }); + }, +}; diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx b/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx new file mode 100644 index 000000000..6eb53562e --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx @@ -0,0 +1,116 @@ +import type { Context } from "@deepseek-ai/cordis"; +import { + IconEditOutlineRegular, + diffTotals, + type DiffHunk, +} from "@deepseek-ai/dsh-client-ui-primitives"; +import type { PropsLocale } from "@deepseek-ai/dsh-client-ui-slots"; +import type { ToolCallViewProps } from "../../contract/slots.ts"; +import { diffCardModel } from "../models/diff-card-model.ts"; +import { toolRowModel } from "../models/tool-call-model.ts"; +import { ToolRow } from "../components/ToolRow.tsx"; +import { CONVERSATION_NS as NS } from "../../locale.ts"; + +type FileMutationRowProps = ToolCallViewProps & PropsLocale<"conversation">; +const FILE_MUTATION_ICON = ; + +const CONTENT_FIELDS = ["content", "old_string", "new_string"]; +const KILOBYTE = 1024; + +/** Kilobytes of decoded input at every stage; null before any content field arrives. */ +function contentKilobytes(block: FileMutationRowProps["block"]): number | null { + const { args } = block; + const open = CONTENT_FIELDS.find((key) => args.has(key) && !args.complete(key)); + const completed = CONTENT_FIELDS.reduce( + (total, key) => + key !== open && args.complete(key) + ? total + (args.stringLength(key, { step: KILOBYTE }) ?? 0) + : total, + 0, + ); + if (open === undefined) { + return CONTENT_FIELDS.some((key) => args.has(key)) ? Math.ceil(completed / KILOBYTE) : null; + } + const chars = completed + (args.stringLength(open, { step: KILOBYTE, offset: completed }) ?? 0); + return Math.ceil(chars / KILOBYTE); +} + +function fileStatsFromDiffs(diffs: DiffHunk[]): { path: string; added: number; removed: number }[] { + const order: string[] = []; + const grouped = new Map(); + for (const hunk of diffs) { + const existing = grouped.get(hunk.path); + if (existing === undefined) { + order.push(hunk.path); + grouped.set(hunk.path, [hunk]); + } else existing.push(hunk); + } + return order.map((path) => ({ path, ...diffTotals(grouped.get(path) ?? []) })); +} + +/** + * Shows the path and decoded input size through preparation, execution, and + * settlement, with the applied diff available once the call settles. + * Several paths collapse to a turn-level file-change summary. + */ +export function FileMutationRow({ + toolName, + block, + cwd, + home, + openFile, + inspect, + useDisclosure, + t, +}: FileMutationRowProps) { + const model = toolRowModel(toolName, block, cwd, home); + const diff = diffCardModel(block); + const fileStats = fileStatsFromDiffs(diff?.card.diffs ?? []); + const multi = fileStats.length > 1; + const running = model.state === "running" || model.state === "preparing"; + const kilobytes = contentKilobytes(block); + const size = kilobytes === null ? null : t("tool.preparing.content", { kilobytes }); + const title = multi + ? t(running ? "tool.title.filesChanging" : "tool.title.filesEdited", { + count: fileStats.length, + }) + : t(model.titleKey); + return ( + + ); +} + +/** Registers the edit and write conversation rows. */ +export const fileMutationToolview = { + name: "file-mutation-toolview", + inject: ["slots"], + apply(ctx: Context): void { + ctx.slots.inject("tool.call.toolview", function* () { + yield ctx.slots.register( + { name: "tool.call.toolview", key: "edit", locale: NS }, + FileMutationRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "write", locale: NS }, + FileMutationRow, + ); + }); + }, +}; diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/plan-summary.ts b/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/plan-summary.ts new file mode 100644 index 000000000..f1b0584d7 --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/plan-summary.ts @@ -0,0 +1,60 @@ +/** + * Pure plan derivation for the todo_write row's one-line summary. Several items + * may be `in_progress` at once — parallel work runs concurrent tasks, so a + * summary built from one active item would silently drop the rest. The plan + * strip header derives its own counts inline and shares nothing with this, so + * this stays inside the toolviews domain rather than in `contract/` (the + * inter-domain face). + * @module + */ + +/** + * One list item as the row sees it: unvalidated model JSON parsed from a call's + * args, so any field may be missing or mistyped. + */ +export interface PlanItemLike { + content?: unknown; + status?: unknown; +} + +/** + * Counts plus the two halves of the summary, deliberately NOT pre-joined: the + * row ellipsizes its summary text, and a count concatenated onto the end of the + * task name is the first thing a narrow row clips — exactly when it carries + * information. The row renders `activeExtra` in its own non-shrinking span + * beside the truncatable text. + */ +export interface PlanSummary { + done: number; + total: number; + /** First `in_progress` content, or null when that first item is unusable. */ + activeContent: string | null; + /** Active items beyond the first; 0 whenever there is no `activeContent` to sit beside. */ + activeExtra: number; +} + +/** + * Derive the counts and the active summary from a whole-list snapshot. It names + * the first `in_progress` item and counts the remaining active ones, so a + * parallel plan reports how many tasks are running rather than naming one and + * hiding the others. `activeContent` is null when nothing is in progress, or + * when the first active item's content is missing, mistyped, or blank once + * trimmed — the tool's own rule for usable content, applied here because a + * rejected call keeps its args verbatim. The row then renders the counts alone + * rather than falling back to the generic tool summary: the counts are already + * known to be good, and the active-item clause is the only part an unusable + * name costs. + * @param todos - the whole list, in model order. + * @returns the done/total counts and the two summary halves. + */ +export function planSummary(todos: readonly PlanItemLike[]): PlanSummary { + const active = todos.filter((t) => t.status === "in_progress"); + const first = active[0]?.content; + const named = typeof first === "string" && first.trim() !== ""; + return { + done: todos.filter((t) => t.status === "completed").length, + total: todos.length, + activeContent: named ? first : null, + activeExtra: named ? active.length - 1 : 0, + }; +} diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/read-family-row.tsx b/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/read-family-row.tsx new file mode 100644 index 000000000..0179ec7eb --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/read-family-row.tsx @@ -0,0 +1,64 @@ +// Shared assembly for the read-family toolview rows (`read`, `read_image`). +// +// Both rows are the same single-file card row: the browse icon in the shared +// ToolRow chrome, the args-derived summary as an openable host path, no args body +// (the path link is the only args interaction), and one result-side card as the +// collapsed-by-default body. Only which card material they carry differs, so the +// row assembly lives here once instead of being copied per tool. + +import type { ReactNode } from "react"; +import { IconBrowseOutlineRegular } from "@deepseek-ai/dsh-client-ui-primitives"; +import type { PropsRenderSlots } from "@deepseek-ai/dsh-client-ui-slots"; +import type { ToolCallViewProps } from "../../contract/slots.ts"; +import { toolRowModel } from "../models/tool-call-model.ts"; +import { ToolRow, type ToolRowProps } from "../components/ToolRow.tsx"; + +/** Full row props of a read-family toolview: the runtime share plus its locale seat. */ +export type ReadFamilyRowProps = ToolCallViewProps & { t: ToolRowProps["t"] }; + +/** read_image row props: the runtime share, the declared image child slot, and the locale seat. */ +export type ReadImageRowProps = ReadFamilyRowProps & PropsRenderSlots<"tool.call.images">; + +/** + * The card material one read-family row contributes: exactly the ToolRow card + * props that row owns. `read` supplies `read` and the line its call named; + * `read_image` supplies `image` together with the slot dispatcher and loader + * that draw it. + */ +export type ReadFamilyCard = Pick< + ToolRowProps, + "read" | "image" | "renderSlot" | "loadImage" | "filePathLine" +>; + +/** + * Compose a read-family row: the shared chrome and model-derived fields, plus the + * caller's card material. + * @param props - the toolview runtime share and locale seat. + * @param card - the card props this row owns. + * @returns the assembled ToolRow. + */ +export function readFamilyRow( + { toolName, block, cwd, home, openFile, inspect, useDisclosure, t }: ReadFamilyRowProps, + card: ReadFamilyCard, +): ReactNode { + const model = toolRowModel(toolName, block, cwd, home); + return ( + } + title={t(model.titleKey)} + summary={model.summary} + bodyRaw={null} + output={model.output} + errorSummary={model.errorSummary} + {...card} + state={model.state} + filePath={model.filePath} + onOpenFile={openFile} + inspect={inspect} + /> + ); +} diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/read-image-row.tsx b/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/read-image-row.tsx new file mode 100644 index 000000000..c3327f4ba --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/read-image-row.tsx @@ -0,0 +1,65 @@ +// read_image toolview registrant: the keyed toolview hole for the read_image +// tool. The row composes the shared read-family assembly and feeds it the durable +// image reference as ToolRow's `image` card material, so the image renders through +// the Tool-owned `tool.call.images` slot inside the collapsed-by-default expanded +// body — the same unified interaction every other card row has. The attachment +// presentation plugin fills that slot; the tool layer only ever supplies the +// references and the session-authorized loader it received from the chat node. +// +// Claiming the `read_image` key suppresses the generic fallback for EVERY +// read_image result, so this component must cover all of the tool's shapes, not +// only the happy one: a running call (no result yet), a settled image, a refusal +// (a text-only route, a missing attachment service, an unreadable file), and a +// cancelled call. Each of those settles without an `image` card, and the row falls +// back to its text body for them. +// +// Like the read card, the image card is result-side only: a call carries no +// content until `execute` returns, so a running read_image shows the summary row +// alone. + +import type { Context } from "@deepseek-ai/cordis"; +import { imageCardModel } from "../models/image-card-model.ts"; +import { readFamilyRow, type ReadImageRowProps } from "./read-family-row.tsx"; +import { CONVERSATION_NS as NS } from "../../locale.ts"; + +/** + * read_image row: the read-family chrome with the durably committed image as the + * row's collapsed-by-default card body, rendered through the `tool.call.images` + * slot this entry declares. + */ +export function ReadImageRow(props: ReadImageRowProps) { + const { block, cwd, home, renderSlot, loadImage } = props; + return readFamilyRow(props, { + image: imageCardModel(block, cwd, home), + renderSlot, + loadImage, + }); +} + +/** + * The read_image row as a plain registrant plugin following the atomic Tool-view + * declaration across independent activation and reload lifetimes. Declaring + * `tool.call.images` as a child slot authorizes this entry's `renderSlot` to + * dispatch the gallery. + */ +export const readImageToolview = { + name: "read-image-toolview", + inject: ["slots"], + /** + * Register the read_image row into the Tool-owned keyed view slot. + * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). + */ + apply(ctx: Context): void { + ctx.slots.inject("tool.call.toolview", () => + ctx.slots.register( + { + name: "tool.call.toolview", + key: "read_image", + locale: NS, + children: { "tool.call.images": { kind: "single", scope: "session" } }, + }, + ReadImageRow, + ), + ); + }, +}; diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/read-row.tsx b/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/read-row.tsx new file mode 100644 index 000000000..ba0f2429e --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/read-row.tsx @@ -0,0 +1,31 @@ +import type { Context } from "@deepseek-ai/cordis"; +import type { PropsLocale } from "@deepseek-ai/dsh-client-ui-slots"; +import type { ToolCallViewProps } from "../../contract/slots.ts"; +import { readCallLine, readCardModel } from "../models/read-card-model.ts"; +import { readFamilyRow } from "./read-family-row.tsx"; +import { CONVERSATION_NS as NS } from "../../locale.ts"; + +type ReadRowProps = ToolCallViewProps & PropsLocale<"conversation">; + +/** + * Lets users expand a completed read result and open its reported path at the + * line the call started from. + */ +export function ReadRow(props: ReadRowProps) { + const { block, cwd, home } = props; + return readFamilyRow(props, { + read: readCardModel(block, cwd, home), + filePathLine: readCallLine(block), + }); +} + +/** Registers the read tool's conversation row. */ +export const readToolview = { + name: "read-toolview", + inject: ["slots"], + apply(ctx: Context): void { + ctx.slots.inject("tool.call.toolview", () => + ctx.slots.register({ name: "tool.call.toolview", key: "read", locale: NS }, ReadRow), + ); + }, +}; diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx b/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx new file mode 100644 index 000000000..ec3298225 --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx @@ -0,0 +1,57 @@ +import type { Context } from "@deepseek-ai/cordis"; +import { IconSearchOutlineRegular } from "@deepseek-ai/dsh-client-ui-primitives"; +import type { PropsLocale } from "@deepseek-ai/dsh-client-ui-slots"; +import type { ToolCallViewProps } from "../../contract/slots.ts"; +import { searchCardModel } from "../models/search-card-model.ts"; +import { toolRowModel } from "../models/tool-call-model.ts"; +import { ToolRow } from "../components/ToolRow.tsx"; +import { CONVERSATION_NS as NS } from "../../locale.ts"; + +type SearchRowProps = ToolCallViewProps & PropsLocale<"conversation">; + +const SEARCH_TITLE_KEYS = { + grep: "tool.title.grep", + glob: "tool.title.glob", +} as const; + +/** Lets users expand grep or glob results and recover capped searches. */ +export function SearchRow({ toolName, block, inspect, useDisclosure, t }: SearchRowProps) { + const model = toolRowModel(toolName, block); + const search = searchCardModel(block); + return ( + } + title={t( + toolName === "grep" + ? SEARCH_TITLE_KEYS.grep + : toolName === "glob" + ? SEARCH_TITLE_KEYS.glob + : model.titleKey, + )} + summary={model.summary} + // ToolRow ignores output when a structured card is present; otherwise it + // preserves the generic fallback for errors and legacy results. + output={model.output} + errorSummary={model.errorSummary} + search={search} + state={model.state} + inspect={inspect} + /> + ); +} + +/** Registers the grep and glob conversation rows. */ +export const searchToolview = { + name: "search-toolview", + inject: ["slots"], + apply(ctx: Context): void { + ctx.slots.inject("tool.call.toolview", function* () { + yield ctx.slots.register({ name: "tool.call.toolview", key: "grep", locale: NS }, SearchRow); + yield ctx.slots.register({ name: "tool.call.toolview", key: "glob", locale: NS }, SearchRow); + }); + }, +}; diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx b/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx new file mode 100644 index 000000000..bf3c90179 --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx @@ -0,0 +1,118 @@ +import { useMemo } from "react"; +import { IconChecklistOutlineRegular } from "@deepseek-ai/dsh-client-ui-primitives"; +import type { Context } from "@deepseek-ai/cordis"; +import type { HostObservable, InjectFace, PropsLocale } from "@deepseek-ai/dsh-client-ui-slots"; +import type { ToolCallViewProps } from "../../contract/slots.ts"; +import { registerTodoHistory, type TodoHistory } from "../models/todo-history.ts"; +import { todoDiffModel } from "../models/todo-diff-model.ts"; +import { toolRowModel } from "../models/tool-call-model.ts"; +import { ToolRow } from "../components/ToolRow.tsx"; +import { CONVERSATION_NS as NS } from "../../locale.ts"; +import { planSummary, type PlanItemLike } from "./plan-summary.ts"; + +type TodoHistoryInjected = { hooks: { todoHistory: HostObservable } }; +type TodoRowProps = ToolCallViewProps & + PropsLocale<"conversation"> & + InjectFace; + +function isItem(value: unknown): value is PlanItemLike { + return typeof value === "object" && value !== null; +} + +/** + * The row's summary split at the ellipsis boundary: `text` truncates, `extra` + * is the parallel-active count that must not, so a narrow row never clips the + * one part that says several tasks are running. + */ +interface RowSummary { + text: string; + extra: number; +} + +function summarize(argsRaw: string | null, t: TodoRowProps["t"]): RowSummary | null { + if (argsRaw === null) return null; + let parsed: unknown; + try { + parsed = JSON.parse(argsRaw); + } catch { + // Mid-stream truncation or malformed model JSON: fall back to the generic summary. + return null; + } + // Valid JSON with invalid todo fields (null root, non-array todos, null items — + // a rejected tool/call retains such args verbatim): same generic fallback. + if (typeof parsed !== "object" || parsed === null) return null; + const todos = (parsed as { todos?: unknown }).todos; + if (!Array.isArray(todos) || !todos.every(isItem)) return null; + const { done, total, activeContent, activeExtra } = planSummary(todos); + const head = t("todo.completed", { done, total }); + return { + text: activeContent === null ? head : `${head} · ${activeContent}`, + extra: activeExtra, + }; +} + +/** Summarizes a plan update without presenting a cancelled call as completed. */ +export function TodoRow({ + toolName, + block, + inspect, + useDisclosure, + useTodoHistory, + useSession, + t, +}: TodoRowProps) { + const baseline = useTodoHistory((snapshot) => snapshot?.get(block.callId)); + const hasMore = useSession((snapshot) => snapshot.hasMore); + const diff = useMemo( + () => todoDiffModel(block, baseline, hasMore, t), + [block, baseline, hasMore, t], + ); + const model = toolRowModel(toolName, block); + const summary = summarize(model.bodyRaw, t) ?? { text: model.summary, extra: 0 }; + return ( + } + title={t(model.titleKey)} + summary={summary.text} + summarySuffix={ + [diff?.summary, summary.extra > 0 ? `+${summary.extra}` : null] + .filter((part): part is string => part !== null && part !== undefined) + .join(" · ") || null + } + bodyRaw={model.bodyRaw} + output={model.output} + details={diff?.details} + errorSummary={model.errorSummary} + state={model.state} + inspect={inspect} + /> + ); +} + +/** Registers the todo conversation row. */ +export const todoToolview = { + name: "todo-toolview", + inject: ["slots", "uiConversation"], + apply(ctx: Context): void { + registerTodoHistory(ctx); + ctx.slots.inject("tool.call.toolview", () => + ctx.slots.register( + { + name: "tool.call.toolview", + key: "todo_write", + locale: NS, + inject: (sessionId): TodoHistoryInjected => ({ + hooks: { + todoHistory: ctx.uiConversation.binding(sessionId).target("tool-todo-history"), + }, + }), + }, + TodoRow, + ), + ); + }, +}; diff --git a/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx b/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx new file mode 100644 index 000000000..9a3d0d8b1 --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx @@ -0,0 +1,71 @@ +import type { Context } from "@deepseek-ai/cordis"; +import { + IconBrowseOutlineRegular, + IconGlobeOutlineRegular, +} from "@deepseek-ai/dsh-client-ui-primitives"; +import type { PropsLocale } from "@deepseek-ai/dsh-client-ui-slots"; +import type { ToolCallViewProps } from "../../contract/slots.ts"; +import { webCardModel, webFetchHref } from "../models/web-card-model.ts"; +import { toolRowModel } from "../models/tool-call-model.ts"; +import { ToolRow } from "../components/ToolRow.tsx"; +import { CONVERSATION_NS as NS } from "../../locale.ts"; + +type WebRowProps = ToolCallViewProps & PropsLocale<"conversation">; + +const WEB_TITLE_KEYS = { + web_search: "tool.title.webSearch", + web_fetch: "tool.title.webFetch", +} as const; + +/** Lets users expand a completed web search or fetch result. */ +export function WebRow({ toolName, block, inspect, useDisclosure, t }: WebRowProps) { + const model = toolRowModel(toolName, block); + const web = webCardModel(block); + const icon = + toolName === "web_fetch" ? ( + + ) : ( + + ); + return ( + + ); +} + +/** Registers the web search and fetch conversation rows. */ +export const webToolview = { + name: "web-toolview", + inject: ["slots"], + apply(ctx: Context): void { + ctx.slots.inject("tool.call.toolview", function* () { + yield ctx.slots.register( + { name: "tool.call.toolview", key: "web_search", locale: NS }, + WebRow, + ); + yield ctx.slots.register( + { name: "tool.call.toolview", key: "web_fetch", locale: NS }, + WebRow, + ); + }); + }, +}; diff --git a/apps/web-ui/packages/client/ui-tool/src/css-modules.d.ts b/apps/web-ui/packages/client/ui-tool/src/css-modules.d.ts new file mode 100644 index 000000000..c65e67a29 --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/css-modules.d.ts @@ -0,0 +1,4 @@ +declare module "*.module.css" { + const classes: Record; + export default classes; +} diff --git a/apps/web-ui/packages/client/ui-tool/src/index.ts b/apps/web-ui/packages/client/ui-tool/src/index.ts new file mode 100644 index 000000000..3f893bdf6 --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/src/index.ts @@ -0,0 +1,4 @@ +/** Host loader entry for the browser-only Tool UI plugin. */ + +/** Provides no host-side behavior. */ +export function apply(): void {} diff --git a/apps/web-ui/packages/client/ui-tool/tsdown.config.ts b/apps/web-ui/packages/client/ui-tool/tsdown.config.ts new file mode 100644 index 000000000..239de8673 --- /dev/null +++ b/apps/web-ui/packages/client/ui-tool/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from "../tsdown.client.ts"; + +export default clientBundle("@deepseek-ai/dsh-client-ui-tool", ["lib/types/index.js"]); diff --git a/apps/web-ui/packages/client/ui-user-questions/package.json b/apps/web-ui/packages/client/ui-user-questions/package.json new file mode 100644 index 000000000..a815815d3 --- /dev/null +++ b/apps/web-ui/packages/client/ui-user-questions/package.json @@ -0,0 +1,41 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-user-questions", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./client": { + "types": "./src/client/index.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dependencies": { + "@deepseek-ai/dsh-brand": "*", + "react": "^18.2.0", + "@deepseek-ai/dsh-client-ui-primitives": "*", + "@deepseek-ai/dsh-session": "*", + "clsx": "^2.0.0", + "@deepseek-ai/dsh-client-store": "*" + }, + "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", + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-session", + "@deepseek-ai/dsh-client-ui-chat" + ], + "platform": "web" + } + } +} diff --git a/apps/web-ui/packages/client/ui-user-questions/src/client/PlanReviewPanel.module.css b/apps/web-ui/packages/client/ui-user-questions/src/client/PlanReviewPanel.module.css new file mode 100644 index 000000000..67b302e3b --- /dev/null +++ b/apps/web-ui/packages/client/ui-user-questions/src/client/PlanReviewPanel.module.css @@ -0,0 +1,109 @@ +.frame { + display: flex; + justify-content: center; + /* Sides = clearance + 16px: the card lands on the shared content width + (input card - 32) at every viewport. */ + padding: 6px calc(var(--dsh-composer-side-clearance) + 16px) 10px; +} + +.card { + display: flex; + overflow: hidden; + flex-direction: column; + width: 100%; + max-width: var(--dsh-chat-content-width); + --dsw-elevation-stroke-color: var(--dsw-alias-border-l2); + border: 0; + border-radius: var(--dsw-radius-xl); + background: var(--dsw-specific-input-major); + box-shadow: var(--dsw-elevation-panel); + color: var(--dsw-alias-label-primary); +} + +.card, +.card * { + box-sizing: border-box; +} + +/* Tinted full-width header band, as on the approval takeover. */ +.strip { + display: flex; + align-items: center; + flex-shrink: 0; + gap: 8px; + padding: 12px 16px; + background: var(--dsw-alias-state-warn-tertiary); + color: var(--dsw-alias-state-warn-primary); + font-size: 14px; + line-height: 20px; +} + +.footer { + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + gap: 12px; + padding: 8px 16px 12px; +} + +.feedback { + min-height: 16px; + color: var(--dsw-alias-state-error-primary); + font-size: 11px; + line-height: 16px; +} + +.actions { + display: flex; + align-items: center; + flex-shrink: 0; + gap: 8px; +} + +.summary { + min-width: 0; + padding: 14px 16px 12px; +} + +.title { + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 15px; + font-weight: 500; + line-height: 22px; +} + +.description { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + margin: 8px 0 0; + color: var(--dsw-alias-label-secondary); + font-size: 14px; + line-height: 24px; +} + +.discuss { + gap: 6px; +} + +@media (max-width: 720px) { + .card { + border-radius: var(--dsw-radius-xl); + } + + .footer { + align-items: flex-end; + padding: 8px 12px 10px; + } +} + +.previewActions { + margin-left: auto; + display: flex; + align-items: center; +} diff --git a/apps/web-ui/packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx b/apps/web-ui/packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx new file mode 100644 index 000000000..add395b6d --- /dev/null +++ b/apps/web-ui/packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx @@ -0,0 +1,112 @@ +import { useMemo, useState } from "react"; +import { + Button, + extractMarkdownPlainText, + IconEditOutlineRegular, + StateDot, +} from "@deepseek-ai/dsh-client-ui-primitives"; +import type { PendingQuestion, PlanReview, QuestionComposerProps } from "./contract/slots.ts"; +import css from "./PlanReviewPanel.module.css"; + +/** The panel's own props: the question domain face, the narrowed review, and the locale seat. */ +export type PlanReviewPanelProps = { pending: PendingQuestion; review: PlanReview } & Pick< + QuestionComposerProps, + "t" | "renderSlot" +>; + +/** + * Optional-prop spread for a decision button's tooltip: `title` is optional on + * the DOM props, and exactOptionalPropertyTypes rejects an explicit undefined. + * + * @param description - the asker's option description, when it carries one. + * @returns The `title` prop to spread, or nothing. + */ +function tooltip(description: string | undefined): { title?: string } { + return description === undefined ? {} : { title: description }; +} + +/** + * Render plan review controls; the submitted document opens in the sidebar. + * + * @param props - the question domain face, the narrowed plan review, and `t`. + * @returns The plan-review takeover for this request. + */ +export function PlanReviewPanel({ pending, review, t, renderSlot }: PlanReviewPanelProps) { + // Foreground decisions wait for the resolved frame; accepted Remote decisions + // hide the panel. A failed send re-enables the buttons and shows the error. + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const settle = (send: () => Promise, remote = false): void => { + setBusy(true); + setError(null); + void send() + .then(() => { + if (!remote) return; + setBusy(false); + void pending.dismiss().catch(() => { + setError(t("status.sent")); + }); + }) + .catch((cause: unknown) => { + setBusy(false); + setError(cause instanceof Error ? cause.message : String(cause)); + }); + }; + const decide = (label: string): void => { + settle( + () => pending.answer({ answers: [{ id: review.id, selected: [label] }] }), + pending.snapshot().channel === "rpc", + ); + }; + const summary = useMemo(() => { + const title = extractMarkdownPlainText(review.plan, { mode: "first-line" }); + const description = extractMarkdownPlainText(review.plan, { mode: "first-paragraph" }); + return { title, description: description === title ? "" : description }; + }, [review.plan]); + + return ( +
    +
    +
    + + {t("plan.header")} +
    + {renderSlot("conversation.plan-review.actions", { review, requestKey: pending.key })} +
    +
    +
    +

    {summary.title}

    + {summary.description !== "" &&

    {summary.description}

    } +
    +
    +
    + {error} +
    +
    + + +
    +
    +
    +
    + ); +} diff --git a/apps/web-ui/packages/client/ui-user-questions/src/client/QuestionComposer.module.css b/apps/web-ui/packages/client/ui-user-questions/src/client/QuestionComposer.module.css new file mode 100644 index 000000000..13fb30d9d --- /dev/null +++ b/apps/web-ui/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -0,0 +1,487 @@ +/* The takeover seats where the input card sits, centered on the InputBar + axis at the shared content width (input card - 32): sides = clearance + + 16px so the relation also holds on narrow viewports. */ +.frame { + display: flex; + justify-content: center; + padding: 6px calc(var(--dsh-composer-side-clearance) + 16px) 10px; +} + +/* Figma Input 973:36348 body over the 1019:36938 header: no banner strip — + the card keeps zero padding and sections carry their own insets. */ +.card { + display: flex; + flex-direction: column; + width: 100%; + max-width: var(--dsh-chat-content-width); + /* Composer seat sits in a fixed-height conversation column (overflow + hidden): cap the card against the viewport and scroll the option list + so header and footer actions stay reachable on long batches. */ + max-height: min(60vh, 520px); + padding: 0 0 10px; + border: 0; + --dsw-elevation-stroke-color: var(--dsw-alias-border-l2-darkmode-thin); + border-radius: var(--dsw-radius-xl); + background: var(--dsw-specific-input-major); + box-shadow: var(--dsw-elevation-panel); + color: var(--dsw-alias-label-primary); + overflow: hidden; + /* Elevated surface in dark, same as the menus: the body inside scrolls + once the card hits the cap above, so the thumb takes the l2 pair. Declared + on the card because the elevation belongs to the surface, and the custom + properties inherit down to `.body` (see ui-theme styles/scrollbar.css + for the rebinding contract). */ + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); +} + +.card, +.card * { + box-sizing: border-box; +} + +/* Collapsed to the header strip: drop the height cap and the inner scroll + seat so the card hugs the title row, freeing the viewport for the + conversation above while the question stays pending. */ +.cardMinimized { + max-height: none; +} + +/* The header strip is the whole card when collapsed: the title row needs + bottom padding once the body that normally carries it is hidden. */ +.cardMinimized .header { + padding-bottom: 14px; +} + +/* Header button group: minimize sits next to the close action, both on the + same 24px icon-button grid. */ +.headerActions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +.waitButton { + white-space: nowrap; +} + +.waitStatus { + color: var(--dsw-alias-label-secondary); + font-size: 12px; + line-height: 24px; + white-space: nowrap; +} + +/* Figma 1019:36938 header, user-tuned: heading block left, close right; the + pager sits in the footer to balance the card. */ +.header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-shrink: 0; + padding: 20px 16px 0 24px; +} + +.headingBlock { + min-width: 0; +} + +.eyebrow { + /* Eyebrow-to-title gap widened from the figma 2px (user-tuned). */ + margin-bottom: 5px; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 16px; +} + +.title { + margin: 0; + font-size: 16px; + line-height: 22px; + font-weight: 500; +} + +.detail { + margin: 0 2px 8px; +} + +.footerActions { + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; +} + +.pager { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.progress { + padding: 0 4px; + color: var(--dsw-alias-label-secondary); + font-size: 14px; + line-height: 24px; + font-weight: 500; + white-space: nowrap; + /* Narrow the plain spaces around the slash without touching glyph tracking. */ + word-spacing: -2px; +} + +.iconButton { + display: grid; + place-items: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: 999px; + corner-shape: round; + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.iconButton:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} + +.iconButton:disabled { + color: var(--dsw-alias-label-dimmed); + cursor: default; +} + +.body { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; +} + +.options { + display: flex; + flex-direction: column; + gap: 1px; + margin: 8px 0 0; + padding: 4px 12px; +} + +.option { + display: flex; + /* flex-start, not center: with a wrapped description the indicator must + stay on the FIRST line (centering drifts it down the taller copy block). + The 8px padding makes a single-line row 40px exactly, so nothing reads + as top-heavy; .number/.checkbox re-center against the first line box. */ + align-items: flex-start; + gap: 8px; + width: 100%; + min-height: 40px; + /* Rows are the scroll content, never the slack absorber: a shrinkable row + collapses to min-height while its wrapped copy keeps the taller + intrinsic height, and centered content then paints outside the row box — + over the title and the next row. Overflow belongs to .options. */ + flex-shrink: 0; + padding: 8px 12px 8px 8px; + border: 1px solid transparent; + border-radius: var(--dsw-radius-md); + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; + transition: + background-color 120ms ease, + border-color 120ms ease; +} + +.option:hover:not(:disabled), +.optionSelected { + background: var(--dsw-alias-interactive-bg-hover); +} + +.optionSelected { + border-color: var(--dsw-alias-border-l2); +} + +.option:disabled { + cursor: default; +} + +/* Leading indicator (20×20): single-select shows the option + number, multi-select swaps in a checkbox; the custom-answer row follows — + its checkbox mirrors the typed draft (styling only, exclusivity holds). */ +.number { + display: grid; + place-items: center; + flex: 0 0 20px; + width: 20px; + height: 20px; + /* (24px first-line box − 20px indicator) / 2: centers the indicator against + the first text line under the row's flex-start alignment. */ + margin-top: 2px; + border-radius: var(--dsw-radius-xs); + background: var(--dsw-alias-bg-overlay); + color: var(--dsw-alias-label-secondary); + font-size: 12px; + font-weight: 500; + line-height: 18px; +} + +/* Multi-select box (figma 1055:41594, user-tuned down to 14×14): a radius-4 + box centered in the 20px indicator seat; the box itself is the ::before + layer so the check icon stacks over it in the same grid cell. */ +.checkbox { + display: grid; + place-items: center; + flex: 0 0 20px; + width: 20px; + height: 20px; + /* Same first-line centering as .number under flex-start alignment. */ + margin-top: 2px; +} + +.checkbox::before { + content: ""; + grid-area: 1 / 1; + width: 14px; + height: 14px; + border: 0.5px solid var(--dsw-alias-border-l4); + border-radius: var(--dsw-radius-xs); + transition: + background-color 120ms ease, + border-color 120ms ease; +} + +.checkbox > svg { + grid-area: 1 / 1; +} + +/* Checked: label-primary fill with a primary-foreground check — the pair + inverts with the theme (dark fill in light mode, light fill in dark mode). */ +.checkboxChecked { + color: var(--dsw-alias-label-primary-foreground); +} + +.checkboxChecked::before { + border-color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-label-primary); +} + +.optionCopy { + min-width: 0; + flex: 1; +} + +.optionLine { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 2px 6px; +} + +.optionLabel { + font-size: 14px; + line-height: 24px; + font-weight: 500; +} + +.badge { + padding: 0 4px; + border-radius: var(--dsw-radius-xs); + background: var(--dsw-specific-sidebar-nav-item-active-accent); + color: var(--dsw-alias-button-info-fill); + font-size: 11px; + line-height: 18px; + font-weight: 600; +} + +.description { + color: var(--dsw-alias-label-tertiary); + font-size: 14px; + line-height: 24px; + font-weight: 400; +} + +/* Read-only note standing in for a question the user skipped: it takes the + option rows' horizontal padding so it lines up with the choices above it. */ +.reviewNote { + margin: 0; + padding: 8px 12px; + color: var(--dsw-alias-label-tertiary); + font-size: 14px; + line-height: 24px; +} + +/* Custom answer row (figma 973:36427): an option-shaped row whose copy is an + inline text input; focus or a typed draft lifts it to the selected look. */ +.customRow { + display: flex; + /* Same first-line alignment as .option — the indicator seat carries the + 2px re-centering margin. */ + align-items: flex-start; + gap: 8px; + width: 100%; + min-height: 40px; + /* Same reason as .option: the custom row is scroll content, and shrinking + it pushes the inline input past the footer. */ + flex-shrink: 0; + padding: 8px 12px 8px 8px; + border: 1px solid transparent; + border-radius: var(--dsw-radius-md); + transition: + background-color 120ms ease, + border-color 120ms ease; +} + +.customRow:hover, +.customRow:focus-within, +.customRowActive { + background: var(--dsw-alias-interactive-bg-hover); +} + +.customRow:focus-within, +.customRowActive { + border-color: var(--dsw-alias-border-l2); +} + +/* Auto-growing free-text answer (see AnswerField): mirror and textarea share + one grid cell, the mirror sizes it, and the textarea stretches to it. The + two layers MUST keep identical type, padding and wrapping — a mismatch + makes the box the wrong height for the text the user is typing. */ +.field { + display: grid; + min-width: 0; + --dsh-answer-field-padding: 0; +} + +.field > * { + grid-area: 1 / 1; + min-width: 0; + padding: var(--dsh-answer-field-padding); + font: inherit; + font-size: 14px; + line-height: 24px; + white-space: pre-wrap; + word-break: break-word; + overflow-wrap: anywhere; +} + +/* The height ruler, never painted. Growth stops at 6 lines: the card body is + capped at 520px and still owes that budget to the title, the option rows and + the footer actions, so a long answer scrolls in the textarea instead of + pushing the choices it belongs to out of view. + content-box overrides the card-wide border-box so the cap counts TEXT lines + in both variants: under border-box the block variant's 16px of vertical + padding eats two thirds of a line and the sixth one arrives as an 8px sliver. + Width is unaffected — a stretched grid item's used width comes from the + track, not from this box-sizing. */ +.fieldMirror { + box-sizing: content-box; + max-height: 144px; + overflow: hidden; + visibility: hidden; +} + +.fieldInput { + resize: none; + /* The stack's only scrollport: past the mirror's cap the field stops growing + and this scrolls. */ + overflow-y: auto; + border: none; + outline: none; + background: transparent; + color: var(--dsw-alias-label-primary); + caret-color: var(--dsw-alias-state-business-primary); +} + +.fieldInput::placeholder { + color: var(--dsw-alias-label-caption); +} + +/* Inline variant: the custom row's copy column, borrowing the row's chrome. */ +.customInline { + flex: 1; +} + +/* Optionless question: the free-form answer is the whole body, so it carries + its own frame. The 12px side margins add to the .options 12px padding so both + edges align with the title's 24px inset; two lines are reserved before the + answer starts growing. */ +.customBlock { + min-height: 64px; + flex-shrink: 0; + margin: 0 12px; + border: 0.5px solid var(--dsw-alias-border-l4); + border-radius: var(--dsw-radius-lg); + background: var(--dsw-alias-bg-module-platform); + --dsh-answer-field-padding: 8px 12px; +} + +.customBlock:focus-within { + border-color: var(--dsw-alias-state-business-primary); +} + +.footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-shrink: 0; + margin-top: 12px; + padding: 0 10px 0 18px; +} + +.feedback { + flex: 1; + min-height: 16px; + color: var(--dsw-alias-state-error-primary); + font-size: 11px; + line-height: 16px; + text-align: right; +} + +@media (max-width: 720px) { + .card { + border-radius: var(--dsw-radius-xl); + } + + .header { + padding: 10px 12px 0 18px; + } + + .options { + padding: 4px 8px; + } + + .title { + font-size: 15px; + line-height: 21px; + } + + .option, + .customRow { + padding: 8px 6px; + } + + .footer { + align-items: flex-end; + padding: 0 10px; + } + + .footerActions { + flex-shrink: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .option, + .customRow { + transition: none; + } +} diff --git a/apps/web-ui/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/apps/web-ui/packages/client/ui-user-questions/src/client/QuestionComposer.tsx new file mode 100644 index 000000000..6221027b6 --- /dev/null +++ b/apps/web-ui/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -0,0 +1,771 @@ +import { + useEffect, + useMemo, + useRef, + useState, + type ChangeEvent, + type FocusEvent, + type KeyboardEvent, +} from "react"; +import clsx from "clsx"; +import { + Button, + IconCheckOutlineRegular, + IconChevronDownOutlineRegular, + IconChevronLeftOutlineRegular, + IconChevronRightOutlineRegular, + IconChevronUpOutlineRegular, + IconCloseOutlineRegular, + IconEditOutlineRegular, + MarkdownText, +} from "@deepseek-ai/dsh-client-ui-primitives"; +import { + planReviewOf, + type QuestionAnswer, + type QuestionCardSnapshot, + type QuestionComposerProps, +} from "./contract/slots.ts"; +import type { PendingQuestion } from "./contract/slots.ts"; +import type { QuestionDraftAnswer, QuestionDraftProgress } from "./draft-store.ts"; +import { PlanReviewPanel } from "./PlanReviewPanel.tsx"; +import css from "./QuestionComposer.module.css"; + +/** + * Displayed feedback: validation feedback is stored as a dictionary KEY and + * translated at render, so already-shown feedback follows a locale switch; + * runtime failure messages (finished strings from the wire) pass through + * verbatim. + */ +type Feedback = + | { + key: + | "error.incomplete" + | "error.unanswered" + | "error.unavailable" + | "error.resubmit" + | "status.sent"; + } + | { text: string }; + +/** A removed card can remain mounted until the composer seat updates. */ +const REMOVED_CARD: QuestionCardSnapshot = { + state: "open", + waitState: "counting", + countdown: undefined, + channel: "none", + closed: true, +}; + +/** + * Split the conventional recommendation suffix without changing the answer value. + * @param label - Original option label returned if selected. + * @returns Display label plus recommendation state. + */ +export function parseRecommendedLabel(label: string): { label: string; recommended: boolean } { + const suffix = /\s*(?:\((?:recommended|推荐)\)|((?:recommended|推荐)))\s*$/i; + return suffix.test(label) + ? { label: label.replace(suffix, ""), recommended: true } + : { label, recommended: false }; +} + +/** Only a marked first choice is an implicit draft; the user still submits it. */ +function recommendedFirstOption( + question: PendingQuestion["questions"][number], +): string | undefined { + const label = question.options?.[0]?.label; + return label !== undefined && parseRecommendedLabel(label).recommended ? label : undefined; +} + +/** Accept persisted progress only when it still describes this question batch. */ +function isQuestionDraftProgress( + value: unknown, + questionCount: number, +): value is QuestionDraftProgress { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const progress = value as Record; + if ( + typeof progress.index !== "number" || + !Number.isInteger(progress.index) || + progress.index < 0 || + progress.index >= questionCount || + !Array.isArray(progress.drafts) || + progress.drafts.length !== questionCount || + (progress.wait !== undefined && progress.wait !== "editing" && progress.wait !== "waiting") + ) + return false; + return progress.drafts.every((item: unknown) => { + if (typeof item !== "object" || item === null || Array.isArray(item)) return false; + const draft = item as Record; + return ( + Array.isArray(draft.selected) && + draft.selected.every((label: unknown) => typeof label === "string") && + typeof draft.custom === "string" && + typeof draft.skipped === "boolean" + ); + }); +} + +/** Return whether a text-field key event belongs to an active IME composition. */ +function isComposing(event: KeyboardEvent): boolean { + // keyCode 229 is the legacy IME-composition signal engines emit without isComposing. + return event.nativeEvent.isComposing || Reflect.get(event.nativeEvent, "keyCode") === 229; +} + +/** The free-text answer field shared by both question variants. */ +interface AnswerFieldProps { + /** Visual variant: the custom row's inline column or the optionless question's framed block. */ + variant: "inline" | "block"; + /** Current draft text. */ + value: string; + /** Empty-field prompt. */ + placeholder: string; + /** Whether a submission in flight has frozen the field. */ + disabled: boolean; + /** Whether this field takes focus on mount. */ + autoFocus?: boolean; + /** Called when the field takes focus. */ + onFocus?: () => void; + /** Called with each edit of the draft. */ + onChange: (event: ChangeEvent) => void; + /** Called with each key press, before the browser's own handling. */ + onKeyDown: (event: KeyboardEvent) => void; +} + +/** + * Auto-growing free-text answer: a textarea, so a long answer soft-wraps and + * Shift+Enter breaks a line, over a hidden mirror that owns the height. + * + * The mirror renders the draft plus a trailing newline in normal flow and so + * sizes the grid row (counting rows by '\n' cannot see soft wraps); the + * textarea shares that one cell and stretches to it, and `rows={1}` keeps the + * control's own intrinsic height out of the row sizing so the mirror alone + * decides. Past the mirror's cap the textarea scrolls itself — it is the only + * scrollport in the stack, there being no second glyph layer to keep aligned. + * Mirror and textarea MUST share font, line-height, padding and wrapping rules + * or the two heights diverge. + * + * @param props - visual variant, draft text, and the field's event handlers. + * @returns The mirrored auto-growing field. + */ +function AnswerField(props: AnswerFieldProps) { + return ( +
    +
    {`${props.value}\n`}
    +