Merge upstream master into feat/produced-files-folder
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/bash-local/README.md
|
||||
README.md: 253d1c9efb518ca204956f062b2f529f4e83531b
|
||||
README.zh.md: 5a0d943a08d1da6c73ad07f469c056180b476ff3
|
||||
README.md: 386ffc00466108ab14352b6d39d3a20da3321e1c
|
||||
README.zh.md: 39d37bededa72ec96911bb1ce055ed105fdd7082
|
||||
|
||||
@@ -23,6 +23,7 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i
|
||||
## Behavior
|
||||
|
||||
- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` with no rc files.
|
||||
- **The composition entry is a layer, not the last word** — when a settings provider is composed, this executor registers the capability's [`bash` namespace](../bash/README.md) with the entry above as its base, so a user section in `settings.yaml` layers over it and the next command runs with the new budgets. Values the schema cannot judge (positive and finite, the `graceMs` timer bound) are refused at the write, leaving the running executor on its last good section; without a provider, or after one detaches, the composition entry is what runs.
|
||||
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Process-group kills, post-exit pipe draining, tail retention, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
|
||||
- **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-signaled command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)).
|
||||
- **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` prevents pagers and ANSI color from garbling results. These values merge as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
## 行为
|
||||
|
||||
- **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`,且不读取 rc 文件。
|
||||
- **组装条目是一层,而不是最终值**:当组装中存在 settings 提供方时,本执行器以上面的条目为 base 注册该能力的 [`bash` 命名空间](../bash/README.md),因此 `settings.yaml` 中的用户段会叠加其上,下一条命令即按新预算运行。schema 无法判定的值(正有限、`graceMs` 的定时器上界)会在写入时被拒绝,运行中的执行器保持它最后一份可用的段;没有提供方、或提供方脱离之后,运行的就是组装条目。
|
||||
- **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程组终止、退出后管道排空、尾部保留与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。
|
||||
- **超时与取消分类**:`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自身因信号终止的命令两者皆不报告(见[超时库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。
|
||||
- **适合模型的终端环境**:`NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` 防止分页器与 ANSI 颜色破坏结果。这些值作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
@@ -47,6 +48,7 @@
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,10 @@
|
||||
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import { BASH_SETTINGS_NAMESPACE, BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import { installSettingsSection } from '@deepseek-ai/dsh-settings'
|
||||
import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
|
||||
/**
|
||||
@@ -71,6 +72,26 @@ function assertPositiveFinite(name: string, value: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a resolved section this executor could not run with. The schema
|
||||
* expresses neither "positive and finite" nor the timer bound `graceMs` has to
|
||||
* fit, so a stored value is refused where it is written instead of failing at
|
||||
* the next command.
|
||||
* @param config - the resolved section, schema-valid by construction.
|
||||
* @throws Error naming the field that cannot be used.
|
||||
*/
|
||||
export function assertServiceableBashConfig(config: Config): void {
|
||||
const resolved = config as ResolvedConfig
|
||||
assertPositiveFinite('timeoutMs', resolved.timeoutMs)
|
||||
assertPositiveFinite('maxTimeoutMs', resolved.maxTimeoutMs)
|
||||
assertPositiveFinite('maxOutputBytes', resolved.maxOutputBytes)
|
||||
assertPositiveFinite('maxSpillBytes', resolved.maxSpillBytes)
|
||||
assertPositiveFinite('graceMs', resolved.graceMs)
|
||||
if (resolved.graceMs > MAX_TIMER_DELAY_MS) {
|
||||
throw new Error(`bash-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Local bash executor over `ctx.subprocess`. Bounded output, spill files, and
|
||||
* process-group SIGTERM→SIGKILL escalation are the subprocess service's
|
||||
@@ -90,21 +111,29 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
graceMs: z.number().default(DEFAULT_GRACE_MS),
|
||||
})
|
||||
|
||||
/** The currently authoritative config: the settings section, or the composition entry. */
|
||||
private source: () => ResolvedConfig
|
||||
|
||||
/** Validated config (schemastery applied the defaults before construction). */
|
||||
readonly config: ResolvedConfig
|
||||
get config(): ResolvedConfig {
|
||||
return this.source()
|
||||
}
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx)
|
||||
// Schemastery fills these fields before construction; the type does not encode that step.
|
||||
this.config = config as ResolvedConfig
|
||||
assertPositiveFinite('timeoutMs', this.config.timeoutMs)
|
||||
assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs)
|
||||
assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
|
||||
assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes)
|
||||
assertPositiveFinite('graceMs', this.config.graceMs)
|
||||
if (this.config.graceMs > MAX_TIMER_DELAY_MS) {
|
||||
throw new Error(`bash-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
|
||||
}
|
||||
const entry = config as ResolvedConfig
|
||||
assertServiceableBashConfig(entry)
|
||||
this.source = () => entry
|
||||
installSettingsSection(ctx, BASH_SETTINGS_NAMESPACE, LocalBashExecutor.Config, entry, {
|
||||
validate: assertServiceableBashConfig,
|
||||
setSource: (current) => {
|
||||
this.source = current as () => ResolvedConfig
|
||||
},
|
||||
// Every field is read through the getter at each command, so nothing
|
||||
// derived from the source needs rebuilding when the document changes.
|
||||
onChange: () => {},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
115
packages/bash/bash-local/tests/settings.spec.ts
Normal file
115
packages/bash/bash-local/tests/settings.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/** The `bash` settings section layered over the executor's composition entry. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { Fiber } from '@deepseek-ai/cordis'
|
||||
import { Settings } from '@deepseek-ai/dsh-settings'
|
||||
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import { BASH_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-bash'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
|
||||
/** The smallest real provider: one in-memory document, always writable. */
|
||||
class MemorySettings extends Settings {
|
||||
doc: Record<string, unknown> = {}
|
||||
|
||||
get writable(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
protected load(): Promise<Record<string, unknown>> {
|
||||
return Promise.resolve(structuredClone(this.doc))
|
||||
}
|
||||
|
||||
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
|
||||
this.doc = { ...this.doc, [ns]: structuredClone(section) }
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
async function boot(config: ConstructorParameters<typeof LocalBashExecutor>[1] = {}): Promise<{
|
||||
ctx: Context
|
||||
settingsFiber: Fiber
|
||||
executorFiber: Fiber
|
||||
bash: LocalBashExecutor
|
||||
}> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
const settingsFiber = ctx.plugin(MemorySettings)
|
||||
await settingsFiber.await()
|
||||
const executorFiber = ctx.plugin(LocalBashExecutor, { timeoutMs: 60_000, ...config })
|
||||
await executorFiber.await()
|
||||
return { ctx, settingsFiber, executorFiber, bash: ctx.bash as LocalBashExecutor }
|
||||
}
|
||||
|
||||
describe('bash settings section', () => {
|
||||
it('resolves the user layer over the composition entry', async () => {
|
||||
const bench = await boot()
|
||||
expect(bench.bash.config.timeoutMs).toBe(60_000)
|
||||
|
||||
await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 5_000 })
|
||||
|
||||
expect(bench.bash.config.timeoutMs).toBe(5_000)
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('refuses a stored value the constructor would have rejected', async () => {
|
||||
const bench = await boot()
|
||||
|
||||
await expect(bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 0 }))
|
||||
.rejects.toThrow(/positive finite/)
|
||||
|
||||
expect(bench.bash.config.timeoutMs).toBe(60_000)
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('refuses a grace period longer than a timer can carry', async () => {
|
||||
const bench = await boot()
|
||||
|
||||
await expect(bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { graceMs: Number.MAX_SAFE_INTEGER }))
|
||||
.rejects.toThrow(/graceMs must be no greater than/)
|
||||
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('serves the stored section to every later read', async () => {
|
||||
const bench = await boot()
|
||||
await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { maxOutputBytes: 1_024, cwd: '/tmp' })
|
||||
|
||||
const spec = bench.bash.resolve({ command: 'true' })
|
||||
|
||||
expect(spec.stdoutMaxBytes).toBe(1_024)
|
||||
expect(spec.workdir).toBe('/tmp')
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('falls back to the composition entry when the settings provider detaches', async () => {
|
||||
const bench = await boot()
|
||||
await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 5_000 })
|
||||
expect(bench.bash.config.timeoutMs).toBe(5_000)
|
||||
|
||||
await bench.settingsFiber.dispose()
|
||||
|
||||
expect(bench.bash.config.timeoutMs).toBe(60_000)
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps the composition entry when no settings provider is mounted', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 1_234 })
|
||||
|
||||
expect((ctx.bash as LocalBashExecutor).config.timeoutMs).toBe(1_234)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('releases the namespace when the executor unloads', async () => {
|
||||
const bench = await boot()
|
||||
expect(bench.ctx.settings.describe().map(row => String(row.ns))).toContain('bash')
|
||||
|
||||
await bench.executorFiber.dispose()
|
||||
|
||||
expect(bench.ctx.settings.describe().map(row => String(row.ns))).not.toContain('bash')
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -29,6 +29,9 @@
|
||||
{
|
||||
"path": "../../subprocess/subprocess"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/bash/README.md
|
||||
README.md: a076c6ef3150de86c251f0501b18de01c4491c67
|
||||
README.zh.md: 1be9f817a8379b2974fb4cfd63c197c320934f00
|
||||
README.md: ef873dfb87cb330847274be59d5f2a0c3a5bc0b9
|
||||
README.zh.md: 4047765f248a25ff700ba62bf87636c0e8bfe7ac
|
||||
|
||||
@@ -27,6 +27,8 @@ The split is a standard capability seam ([capability-seams Agent Note](../../../
|
||||
|
||||
Implementations subclass `BashExecutor` and implement the abstract methods. Disposal must kill every running process and await its exit.
|
||||
|
||||
`BASH_SETTINGS_NAMESPACE` (`bash`) is exported here rather than by a provider because it names the capability, not an implementation. A host composes exactly one provider of `ctx.bash` — the win32 layer swaps the POSIX rows for the pwsh ones, and mounting both fails loud on a duplicate service registration — so every provider can register this one namespace with its own schema and composition entry without two of them ever colliding, and a `settings.yaml` carried between platforms keeps resolving on both.
|
||||
|
||||
## Vocabulary
|
||||
|
||||
`BashExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, dshEnv?, sandboxPolicy?) resolves to `BashExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, dshEnv?, sandboxPolicy) before execution. `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete bounded stdout; the model-facing bash tool does not expose it. `sandboxPolicy` is optional on the request and required-but-nullable on the resolved spec: it carries the complete per-call mode and workspace root. The sandbox tool path resolves it from the calling session through `ctx.sandboxPolicy`; a direct sandbox-executor caller falls back to deployment policy, while a non-sandboxing executor carries the field and confines nothing.
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
|
||||
实现会继承 `BashExecutor` 并实现抽象方法。dispose(资源释放)必须终止每个运行中的进程并等待其退出。
|
||||
|
||||
`BASH_SETTINGS_NAMESPACE`(`bash`)由此处导出而非由某个提供方导出,因为它命名的是能力而不是实现。一个宿主只组装一个 `ctx.bash` 提供方——win32 层会把 POSIX 行换成 pwsh 行,同时挂载两者会因服务重复注册而在加载期失败——所以每个提供方都能用自己的 schema 与组装条目注册这同一个命名空间,两者永不相撞;在平台间携带的 `settings.yaml` 也能在两边继续解析。
|
||||
|
||||
## 词汇
|
||||
|
||||
`BashExecRequest`(command、workdir?、timeoutMs?、stdoutMaxBytes?、signal?、stdin?、env?、dshEnv?、sandboxPolicy?)在执行前解析为 `BashExecSpec`(command、workdir、timeoutMs、stdoutMaxBytes、signal?、stdin?、env?、dshEnv?、sandboxPolicy)。`stdoutMaxBytes` 是受信任前台运行的捕获预算,用于必须解析完整有界 stdout 的消费方;面向模型的 bash 工具不公开该字段。`sandboxPolicy` 在请求上可选,在已解析 spec 上必填但可为 null:它携带完整的每次调用模式与工作区根目录。沙箱工具路径通过 `ctx.sandboxPolicy` 从调用会话解析它;沙箱执行器的直接调用方回退到部署策略,非沙箱执行器则携带该字段但不作限制。
|
||||
|
||||
@@ -35,12 +35,14 @@
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,21 @@
|
||||
*/
|
||||
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from './types.ts'
|
||||
|
||||
/**
|
||||
* Settings namespace of this capability, owned here rather than by either
|
||||
* executor family because it names the capability, not an implementation: a
|
||||
* host composes exactly one provider of `ctx.bash` (the win32 layer swaps the
|
||||
* POSIX rows for the pwsh ones, and mounting both fails loud on a duplicate
|
||||
* service registration), so the providers share one namespace without ever
|
||||
* registering it twice, and a settings document carried between platforms
|
||||
* keeps resolving on both.
|
||||
*/
|
||||
export const BASH_SETTINGS_NAMESPACE = settingsNamespace('bash')
|
||||
|
||||
export { DSH_ENV_PREFIX } from './types.ts'
|
||||
export type {
|
||||
BashExecRequest,
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/pwsh-local/README.md
|
||||
README.md: eb3365b009e3595230e5fb0f616079bd73c55840
|
||||
README.zh.md: d79201c756a26bbc343e2b284a803b0cf9aee69b
|
||||
README.md: 76f3071dc1049e2ea5929d5990ee0cb526ef702e
|
||||
README.zh.md: e773e0e83e81ffa311bd555b7a75433ba22dfd22
|
||||
|
||||
@@ -28,8 +28,9 @@ The package root exports the default and named `PwshLocalExecutor` plugin, its `
|
||||
The Windows counterpart of `dsh-bash-local`, deliberately mirroring its semantics call-for-call:
|
||||
|
||||
- **Spawn per call, no shell state** — every call is a fresh non-interactive `pwsh -Command` (deterministic; no profile files). The `-NoLogo -NoProfile -NonInteractive` flags disable startup banners, profile loading, and prompts that would garble tool output.
|
||||
- **The composition entry is a layer, not the last word** — when a settings provider is composed, this executor registers the capability's [`bash` namespace](../bash/README.md) with the entry above as its base, so a user section in `settings.yaml` layers over it and the next command runs with the new budgets. The namespace is shared with the POSIX family because a host composes exactly one provider of `ctx.bash`; a document written on either platform keeps resolving on the other. Values the schema cannot judge (positive and finite, the `graceMs` timer bound) are refused at the write, leaving the running executor on its last good section.
|
||||
- **UTF-8 output pinned** — every command runs with `[Console]::OutputEncoding` and `$OutputEncoding` set to UTF-8 first, so the Windows PowerShell 5.1 fallback (or any host whose console code page is not UTF-8) cannot garble non-ASCII output: the subprocess collector decodes bytes as UTF-8. Input encoding is left at the host default; pwsh 7 defaults to UTF-8 and is unaffected.
|
||||
- **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking `existsSync` on each; elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)` and happens once at construction.
|
||||
- **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking `existsSync` on each; elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)`; it runs at construction and again only when a stored `pwshPath` differs from the one the current executable was resolved from, so an unrelated settings change never re-probes the filesystem.
|
||||
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Tree termination (taskkill on Windows, process-group signals on POSIX), the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
|
||||
- **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-terminated command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). Windows reports forced termination as exit 1 without a signal, so signal-stamped facts (`signal`, `killed` status) are POSIX-only there; the timeout/abort classification is platform-independent.
|
||||
- **Model-friendly terminal env** — `NO_COLOR=1 PAGER=cat GIT_PAGER=cat` (no `TERM=dumb`: that is a POSIX concept; `NO_COLOR` is honored by modern PowerShell renderers) merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins.
|
||||
|
||||
@@ -28,8 +28,9 @@
|
||||
作为 `dsh-bash-local` 的 Windows 对应物,逐调用地镜像其语义:
|
||||
|
||||
- **每次调用新建进程,无 shell 状态**——每次调用都是全新的非交互 `pwsh -Command`(确定性;不加载 profile 文件)。`-NoLogo -NoProfile -NonInteractive` 关闭启动横幅、profile 加载与会干扰工具输出的提示符。
|
||||
- **组装条目是一层,而不是最终值**——当组装中存在 settings 提供方时,本执行器以上面的条目为 base 注册该能力的 [`bash` 命名空间](../bash/README.md),因此 `settings.yaml` 中的用户段会叠加其上,下一条命令即按新预算运行。该命名空间与 POSIX 家族共用,因为一个宿主只组装一个 `ctx.bash` 提供方;在任一平台写下的文档在另一平台仍能解析。schema 无法判定的值(正有限、`graceMs` 的定时器上界)会在写入时被拒绝,运行中的执行器保持它最后一份可用的段。
|
||||
- **UTF-8 输出固定**——每条命令都先以 UTF-8 设置 `[Console]::OutputEncoding` 与 `$OutputEncoding`,因此 Windows PowerShell 5.1 兜底(或任何控制台代码页非 UTF-8 的主机)不会破坏非 ASCII 输出:subprocess collector 以 UTF-8 解码字节。输入编码保持宿主默认;pwsh 7 默认为 UTF-8,不受影响。
|
||||
- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目(Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一检查 `existsSync`;其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数,在构造时执行一次。
|
||||
- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目(Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一检查 `existsSync`;其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数;它在构造时执行,此后仅当存储的 `pwshPath` 与当前可执行文件所依据的值不同才再次执行,因此无关的设置变更绝不会重新探测文件系统。
|
||||
- **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程树终止(Windows 用 taskkill,POSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算;stderr 与后台运行仍使用 `maxOutputBytes`。
|
||||
- **超时与取消分类**——`run()` 通过一个 deadline 融合按配置上限截取的超时与调用方信号;只有执行器自身超时报告 `timedOut`,上游取消报告 `aborted`,自我终止的命令两者都不报告(见 [timeout 库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。Windows 将强制终止报告为退出码 1 且无信号,因此带信号标记的事实(`signal`、`killed` 状态)在那里仅限 POSIX;超时/取消分类与平台无关。
|
||||
- **面向模型的终端环境**——`NO_COLOR=1 PAGER=cat GIT_PAGER=cat`(没有 `TERM=dumb`:那是 POSIX 概念;现代 PowerShell 渲染器遵循 `NO_COLOR`),作为普通 env 在服务的凭据清理与 `DSH_*` 通道规则之下合并;显式调用方条目仍然优先。
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
@@ -47,6 +48,7 @@
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,16 @@
|
||||
* @module @deepseek-ai/dsh-pwsh-local
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start -- this executor mirrors dsh-bash-local call-for-call by
|
||||
design (see this package's README), so the two import the same seam surface */
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import { BASH_SETTINGS_NAMESPACE, BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import { installSettingsSection } from '@deepseek-ai/dsh-settings'
|
||||
import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
/* jscpd:ignore-end */
|
||||
import { resolvePwshPath } from './resolve.ts'
|
||||
|
||||
/* jscpd:ignore-start -- deliberate call-for-call mirror of dsh-bash-local (Agent Note: pwsh-tool-and-executor). */
|
||||
@@ -96,6 +100,26 @@ function assertPositiveFinite(name: string, value: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a resolved section this executor could not run with. The schema
|
||||
* expresses neither "positive and finite" nor the timer bound `graceMs` has to
|
||||
* fit, so a stored value is refused where it is written instead of failing at
|
||||
* the next command.
|
||||
* @param config - the resolved section, schema-valid by construction.
|
||||
* @throws Error naming the field that cannot be used.
|
||||
*/
|
||||
export function assertServiceablePwshConfig(config: Config): void {
|
||||
const resolved = config as ResolvedConfig
|
||||
assertPositiveFinite('timeoutMs', resolved.timeoutMs)
|
||||
assertPositiveFinite('maxTimeoutMs', resolved.maxTimeoutMs)
|
||||
assertPositiveFinite('maxOutputBytes', resolved.maxOutputBytes)
|
||||
assertPositiveFinite('maxSpillBytes', resolved.maxSpillBytes)
|
||||
assertPositiveFinite('graceMs', resolved.graceMs)
|
||||
if (resolved.graceMs > MAX_TIMER_DELAY_MS) {
|
||||
throw new Error(`pwsh-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Local PowerShell executor over `ctx.subprocess`. Bounded output, spill
|
||||
* files, and process-tree termination are the subprocess service's mechanics;
|
||||
@@ -114,25 +138,47 @@ export class PwshLocalExecutor extends BashExecutor {
|
||||
pwshPath: z.string(),
|
||||
})
|
||||
|
||||
/** Validated config (schemastery applied the defaults before construction). */
|
||||
readonly config: ResolvedConfig
|
||||
/** The currently authoritative config: the settings section, or the composition entry. */
|
||||
private source: () => ResolvedConfig
|
||||
|
||||
/** The pwsh executable resolved once at construction. */
|
||||
readonly pwshPath: string
|
||||
/** The declared executable the current {@link pwshPath} was resolved from. */
|
||||
private declaredPwshPath: string | undefined
|
||||
|
||||
/** The pwsh executable resolved from the current config. */
|
||||
private resolvedPwshPath: string
|
||||
|
||||
/** Validated config (schemastery applied the defaults before construction). */
|
||||
get config(): ResolvedConfig {
|
||||
return this.source()
|
||||
}
|
||||
|
||||
/** The pwsh executable every command runs through. */
|
||||
get pwshPath(): string {
|
||||
return this.resolvedPwshPath
|
||||
}
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx)
|
||||
// Schemastery fills these fields before construction; the type does not encode that step.
|
||||
this.config = config as ResolvedConfig
|
||||
assertPositiveFinite('timeoutMs', this.config.timeoutMs)
|
||||
assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs)
|
||||
assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
|
||||
assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes)
|
||||
assertPositiveFinite('graceMs', this.config.graceMs)
|
||||
if (this.config.graceMs > MAX_TIMER_DELAY_MS) {
|
||||
throw new Error(`pwsh-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
|
||||
}
|
||||
this.pwshPath = resolvePwshPath(this.config.pwshPath)
|
||||
const entry = config as ResolvedConfig
|
||||
assertServiceablePwshConfig(entry)
|
||||
this.source = () => entry
|
||||
this.declaredPwshPath = entry.pwshPath
|
||||
this.resolvedPwshPath = resolvePwshPath(entry.pwshPath)
|
||||
installSettingsSection(ctx, BASH_SETTINGS_NAMESPACE, PwshLocalExecutor.Config, entry, {
|
||||
validate: assertServiceablePwshConfig,
|
||||
setSource: (current) => {
|
||||
this.source = current as () => ResolvedConfig
|
||||
},
|
||||
// Probing the filesystem is the one fact derived from the source: every
|
||||
// other field is read through the getter at each command.
|
||||
onChange: () => {
|
||||
const declared = this.source().pwshPath
|
||||
if (declared === this.declaredPwshPath) return
|
||||
this.declaredPwshPath = declared
|
||||
this.resolvedPwshPath = resolvePwshPath(declared)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
108
packages/bash/pwsh-local/tests/settings.spec.ts
Normal file
108
packages/bash/pwsh-local/tests/settings.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/** The shared `bash` settings section as the pwsh executor family resolves it. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { Fiber } from '@deepseek-ai/cordis'
|
||||
import { Settings } from '@deepseek-ai/dsh-settings'
|
||||
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { BASH_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-bash'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local'
|
||||
|
||||
/** The smallest real provider: one in-memory document, always writable. */
|
||||
class MemorySettings extends Settings {
|
||||
doc: Record<string, unknown> = {}
|
||||
|
||||
get writable(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
protected load(): Promise<Record<string, unknown>> {
|
||||
return Promise.resolve(structuredClone(this.doc))
|
||||
}
|
||||
|
||||
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
|
||||
this.doc = { ...this.doc, [ns]: structuredClone(section) }
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
async function boot(config: ConstructorParameters<typeof PwshLocalExecutor>[1] = {}): Promise<{
|
||||
ctx: Context
|
||||
settingsFiber: Fiber
|
||||
executorFiber: Fiber
|
||||
pwsh: PwshLocalExecutor
|
||||
}> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
const settingsFiber = ctx.plugin(MemorySettings)
|
||||
await settingsFiber.await()
|
||||
const executorFiber = ctx.plugin(PwshLocalExecutor, { timeoutMs: 60_000, ...config })
|
||||
await executorFiber.await()
|
||||
return { ctx, settingsFiber, executorFiber, pwsh: ctx.bash as PwshLocalExecutor }
|
||||
}
|
||||
|
||||
describe('pwsh executor over the bash settings section', () => {
|
||||
it('resolves the user layer over the composition entry', async () => {
|
||||
const bench = await boot()
|
||||
expect(bench.pwsh.config.timeoutMs).toBe(60_000)
|
||||
|
||||
await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 5_000 })
|
||||
|
||||
expect(bench.pwsh.config.timeoutMs).toBe(5_000)
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('refuses a stored value the constructor would have rejected', async () => {
|
||||
const bench = await boot()
|
||||
|
||||
await expect(bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 0 }))
|
||||
.rejects.toThrow(/pwsh-local: timeoutMs must be a positive finite number/)
|
||||
|
||||
expect(bench.pwsh.config.timeoutMs).toBe(60_000)
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('re-resolves the executable when the stored path changes', async () => {
|
||||
const bench = await boot({ pwshPath: '/opt/first/pwsh' })
|
||||
expect(bench.pwsh.pwshPath).toBe('/opt/first/pwsh')
|
||||
|
||||
await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { pwshPath: '/opt/second/pwsh' })
|
||||
|
||||
expect(bench.pwsh.pwshPath).toBe('/opt/second/pwsh')
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps the resolved executable when an unrelated field changes', async () => {
|
||||
const bench = await boot({ pwshPath: '/opt/first/pwsh' })
|
||||
const before = bench.pwsh.pwshPath
|
||||
|
||||
await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 5_000 })
|
||||
|
||||
expect(bench.pwsh.pwshPath).toBe(before)
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('falls back to the composition entry when the settings provider detaches', async () => {
|
||||
const bench = await boot({ pwshPath: '/opt/first/pwsh' })
|
||||
await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 5_000, pwshPath: '/opt/second/pwsh' })
|
||||
expect(bench.pwsh.config.timeoutMs).toBe(5_000)
|
||||
expect(bench.pwsh.pwshPath).toBe('/opt/second/pwsh')
|
||||
|
||||
await bench.settingsFiber.dispose()
|
||||
|
||||
expect(bench.pwsh.config.timeoutMs).toBe(60_000)
|
||||
expect(bench.pwsh.pwshPath).toBe('/opt/first/pwsh')
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('releases the namespace when the executor unloads', async () => {
|
||||
const bench = await boot()
|
||||
expect(bench.ctx.settings.describe().map(row => String(row.ns))).toContain('bash')
|
||||
|
||||
await bench.executorFiber.dispose()
|
||||
|
||||
expect(bench.ctx.settings.describe().map(row => String(row.ns))).not.toContain('bash')
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -29,6 +29,9 @@
|
||||
{
|
||||
"path": "../../subprocess/subprocess"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
@@ -227,6 +227,11 @@
|
||||
- id: ui-agent-preset
|
||||
name: '@deepseek-ai/dsh-client-ui-agent-preset'
|
||||
|
||||
# Plugin configuration: the host-plane sections a user owns, as expandable
|
||||
# cards. A namespace this deployment does not expose renders nothing.
|
||||
- id: ui-plugin-config
|
||||
name: '@deepseek-ai/dsh-client-ui-plugin-config'
|
||||
|
||||
# Plan control: the composer plan seat over the plan projection + /plan channel.
|
||||
- id: ui-plan
|
||||
name: '@deepseek-ai/dsh-client-ui-plan'
|
||||
|
||||
@@ -45,12 +45,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-agent-presets": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-hmr": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-modules": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-command": "workspace:^",
|
||||
@@ -62,6 +62,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-models": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-permission": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-plan": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-plugin-config": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-question": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings-general": "workspace:^",
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/README.md
|
||||
README.md: 42258a961b522271cf61367856c162a7312c79f2
|
||||
README.zh.md: 5ecebbfd1e3c8aaa294e3bfd1bf943600ab3366e
|
||||
README.md: 75abe408952ed66dcc237ce489e417f61159bcc3
|
||||
README.zh.md: 5432efcb0a5ebc410093da4c3ec6c2e07c4520ca
|
||||
|
||||
@@ -18,6 +18,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha
|
||||
| [`ui-slots/`](ui-slots/README.md) | Defines how UI features register and compose extension slots. |
|
||||
| [`ui-theme/`](ui-theme/README.md) | Applies the selected color theme. |
|
||||
| [`ui-primitives/`](ui-primitives/README.md) | Provides shared React controls, icons, and content renderers. |
|
||||
| [`ui-attachment/`](ui-attachment/README.md) | Provides attachment display atoms: draft-image rail, message gallery, and lightbox. |
|
||||
| [`ui-layout/`](ui-layout/README.md) | Arranges the main application regions. |
|
||||
| [`ui-sidebar/`](ui-sidebar/README.md) | Presents workspace and session navigation. |
|
||||
| [`ui-workspace/`](ui-workspace/README.md) | Provides workspace selection and creation surfaces. |
|
||||
@@ -34,6 +35,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha
|
||||
| [`ui-model/`](ui-model/README.md) | Provides model selection in conversation surfaces. |
|
||||
| [`ui-permission/`](ui-permission/README.md) | Configures default permissions and switches the current session's access. |
|
||||
| [`ui-plan/`](ui-plan/README.md) | Presents active plan-mode status and its exit control. |
|
||||
| [`ui-plugin-config/`](ui-plugin-config/README.md) | The Plugins settings section: host-plane plugin configuration as expandable cards. |
|
||||
| [`ui-question/`](ui-question/README.md) | Presents interactive questions requested by the agent. |
|
||||
| [`ui-agent-preset/`](ui-agent-preset/README.md) | Selects a session's agent preset and authors preset compositions. |
|
||||
| [`ui-settings/`](ui-settings/README.md) | Hosts the settings interface and its extension areas. |
|
||||
|
||||
@@ -18,6 +18,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U
|
||||
| [`ui-slots/`](ui-slots/README.md) | 定义 UI 功能注册和组合扩展 slot 的方式。 |
|
||||
| [`ui-theme/`](ui-theme/README.md) | 应用所选颜色主题。 |
|
||||
| [`ui-primitives/`](ui-primitives/README.md) | 提供共享 React 控件、图标和内容渲染器。 |
|
||||
| [`ui-attachment/`](ui-attachment/README.md) | 提供附件展示原子组件:草稿图片栏、消息画廊与灯箱。 |
|
||||
| [`ui-layout/`](ui-layout/README.md) | 排列应用的主要区域。 |
|
||||
| [`ui-sidebar/`](ui-sidebar/README.md) | 展示 Workspace 与会话导航。 |
|
||||
| [`ui-workspace/`](ui-workspace/README.md) | 提供 Workspace 选择与创建界面。 |
|
||||
@@ -34,6 +35,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U
|
||||
| [`ui-model/`](ui-model/README.md) | 在会话界面中提供模型选择。 |
|
||||
| [`ui-permission/`](ui-permission/README.md) | 配置默认权限并切换当前会话的访问模式。 |
|
||||
| [`ui-plan/`](ui-plan/README.md) | 展示生效中的 plan mode 状态及其退出控件。 |
|
||||
| [`ui-plugin-config/`](ui-plugin-config/README.md) | 插件设置分区:把宿主平面的插件配置呈现为可展开卡片。 |
|
||||
| [`ui-question/`](ui-question/README.md) | 展示 agent 请求的交互式问题。 |
|
||||
| [`ui-agent-preset/`](ui-agent-preset/README.md) | 选择会话的 agent 预设,并创作预设组装。 |
|
||||
| [`ui-settings/`](ui-settings/README.md) | 承载设置界面及其扩展区域。 |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
|
||||
README.md: be04f56ac5151c756aa6d4e2233461cc173fe261
|
||||
README.zh.md: 372922b8b02f694512505c97bf08b9ab480912a5
|
||||
README.md: bcc1b070535046ab2af1878f763c806aada49ba0
|
||||
README.zh.md: 7a5d651c862d9f9682688f71a9bdf7d6b22f3e63
|
||||
|
||||
@@ -4,7 +4,8 @@ English | [中文](README.zh.md)
|
||||
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list and scope state, and the shared event window and history paging used by registered conversation view targets. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into Session and Workspace owners and hands each generic `host/remote-event` frame to `ctx.remote.$dispatch`; domain packages subscribe to their owner events through `ctx.remote.$on` and decide which caches or session rows they invalidate. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
|
||||
|
||||
`bindSettingsScope` is the browser mirror of the Host-side settings owner seam for one domain-owned namespace. It subscribes before starting a nonblocking initial read, publishes a uSES snapshot (status, section value, revision, writability, host/memory mode), serializes `set` writes with the latest known namespace revision, suppresses stale publications, recovers a rejected latest write from Host state, and reaches quiescence on plugin disposal. The default decoder validates each section against the namespace's own serialized wire schema (rehydrated through dsh-client-schema-form), so a domain adds a decoder only to narrow beyond that schema. Loopback pages use the Host settings API; remote pages stay in memory mode. Domain packages own the namespace schema, default, and live service rather than putting product policy in runtime.
|
||||
`bindSettingsScope` is the browser mirror of the Host-side settings owner seam for one domain-owned namespace. It subscribes before starting a nonblocking initial read, publishes a uSES snapshot (status, section value, the composition `base` and raw `user` layers, revision, writability, host/memory mode), serializes `set` and `unset` writes with the latest known namespace revision, suppresses stale publications, recovers a rejected latest write from Host state, and reaches quiescence on plugin disposal. The default decoder validates each section against the namespace's own serialized wire schema (rehydrated through dsh-client-schema-form), so a domain adds a decoder only to narrow beyond that schema. Loopback pages use the Host settings API; remote pages stay in memory mode. A field is overridden when it is PRESENT in `user` — an override equal to the composition default is still an override, which comparing values could not see — and `unset` is how a form clears one back to `base`. Domain packages own the namespace schema, default, and live service rather than putting product policy in runtime.
|
||||
|
||||
## Slot declaration injection
|
||||
|
||||
`ctx.slots.inject(name, callback)` makes a full `SlotMap` key the dependency for a contribution whose plugin can activate independently from the declaring entry. It runs `callback` synchronously when the declaration exists, otherwise waits; declaration collapse disposes the callback effect, and redeclaration reruns it. The controller belongs to the caller's plugin fiber, so unloading the contributor cancels either the wait or its active registrations. A direct `slots.register()` into an undeclared slot still throws.
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表与 scope 状态,以及供已注册 conversation view target 共用的事件窗口与历史分页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session 与 Workspace 所有者,并把每个通用 `host/remote-event` 帧交给 `ctx.remote.$dispatch`;各领域包通过 `ctx.remote.$on` 订阅自身 owner 事件,并自行决定使哪些缓存或会话行失效。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。约定:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
|
||||
|
||||
`bindSettingsScope` 面向单个由领域持有的 namespace,是 Host 侧 settings owner seam 的浏览器镜像。它在开始非阻塞初始读取前建立订阅,发布 uSES 快照(状态、分节值、revision、可写性、host/内存模式),使用已知最新 namespace revision 串行执行 `set` 写入,抑制陈旧发布,并在最新写入被拒时从 Host 状态恢复;插件释放时,它会达到完全停稳。默认解码器会对照该 namespace 自身的序列化 wire schema(经 dsh-client-schema-form 还原)校验每个分节,因此领域只有在需要比该 schema 进一步收窄时才添加解码器。回环页面使用 Host settings API,远程页面则停留在内存模式。namespace schema、默认值与实时服务归领域包所有,而非把产品政策放入运行时。
|
||||
`bindSettingsScope` 面向单个由领域持有的 namespace,是 Host 侧 settings owner seam 的浏览器镜像。它在开始非阻塞初始读取前建立订阅,发布 uSES 快照(状态、分节值、组装 `base` 层与原始 `user` 层、revision、可写性、host/内存模式),使用已知最新 namespace revision 串行执行 `set` 与 `unset` 写入,抑制陈旧发布,并在最新写入被拒时从 Host 状态恢复;插件释放时,它会达到完全停稳。默认解码器会对照该 namespace 自身的序列化 wire schema(经 dsh-client-schema-form 还原)校验每个分节,因此领域只有在需要比该 schema 进一步收窄时才添加解码器。回环页面使用 Host settings API,远程页面则停留在内存模式。字段是否被覆盖,取决于它是否**出现**在 `user` 中——与组装默认值相同的覆盖仍然是覆盖,比较值是看不出来的——而 `unset` 就是表单把某个字段清回 `base` 的方式。namespace schema、默认值与实时服务归领域包所有,而非把产品政策放入运行时。
|
||||
|
||||
## Slot 声明注入
|
||||
|
||||
`ctx.slots.inject(name, callback)` 将完整的 `SlotMap` key 作为贡献项的依赖,适用于贡献方插件可独立于声明条目激活的情形。声明存在时,它会同步运行 `callback`,否则等待;声明折叠会 dispose(资源释放)回调 effect,重新声明则会再次运行回调。控制器归调用方的插件 fiber 所有,因此卸载贡献方会取消等待或移除其活跃注册项。直接调用 `slots.register()` 向未声明 slot 注册仍会抛出异常。
|
||||
|
||||
@@ -17,6 +17,17 @@ export interface SettingsScopeSnapshot<T> {
|
||||
status: 'loading' | 'ready' | 'unavailable'
|
||||
/** Last accepted schema-resolved section; undefined before the first acceptance. */
|
||||
value: T | undefined
|
||||
/**
|
||||
* Composition layer the Host resolved {@link value} over, when the owning
|
||||
* plugin declared one. What a field reverts to once cleared.
|
||||
*/
|
||||
base: unknown
|
||||
/**
|
||||
* Raw user layer as stored, when one exists. A field's PRESENCE here is what
|
||||
* marks it overridden — an override whose value equals the composition
|
||||
* default is still an override, and comparing values could not see it.
|
||||
*/
|
||||
user: unknown
|
||||
/** Namespace revision fencing the next write; undefined before the first Host view. */
|
||||
revision: number | undefined
|
||||
/** Whether the Host document accepts writes; memory mode never does. */
|
||||
@@ -60,4 +71,11 @@ export interface SettingsScope<T> {
|
||||
* @returns settlement after the write and any latest-write recovery read.
|
||||
*/
|
||||
set(field: string, value: unknown): Promise<void>
|
||||
/**
|
||||
* Queue one field clear, so the field re-inherits the composition layer.
|
||||
* Shares {@link set}'s ordering, revision, and recovery contract.
|
||||
* @param field - scalar field inside the namespace section.
|
||||
* @returns settlement after the clear and any latest-write recovery read.
|
||||
*/
|
||||
unset(field: string): Promise<void>
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface StubSettingsScope<T> {
|
||||
scope: SettingsScope<T>
|
||||
/** Spy behind `scope.set`; resolves immediately. */
|
||||
set: ReturnType<typeof vi.fn>
|
||||
/** Spy behind `scope.unset`; resolves immediately. */
|
||||
unset: ReturnType<typeof vi.fn>
|
||||
/** @returns how many listeners are currently subscribed (disposal assertions). */
|
||||
listenerCount(): number
|
||||
/**
|
||||
@@ -25,10 +27,12 @@ export interface StubSettingsScope<T> {
|
||||
*/
|
||||
export function stubSettingsScope<T>(): StubSettingsScope<T> {
|
||||
let snapshot: SettingsScopeSnapshot<T> = {
|
||||
status: 'loading', value: undefined, revision: undefined, writable: false, mode: 'host',
|
||||
status: 'loading', value: undefined, base: undefined, user: undefined,
|
||||
revision: undefined, writable: false, mode: 'host',
|
||||
}
|
||||
const listeners = new Set<() => void>()
|
||||
const set = vi.fn(() => Promise.resolve())
|
||||
const unset = vi.fn(() => Promise.resolve())
|
||||
return {
|
||||
scope: {
|
||||
getSnapshot: () => snapshot,
|
||||
@@ -37,8 +41,10 @@ export function stubSettingsScope<T>(): StubSettingsScope<T> {
|
||||
return () => { listeners.delete(listener) }
|
||||
},
|
||||
set,
|
||||
unset,
|
||||
},
|
||||
set,
|
||||
unset,
|
||||
listenerCount: () => listeners.size,
|
||||
publish: (next) => {
|
||||
snapshot = { ...snapshot, ...next }
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* stack — this suite is the fixture the migrated feature specs rely on.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { stubSettingsScope } from '../src/settings-scope.ts'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { defineStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -635,3 +636,32 @@ describe('single-slot mounting edge arms', () => {
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('stubbed settings scope', () => {
|
||||
it('records both write kinds and publishes a Host acceptance to its listeners', async () => {
|
||||
const host = stubSettingsScope<{ preference: string }>()
|
||||
let notified = 0
|
||||
const stop = host.scope.subscribe(() => { notified += 1 })
|
||||
expect(host.listenerCount()).toBe(1)
|
||||
expect(host.scope.getSnapshot()).toMatchObject({
|
||||
status: 'loading', base: undefined, user: undefined,
|
||||
})
|
||||
|
||||
await host.scope.set('preference', 'dark')
|
||||
await host.scope.unset('preference')
|
||||
host.publish({
|
||||
status: 'ready',
|
||||
value: { preference: 'system' },
|
||||
base: { preference: 'system' },
|
||||
revision: 2,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
expect(host.set).toHaveBeenCalledWith('preference', 'dark')
|
||||
expect(host.unset).toHaveBeenCalledWith('preference')
|
||||
expect(notified).toBe(1)
|
||||
expect(host.scope.getSnapshot()).toMatchObject({ status: 'ready', revision: 2, writable: true })
|
||||
stop()
|
||||
expect(host.listenerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
6
packages/client/ui-attachment/README.i18n.yaml
Normal file
6
packages/client/ui-attachment/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-attachment/README.md
|
||||
README.md: 9fab9c23b958606030b1e87fcbfa45130c980947
|
||||
README.zh.md: 668dba11154538f52a9a87692020868c1b8a63d5
|
||||
27
packages/client/ui-attachment/README.md
Normal file
27
packages/client/ui-attachment/README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# @deepseek-ai/dsh-client-ui-attachment
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Pure React attachment atoms (zero cordis): the composer draft-image rail (`AttachmentRail`), the chat-history image gallery (`MessageImage`/`ImageGallery`), and the original-image lightbox (`ImageLightbox`). Every string arrives through label props resolved by the owning plugin's own locale namespace, and nothing here reads application state; `@deepseek-ai/dsh-client-ui-conversation` is the current consumer, bridging its `conversation` dictionary through its `image-labels` module.
|
||||
|
||||
## Attachment rail
|
||||
|
||||
`AttachmentRail` renders pending draft images as fixed 64px thumbnails (16px radius) in one horizontally scrolling row whose scrollbar stays hidden. Overflow is announced by circular edge arrows instead: each pages one viewport (minus one card of context, floored at 200px) with smooth scrolling (instant under `prefers-reduced-motion: reduce`), and arrow visibility is recomputed from scroll geometry on scroll, item-count changes, and rail size changes (a ResizeObserver on the rail element, so sidebar and panel resizes count, not only window resizes). The rail scrolls horizontally only: a non-passive listener consumes every wheel tick with a vertical component — nothing scrolls the conversation behind the composer — converting a pure vertical wheel to a horizontal step (LINE/PAGE deltas normalized to pixels, per-tick travel clamped to 60px) and keeping a diagonal pan's horizontal intent, while purely horizontal pans stay native. A newly added item is revealed at the rail's end; removal keeps the scroll position, and a rail that mounts over an already-populated draft keeps its start position. Each thumbnail opens its original through `onOpen` on a single click, and its remove control sits inside the card's top-right corner, hidden until the card is hovered or the control keyboard-focused; coarse-pointer (touch) surfaces show it permanently because they have no hover. The owner decides mounting and renders the rail only while items exist.
|
||||
|
||||
## Message images and the lightbox
|
||||
|
||||
`MessageImage` renders one durable history image bounded to 240px on its longer edge, loading a session-authorized URL through the owner's `ImageLoader`; a failed load renders an explicit retry control, and a settled load answers a single click by opening `ImageLightbox` (clicks during loading are ignored). `ImageGallery` wraps a message's images in one aligned flex group (`end` for user messages, `start` for assistant messages) and renders nothing for an empty list. `ImageLightbox` is a document-level modal preview that closes on Escape, a backdrop press, or its close control, and restores focus to its opener on unmount.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the package renders pure React atoms in the browser; nothing here reaches a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Images only** — non-image files have no rail card or history renderer yet; DeepSeek Chat-style file cards and upload-progress states wait until the composer accepts non-image attachments.
|
||||
- **No zoom or download in the lightbox** — the preview renders the original at fit-to-viewport size only.
|
||||
- **The lightbox does not trap focus** — it sets `aria-modal` and restores focus on close, but Tab can reach the page behind it (behavior carried over from the pre-package component).
|
||||
27
packages/client/ui-attachment/README.zh.md
Normal file
27
packages/client/ui-attachment/README.zh.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# @deepseek-ai/dsh-client-ui-attachment
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
纯 React 附件原子组件(零 cordis):输入框草稿图片栏(`AttachmentRail`)、聊天历史图片画廊(`MessageImage`/`ImageGallery`)与原图灯箱(`ImageLightbox`)。所有文案都由持有方插件在自己的语言命名空间中解析后经 label props 传入,此包不读取任何应用状态;当前消费者是 `@deepseek-ai/dsh-client-ui-conversation`,经其 `image-labels` 模块桥接 `conversation` 词典。
|
||||
|
||||
## 附件栏
|
||||
|
||||
`AttachmentRail` 将待发送草稿图片渲染为固定 64px(16px 圆角)的缩略图横排,滚动条始终隐藏,溢出改由两端的圆形箭头提示:每次翻页滚动一个视口宽度(减去一张卡片作为上下文,下限 200px)并平滑滚动(`prefers-reduced-motion: reduce` 下瞬时完成),箭头的显隐在滚动、条目数量变化和栏自身尺寸变化时依据滚动几何重算(rail 元素上的 ResizeObserver,因此侧栏、面板的宽度变化也计入,不只是窗口尺寸变化)。附件栏只允许横向滚动:非 passive 监听器消费所有带纵向分量的滚轮事件——不会滚动输入框背后的会话记录——纯纵向滚轮转为横向步进(LINE/PAGE 单位先归一化为像素,单次行程钳制在 60px 内),对角平移保留其横向分量,纯横向平移保持原生滚动。新增条目会滚动到栏尾展示,删除则保持原位,带着已有草稿重新挂载的栏保持起始位置。每张缩略图单击经 `onOpen` 打开原图,删除按钮位于卡片内部右上角,悬停卡片或键盘聚焦时才显示;粗指针(触屏)设备没有悬停,因此常显。是否挂载由持有方决定,仅在有条目时渲染。
|
||||
|
||||
## 消息图片与灯箱
|
||||
|
||||
`MessageImage` 渲染一张持久化历史图片,长边收敛到 240px,经持有方的 `ImageLoader` 加载会话授权 URL;加载失败渲染显式重试按钮,加载完成后单击打开 `ImageLightbox`(加载中的点击被忽略)。`ImageGallery` 将一条消息的图片包为一个对齐的弹性分组(用户消息 `end`,助手消息 `start`),空列表不渲染。`ImageLightbox` 是文档级模态预览,按 Escape、按下遮罩或点关闭按钮均可关闭,卸载时将焦点还给打开者。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。该包(package)在浏览器中渲染纯 React 原子组件;这里没有任何内容进入模型请求。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **仅支持图片** — 非图片文件尚无附件栏卡片与历史渲染;DeepSeek Chat 风格的文件卡片和上传进度状态等输入框接受非图片附件后再做。
|
||||
- **灯箱无缩放与下载** — 预览仅以适配视口的尺寸渲染原图。
|
||||
- **灯箱不锁定焦点** — 它设置 `aria-modal` 并在关闭时归还焦点,但 Tab 仍可移动到背后的页面(沿袭入包前组件的行为)。
|
||||
51
packages/client/ui-attachment/package.json
Normal file
51
packages/client/ui-attachment/package.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-attachment",
|
||||
"description": "Pure React attachment atoms for the dsh web UI: draft-image rail, message image gallery, and original-image lightbox (zero cordis)",
|
||||
"version": "0.0.1-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/client/ui-attachment"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"clsx": "^2.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"@types/react-dom": "~18.3.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^"
|
||||
}
|
||||
}
|
||||
118
packages/client/ui-attachment/src/AttachmentRail.module.css
Normal file
118
packages/client/ui-attachment/src/AttachmentRail.module.css
Normal file
@@ -0,0 +1,118 @@
|
||||
/* Thumbnail geometry mirrors DeepSeek Chat's composer rail: 64px cards with a
|
||||
16px radius, remove control fully inside the card, arrows overlaid at the
|
||||
edges instead of a scrollbar. */
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rail {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
/* Edge arrows page the overflow; the scrollbar stays hidden (both engines). */
|
||||
scrollbar-width: none;
|
||||
/* The rail scrolls on the composer's elevated input surface: bind the l2
|
||||
pair (ui-theme styles/scrollbar.css rebinding contract) so anything that
|
||||
does draw a thumb here matches the surface. */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
.rail::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.item {
|
||||
position: relative;
|
||||
flex: 0 0 64px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
border-radius: 16px;
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.thumbnail img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.remove {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-button-contrast-fill);
|
||||
color: var(--dsw-alias-label-primary-inverted);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.item:hover .remove,
|
||||
.remove:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Touch surfaces have no hover to reveal the control. */
|
||||
@media (pointer: coarse) {
|
||||
.remove {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.remove {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
border-radius: 999px;
|
||||
background: var(--dsw-specific-input-major);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
box-shadow: var(--dsw-shadow-lv2);
|
||||
cursor: pointer;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.arrow:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover-solid);
|
||||
}
|
||||
|
||||
.arrowLeft {
|
||||
left: 4px;
|
||||
}
|
||||
|
||||
.arrowRight {
|
||||
right: 4px;
|
||||
}
|
||||
200
packages/client/ui-attachment/src/AttachmentRail.tsx
Normal file
200
packages/client/ui-attachment/src/AttachmentRail.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
/** Draft-attachment thumbnail rail: scrollbar-less horizontal overflow paged
|
||||
* by edge arrows, hover-revealed per-item remove, single-click open. */
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconChevronLeftOutline14, IconChevronRightOutline14, IconCloseFill14,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import css from './AttachmentRail.module.css'
|
||||
|
||||
/** One rail thumbnail; strings arrive resolved (zero-cordis atom). */
|
||||
export interface AttachmentRailItem {
|
||||
/** Stable identity for the React key. */
|
||||
id: string
|
||||
/** Object or data URL rendered as the thumbnail. */
|
||||
previewUrl: string
|
||||
/** Image alt text (display name with the owner's fallback applied). */
|
||||
alt: string
|
||||
/** Accessible label of the item's remove control. */
|
||||
removeLabel: string
|
||||
}
|
||||
|
||||
/** Rail-level strings the owner resolves from its own locale namespace. */
|
||||
export interface AttachmentRailLabels {
|
||||
/** Accessible name of the rail group. */
|
||||
group: string
|
||||
/** Thumbnail tooltip inviting the original-image preview. */
|
||||
open: string
|
||||
/** Accessible label of the left paging arrow. */
|
||||
scrollLeft: string
|
||||
/** Accessible label of the right paging arrow. */
|
||||
scrollRight: string
|
||||
}
|
||||
|
||||
/** Approximate pixels per wheel step for `deltaMode` LINE deltas (Firefox
|
||||
* notch wheels report lines, not pixels). */
|
||||
const WHEEL_LINE_PX = 16
|
||||
|
||||
/** Smooth paging unless the user asked for reduced motion. */
|
||||
function pageBehavior(): ScrollBehavior {
|
||||
// jsdom (the unit lane) implements no matchMedia despite lib.dom's
|
||||
// non-optional typing; the optional call keeps that lane on the default.
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition
|
||||
return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth'
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal thumbnail rail over the caller's draft attachments.
|
||||
*
|
||||
* The rail scrolls with its scrollbar hidden; overflow is announced by edge
|
||||
* arrows recomputed from scroll geometry on scroll, item-count changes, and
|
||||
* rail size changes (a ResizeObserver on the rail element, so sidebar or
|
||||
* panel resizes count, not only window resizes). A vertical wheel pans the
|
||||
* rail horizontally and is consumed exclusively (non-passive listener), a
|
||||
* newly added item is revealed at the rail's end while a rail that mounts
|
||||
* over an existing draft keeps its start position, and each thumbnail opens
|
||||
* on a single click while its remove control sits inside the card and
|
||||
* reveals on hover or focus. The owner decides mounting; it renders the rail
|
||||
* only while items exist.
|
||||
*
|
||||
* @param props.items - resolved thumbnails in draft order.
|
||||
* @param props.labels - rail-level strings (group name, open tooltip, arrows).
|
||||
* @param props.onOpen - single-click open of one item's original image.
|
||||
* @param props.onRemove - remove one item from the draft.
|
||||
* @returns the rail group with its paging arrows.
|
||||
*/
|
||||
export function AttachmentRail<T extends AttachmentRailItem>({ items, labels, onOpen, onRemove }: {
|
||||
items: readonly T[]
|
||||
labels: AttachmentRailLabels
|
||||
onOpen: (item: T) => void
|
||||
onRemove: (item: T) => void
|
||||
}) {
|
||||
const railRef = useRef<HTMLDivElement | null>(null)
|
||||
// null marks the first layout pass: a rail that MOUNTS over an existing
|
||||
// draft (session switch back to held images) is initial display, not
|
||||
// growth, and must not jump to the end.
|
||||
const countRef = useRef<number | null>(null)
|
||||
const [edges, setEdges] = useState({ left: false, right: false })
|
||||
const updateEdges = useCallback(() => {
|
||||
const el = railRef.current
|
||||
/* v8 ignore next -- defensive: every caller runs while the rail element is mounted. */
|
||||
if (el === null) return
|
||||
// 1px slack: engines report fractional scroll positions at the edges.
|
||||
const left = el.scrollLeft > 1
|
||||
const right = el.scrollLeft < el.scrollWidth - el.clientWidth - 1
|
||||
setEdges(prev => prev.left === left && prev.right === right ? prev : { left, right })
|
||||
}, [])
|
||||
useLayoutEffect(() => {
|
||||
const grew = countRef.current !== null && items.length > countRef.current
|
||||
countRef.current = items.length
|
||||
const el = railRef.current
|
||||
/* v8 ignore next -- defensive: the rail div renders unconditionally, so the layout effect always finds it. */
|
||||
if (el === null) return
|
||||
// A newly added attachment lands at the rail's end: reveal it.
|
||||
if (grew) el.scrollLeft = el.scrollWidth - el.clientWidth
|
||||
updateEdges()
|
||||
}, [items.length, updateEdges])
|
||||
useEffect(() => {
|
||||
const el = railRef.current
|
||||
/* v8 ignore next -- defensive: the rail div renders unconditionally, so the mount effect always finds it. */
|
||||
if (el === null) return
|
||||
// The rail's width follows the composer, which resizes with sidebars and
|
||||
// panels, not only the window — observe the element itself. jsdom (the
|
||||
// unit lane) implements no ResizeObserver; every browser gets the
|
||||
// subscription.
|
||||
let disconnect = (): void => {}
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const observer = new ResizeObserver(updateEdges)
|
||||
observer.observe(el)
|
||||
disconnect = () => { observer.disconnect() }
|
||||
}
|
||||
// The rail scrolls horizontally ONLY: any wheel tick with a vertical
|
||||
// component is consumed — without preventDefault it would also scroll the
|
||||
// conversation behind the composer, and React's root wheel listener is
|
||||
// passive, so the exclusion needs this manually attached non-passive
|
||||
// listener. A diagonal trackpad pan keeps its horizontal intent; a pure
|
||||
// vertical wheel converts to a horizontal step, with LINE and PAGE deltas
|
||||
// (Firefox notch wheels) normalized to pixels before the per-tick clamp
|
||||
// that keeps a fast wheel followable. A purely horizontal pan stays
|
||||
// native.
|
||||
const onWheel = (event: globalThis.WheelEvent): void => {
|
||||
if (event.deltaY === 0) return
|
||||
const scale = event.deltaMode === WheelEvent.DOM_DELTA_LINE
|
||||
? WHEEL_LINE_PX
|
||||
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? el.clientWidth : 1
|
||||
event.preventDefault()
|
||||
el.scrollBy({
|
||||
left: event.deltaX !== 0
|
||||
? event.deltaX * scale
|
||||
: Math.sign(event.deltaY) * Math.min(Math.abs(event.deltaY) * scale, 60),
|
||||
behavior: 'auto',
|
||||
})
|
||||
}
|
||||
el.addEventListener('wheel', onWheel, { passive: false })
|
||||
return () => {
|
||||
disconnect()
|
||||
el.removeEventListener('wheel', onWheel)
|
||||
}
|
||||
}, [updateEdges])
|
||||
const page = (direction: -1 | 1): void => {
|
||||
const el = railRef.current
|
||||
/* v8 ignore next -- defensive: the arrows render only while the rail is mounted, so a click cannot find a null ref. */
|
||||
if (el === null) return
|
||||
// One viewport minus a card keeps the last visible thumbnail as context;
|
||||
// the floor keeps narrow rails paging a useful distance.
|
||||
el.scrollBy({ left: direction * Math.max(el.clientWidth - 64, 200), behavior: pageBehavior() })
|
||||
}
|
||||
return (
|
||||
<div className={css.root}>
|
||||
{edges.left && (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.arrow, css.arrowLeft)}
|
||||
aria-label={labels.scrollLeft}
|
||||
onClick={() => { page(-1) }}
|
||||
>
|
||||
<IconChevronLeftOutline14 />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
ref={railRef}
|
||||
className={css.rail}
|
||||
role="group"
|
||||
aria-label={labels.group}
|
||||
onScroll={updateEdges}
|
||||
>
|
||||
{items.map(item => (
|
||||
<div key={item.id} className={css.item}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.thumbnail}
|
||||
title={labels.open}
|
||||
onClick={() => { onOpen(item) }}
|
||||
>
|
||||
<img src={item.previewUrl} alt={item.alt} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.remove}
|
||||
aria-label={item.removeLabel}
|
||||
onClick={() => { onRemove(item) }}
|
||||
>
|
||||
<IconCloseFill14 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{edges.right && (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.arrow, css.arrowRight)}
|
||||
aria-label={labels.scrollRight}
|
||||
onClick={() => { page(1) }}
|
||||
>
|
||||
<IconChevronRightOutline14 />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
61
packages/client/ui-attachment/src/ImageLightbox.tsx
Normal file
61
packages/client/ui-attachment/src/ImageLightbox.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import css from './ImageLightbox.module.css'
|
||||
|
||||
/** Lightbox strings the owner resolves from its own locale namespace. */
|
||||
export interface ImageLightboxLabels {
|
||||
/** Accessible name of the preview dialog. */
|
||||
dialog: string
|
||||
/** Accessible label of the close control. */
|
||||
close: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Document-level original-image preview opened by clicking a thumbnail.
|
||||
* Closes on Escape, backdrop press, or the close control, and restores focus
|
||||
* to the opener on unmount. Rendered through a body portal: an opener inside
|
||||
* a transformed or filtered ancestor would otherwise trap the fixed backdrop
|
||||
* in that ancestor's box instead of covering the viewport.
|
||||
*
|
||||
* @param props.src - the original image URL.
|
||||
* @param props.alt - the image's alt text.
|
||||
* @param props.labels - dialog and close-control strings.
|
||||
* @param props.onClose - dismiss callback owned by the opener.
|
||||
* @returns the modal preview dialog.
|
||||
*/
|
||||
export function ImageLightbox({ src, alt, labels, onClose }: {
|
||||
src: string
|
||||
alt: string
|
||||
labels: ImageLightboxLabels
|
||||
onClose: () => void
|
||||
}) {
|
||||
const closeRef = useRef<HTMLButtonElement | null>(null)
|
||||
const restoreRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
restoreRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
closeRef.current?.focus()
|
||||
const onKeyDown = (event: globalThis.KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
restoreRef.current?.focus()
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={css.backdrop}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={labels.dialog}
|
||||
onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}
|
||||
>
|
||||
<img className={css.image} src={src} alt={alt} />
|
||||
<button ref={closeRef} type="button" className={css.close} aria-label={labels.close} onClick={onClose}>×</button>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -24,7 +24,7 @@
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
border-radius: 12px;
|
||||
border-radius: 16px;
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
95
packages/client/ui-attachment/src/MessageImage.tsx
Normal file
95
packages/client/ui-attachment/src/MessageImage.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import { ImageLightbox } from './ImageLightbox.tsx'
|
||||
import type { ImageLightboxLabels } from './ImageLightbox.tsx'
|
||||
import css from './MessageImage.module.css'
|
||||
|
||||
/** Loads a session-authorized durable image URL. */
|
||||
export type ImageLoader = (attachment: ImageAttachmentRef) => Promise<string>
|
||||
|
||||
/** Message-image strings the owner resolves from its own locale namespace. */
|
||||
export interface MessageImageLabels {
|
||||
/** Fallback display name for an unnamed image. */
|
||||
image: string
|
||||
/** Thumbnail tooltip inviting the original-image preview. */
|
||||
open: string
|
||||
/** Accessible thumbnail label; receives the image's display name. */
|
||||
openNamed: (label: string) => string
|
||||
/** Loading placeholder shown until bytes resolve. */
|
||||
loading: string
|
||||
/** Retry-control label shown when the load fails. */
|
||||
loadFailed: string
|
||||
/** Lightbox strings forwarded to the opened preview. */
|
||||
lightbox: ImageLightboxLabels
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact history renderer with retryable loading and click-to-open original
|
||||
* preview.
|
||||
*
|
||||
* @param props.attachment - the durable image reference to load and bound.
|
||||
* @param props.load - session-authorized URL loader.
|
||||
* @param props.labels - resolved strings (tooltip, loading, retry, lightbox).
|
||||
* @returns the bounded thumbnail button, or the retry control on failure.
|
||||
*/
|
||||
export function MessageImage({ attachment, load, labels }: {
|
||||
attachment: ImageAttachmentRef
|
||||
load: ImageLoader
|
||||
labels: MessageImageLabels
|
||||
}) {
|
||||
const [src, setSrc] = useState<string | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
// Retry re-arms the one load effect below, so every attempt — first load or
|
||||
// retry — runs under the same liveness guard and the same reset.
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
const request = useCallback(() => { setAttempt(a => a + 1) }, [])
|
||||
const close = useCallback(() => { setOpen(false) }, [])
|
||||
const size = useMemo(() => {
|
||||
const scale = Math.min(1, 240 / attachment.width, 240 / attachment.height)
|
||||
return { width: Math.max(1, Math.round(attachment.width * scale)), height: Math.max(1, Math.round(attachment.height * scale)) }
|
||||
}, [attachment.height, attachment.width])
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
setError(false)
|
||||
setSrc(null)
|
||||
void load(attachment).then((url) => { if (live) setSrc(url) }).catch(() => { if (live) setError(true) })
|
||||
return () => { live = false }
|
||||
}, [attachment, load, attempt])
|
||||
|
||||
const label = attachment.name ?? labels.image
|
||||
if (error) return <button type="button" className={css.error} onClick={request}>{labels.loadFailed}</button>
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={css.frame}
|
||||
style={size}
|
||||
title={labels.open}
|
||||
aria-label={labels.openNamed(label)}
|
||||
onClick={() => { if (src !== null) setOpen(true) }}
|
||||
>
|
||||
{src === null ? <span className={css.loading}>{labels.loading}</span> : <img src={src} alt={label} />}
|
||||
</button>
|
||||
{open && src !== null && <ImageLightbox src={src} alt={label} labels={labels.lightbox} onClose={close} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Wrapping image group shared by user and assistant history. */
|
||||
export function ImageGallery({ images, load, align, labels }: {
|
||||
images: readonly { attachment: ImageAttachmentRef }[]
|
||||
load: ImageLoader
|
||||
align: 'start' | 'end'
|
||||
labels: MessageImageLabels
|
||||
}) {
|
||||
if (images.length === 0) return null
|
||||
return (
|
||||
<div className={css.gallery} data-align={align}>
|
||||
{images.map((image, index) => (
|
||||
<MessageImage key={`${image.attachment.attachmentId}:${index}`} {...image} load={load} labels={labels} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
6
packages/client/ui-attachment/src/css-modules.d.ts
vendored
Normal file
6
packages/client/ui-attachment/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
14
packages/client/ui-attachment/src/index.ts
Normal file
14
packages/client/ui-attachment/src/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Pure React attachment atoms (zero cordis): the composer draft-image rail,
|
||||
* the chat-history image gallery, and the original-image lightbox. Owners
|
||||
* resolve every string through their own locale namespace and pass it down;
|
||||
* nothing here reads application state.
|
||||
* @module @deepseek-ai/dsh-client-ui-attachment
|
||||
*/
|
||||
|
||||
export { AttachmentRail } from './AttachmentRail.tsx'
|
||||
export type { AttachmentRailItem, AttachmentRailLabels } from './AttachmentRail.tsx'
|
||||
export { ImageLightbox } from './ImageLightbox.tsx'
|
||||
export type { ImageLightboxLabels } from './ImageLightbox.tsx'
|
||||
export { ImageGallery, MessageImage } from './MessageImage.tsx'
|
||||
export type { ImageLoader, MessageImageLabels } from './MessageImage.tsx'
|
||||
31
packages/client/ui-attachment/src/invariant.ts
Normal file
31
packages/client/ui-attachment/src/invariant.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-attachment`.
|
||||
* @module @deepseek-ai/dsh-client-ui-attachment/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-attachment'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-attachment-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: pure props-in React atoms with no Cordis API —
|
||||
* no events, no services, no mutable cross-plugin state; rendering contracts
|
||||
* are asserted directly by this package's component specs.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
173
packages/client/ui-attachment/tests/attachment-rail.spec.tsx
Normal file
173
packages/client/ui-attachment/tests/attachment-rail.spec.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
// @vitest-environment jsdom
|
||||
// AttachmentRail behavior in the jsdom lane: item rendering and callbacks,
|
||||
// arrow paging over stubbed scroll geometry (jsdom lays nothing out), the
|
||||
// exclusive vertical-wheel pan, and the new-item end reveal.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { AttachmentRail } from '../src/AttachmentRail.tsx'
|
||||
import type { AttachmentRailItem, AttachmentRailLabels } from '../src/AttachmentRail.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
// jsdom implements no ResizeObserver; the stub records instances so a test
|
||||
// can drive the size-change recompute path.
|
||||
const observers: { callback: ResizeObserverCallback; observed: Element[] }[] = []
|
||||
beforeEach(() => {
|
||||
observers.length = 0
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
observed: Element[] = []
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
observers.push({ callback, observed: this.observed })
|
||||
}
|
||||
|
||||
observe(el: Element) { this.observed.push(el) }
|
||||
disconnect() { this.observed.length = 0 }
|
||||
})
|
||||
})
|
||||
afterEach(() => { vi.unstubAllGlobals() })
|
||||
|
||||
const labels: AttachmentRailLabels = {
|
||||
group: '待发送图片',
|
||||
open: '查看原图',
|
||||
scrollLeft: '向左滚动图片',
|
||||
scrollRight: '向右滚动图片',
|
||||
}
|
||||
|
||||
function item(id: string): AttachmentRailItem {
|
||||
return { id, previewUrl: `blob:${id}`, alt: `${id}.png`, removeLabel: `移除图片 ${id}.png` }
|
||||
}
|
||||
|
||||
/** Stub the rail's scroll geometry (jsdom reports 0 for every metric). */
|
||||
function stubGeometry(rail: HTMLElement, { scrollWidth, clientWidth }: { scrollWidth: number; clientWidth: number }) {
|
||||
Object.defineProperty(rail, 'scrollWidth', { value: scrollWidth, configurable: true })
|
||||
Object.defineProperty(rail, 'clientWidth', { value: clientWidth, configurable: true })
|
||||
let scrollLeft = 0
|
||||
Object.defineProperty(rail, 'scrollLeft', {
|
||||
configurable: true,
|
||||
get: () => scrollLeft,
|
||||
set: (value: number) => { scrollLeft = value },
|
||||
})
|
||||
const scrollBy = vi.fn((options: { left: number }) => {
|
||||
scrollLeft = Math.max(0, Math.min(scrollWidth - clientWidth, scrollLeft + options.left))
|
||||
})
|
||||
rail.scrollBy = scrollBy as unknown as typeof rail.scrollBy
|
||||
return { scrollBy, setScrollLeft: (value: number) => { scrollLeft = value } }
|
||||
}
|
||||
|
||||
describe('AttachmentRail', () => {
|
||||
it('renders thumbnails in order and routes open and remove clicks', () => {
|
||||
const onOpen = vi.fn()
|
||||
const onRemove = vi.fn()
|
||||
const items = [item('a'), item('b')]
|
||||
const view = render(<AttachmentRail items={items} labels={labels} onOpen={onOpen} onRemove={onRemove} />)
|
||||
const rail = view.getByRole('group', { name: '待发送图片' })
|
||||
expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))).toEqual(['a.png', 'b.png'])
|
||||
fireEvent.click(view.getAllByTitle('查看原图')[0]!)
|
||||
expect(onOpen).toHaveBeenCalledWith(items[0])
|
||||
fireEvent.click(view.getByRole('button', { name: '移除图片 b.png' }))
|
||||
expect(onRemove).toHaveBeenCalledWith(items[1])
|
||||
})
|
||||
|
||||
it('shows edge arrows from scroll geometry and pages a viewport at a time', () => {
|
||||
const view = render(
|
||||
<AttachmentRail items={[item('a'), item('b'), item('c')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
|
||||
)
|
||||
const rail = view.getByRole('group', { name: '待发送图片' })
|
||||
const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
|
||||
// No arrows until geometry is observed (mount saw jsdom's zero metrics).
|
||||
expect(view.queryByLabelText('向右滚动图片')).toBeNull()
|
||||
fireEvent.scroll(rail)
|
||||
// Same-edges scroll takes the memoized-state path.
|
||||
fireEvent.scroll(rail)
|
||||
expect(view.queryByLabelText('向左滚动图片')).toBeNull()
|
||||
const right = view.getByLabelText('向右滚动图片')
|
||||
// clientWidth 200 - 64 < the 200 floor: pages by the floor.
|
||||
fireEvent.click(right)
|
||||
expect(scrollBy).toHaveBeenCalledWith({ left: 200, behavior: 'smooth' })
|
||||
fireEvent.scroll(rail)
|
||||
// Scrolled to the far edge: only the left arrow remains.
|
||||
expect(view.queryByLabelText('向右滚动图片')).toBeNull()
|
||||
fireEvent.click(view.getByLabelText('向左滚动图片'))
|
||||
expect(scrollBy).toHaveBeenCalledWith({ left: -200, behavior: 'smooth' })
|
||||
fireEvent.scroll(rail)
|
||||
expect(view.queryByLabelText('向左滚动图片')).toBeNull()
|
||||
expect(view.getByLabelText('向右滚动图片')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows both arrows mid-scroll and recomputes when the rail itself resizes', () => {
|
||||
const view = render(
|
||||
<AttachmentRail items={[item('a'), item('b'), item('c')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
|
||||
)
|
||||
const rail = view.getByRole('group', { name: '待发送图片' })
|
||||
const { setScrollLeft } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
|
||||
setScrollLeft(100)
|
||||
// The component observes the rail element, not the window: a sidebar or
|
||||
// panel resize reaches it through the ResizeObserver callback.
|
||||
expect(observers.at(-1)?.observed).toContain(rail)
|
||||
act(() => { observers.at(-1)!.callback([], undefined as never) })
|
||||
expect(view.getByLabelText('向左滚动图片')).toBeTruthy()
|
||||
expect(view.getByLabelText('向右滚动图片')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('pans horizontally on a vertical wheel, consuming the event, with clamped normalized travel', () => {
|
||||
const view = render(
|
||||
<AttachmentRail items={[item('a'), item('b')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
|
||||
)
|
||||
const rail = view.getByRole('group', { name: '待发送图片' })
|
||||
const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
|
||||
// Converted ticks are consumed (preventDefault): fireEvent returns false.
|
||||
expect(fireEvent.wheel(rail, { deltaY: 30 })).toBe(false)
|
||||
expect(scrollBy).toHaveBeenCalledWith({ left: 30, behavior: 'auto' })
|
||||
fireEvent.wheel(rail, { deltaY: 500 })
|
||||
expect(scrollBy).toHaveBeenCalledWith({ left: 60, behavior: 'auto' })
|
||||
fireEvent.wheel(rail, { deltaY: -500 })
|
||||
expect(scrollBy).toHaveBeenCalledWith({ left: -60, behavior: 'auto' })
|
||||
// Firefox notch wheels report lines; a page-mode wheel reports viewports.
|
||||
fireEvent.wheel(rail, { deltaY: 2, deltaMode: WheelEvent.DOM_DELTA_LINE })
|
||||
expect(scrollBy).toHaveBeenCalledWith({ left: 32, behavior: 'auto' })
|
||||
fireEvent.wheel(rail, { deltaY: -1, deltaMode: WheelEvent.DOM_DELTA_PAGE })
|
||||
expect(scrollBy).toHaveBeenCalledWith({ left: -60, behavior: 'auto' })
|
||||
// A diagonal pan is consumed too — nothing vertical may escape the rail —
|
||||
// and keeps its horizontal intent.
|
||||
expect(fireEvent.wheel(rail, { deltaX: 12, deltaY: 30 })).toBe(false)
|
||||
expect(scrollBy).toHaveBeenCalledWith({ left: 12, behavior: 'auto' })
|
||||
// A purely horizontal pan and a zero-delta wheel keep native behavior.
|
||||
expect(fireEvent.wheel(rail, { deltaX: 12, deltaY: 0 })).toBe(true)
|
||||
fireEvent.wheel(rail, { deltaY: 0 })
|
||||
expect(scrollBy).toHaveBeenCalledTimes(6)
|
||||
})
|
||||
|
||||
it('pages instantly under a reduced-motion preference, smoothly otherwise', () => {
|
||||
for (const [matches, behavior] of [[true, 'auto'], [false, 'smooth']] as const) {
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches }) as MediaQueryList))
|
||||
const view = render(
|
||||
<AttachmentRail items={[item('a'), item('b'), item('c')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
|
||||
)
|
||||
const rail = view.getByRole('group', { name: '待发送图片' })
|
||||
const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
|
||||
fireEvent.scroll(rail)
|
||||
fireEvent.click(view.getByLabelText('向右滚动图片'))
|
||||
expect(scrollBy).toHaveBeenCalledWith({ left: 200, behavior })
|
||||
view.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('reveals the rail end when an item is added, not when one is removed', () => {
|
||||
const first = [item('a'), item('b')]
|
||||
const view = render(
|
||||
<AttachmentRail items={first} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
|
||||
)
|
||||
const rail = view.getByRole('group', { name: '待发送图片' })
|
||||
stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
|
||||
view.rerender(
|
||||
<AttachmentRail items={[...first, item('c')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
|
||||
)
|
||||
expect(rail.scrollLeft).toBe(200)
|
||||
view.rerender(
|
||||
<AttachmentRail items={first} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
|
||||
)
|
||||
// Removal keeps the position; only growth jumps to the end.
|
||||
expect(rail.scrollLeft).toBe(200)
|
||||
})
|
||||
})
|
||||
50
packages/client/ui-attachment/tests/image-lightbox.spec.tsx
Normal file
50
packages/client/ui-attachment/tests/image-lightbox.spec.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { ImageLightbox } from '../src/ImageLightbox.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const labels = { dialog: '原图预览', close: '关闭原图预览' }
|
||||
|
||||
describe('ImageLightbox', () => {
|
||||
it('focuses its close control, closes by button and Escape, and restores focus', () => {
|
||||
const opener = document.createElement('button')
|
||||
document.body.appendChild(opener)
|
||||
opener.focus()
|
||||
const onClose = vi.fn()
|
||||
const view = render(<ImageLightbox src="blob:original" alt="原图" labels={labels} onClose={onClose} />)
|
||||
const close = view.getByRole('button', { name: '关闭原图预览' })
|
||||
expect(document.activeElement).toBe(close)
|
||||
fireEvent.keyDown(window, { key: 'a' })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
fireEvent.click(close)
|
||||
expect(onClose).toHaveBeenCalledTimes(2)
|
||||
view.unmount()
|
||||
expect(document.activeElement).toBe(opener)
|
||||
opener.remove()
|
||||
})
|
||||
|
||||
it('tolerates a focus owner it cannot restore (no active element at mount)', () => {
|
||||
// jsdom always reports body as the fallback active element; stub the
|
||||
// element-less state a detached focus can leave.
|
||||
Object.defineProperty(document, 'activeElement', { configurable: true, get: () => null })
|
||||
try {
|
||||
const view = render(<ImageLightbox src="blob:original" alt="原图" labels={labels} onClose={vi.fn()} />)
|
||||
view.unmount()
|
||||
} finally {
|
||||
delete (document as { activeElement?: unknown }).activeElement
|
||||
}
|
||||
})
|
||||
|
||||
it('closes on a backdrop press but not on a press over the image', () => {
|
||||
const onClose = vi.fn()
|
||||
const view = render(<ImageLightbox src="blob:original" alt="原图" labels={labels} onClose={onClose} />)
|
||||
fireEvent.mouseDown(view.getByRole('img'))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
fireEvent.mouseDown(view.getByRole('dialog', { name: '原图预览' }))
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
12
packages/client/ui-attachment/tests/invariant.spec.ts
Normal file
12
packages/client/ui-attachment/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import * as AttachmentInvariant from '@deepseek-ai/dsh-client-ui-attachment/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
describe('invariant companion', () => {
|
||||
it('registers under the package name with an empty installer', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await expect(ctx.plugin(AttachmentInvariant).await()).resolves.toBeDefined()
|
||||
})
|
||||
})
|
||||
103
packages/client/ui-attachment/tests/message-image.spec.tsx
Normal file
103
packages/client/ui-attachment/tests/message-image.spec.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import { ImageGallery, MessageImage } from '../src/MessageImage.tsx'
|
||||
import type { MessageImageLabels } from '../src/MessageImage.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const labels: MessageImageLabels = {
|
||||
image: '图片',
|
||||
open: '查看原图',
|
||||
openNamed: label => `${label},点击查看原图`,
|
||||
loading: '图片加载中…',
|
||||
loadFailed: '图片加载失败,点击重试',
|
||||
lightbox: { dialog: '原图预览', close: '关闭原图预览' },
|
||||
}
|
||||
|
||||
const attachment = {
|
||||
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
|
||||
mediaType: 'image/png' as const,
|
||||
bytes: 68,
|
||||
width: 640,
|
||||
height: 320,
|
||||
name: 'history.png',
|
||||
}
|
||||
|
||||
describe('MessageImage', () => {
|
||||
it('loads a session-authorized URL, bounds the thumbnail, and clicks into the original', async () => {
|
||||
const load = vi.fn().mockResolvedValue('blob:history')
|
||||
const view = render(<MessageImage attachment={attachment} load={load} labels={labels} />)
|
||||
const frame = view.getByRole('button', { name: 'history.png,点击查看原图' })
|
||||
expect(frame.getAttribute('style')).toContain('width: 240px')
|
||||
expect(frame.getAttribute('style')).toContain('height: 120px')
|
||||
expect(frame.getAttribute('title')).toBe('查看原图')
|
||||
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
|
||||
expect(load).toHaveBeenCalledWith(attachment)
|
||||
fireEvent.click(frame)
|
||||
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
|
||||
fireEvent.click(view.getByRole('button', { name: '关闭原图预览' }))
|
||||
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores a click while the thumbnail is still loading', () => {
|
||||
const load = vi.fn(() => new Promise<string>(() => {}))
|
||||
const view = render(<MessageImage attachment={attachment} load={load} labels={labels} />)
|
||||
const frame = view.getByRole('button', { name: 'history.png,点击查看原图' })
|
||||
expect(view.getByText('图片加载中…')).toBeTruthy()
|
||||
fireEvent.click(frame)
|
||||
expect(view.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to the image label for an unnamed attachment', async () => {
|
||||
const { name: _named, ...unnamed } = attachment
|
||||
const load = vi.fn().mockResolvedValue('blob:unnamed')
|
||||
const view = render(<MessageImage attachment={unnamed} load={load} labels={labels} />)
|
||||
await waitFor(() => { expect(view.getByAltText('图片')).toBeTruthy() })
|
||||
expect(view.getByRole('button', { name: '图片,点击查看原图' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('surfaces a retry control when durable bytes cannot be read, including a failed retry', async () => {
|
||||
const load = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
.mockRejectedValueOnce(new Error('still offline'))
|
||||
.mockResolvedValueOnce('blob:retry')
|
||||
const view = render(<MessageImage attachment={attachment} load={load} labels={labels} />)
|
||||
const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
|
||||
fireEvent.click(retry)
|
||||
const retryAgain = await view.findByRole('button', { name: '图片加载失败,点击重试' })
|
||||
fireEvent.click(retryAgain)
|
||||
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
|
||||
expect(load).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('ignores a load settling after unmount', async () => {
|
||||
let resolve: ((url: string) => void) | undefined
|
||||
const load = vi.fn(() => new Promise<string>((r) => { resolve = r }))
|
||||
const view = render(<MessageImage attachment={attachment} load={load} labels={labels} />)
|
||||
view.unmount()
|
||||
resolve?.('blob:late')
|
||||
await Promise.resolve()
|
||||
let reject: ((error: Error) => void) | undefined
|
||||
const failing = vi.fn(() => new Promise<string>((_r, rej) => { reject = rej }))
|
||||
const second = render(<MessageImage attachment={attachment} load={failing} labels={labels} />)
|
||||
second.unmount()
|
||||
reject?.(new Error('late failure'))
|
||||
await Promise.resolve()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ImageGallery', () => {
|
||||
it('renders nothing without images and an aligned wrapping group with them', async () => {
|
||||
const load = vi.fn().mockResolvedValue('blob:gallery')
|
||||
const empty = render(<ImageGallery images={[]} load={load} align="start" labels={labels} />)
|
||||
expect(empty.container.firstChild).toBeNull()
|
||||
const view = render(
|
||||
<ImageGallery images={[{ attachment }, { attachment }]} load={load} align="end" labels={labels} />,
|
||||
)
|
||||
expect(view.container.querySelector('[data-align="end"]')).not.toBeNull()
|
||||
await waitFor(() => { expect(view.getAllByAltText('history.png')).toHaveLength(2) })
|
||||
})
|
||||
})
|
||||
21
packages/client/ui-attachment/tsconfig.json
Normal file
21
packages/client/ui-attachment/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../attachment/attachment"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
}
|
||||
]
|
||||
}
|
||||
35
packages/client/ui-attachment/tsdown.config.ts
Normal file
35
packages/client/ui-attachment/tsdown.config.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { clientOnly } from '../tsdown.client.ts'
|
||||
|
||||
// TODO(client-atoms): verbatim copy of ui-primitives/tsdown.config.ts (only
|
||||
// the package differs). On a third atoms package, extract a shared css-stub
|
||||
// client-library preset in packages/client/tsdown.client.ts instead of a
|
||||
// fourth copy.
|
||||
/**
|
||||
* ui-attachment is browser-only, but its lib bundle IS imported under plain
|
||||
* Node because the web shell is a lib (dsh-client-web's lib chain reaches
|
||||
* this package). CSS imports are therefore stubbed to empty modules instead
|
||||
* of externalized — the hashed class maps only matter in bundler contexts
|
||||
* (loader module table / vite source paths), which compile src directly and
|
||||
* never read lib.
|
||||
*/
|
||||
export default clientOnly([{
|
||||
entry: ['lib/types/index.js', 'lib/types/invariant.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'neutral',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
plugins: [{
|
||||
name: 'dsh-css-stub',
|
||||
resolveId(source: string) {
|
||||
if (!source.endsWith('.css')) return null
|
||||
return `\0dsh-css-stub:${source}.mjs`
|
||||
},
|
||||
load(id: string) {
|
||||
if (!id.startsWith('\0dsh-css-stub:')) return null
|
||||
return 'export default {};'
|
||||
},
|
||||
}],
|
||||
}])
|
||||
@@ -61,6 +61,7 @@
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
@@ -84,6 +85,7 @@
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
|
||||
@@ -13,8 +13,9 @@ import { memo, useMemo } from 'react'
|
||||
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { ImageGallery, type ImageLoader } from '@deepseek-ai/dsh-client-ui-attachment'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { ImageGallery, type ImageLoader } from './MessageImage.tsx'
|
||||
import { messageImageLabels } from '../image-labels.ts'
|
||||
import { ReasoningRow } from './ReasoningRow.tsx'
|
||||
import css from './AssistantMarkdown.module.css'
|
||||
|
||||
@@ -62,7 +63,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
/>
|
||||
)
|
||||
case 'reasoning': return <ReasoningRow key={i} text={block.text} running={streaming && i === last} t={t} />
|
||||
case 'image': return <ImageGallery key={i} images={[block]} load={imageLoader} align="start" t={t} />
|
||||
case 'image': return <ImageGallery key={i} images={[block]} load={imageLoader} align="start" labels={messageImageLabels(t)} />
|
||||
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
|
||||
case 'tool-call': return null
|
||||
default: return (
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { ImageLightbox } from '../skeleton/ImageLightbox.tsx'
|
||||
import css from './MessageImage.module.css'
|
||||
|
||||
/** Loads a session-authorized durable image URL. */
|
||||
export type ImageLoader = (attachment: ImageAttachmentRef) => Promise<string>
|
||||
|
||||
/** Compact history renderer with retryable loading and double-click original preview. */
|
||||
export function MessageImage({ attachment, load, t }: {
|
||||
attachment: ImageAttachmentRef
|
||||
load: ImageLoader
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
const [src, setSrc] = useState<string | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const close = useCallback(() => { setOpen(false) }, [])
|
||||
const size = useMemo(() => {
|
||||
const scale = Math.min(1, 240 / attachment.width, 240 / attachment.height)
|
||||
return { width: Math.max(1, Math.round(attachment.width * scale)), height: Math.max(1, Math.round(attachment.height * scale)) }
|
||||
}, [attachment.height, attachment.width])
|
||||
|
||||
const request = useCallback(() => {
|
||||
setError(false)
|
||||
setSrc(null)
|
||||
void load(attachment).then(setSrc).catch(() => { setError(true) })
|
||||
}, [attachment, load])
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
setError(false)
|
||||
void load(attachment).then((url) => { if (live) setSrc(url) }).catch(() => { if (live) setError(true) })
|
||||
return () => { live = false }
|
||||
}, [attachment, load])
|
||||
|
||||
const label = attachment.name ?? t('image.label')
|
||||
if (error) return <button type="button" className={css.error} onClick={request}>{t('image.loadFailed')}</button>
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={css.frame}
|
||||
style={size}
|
||||
title={t('image.openOriginal')}
|
||||
aria-label={t('image.openOriginalLabel', { label })}
|
||||
onDoubleClick={() => { if (src !== null) setOpen(true) }}
|
||||
>
|
||||
{src === null ? <span className={css.loading}>{t('image.loading')}</span> : <img src={src} alt={label} />}
|
||||
</button>
|
||||
{open && src !== null && <ImageLightbox src={src} alt={label} onClose={close} t={t} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Wrapping image group shared by user and assistant history. */
|
||||
export function ImageGallery({ images, load, align, t }: {
|
||||
images: readonly { attachment: ImageAttachmentRef }[]
|
||||
load: ImageLoader
|
||||
align: 'start' | 'end'
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
if (images.length === 0) return null
|
||||
return (
|
||||
<div className={css.gallery} data-align={align}>
|
||||
{images.map((image, index) => (
|
||||
<MessageImage key={`${image.attachment.attachmentId}:${index}`} {...image} load={load} t={t} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,10 +10,11 @@ import type {
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { ImageGallery, type ImageLoader } from '@deepseek-ai/dsh-client-ui-attachment'
|
||||
import { messageImageLabels } from '../image-labels.ts'
|
||||
import { CompactionItem } from './CompactionItem.tsx'
|
||||
import { ContextInjectionRow } from './ContextInjectionRow.tsx'
|
||||
import { MessageIconActions } from './MessageIconActions.tsx'
|
||||
import { ImageGallery, type ImageLoader } from './MessageImage.tsx'
|
||||
import css from './MessageItem.module.css'
|
||||
|
||||
type UserImage = Extract<UserMessageNode['content'][number], { type: 'image' }>
|
||||
@@ -177,7 +178,7 @@ function UserStyleBubble({
|
||||
return (
|
||||
<div className={css.userRow} data-pending-steering={pending || undefined} data-time-hover-root>
|
||||
<div className={css.userStack}>
|
||||
<ImageGallery images={images} load={imageLoader} align="end" t={t} />
|
||||
<ImageGallery images={images} load={imageLoader} align="end" labels={messageImageLabels(t)} />
|
||||
{showBubble && <div className={css.bubble}>
|
||||
{projectUserText(text)}
|
||||
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
|
||||
|
||||
48
packages/client/ui-conversation/src/client/image-labels.ts
Normal file
48
packages/client/ui-conversation/src/client/image-labels.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/** Bridges the `conversation` locale namespace to the zero-cordis attachment
|
||||
* atoms' label props (`@deepseek-ai/dsh-client-ui-attachment` reads no
|
||||
* application state; owners resolve every string). */
|
||||
|
||||
import type {
|
||||
AttachmentRailLabels, ImageLightboxLabels, MessageImageLabels,
|
||||
} from '@deepseek-ai/dsh-client-ui-attachment'
|
||||
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ConversationKey } from './locales.ts'
|
||||
|
||||
/**
|
||||
* Resolve the original-image lightbox strings.
|
||||
* @param t - the conversation-namespace translate.
|
||||
* @returns the lightbox dialog and close-control labels.
|
||||
*/
|
||||
export function lightboxLabels(t: Translate<ConversationKey>): ImageLightboxLabels {
|
||||
return { dialog: t('image.preview'), close: t('image.closePreview') }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the chat-history image strings.
|
||||
* @param t - the conversation-namespace translate.
|
||||
* @returns the message-image labels including the forwarded lightbox strings.
|
||||
*/
|
||||
export function messageImageLabels(t: Translate<ConversationKey>): MessageImageLabels {
|
||||
return {
|
||||
image: t('image.label'),
|
||||
open: t('image.openOriginal'),
|
||||
openNamed: label => t('image.openOriginalLabel', { label }),
|
||||
loading: t('image.loading'),
|
||||
loadFailed: t('image.loadFailed'),
|
||||
lightbox: lightboxLabels(t),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the composer draft-image rail strings.
|
||||
* @param t - the conversation-namespace translate.
|
||||
* @returns the rail group, open-tooltip, and paging-arrow labels.
|
||||
*/
|
||||
export function attachmentRailLabels(t: Translate<ConversationKey>): AttachmentRailLabels {
|
||||
return {
|
||||
group: t('image.pending'),
|
||||
open: t('image.openOriginal'),
|
||||
scrollLeft: t('image.scrollLeft'),
|
||||
scrollRight: t('image.scrollRight'),
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,11 @@ export const zh = {
|
||||
'input.accessMode': '访问模式,当前:{name}',
|
||||
'image.dropHint': '松开以添加图片',
|
||||
'image.pending': '待发送图片',
|
||||
'image.openOriginal': '双击查看原图',
|
||||
'image.openOriginalLabel': '{label},双击查看原图',
|
||||
'image.openOriginal': '查看原图',
|
||||
'image.openOriginalLabel': '{label},点击查看原图',
|
||||
'image.remove': '移除图片 {name}',
|
||||
'image.scrollLeft': '向左滚动图片',
|
||||
'image.scrollRight': '向右滚动图片',
|
||||
'image.original': '原图',
|
||||
'image.label': '图片',
|
||||
'image.loadFailed': '图片加载失败,点击重试',
|
||||
@@ -184,9 +186,11 @@ export const en = {
|
||||
'input.accessMode': 'Access mode, current: {name}',
|
||||
'image.dropHint': 'Drop to add images',
|
||||
'image.pending': 'Pending images',
|
||||
'image.openOriginal': 'Double-click to view original',
|
||||
'image.openOriginalLabel': '{label}, double-click to view original',
|
||||
'image.openOriginal': 'View original',
|
||||
'image.openOriginalLabel': '{label}, click to view original',
|
||||
'image.remove': 'Remove image {name}',
|
||||
'image.scrollLeft': 'Scroll images left',
|
||||
'image.scrollRight': 'Scroll images right',
|
||||
'image.original': 'Original image',
|
||||
'image.label': 'Image',
|
||||
'image.loadFailed': 'Image failed to load; click to retry',
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import css from './ImageLightbox.module.css'
|
||||
|
||||
/** Document-level original-image preview opened by an explicit double-click. */
|
||||
export function ImageLightbox({ src, alt, onClose, t }: {
|
||||
src: string
|
||||
alt: string
|
||||
onClose: () => void
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
const closeRef = useRef<HTMLButtonElement | null>(null)
|
||||
const restoreRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
restoreRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
closeRef.current?.focus()
|
||||
const onKeyDown = (event: globalThis.KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
restoreRef.current?.focus()
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css.backdrop}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('image.preview')}
|
||||
onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}
|
||||
>
|
||||
<img className={css.image} src={src} alt={alt} />
|
||||
<button ref={closeRef} type="button" className={css.close} aria-label={t('image.closePreview')} onClick={onClose}>×</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -35,22 +35,6 @@
|
||||
padding: 0 var(--dsh-composer-side-clearance);
|
||||
}
|
||||
|
||||
.error,
|
||||
.status {
|
||||
width: 100%;
|
||||
max-width: var(--dsh-composer-card-max-width);
|
||||
margin-bottom: 6px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.status {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.notice {
|
||||
width: 100%;
|
||||
max-width: var(--dsh-composer-card-max-width);
|
||||
@@ -68,11 +52,6 @@
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.error {
|
||||
background: var(--dsw-alias-interactive-bg-hover-danger);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.card {
|
||||
box-sizing: border-box;
|
||||
position: relative; /* overlay anchor positioning context */
|
||||
@@ -162,55 +141,13 @@
|
||||
padding: 10px 12px 0;
|
||||
}
|
||||
|
||||
/* Rail seat: the card's top padding (10px) plus this 4px matches DeepSeek
|
||||
Chat's spacing above the thumbnails; the card's 12px flex gap owns the space
|
||||
below. The rail itself (arrows, hidden scrollbar, card geometry) is the
|
||||
ui-attachment atom's. */
|
||||
.attachments {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 12px 12px 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.attachment {
|
||||
position: relative;
|
||||
flex: 0 0 72px;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.thumbnail img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.remove {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--dsw-specific-input-major);
|
||||
border-radius: 999px;
|
||||
background: var(--dsw-alias-label-primary);
|
||||
color: var(--dsw-specific-input-major);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 4px 12px 0;
|
||||
}
|
||||
|
||||
/* Floating overlay anchor (menu / popupSelect shell): entries position
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ChangeEvent, DragEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconPlusOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import {
|
||||
IconPlusOutline16, IconWarningOutline16, Toast, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { AttachmentRail, ImageLightbox } from '@deepseek-ai/dsh-client-ui-attachment'
|
||||
import type { AttachmentRailItem } from '@deepseek-ai/dsh-client-ui-attachment'
|
||||
// Type-only: the `plan` projection key merge (the TodoDock posture — the
|
||||
// composer reads a host-computed value; the domain owns the key).
|
||||
import type {} from '@deepseek-ai/dsh-plan-mode/client'
|
||||
@@ -19,18 +23,17 @@ import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ComposerAttachment, ComposerBarProps } from '../contract/slots.ts'
|
||||
import { deriveDecorations } from '../input/decorations.ts'
|
||||
import type { DraftDecorations } from '../input/decorations.ts'
|
||||
import { attachmentRailLabels, lightboxLabels } from '../image-labels.ts'
|
||||
import { ContextMeter } from './ContextMeter.tsx'
|
||||
import { ImageLightbox } from './ImageLightbox.tsx'
|
||||
import { PermissionSelect } from './PermissionSelect.tsx'
|
||||
import css from './InputBar.module.css'
|
||||
|
||||
/** Decoration product of the no-session state (no machine, empty draft). */
|
||||
const INERT_DECORATIONS: DraftDecorations = { token: null, chips: [], textRefs: [], hint: null }
|
||||
|
||||
/** Prompt failure surface (derived from promptError). */
|
||||
export interface InputBarError {
|
||||
op: 'send' | 'stop'
|
||||
message: string
|
||||
/** Rail thumbnail carrying its source attachment for the open/remove callbacks. */
|
||||
interface ComposerRailItem extends AttachmentRailItem {
|
||||
attachment: ComposerAttachment
|
||||
}
|
||||
|
||||
export type InputBarProps = ComposerBarProps
|
||||
@@ -56,12 +59,6 @@ export function InputBar({
|
||||
const planActive = useProjection('plan', plan => plan !== undefined && (plan.pending ? !plan.active : plan.active))
|
||||
// Absent (undefined: no frame yet) and cleared (null) both mean no goal.
|
||||
const hasGoal = useProjection('goal', goal => goal != null)
|
||||
// Prompt failures are ordinary failures (no create/attach transaction
|
||||
// exists anymore): the strip renders promptError, the draft stays in the
|
||||
// machine, and the user resubmits.
|
||||
const error: InputBarError | null = promptError === null
|
||||
? null
|
||||
: { op: promptError.op, message: `${promptError.error.message} (${promptError.error.code})` }
|
||||
// Session-maybe: the machine faces are absent together while no session is
|
||||
// current; the bar renders the same DOM inert instead of a parallel tree.
|
||||
const live = input !== undefined && keyboard !== undefined && inputActions !== undefined
|
||||
@@ -73,8 +70,26 @@ export function InputBar({
|
||||
const empty = draft.trim() === '' && attachments.length === 0
|
||||
const [preview, setPreview] = useState<ComposerAttachment | null>(null)
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const [dropError, setDropError] = useState<string | null>(null)
|
||||
// Transient error banner (image-intake rejections and prompt failures): the
|
||||
// seq keys the Toast so an identical repeated message restarts the
|
||||
// hold-then-fade cycle instead of silently reusing the faded one.
|
||||
const [toast, setToast] = useState<{ seq: number; text: string } | null>(null)
|
||||
const toastSeq = useRef(0)
|
||||
const showToast = useCallback((text: string) => {
|
||||
toastSeq.current += 1
|
||||
setToast({ seq: toastSeq.current, text })
|
||||
}, [])
|
||||
const dismissToast = useCallback(() => { setToast(null) }, [])
|
||||
// Prompt failures are ordinary failures (no create/attach transaction exists
|
||||
// anymore): the toast announces promptError, the draft stays in the machine,
|
||||
// and the user resubmits. A remount over a session whose machine still holds
|
||||
// an unresolved promptError deliberately re-announces it once — the failure
|
||||
// is still pending, and a transient banner is its only surface.
|
||||
useEffect(() => {
|
||||
if (promptError !== null) showToast(`${promptError.error.message} (${promptError.error.code})`)
|
||||
}, [promptError, showToast])
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
const cardRef = useRef<HTMLDivElement | null>(null)
|
||||
const dragDepthRef = useRef(0)
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const mirrorRef = useRef<HTMLDivElement | null>(null)
|
||||
@@ -369,7 +384,10 @@ export function InputBar({
|
||||
.filter(item => item.kind === 'file')
|
||||
.map(item => item.getAsFile())
|
||||
.filter((file): file is File => file !== null)
|
||||
if (files.length > 0 && addImages !== undefined) setDropError(addImages(files))
|
||||
if (files.length > 0 && addImages !== undefined) {
|
||||
const rejected = addImages(files)
|
||||
if (rejected !== null) showToast(rejected)
|
||||
}
|
||||
const text = e.clipboardData.getData('text/plain')
|
||||
if (text === '') {
|
||||
if (files.length > 0) e.preventDefault()
|
||||
@@ -393,7 +411,6 @@ export function InputBar({
|
||||
event.preventDefault()
|
||||
if (locked || machineBusy || addImages === undefined) return
|
||||
dragDepthRef.current += 1
|
||||
setDropError(null)
|
||||
setDragActive(true)
|
||||
}
|
||||
|
||||
@@ -416,11 +433,24 @@ export function InputBar({
|
||||
setDragActive(false)
|
||||
if (locked || machineBusy || addImages === undefined) return
|
||||
const dropped = [...event.dataTransfer.files]
|
||||
if (dropped.length > 0) setDropError(addImages(dropped))
|
||||
if (dropped.length > 0) {
|
||||
const rejected = addImages(dropped)
|
||||
if (rejected !== null) showToast(rejected)
|
||||
}
|
||||
}
|
||||
|
||||
const closePreview = useCallback(() => { setPreview(null) }, [])
|
||||
|
||||
// Rail thumbnails with their strings resolved here: the attachment atoms are
|
||||
// zero-cordis and read no locale.
|
||||
const railItems = useMemo<ComposerRailItem[]>(() => attachments.map(attachment => ({
|
||||
id: attachment.id,
|
||||
previewUrl: attachment.previewUrl,
|
||||
alt: attachment.file.name || t('image.pending'),
|
||||
removeLabel: t('image.remove', { name: attachment.file.name }),
|
||||
attachment,
|
||||
})), [attachments, t])
|
||||
|
||||
const onSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>): void => {
|
||||
// Any caret/selection gesture ends a live paste attempt (the machine
|
||||
// cannot observe DOM selection). Cheap no-op when none is live.
|
||||
@@ -543,10 +573,14 @@ export function InputBar({
|
||||
|
||||
return (
|
||||
<div className={clsx(css.root, variant === 'hero' && css.hero)}>
|
||||
{error !== null && (
|
||||
<div className={css.error} role="alert">
|
||||
{error.message}
|
||||
</div>
|
||||
{toast !== null && (
|
||||
<Toast
|
||||
key={toast.seq}
|
||||
text={toast.text}
|
||||
icon={<IconWarningOutline16 />}
|
||||
anchor={cardRef.current}
|
||||
onDone={dismissToast}
|
||||
/>
|
||||
)}
|
||||
{notice !== null && (
|
||||
<div className={clsx(css.notice, notice.level === 'error' && css.noticeError)} role="status">
|
||||
@@ -558,8 +592,8 @@ export function InputBar({
|
||||
their pointer events), so the WHOLE capsule is the pick target.
|
||||
pointerdown stops here so the Menu's outside-close cannot race the
|
||||
click's reopen (close-then-open flickers the chip's open echo). */}
|
||||
{dropError !== null && <div className={css.error} role="alert">{dropError}</div>}
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={clsx(css.card, workspaceTrigger && css.cardWorkspaceTrigger, dragActive && css.dragActive)}
|
||||
data-composer-card
|
||||
onClick={workspaceTrigger ? onRequestWorkspace : undefined}
|
||||
@@ -572,29 +606,14 @@ export function InputBar({
|
||||
{dragActive && <div className={css.dropHint} role="status">{t('image.dropHint')}</div>}
|
||||
{overlay !== undefined && <div className={css.overlayAnchor}>{overlay}</div>}
|
||||
{accessory !== undefined && <div className={css.accessory}>{accessory}</div>}
|
||||
{attachments.length > 0 && (
|
||||
<div className={css.attachments} role="group" aria-label={t('image.pending')}>
|
||||
{attachments.map(attachment => (
|
||||
<div key={attachment.id} className={css.attachment}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.thumbnail}
|
||||
title={t('image.openOriginal')}
|
||||
onDoubleClick={() => { setPreview(attachment) }}
|
||||
>
|
||||
<img src={attachment.previewUrl} alt={attachment.file.name || t('image.pending')} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.remove}
|
||||
aria-label={t('image.remove', { name: attachment.file.name })}
|
||||
onClick={() => {
|
||||
setDropError(null)
|
||||
removeImage?.(attachment.id)
|
||||
}}
|
||||
>×</button>
|
||||
</div>
|
||||
))}
|
||||
{railItems.length > 0 && (
|
||||
<div className={css.attachments}>
|
||||
<AttachmentRail
|
||||
items={railItems}
|
||||
labels={attachmentRailLabels(t)}
|
||||
onOpen={(item) => { setPreview(item.attachment) }}
|
||||
onRemove={(item) => { removeImage?.(item.attachment.id) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* One scrollport, two text layers. The hidden mirror renders draft+'\n' and stretches the
|
||||
@@ -628,10 +647,7 @@ export function InputBar({
|
||||
? t('placeholder.steerQueue')
|
||||
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
|
||||
rows={2}
|
||||
onChange={(event) => {
|
||||
setDropError(null)
|
||||
onChange(event)
|
||||
}}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onSelect={onSelect}
|
||||
onCopy={(e) => { onCopyOrCut(e, false) }}
|
||||
@@ -712,8 +728,8 @@ export function InputBar({
|
||||
<ImageLightbox
|
||||
src={preview.previewUrl}
|
||||
alt={preview.file.name || t('image.original')}
|
||||
labels={lightboxLabels(t)}
|
||||
onClose={closePreview}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{footer}
|
||||
|
||||
82
packages/client/ui-conversation/tests/image-labels.spec.tsx
Normal file
82
packages/client/ui-conversation/tests/image-labels.spec.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
// @vitest-environment jsdom
|
||||
// The conversation-side bridge to the ui-attachment atoms: dictionary strings
|
||||
// flow through image-labels into the gallery, and assistant images keep their
|
||||
// block position between text blocks.
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
const enT = makeTranslate(en, commonZh)
|
||||
|
||||
const attachment = {
|
||||
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
|
||||
mediaType: 'image/png' as const,
|
||||
bytes: 68,
|
||||
width: 640,
|
||||
height: 320,
|
||||
name: 'history.png',
|
||||
}
|
||||
|
||||
describe('assistant images through the label bridge', () => {
|
||||
it('resolves zh dictionary strings and opens the lightbox on a single click', async () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[{ kind: 'image', attachment }]}
|
||||
streaming={false}
|
||||
loadImage={() => Promise.resolve('blob:history')}
|
||||
/>,
|
||||
)
|
||||
const frame = await view.findByRole('button', { name: 'history.png,点击查看原图' })
|
||||
expect(frame.getAttribute('title')).toBe('查看原图')
|
||||
await view.findByAltText('history.png')
|
||||
fireEvent.click(frame)
|
||||
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
|
||||
fireEvent.click(view.getByRole('button', { name: '关闭原图预览' }))
|
||||
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves the active English dictionary', async () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown
|
||||
t={enT}
|
||||
blocks={[{ kind: 'image', attachment }]}
|
||||
streaming={false}
|
||||
loadImage={() => Promise.resolve('blob:history')}
|
||||
/>,
|
||||
)
|
||||
const frame = await view.findByRole('button', { name: 'history.png, click to view original' })
|
||||
await view.findByAltText('history.png')
|
||||
fireEvent.click(frame)
|
||||
expect(view.getByRole('dialog', { name: 'Original image preview' })).toBeTruthy()
|
||||
expect(view.getByRole('button', { name: 'Close original image preview' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps assistant images at their original position between text blocks', async () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[
|
||||
{ kind: 'text', text: 'before' },
|
||||
{ kind: 'image', attachment },
|
||||
{ kind: 'text', text: 'after' },
|
||||
]}
|
||||
streaming={false}
|
||||
loadImage={() => Promise.resolve('blob:middle')}
|
||||
/>,
|
||||
)
|
||||
const image = await view.findByAltText('history.png')
|
||||
const before = view.getByText('before')
|
||||
const after = view.getByText('after')
|
||||
expect(before.compareDocumentPosition(image) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
|
||||
expect(image.compareDocumentPosition(after) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -237,15 +237,49 @@ describe('image draft rail', () => {
|
||||
expect(removeImage).toHaveBeenCalledWith('draft-1')
|
||||
})
|
||||
|
||||
it('opens the original image on double-click and closes it with Escape', () => {
|
||||
it('opens the original image on a single click and closes it with Escape', () => {
|
||||
const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
|
||||
const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' }
|
||||
const { view } = bench({ attachments: [attachment] })
|
||||
fireEvent.doubleClick(view.getByTitle('双击查看原图'))
|
||||
fireEvent.click(view.getByTitle('查看原图'))
|
||||
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
|
||||
})
|
||||
|
||||
it('announces an image-intake rejection as a fading toast, repeatable for the same reason', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const addImages = vi.fn(() => '不支持的图片格式:text/plain')
|
||||
const { view, textarea } = bench({ addImages })
|
||||
const paste = () => {
|
||||
fireEvent.paste(textarea, {
|
||||
clipboardData: {
|
||||
items: [{ kind: 'file', type: 'text/plain', getAsFile: () => new File(['x'], 'note.txt', { type: 'text/plain' }) }],
|
||||
getData: () => '',
|
||||
},
|
||||
})
|
||||
}
|
||||
paste()
|
||||
expect(view.getByRole('alert').textContent).toContain('不支持的图片格式:text/plain')
|
||||
act(() => { vi.advanceTimersByTime(4000) })
|
||||
expect(view.queryByRole('alert')).toBeNull()
|
||||
// The identical rejection re-announces: the toast is keyed per show.
|
||||
paste()
|
||||
expect(view.getByRole('alert').textContent).toContain('不支持的图片格式:text/plain')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('announces a rejected drop through the same toast', () => {
|
||||
const addImages = vi.fn(() => '图片读取服务不可用')
|
||||
const { view } = bench({ addImages })
|
||||
const card = view.container.querySelector('[class*="card"]')!
|
||||
const dataTransfer = { types: ['Files'], files: [new File([Uint8Array.of(1)], 'x.png', { type: 'image/png' })], dropEffect: 'none' }
|
||||
fireEvent.drop(card, { dataTransfer })
|
||||
expect(view.getByRole('alert').textContent).toContain('图片读取服务不可用')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Enter semantics', () => {
|
||||
@@ -941,10 +975,19 @@ describe('insertText (scoped event body)', () => {
|
||||
})
|
||||
|
||||
describe('strips and variants', () => {
|
||||
it('derives the failure strip from promptError (ordinary failure — no transaction UI, no Retry)', () => {
|
||||
const send = bench({ promptError: { op: 'send', error: { code: 'agent-busy', message: 'boom', details: { reason: 'boom' } } } })
|
||||
expect(send.view.container.querySelector('[role="alert"]')?.textContent).toBe('boom (agent-busy)')
|
||||
expect(send.view.queryByRole('button', { name: 'Retry' })).toBeNull()
|
||||
it('announces promptError as a fading toast (ordinary failure — no transaction UI, no Retry)', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const send = bench({ promptError: { op: 'send', error: { code: 'agent-busy', message: 'boom', details: { reason: 'boom' } } } })
|
||||
// The toast body-portals (transformed ancestors must not trap it), so
|
||||
// queries go through the view's document-bound helpers.
|
||||
expect(send.view.getByRole('alert').textContent).toContain('boom (agent-busy)')
|
||||
expect(send.view.queryByRole('button', { name: 'Retry' })).toBeNull()
|
||||
act(() => { vi.advanceTimersByTime(4000) })
|
||||
expect(send.view.queryByRole('alert')).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('renders the notice strip from the machine notice store', () => {
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { MessageImage } from '../src/client/chat/MessageImage.tsx'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
const enT = makeTranslate(en, commonZh)
|
||||
|
||||
const attachment = {
|
||||
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
|
||||
mediaType: 'image/png' as const,
|
||||
bytes: 68,
|
||||
width: 640,
|
||||
height: 320,
|
||||
name: 'history.png',
|
||||
}
|
||||
|
||||
describe('MessageImage', () => {
|
||||
it('loads a session-authorized URL, bounds the thumbnail, and double-clicks into the original', async () => {
|
||||
const load = vi.fn().mockResolvedValue('blob:history')
|
||||
const view = render(<MessageImage attachment={attachment} load={load} t={t} />)
|
||||
const frame = view.getByRole('button', { name: 'history.png,双击查看原图' })
|
||||
expect(frame.getAttribute('style')).toContain('width: 240px')
|
||||
expect(frame.getAttribute('style')).toContain('height: 120px')
|
||||
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
|
||||
expect(load).toHaveBeenCalledWith(attachment)
|
||||
fireEvent.doubleClick(frame)
|
||||
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
|
||||
fireEvent.click(view.getByRole('button', { name: '关闭原图预览' }))
|
||||
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
|
||||
})
|
||||
|
||||
it('surfaces a retry control when durable bytes cannot be read', async () => {
|
||||
const load = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
.mockResolvedValueOnce('blob:retry')
|
||||
const view = render(<MessageImage attachment={attachment} load={load} t={t} />)
|
||||
const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
|
||||
fireEvent.click(retry)
|
||||
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
|
||||
expect(load).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('renders image controls from the active English dictionary', async () => {
|
||||
const load = vi.fn().mockResolvedValue('blob:history')
|
||||
const view = render(<MessageImage attachment={attachment} load={load} t={enT} />)
|
||||
const frame = view.getByRole('button', { name: 'history.png, double-click to view original' })
|
||||
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
|
||||
fireEvent.doubleClick(frame)
|
||||
expect(view.getByRole('dialog', { name: 'Original image preview' })).toBeTruthy()
|
||||
expect(view.getByRole('button', { name: 'Close original image preview' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps assistant images at their original position between text blocks', async () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[
|
||||
{ kind: 'text', text: 'before' },
|
||||
{ kind: 'image', attachment },
|
||||
{ kind: 'text', text: 'after' },
|
||||
]}
|
||||
streaming={false}
|
||||
loadImage={() => Promise.resolve('blob:middle')}
|
||||
/>,
|
||||
)
|
||||
const image = await view.findByAltText('history.png')
|
||||
const before = view.getByText('before')
|
||||
const after = view.getByText('after')
|
||||
expect(before.compareDocumentPosition(image) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
|
||||
expect(image.compareDocumentPosition(after) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,9 @@
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../ui-attachment"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
* ToggleButton) shows both: model name + effort in the caption tone.
|
||||
* Data and submission ride the SAME per-session ModelDirectory as the
|
||||
* /model popup; exact-model reasoning metadata and the selected effort come
|
||||
* from the Host rather than a client-owned vocabulary.
|
||||
* from the Host rather than a client-owned vocabulary. A rejected selection
|
||||
* announces through the shared transient Toast anchored to the composer
|
||||
* card; the in-menu strip with Retry remains the catalog-load surface.
|
||||
*/
|
||||
import {
|
||||
useEffect, useId, useMemo, useRef, useState, useSyncExternalStore,
|
||||
@@ -17,6 +19,7 @@ import clsx from 'clsx'
|
||||
import type { ModelReasoningEffort, ModelSelection } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import {
|
||||
IconCheckOutline16, IconChevronDownOutline14, IconChevronRightOutline14,
|
||||
IconWarningOutline16, Toast,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ModelSelectInjected } from './slots.ts'
|
||||
@@ -49,6 +52,13 @@ export function ModelSelect(
|
||||
)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [pane, setPane] = useState<Pane>('root')
|
||||
// The in-menu error strip serves catalog loads (its Retry re-runs the
|
||||
// load); a rejected SELECTION announces through the transient toast
|
||||
// instead, so the strip renders only while the latest failure-capable
|
||||
// action was a load.
|
||||
const lastActionRef = useRef<'load' | 'select'>('load')
|
||||
const [toast, setToast] = useState<{ seq: number; text: string } | null>(null)
|
||||
const toastSeq = useRef(0)
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null)
|
||||
const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||
@@ -92,9 +102,17 @@ export function ModelSelect(
|
||||
], [reasoning, t])
|
||||
const busy = state.status === 'selecting'
|
||||
|
||||
const reload = (): void => {
|
||||
lastActionRef.current = 'load'
|
||||
load()
|
||||
}
|
||||
|
||||
// Mount-time load resolves the trigger label; every open refreshes.
|
||||
useEffect(() => {
|
||||
if (available) load()
|
||||
if (available) {
|
||||
lastActionRef.current = 'load'
|
||||
load()
|
||||
}
|
||||
}, [available, load])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -111,7 +129,7 @@ export function ModelSelect(
|
||||
const show = (): void => {
|
||||
setPane('root')
|
||||
setOpen(true)
|
||||
load()
|
||||
reload()
|
||||
}
|
||||
|
||||
const close = (restoreFocus = false): void => {
|
||||
@@ -148,14 +166,25 @@ export function ModelSelect(
|
||||
close()
|
||||
}
|
||||
|
||||
const settleSelection = (accepted: boolean): void => {
|
||||
if (accepted) {
|
||||
if (rootRef.current !== null) close(true)
|
||||
return
|
||||
}
|
||||
const message = directory.getSnapshot().error
|
||||
if (message !== null) {
|
||||
toastSeq.current += 1
|
||||
setToast({ seq: toastSeq.current, text: t('error.action', { message }) })
|
||||
}
|
||||
}
|
||||
|
||||
const choose = (selection: ModelSelection): void => {
|
||||
if (state.current?.provider === selection.provider && state.current.model === selection.model) {
|
||||
close(true)
|
||||
return
|
||||
}
|
||||
void select(selection).then((accepted) => {
|
||||
if (accepted && rootRef.current !== null) close(true)
|
||||
})
|
||||
lastActionRef.current = 'select'
|
||||
void select(selection).then(settleSelection)
|
||||
}
|
||||
|
||||
const chooseEffort = (effort: string | undefined): void => {
|
||||
@@ -169,9 +198,8 @@ export function ModelSelect(
|
||||
model: state.current.model,
|
||||
...effort === undefined ? {} : { reasoningEffort: effort },
|
||||
}
|
||||
void select(selection).then((accepted) => {
|
||||
if (accepted && rootRef.current !== null) close(true)
|
||||
})
|
||||
lastActionRef.current = 'select'
|
||||
void select(selection).then(settleSelection)
|
||||
}
|
||||
|
||||
const modelLabel = currentChoice?.model.name ?? t('trigger.fallback')
|
||||
@@ -243,16 +271,16 @@ export function ModelSelect(
|
||||
{state.status === 'loading' && (
|
||||
<div className={css.status}>{t('status.loading')}</div>
|
||||
)}
|
||||
{state.error !== null && (
|
||||
{state.error !== null && lastActionRef.current === 'load' && (
|
||||
<div className={css.error}>
|
||||
<span>{t('error.action', { message: state.error })}</span>
|
||||
<button type="button" className={css.retry} onClick={() => { load() }}>{t('retry')}</button>
|
||||
<button type="button" className={css.retry} onClick={reload}>{t('retry')}</button>
|
||||
</div>
|
||||
)}
|
||||
{state.failures.map(failure => (
|
||||
<div className={css.warning} key={failure.id}>
|
||||
<span>{t('warning.groupLoad', { name: failure.name, message: failure.message })}</span>
|
||||
<button type="button" className={css.retry} onClick={() => { load() }}>{t('retry')}</button>
|
||||
<button type="button" className={css.retry} onClick={reload}>{t('retry')}</button>
|
||||
</div>
|
||||
))}
|
||||
<div className={clsx(css.groups, 'scrollable')}>
|
||||
@@ -299,10 +327,10 @@ export function ModelSelect(
|
||||
|
||||
{pane === 'effort' && (
|
||||
<>
|
||||
{state.error !== null && (
|
||||
{state.error !== null && lastActionRef.current === 'load' && (
|
||||
<div className={css.error}>
|
||||
<span>{t('error.action', { message: state.error })}</span>
|
||||
<button type="button" className={css.retry} onClick={() => { load() }}>{t('action.reload')}</button>
|
||||
<button type="button" className={css.retry} onClick={reload}>{t('action.reload')}</button>
|
||||
</div>
|
||||
)}
|
||||
{effortChoices.length === 0
|
||||
@@ -333,6 +361,15 @@ export function ModelSelect(
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{toast !== null && (
|
||||
<Toast
|
||||
key={toast.seq}
|
||||
text={toast.text}
|
||||
icon={<IconWarningOutline16 />}
|
||||
anchor={rootRef.current?.closest<HTMLElement>('[data-composer-card]') ?? null}
|
||||
onDone={() => { setToast(null) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -135,6 +135,38 @@ describe('ModelSelect reasoning effort', () => {
|
||||
expect(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('announces a rejected selection as a transient toast and keeps the in-menu strip for loads', async () => {
|
||||
const groups = [{
|
||||
id: 'deepseek-official',
|
||||
name: 'DeepSeek',
|
||||
models: [
|
||||
{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', reasoning },
|
||||
{ id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' },
|
||||
],
|
||||
}]
|
||||
const directory = createSnapshotStore<ModelDirectoryState>(state({ groups }))
|
||||
const select = vi.fn(async () => {
|
||||
directory.set(state({ groups, status: 'error', error: 'model-unavailable: session already contains images' }))
|
||||
return false
|
||||
})
|
||||
render(<ModelSelect
|
||||
locked={false}
|
||||
available
|
||||
directory={directory}
|
||||
load={vi.fn()}
|
||||
select={select}
|
||||
t={t}
|
||||
/>)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /选择模型|当前/ }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /模型/ }))
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: /DeepSeek-V4-Pro/ }))
|
||||
const toast = await screen.findByRole('alert')
|
||||
expect(toast.textContent).toContain('模型操作失败:model-unavailable: session already contains images')
|
||||
// The selection failure does not render the in-menu load strip (no Retry).
|
||||
expect(screen.queryByRole('button', { name: '重试' })).toBeNull()
|
||||
})
|
||||
|
||||
it('renders no Agent-bound control for an addressed subagent session', () => {
|
||||
const load = vi.fn()
|
||||
render(<ModelSelect
|
||||
|
||||
6
packages/client/ui-plugin-config/README.i18n.yaml
Normal file
6
packages/client/ui-plugin-config/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-plugin-config/README.md
|
||||
README.md: 7e530d70f6573d619378e43b0245345b45d6db18
|
||||
README.zh.md: fd4f980fcf71c00c2357017fb40c76a9ca7a72cc
|
||||
40
packages/client/ui-plugin-config/README.md
Normal file
40
packages/client/ui-plugin-config/README.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# dsh-client-ui-plugin-config
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The **Plugins** settings section: one expandable card per Host plugin whose configuration a user owns. A card shows the plugin's name and what it governs; expanding it in place reveals hand-written controls bound to that plugin's settings namespace, each field marking whether the user overrode it and offering a reset back to the value the deployment composed.
|
||||
|
||||
## What appears here
|
||||
|
||||
A card renders only when its namespace is both registered by a live Host plugin and served to the browser. A deployment that does not compose the owning plugin — or serves the namespace to no client — renders nothing for it rather than an empty or disabled card, so the section reflects what this deployment actually runs.
|
||||
|
||||
The first batch covers the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), and the DeepSeek search provider (`web-search-deepseek`).
|
||||
|
||||
## Extension point
|
||||
|
||||
The section declares `settings.plugin.item`, a root list slot. A plugin that ships a browser half registers its own card into that slot and owns its controls; this package neither enumerates namespaces nor renders a form it was not given. Ordering follows the slot's `order`.
|
||||
|
||||
## Writes
|
||||
|
||||
A card stages what the user types and writes it only when they save. Each control renders staged text, so what is on screen is exactly what a save would store; **Discard** drops the drafts, and a card holding unsaved edits says so on its header even while collapsed. A reset stages the composed default rather than writing immediately, and a draft the field does not accept blocks the save instead of being dropped.
|
||||
|
||||
Saving writes each staged field through the client settings scope, which fences every write with the namespace revision it read, so a form that has drifted from the document is refused rather than overwriting a concurrent change. The Host is the only authority on whether a value was accepted — its validators own the constraints no schema can express — so the card reads the section back afterwards and reports a save that did not land, keeping those drafts for the user to correct.
|
||||
|
||||
A key can also be written from another surface — the Models page addresses the same reference — which changes no settings section, so the card re-reads on the forwarded `credentials/updated` event for the reference it watches.
|
||||
|
||||
A field's presence in the raw user layer — not its value — is what marks it overridden; a reset clears that field so it re-inherits the composition layer. Secret-role fields never ride a response, so a key control starts blank, reports only whether one is configured, and writes through the credentials domain rather than the settings section; a blank draft writes nothing and keeps the stored key.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the section renders a browser configuration UI; the values it writes reach a model only through the plugins that own them, each documenting that effect itself.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Only host-plane plugins appear** — a plugin an agent preset mounts carries its configuration inline in that preset's `agent.cordis.yml` and cannot register a settings namespace at all (a second session mounting the same preset would fail on a duplicate registration), so this section lists nothing for it. Editing those values remains the preset editor's job.
|
||||
- **Exposure is a Host allowlist, not a plugin declaration** — a namespace absent from the api-proxy's allowlist answers `settings-not-exposed` even when its owner registered it, so a plugin distributed outside this repository cannot surface its own configuration here without a change in `packages/host/apiproxy`.
|
||||
- **The shell card follows the composed executor** — the POSIX and PowerShell executor families share the `bash` namespace because a host composes exactly one of them, so the served schema differs by platform (PowerShell adds `pwshPath`) even though the card edits the same two fields on both, and a deployment composing neither shows no card.
|
||||
- **The empty line counts registered cards, not visible ones** — a card whose namespace this deployment does not expose renders nothing, but still counts, so a deployment that exposes none shows an empty list rather than the empty line. The count is also read once, because the renderer caches a root entry's inject face; a card registered later does not raise it.
|
||||
40
packages/client/ui-plugin-config/README.zh.md
Normal file
40
packages/client/ui-plugin-config/README.zh.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# dsh-client-ui-plugin-config
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
**插件**设置分区:每个配置由用户拥有的 Host 插件占一张可展开卡片。卡片展示插件名称及其管辖范围;就地展开后是绑定到该插件 settings 命名空间的手写控件,每个字段标注用户是否覆盖过它,并提供重置回部署组装值的入口。
|
||||
|
||||
## 这里会出现什么
|
||||
|
||||
只有当某个命名空间既被存活的 Host 插件注册、又被服务给浏览器时,它的卡片才会渲染。未组装该插件的部署——或未向任何客户端服务该命名空间的部署——不会渲染空卡片或禁用卡片,而是什么都不渲染,因此这一分区反映的是该部署实际运行的东西。
|
||||
|
||||
第一批覆盖 shell 执行器(`bash`)、agent 循环的工具调用并行度(`agent-loop`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。
|
||||
|
||||
## 扩展点
|
||||
|
||||
本分区声明了根级列表 slot `settings.plugin.item`。带浏览器半侧的插件把自己的卡片注册进该 slot 并拥有其控件;本包既不枚举命名空间,也不渲染未被交给它的表单。排序遵循 slot 的 `order`。
|
||||
|
||||
## 写入
|
||||
|
||||
卡片暂存用户输入,只有用户保存时才写入。每个控件渲染的都是暂存文本,因此屏幕上所见即保存后所存;**放弃修改**丢弃这些草稿,持有未保存修改的卡片即使收起也会在标题上标明。重置暂存的是组装默认值而非立即写入;字段不接受的草稿会阻塞保存,而不是被丢弃。
|
||||
|
||||
保存时,每个暂存字段都通过客户端 settings scope 写入,该 scope 用读取时的命名空间 revision 为每次写入设栅,因此已与文档脱节的表单会被拒绝,而不是覆盖并发变更。某个值是否被接受只有 Host 说了算——schema 表达不了的约束归它的校验器所有——因此卡片在写入后回读分节,报告没有落盘的保存,并保留这些草稿供用户修改。
|
||||
|
||||
密钥也可能从别的表层写入——模型页寻址的是同一个引用——而那不改变任何 settings 分节,因此卡片会在转发来的 `credentials/updated` 事件报告它所关注的引用时重读。
|
||||
|
||||
字段是否被覆盖,取决于它是否出现在原始用户层中,而非取决于它的值;重置会清除该字段,使其重新继承组装层。secret 角色的字段绝不搭乘响应,因此密钥控件初始为空、只报告是否已配置,并经由 credentials 领域而非 settings 分节写入;空草稿不写入任何东西,保留已存密钥。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。该分区渲染浏览器配置 UI;它写入的值只通过拥有这些值的插件到达模型,而这些效应各由其拥有方的包记录。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **只有宿主平面的插件会出现**——由 agent preset 挂载的插件把配置内联在该 preset 的 `agent.cordis.yml` 中,且根本无法注册 settings 命名空间(同一 preset 挂载第二个会话时会因重复注册而失败),因此本分区不会列出它。编辑那些值仍是 preset 编辑器的职责。
|
||||
- **暴露是 Host 的白名单,而非插件的声明**——不在 api-proxy 白名单中的命名空间,即便其拥有方已注册,也只会得到 `settings-not-exposed`,因此在本仓库之外分发的插件无法在不改动 `packages/host/apiproxy` 的前提下让自己的配置出现在这里。
|
||||
- **shell 卡片跟随被组装的执行器**——POSIX 与 PowerShell 两个执行器家族共用 `bash` 命名空间,因为一个宿主只组装其中之一,所以被服务的 schema 随平台不同(PowerShell 多出 `pwshPath`),尽管卡片在两者下编辑的都是同样两个字段;而两者都不组装的部署不会显示这张卡片。
|
||||
- **空态数的是已注册卡片,不是可见卡片**——命名空间未被本部署暴露的卡片什么都不渲染,但仍计入数量,因此一个都不暴露的部署看到的是空列表而非那行空态文案。该计数还只读取一次,因为渲染器会缓存根级 entry 的 inject face;之后注册的卡片不会让它变大。
|
||||
86
packages/client/ui-plugin-config/package.json
Normal file
86
packages/client/ui-plugin-config/package.json
Normal file
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-plugin-config",
|
||||
"description": "Plugin configuration section: host-plane plugin settings as expandable cards",
|
||||
"version": "0.0.1-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/client/ui-plugin-config"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-settings",
|
||||
"@deepseek-ai/dsh-api-remotes"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/** The agent loop's card: how many tool calls one step may run at once. */
|
||||
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { ValueField } from './fields.tsx'
|
||||
import { PluginCard } from './PluginCard.tsx'
|
||||
import type { AgentLoopCardFace } from './agent-loop-store.ts'
|
||||
import type {} from './slot-contract.ts'
|
||||
|
||||
/** Props the renderer binds for the agent-loop card. */
|
||||
export type AgentLoopCardProps =
|
||||
PropsRuntime<'settings.plugin.item'>
|
||||
& PropsLocale<'settings.pluginConfig'>
|
||||
& InjectFace<AgentLoopCardFace>
|
||||
|
||||
/**
|
||||
* Render the agent-loop card.
|
||||
* @param props - locale copy, the card snapshot, and its form actions.
|
||||
* @returns the card.
|
||||
*/
|
||||
export function AgentLoopCard(props: AgentLoopCardProps) {
|
||||
const { t } = props
|
||||
const state = props.useAgentLoopCard(snapshot => snapshot)
|
||||
return (
|
||||
<PluginCard
|
||||
t={t}
|
||||
titleKey="agentLoopTitle"
|
||||
descriptionKey="agentLoopDescription"
|
||||
state={state}
|
||||
onSave={props.save}
|
||||
onDiscard={props.discard}
|
||||
>
|
||||
<ValueField
|
||||
id="plugin-config-agent-loop-parallel"
|
||||
label={t('agentLoopMaxParallel')}
|
||||
hint={t('agentLoopMaxParallelHint')}
|
||||
overriddenLabel={t('overridden')}
|
||||
resetLabel={t('reset')}
|
||||
invalidLabel={t('invalidNumber')}
|
||||
numeric
|
||||
disabled={!state.writable}
|
||||
{...state.maxParallelToolCalls}
|
||||
onEdit={(text) => { props.edit('maxParallelToolCalls', text) }}
|
||||
onReset={() => { props.resetField('maxParallelToolCalls') }}
|
||||
/>
|
||||
</PluginCard>
|
||||
)
|
||||
}
|
||||
61
packages/client/ui-plugin-config/src/client/BashCard.tsx
Normal file
61
packages/client/ui-plugin-config/src/client/BashCard.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
/** The shell plugin's card: the limits every command the agent runs is bound by. */
|
||||
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { ValueField } from './fields.tsx'
|
||||
import { PluginCard } from './PluginCard.tsx'
|
||||
import type { BashCardFace } from './bash-store.ts'
|
||||
import type {} from './slot-contract.ts'
|
||||
|
||||
/** Props the renderer binds for the shell card. */
|
||||
export type BashCardProps =
|
||||
PropsRuntime<'settings.plugin.item'>
|
||||
& PropsLocale<'settings.pluginConfig'>
|
||||
& InjectFace<BashCardFace>
|
||||
|
||||
/**
|
||||
* Render the shell card.
|
||||
* @param props - locale copy, the card snapshot, and its form actions.
|
||||
* @returns the card.
|
||||
*/
|
||||
export function BashCard(props: BashCardProps) {
|
||||
const { t } = props
|
||||
const state = props.useBashCard(snapshot => snapshot)
|
||||
const disabled = !state.writable
|
||||
return (
|
||||
<PluginCard
|
||||
t={t}
|
||||
titleKey="bashTitle"
|
||||
descriptionKey="bashDescription"
|
||||
state={state}
|
||||
onSave={props.save}
|
||||
onDiscard={props.discard}
|
||||
>
|
||||
<ValueField
|
||||
id="plugin-config-bash-timeout"
|
||||
label={t('bashTimeoutMs')}
|
||||
hint={t('bashTimeoutMsHint')}
|
||||
overriddenLabel={t('overridden')}
|
||||
resetLabel={t('reset')}
|
||||
invalidLabel={t('invalidNumber')}
|
||||
numeric
|
||||
disabled={disabled}
|
||||
{...state.timeoutMs}
|
||||
onEdit={(text) => { props.edit('timeoutMs', text) }}
|
||||
onReset={() => { props.resetField('timeoutMs') }}
|
||||
/>
|
||||
<ValueField
|
||||
id="plugin-config-bash-output"
|
||||
label={t('bashMaxOutputBytes')}
|
||||
hint={t('bashMaxOutputBytesHint')}
|
||||
overriddenLabel={t('overridden')}
|
||||
resetLabel={t('reset')}
|
||||
invalidLabel={t('invalidNumber')}
|
||||
numeric
|
||||
disabled={disabled}
|
||||
{...state.maxOutputBytes}
|
||||
onEdit={(text) => { props.edit('maxOutputBytes', text) }}
|
||||
onReset={() => { props.resetField('maxOutputBytes') }}
|
||||
/>
|
||||
</PluginCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/* Plugin card: a header that names the plugin, disclosing its controls in place. */
|
||||
|
||||
.card {
|
||||
list-style: none;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
transition: border-color .16s, background .16s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
/* An open card reads as the one being worked on, not merely taller. */
|
||||
.cardOpen {
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
border-color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
.header {
|
||||
width: 100%;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.header:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Name over description: the description is what tells two plugins apart, so
|
||||
it gets its own line rather than trailing the name. */
|
||||
.headText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
transition: transform .16s;
|
||||
}
|
||||
|
||||
.chevronOpen {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.body {
|
||||
border-top: 1px solid var(--dsw-alias-border-l2);
|
||||
margin: 0 16px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.readOnly {
|
||||
margin: 12px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* Carried on the header so a collapsed card still says it holds edits. */
|
||||
.pending {
|
||||
flex: none;
|
||||
border-radius: 999px;
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
line-height: 17px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 0 4px;
|
||||
border-top: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.failed {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--dsw-alias-label-error);
|
||||
}
|
||||
|
||||
.discard,
|
||||
.save {
|
||||
appearance: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 5px 14px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.discard {
|
||||
border-color: var(--dsw-alias-border-l2);
|
||||
background: none;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.discard:hover:not(:disabled) {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
border-color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
.save {
|
||||
background: var(--dsw-alias-label-primary);
|
||||
color: var(--dsw-alias-bg-layer-3);
|
||||
}
|
||||
|
||||
.discard:disabled,
|
||||
.save:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.discard:focus-visible,
|
||||
.save:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
98
packages/client/ui-plugin-config/src/client/PluginCard.tsx
Normal file
98
packages/client/ui-plugin-config/src/client/PluginCard.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* One plugin's card: a header naming the plugin and what its settings govern,
|
||||
* disclosing that plugin's controls in place, with the save that writes them.
|
||||
*
|
||||
* The header is its own button rather than a shared disclosure row because a
|
||||
* card stacks its name over its description, while that row lays the two side
|
||||
* by side — the layout, not the behavior, is what differs. Disclosure is
|
||||
* card-local state: which card a user has open is a reading gesture, not
|
||||
* something the Host or the section has any stake in. Staged edits outlive
|
||||
* collapsing, so the header marks a card holding unsaved edits.
|
||||
*
|
||||
* A card renders nothing while its namespace is unavailable: a deployment that
|
||||
* does not compose the owning plugin should show no trace of it, rather than a
|
||||
* disabled card the user cannot act on.
|
||||
*/
|
||||
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { CardShell } from './card-store.ts'
|
||||
import type { PluginConfigKey } from './locales.ts'
|
||||
import css from './PluginCard.module.css'
|
||||
|
||||
/** Card chrome shared by every plugin section. */
|
||||
export interface PluginCardProps {
|
||||
/** Locale reader for this section's copy. */
|
||||
t: (key: PluginConfigKey) => string
|
||||
/** Locale key of the plugin's name. */
|
||||
titleKey: PluginConfigKey
|
||||
/** Locale key of the line describing what this plugin's settings govern. */
|
||||
descriptionKey: PluginConfigKey
|
||||
/** The card's form state: availability, writability, and what a save would do. */
|
||||
state: CardShell
|
||||
/** Write every staged edit. */
|
||||
onSave: () => void
|
||||
/** Drop every staged edit. */
|
||||
onDiscard: () => void
|
||||
/** The plugin's controls. */
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one plugin card.
|
||||
* @param props - the plugin's copy keys, its form state, and its controls.
|
||||
* @returns the card, or nothing when the namespace is unavailable.
|
||||
*/
|
||||
export function PluginCard(props: PluginCardProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { state } = props
|
||||
if (!state.available) return null
|
||||
const title = props.t(props.titleKey)
|
||||
const blocked = !state.dirty || state.invalid || state.saving
|
||||
return (
|
||||
<li className={clsx(css.card, open && css.cardOpen)}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.header}
|
||||
aria-expanded={open}
|
||||
aria-label={`${props.t(open ? 'collapse' : 'expand')}: ${title}`}
|
||||
onClick={() => { setOpen(!open) }}
|
||||
>
|
||||
<span className={css.headText}>
|
||||
<span className={css.name}>{title}</span>
|
||||
<span className={css.description}>{props.t(props.descriptionKey)}</span>
|
||||
</span>
|
||||
{state.dirty ? <span className={css.pending}>{props.t('unsaved')}</span> : null}
|
||||
<IconChevronDownOutline14 className={clsx(css.chevron, open && css.chevronOpen)} />
|
||||
</button>
|
||||
{open
|
||||
? (
|
||||
<div className={css.body}>
|
||||
{!state.writable ? <p className={css.readOnly} role="status">{props.t('readOnly')}</p> : null}
|
||||
{props.children}
|
||||
<div className={css.footer}>
|
||||
{state.failed ? <p className={css.failed} role="status">{props.t('saveFailed')}</p> : null}
|
||||
<button
|
||||
type="button"
|
||||
className={css.discard}
|
||||
disabled={!state.dirty || state.saving}
|
||||
onClick={props.onDiscard}
|
||||
>
|
||||
{props.t('discard')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.save}
|
||||
disabled={blocked}
|
||||
onClick={props.onSave}
|
||||
>
|
||||
{props.t(state.saving ? 'saving' : 'save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/* Plugin configuration section: heading, intro, and the card list. */
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-width: 720px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.heading {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.intro {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.cards {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Plugin configuration section: the shell around the per-plugin cards. It
|
||||
* enumerates nothing itself — cards arrive through the `settings.plugin.item`
|
||||
* slot it declares, so a plugin that ships a browser half owns its own card
|
||||
* and this section never learns what a namespace means.
|
||||
*/
|
||||
|
||||
import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {} from './slot-contract.ts'
|
||||
import type { PluginConfigKey } from './locales.ts'
|
||||
import css from './PluginConfigSection.module.css'
|
||||
|
||||
/** Registration-side business face for the section. */
|
||||
export interface PluginConfigSectionInjected {
|
||||
/** How many cards the slot ledger currently holds; zero renders the empty line. */
|
||||
cardCount: number
|
||||
}
|
||||
|
||||
/** Props the renderer binds for the section. */
|
||||
export type PluginConfigSectionProps =
|
||||
PropsRuntime<'settings.section'>
|
||||
& PropsLocale<'settings.pluginConfig'>
|
||||
& PropsRenderSlots<'settings.plugin.item'>
|
||||
& InjectFace<PluginConfigSectionInjected>
|
||||
|
||||
/**
|
||||
* Render the plugin configuration section.
|
||||
* @param props - runtime slot rendering, locale copy, and the card count.
|
||||
* @returns the section.
|
||||
*/
|
||||
export function PluginConfigSection(props: PluginConfigSectionProps) {
|
||||
const { t, renderSlot, cardCount } = props
|
||||
return (
|
||||
<div className={css.section}>
|
||||
<h2 className={css.heading}>{t('title')}</h2>
|
||||
<p className={css.intro}>{t('intro')}</p>
|
||||
{cardCount === 0
|
||||
? <p className={css.empty}>{t('empty')}</p>
|
||||
: <ul className={css.cards}>{renderSlot('settings.plugin.item', {})}</ul>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** Plugin configuration section and card copy. */
|
||||
'settings.pluginConfig': PluginConfigKey
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* The web-search provider's card: its endpoint, its per-request search budget,
|
||||
* and the key — which is written through the credentials domain, never into
|
||||
* the settings section, so the literal never rides a response.
|
||||
*/
|
||||
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SecretField, ValueField } from './fields.tsx'
|
||||
import { PluginCard } from './PluginCard.tsx'
|
||||
import type { WebSearchCardFace } from './web-search-store.ts'
|
||||
import type {} from './slot-contract.ts'
|
||||
|
||||
/** Props the renderer binds for the web-search card. */
|
||||
export type WebSearchCardProps =
|
||||
PropsRuntime<'settings.plugin.item'>
|
||||
& PropsLocale<'settings.pluginConfig'>
|
||||
& InjectFace<WebSearchCardFace>
|
||||
|
||||
/**
|
||||
* Render the web-search card.
|
||||
* @param props - locale copy, the card snapshot, and its form actions.
|
||||
* @returns the card.
|
||||
*/
|
||||
export function WebSearchCard(props: WebSearchCardProps) {
|
||||
const { t } = props
|
||||
const state = props.useWebSearchCard(snapshot => snapshot)
|
||||
const disabled = !state.writable
|
||||
return (
|
||||
<PluginCard
|
||||
t={t}
|
||||
titleKey="webSearchTitle"
|
||||
descriptionKey="webSearchDescription"
|
||||
state={state}
|
||||
onSave={props.save}
|
||||
onDiscard={props.discard}
|
||||
>
|
||||
<SecretField
|
||||
id="plugin-config-web-search-key"
|
||||
label={t('webSearchApiKey')}
|
||||
hint={t('webSearchApiKeyHint')}
|
||||
// The credentials domain accepts a key even when the settings document
|
||||
// itself is read-only; they are separate stores with separate refusals.
|
||||
// Its own writability is what disables this control — a key sourced
|
||||
// from the process environment cannot be written from here.
|
||||
disabled={!state.apiKeyWritable}
|
||||
text={state.apiKey.text}
|
||||
configured={state.apiKeyConfigured}
|
||||
stateLabel={state.apiKeyConfigured ? t('webSearchApiKeySet') : t('webSearchApiKeyUnset')}
|
||||
onEdit={(text) => { props.edit('apiKey', text) }}
|
||||
/>
|
||||
<ValueField
|
||||
id="plugin-config-web-search-endpoint"
|
||||
label={t('webSearchBaseUrl')}
|
||||
hint={t('webSearchBaseUrlHint')}
|
||||
overriddenLabel={t('overridden')}
|
||||
resetLabel={t('reset')}
|
||||
invalidLabel={t('invalidNumber')}
|
||||
disabled={disabled}
|
||||
{...state.baseURL}
|
||||
onEdit={(text) => { props.edit('baseURL', text) }}
|
||||
onReset={() => { props.resetField('baseURL') }}
|
||||
/>
|
||||
<ValueField
|
||||
id="plugin-config-web-search-max-uses"
|
||||
label={t('webSearchMaxUses')}
|
||||
hint={t('webSearchMaxUsesHint')}
|
||||
overriddenLabel={t('overridden')}
|
||||
resetLabel={t('reset')}
|
||||
invalidLabel={t('invalidNumber')}
|
||||
numeric
|
||||
disabled={disabled}
|
||||
{...state.maxUses}
|
||||
onEdit={(text) => { props.edit('maxUses', text) }}
|
||||
onReset={() => { props.resetField('maxUses') }}
|
||||
/>
|
||||
</PluginCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/** The agent-loop card's staged form over the `agent-loop` settings namespace. */
|
||||
|
||||
import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { CardForm, numberField, type CardActions, type CardFieldState, type CardShell } from './card-store.ts'
|
||||
|
||||
/**
|
||||
* Namespace of the agent loop's user-owned settings. Spelled here rather than
|
||||
* imported: a client package must not depend on a Host package.
|
||||
*/
|
||||
export const AGENT_LOOP_NS = 'agent-loop'
|
||||
|
||||
/**
|
||||
* The agent-loop fields this card edits. The Host section carries only this
|
||||
* field — the composed `agents` array is deliberately not part of it.
|
||||
*/
|
||||
export interface AgentLoopSettings {
|
||||
/** Upper bound on parallel-safe tool calls in flight per step. */
|
||||
maxParallelToolCalls?: number
|
||||
}
|
||||
|
||||
/** What the agent-loop card renders. */
|
||||
export interface AgentLoopCardState extends CardShell {
|
||||
/** Parallel tool-call cap. */
|
||||
maxParallelToolCalls: CardFieldState
|
||||
}
|
||||
|
||||
/** The registration-side face the agent-loop card's slot entry injects. */
|
||||
export interface AgentLoopCardFace extends CardActions {
|
||||
hooks: {
|
||||
/** Card snapshot bound by the renderer as useAgentLoopCard. */
|
||||
agentLoopCard: SnapshotStore<AgentLoopCardState>
|
||||
}
|
||||
}
|
||||
|
||||
/** Bridges the `agent-loop` scope onto the card's staged form. */
|
||||
export class AgentLoopCardController {
|
||||
private readonly form: CardForm<AgentLoopSettings>
|
||||
private readonly store: SnapshotStore<AgentLoopCardState>
|
||||
|
||||
/** @param scope - the bound settings scope for the `agent-loop` namespace. */
|
||||
constructor(scope: SettingsScope<AgentLoopSettings>) {
|
||||
this.form = new CardForm(scope, [numberField('maxParallelToolCalls')])
|
||||
this.store = this.form.bind(() => this.projection())
|
||||
}
|
||||
|
||||
private projection(): AgentLoopCardState {
|
||||
return { ...this.form.shell(), maxParallelToolCalls: this.form.field('maxParallelToolCalls') }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the face the card's slot registration injects.
|
||||
* @returns the card's snapshot and its form actions.
|
||||
*/
|
||||
inject(): AgentLoopCardFace {
|
||||
return { hooks: { agentLoopCard: this.store }, ...this.form.actions() }
|
||||
}
|
||||
}
|
||||
63
packages/client/ui-plugin-config/src/client/bash-store.ts
Normal file
63
packages/client/ui-plugin-config/src/client/bash-store.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/** The shell card's staged form over the `bash` settings namespace. */
|
||||
|
||||
import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { CardForm, numberField, type CardActions, type CardFieldState, type CardShell } from './card-store.ts'
|
||||
|
||||
/**
|
||||
* Namespace of the shell capability. Spelled here rather than imported: a
|
||||
* client package must not depend on a Host package, and the executor families
|
||||
* that own it spell the same value.
|
||||
*/
|
||||
export const BASH_NS = 'bash'
|
||||
|
||||
/** The shell fields this card edits — a subset of the served schema by design. */
|
||||
export interface BashSettings {
|
||||
/** Foreground command timeout in milliseconds. */
|
||||
timeoutMs?: number
|
||||
/** Per-stream in-memory output cap in bytes. */
|
||||
maxOutputBytes?: number
|
||||
}
|
||||
|
||||
/** What the shell card renders. */
|
||||
export interface BashCardState extends CardShell {
|
||||
/** Command timeout in milliseconds. */
|
||||
timeoutMs: CardFieldState
|
||||
/** Per-stream output cap in bytes. */
|
||||
maxOutputBytes: CardFieldState
|
||||
}
|
||||
|
||||
/** The registration-side face the shell card's slot entry injects. */
|
||||
export interface BashCardFace extends CardActions {
|
||||
hooks: {
|
||||
/** Card snapshot bound by the renderer as useBashCard. */
|
||||
bashCard: SnapshotStore<BashCardState>
|
||||
}
|
||||
}
|
||||
|
||||
/** Bridges the `bash` scope onto the shell card's staged form. */
|
||||
export class BashCardController {
|
||||
private readonly form: CardForm<BashSettings>
|
||||
private readonly store: SnapshotStore<BashCardState>
|
||||
|
||||
/** @param scope - the bound settings scope for the `bash` namespace. */
|
||||
constructor(scope: SettingsScope<BashSettings>) {
|
||||
this.form = new CardForm(scope, [numberField('timeoutMs'), numberField('maxOutputBytes')])
|
||||
this.store = this.form.bind(() => this.projection())
|
||||
}
|
||||
|
||||
private projection(): BashCardState {
|
||||
return {
|
||||
...this.form.shell(),
|
||||
timeoutMs: this.form.field('timeoutMs'),
|
||||
maxOutputBytes: this.form.field('maxOutputBytes'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the face the card's slot registration injects.
|
||||
* @returns the card's snapshot and its form actions.
|
||||
*/
|
||||
inject(): BashCardFace {
|
||||
return { hooks: { bashCard: this.store }, ...this.form.actions() }
|
||||
}
|
||||
}
|
||||
351
packages/client/ui-plugin-config/src/client/card-store.ts
Normal file
351
packages/client/ui-plugin-config/src/client/card-store.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* Shared form model behind every plugin card.
|
||||
*
|
||||
* A card stages what the user types and writes it only when they save. Each
|
||||
* settings write is a durable, revision-fenced document mutation, so a control
|
||||
* that committed as it settled turned one edit into a write the user never
|
||||
* asked for and could not preview; staged text makes what is on screen exactly
|
||||
* what a save would store.
|
||||
*
|
||||
* A field shows its effective value — the user layer over the composition
|
||||
* layer over the schema default — and whether the user layer carries it. That
|
||||
* presence, not a value comparison, is what marks a field overridden: an
|
||||
* override equal to the composition default is still an override.
|
||||
*/
|
||||
|
||||
import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** The write one field's staged text performs when the card is saved. */
|
||||
export type FieldWrite =
|
||||
| { kind: 'set'; value: unknown }
|
||||
| { kind: 'clear' }
|
||||
|
||||
/** How one section field converts between its stored value and its draft text. */
|
||||
export interface CardFieldSpec {
|
||||
/** Field name inside the namespace section. */
|
||||
field: string
|
||||
/** Render a stored value as draft text; the empty string when the section carries none. */
|
||||
format: (value: unknown) => string
|
||||
/**
|
||||
* The write this draft text stages, or undefined when the text is not a
|
||||
* value this field accepts — which blocks the save rather than discarding it.
|
||||
*/
|
||||
parse: (text: string) => FieldWrite | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* A control whose value is written outside the settings section. A credential
|
||||
* literal never rides a response, so its draft has nothing to seed from: it is
|
||||
* blank until typed, and a blank draft writes nothing.
|
||||
*/
|
||||
export interface CardSecretSpec {
|
||||
/** Field name addressing this control inside the card's form. */
|
||||
field: string
|
||||
/** Write the staged text; resolves to whether the Host accepted it. */
|
||||
write: (text: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
/** One field as a card's control renders it. */
|
||||
export interface CardFieldState {
|
||||
/** Draft text the control renders. */
|
||||
text: string
|
||||
/**
|
||||
* Whether saving would leave a user-layer entry for this field. A staged
|
||||
* edit answers for itself, so the badge previews the save rather than
|
||||
* reporting a state the pending edit already contradicts.
|
||||
*/
|
||||
overridden: boolean
|
||||
/** Whether the draft is not a value this field accepts, which blocks saving. */
|
||||
invalid: boolean
|
||||
}
|
||||
|
||||
/** Form state every plugin card shares. */
|
||||
export interface CardShell {
|
||||
/** False while the namespace is not served to this client; the card renders nothing. */
|
||||
available: boolean
|
||||
/** Whether the Host document accepts writes. */
|
||||
writable: boolean
|
||||
/** Whether the form holds edits that a save would write. */
|
||||
dirty: boolean
|
||||
/** Whether any staged draft is invalid, which blocks the save. */
|
||||
invalid: boolean
|
||||
/** Whether a save is crossing the wire. */
|
||||
saving: boolean
|
||||
/** Whether the last save did not land as staged; cleared by the next edit or save. */
|
||||
failed: boolean
|
||||
}
|
||||
|
||||
/** The write actions every plugin card's slot entry injects. */
|
||||
export interface CardActions {
|
||||
/** Stage draft text for one field. */
|
||||
edit: (field: string, text: string) => void
|
||||
/** Stage a clear, so saving lets the field re-inherit the composition layer. */
|
||||
resetField: (field: string) => void
|
||||
/** Write every staged edit, then re-seed from what the Host accepted. */
|
||||
save: () => void
|
||||
/** Drop every staged edit. */
|
||||
discard: () => void
|
||||
}
|
||||
|
||||
/** One field's staged edit. */
|
||||
interface StagedEdit {
|
||||
/** Draft text the control renders. */
|
||||
text: string
|
||||
/** True when this edit clears the field whatever text it shows. */
|
||||
clear: boolean
|
||||
}
|
||||
|
||||
/** One staged edit resolved into the write a save performs. */
|
||||
interface PlannedWrite {
|
||||
/** Field this entry writes. */
|
||||
field: string
|
||||
/**
|
||||
* Perform the write and report whether the Host holds the staged value
|
||||
* afterwards; undefined when the draft is not a value the field accepts.
|
||||
*/
|
||||
run: (() => Promise<boolean>) | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole-number field. An empty draft clears the field; any other draft that
|
||||
* is not a finite number blocks the save.
|
||||
* @param field - field name inside the namespace section.
|
||||
* @returns the field's conversion spec.
|
||||
*/
|
||||
export function numberField(field: string): CardFieldSpec {
|
||||
return {
|
||||
field,
|
||||
// A section that carries no number for this field renders empty rather
|
||||
// than as a value nobody chose.
|
||||
format: value => typeof value === 'number' ? String(value) : '',
|
||||
parse: (text) => {
|
||||
const trimmed = text.trim()
|
||||
if (trimmed === '') return { kind: 'clear' }
|
||||
const parsed = Number(trimmed)
|
||||
return Number.isFinite(parsed) ? { kind: 'set', value: parsed } : undefined
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A free-text field. An empty draft clears the field, so emptying the control
|
||||
* and saving is the same gesture as resetting it.
|
||||
* @param field - field name inside the namespace section.
|
||||
* @returns the field's conversion spec.
|
||||
*/
|
||||
export function textField(field: string): CardFieldSpec {
|
||||
return {
|
||||
field,
|
||||
format: value => typeof value === 'string' ? value : '',
|
||||
parse: (text) => {
|
||||
const trimmed = text.trim()
|
||||
return trimmed === '' ? { kind: 'clear' } : { kind: 'set', value: trimmed }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stages one card's edits over one settings namespace and writes them on save.
|
||||
*
|
||||
* The form publishes through a snapshot store because slot components read
|
||||
* through a snapshot selector, while both the scope and the local drafts
|
||||
* change underneath; every projection is rebuilt from the two together.
|
||||
*/
|
||||
export class CardForm<T> {
|
||||
private readonly specs: Map<string, CardFieldSpec>
|
||||
private readonly secretSpecs: Map<string, CardSecretSpec>
|
||||
private readonly staged = new Map<string, StagedEdit>()
|
||||
private readonly listeners = new Set<() => void>()
|
||||
private saving = false
|
||||
private failed = false
|
||||
|
||||
/**
|
||||
* @param scope - the bound settings scope for this card's namespace.
|
||||
* @param specs - the section fields this card edits.
|
||||
* @param secrets - the card's write-only controls, written outside the section.
|
||||
*/
|
||||
constructor(
|
||||
private readonly scope: SettingsScope<T>,
|
||||
specs: CardFieldSpec[],
|
||||
secrets: CardSecretSpec[] = [],
|
||||
) {
|
||||
this.specs = new Map(specs.map(spec => [spec.field, spec]))
|
||||
this.secretSpecs = new Map(secrets.map(spec => [spec.field, spec]))
|
||||
scope.subscribe(() => { this.publish() })
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a projection of this form, rebuilt whenever the scope or a draft changes.
|
||||
* @param project - build the card's state from the form's current reads.
|
||||
* @returns the store the card's component reads through its bound selector.
|
||||
*/
|
||||
bind<S>(project: () => S): SnapshotStore<S> {
|
||||
const store = createSnapshotStore(project())
|
||||
this.listeners.add(() => { store.set(project()) })
|
||||
return store
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the card-level state: what the Host serves, and what a save would do.
|
||||
* @returns the form state every card shares.
|
||||
*/
|
||||
shell(): CardShell {
|
||||
const snapshot = this.scope.getSnapshot()
|
||||
const plan = this.plan()
|
||||
return {
|
||||
available: snapshot.status === 'ready',
|
||||
writable: snapshot.writable,
|
||||
dirty: plan.length > 0,
|
||||
invalid: plan.some(item => item.run === undefined),
|
||||
saving: this.saving,
|
||||
failed: this.failed,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one control's state.
|
||||
* @param field - field name of a section field or of a write-only control.
|
||||
* @returns the draft text, whether a save would leave an override, and whether it is invalid.
|
||||
*/
|
||||
field(field: string): CardFieldState {
|
||||
const staged = this.staged.get(field)
|
||||
if (this.secretSpecs.has(field)) {
|
||||
return { text: staged?.text ?? '', overridden: false, invalid: false }
|
||||
}
|
||||
const spec = this.spec(field)
|
||||
if (staged === undefined) {
|
||||
return { text: spec.format(this.sectionValue(field)), overridden: this.stored(field), invalid: false }
|
||||
}
|
||||
const write = staged.clear ? { kind: 'clear' as const } : spec.parse(staged.text)
|
||||
return {
|
||||
text: staged.text,
|
||||
overridden: write?.kind === 'set',
|
||||
invalid: write === undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the edit, reset, save, and discard actions bound to this form.
|
||||
* @returns the actions a card's slot entry injects.
|
||||
*/
|
||||
actions(): CardActions {
|
||||
return {
|
||||
edit: (field, text) => { this.stage(field, { text, clear: false }) },
|
||||
resetField: (field) => {
|
||||
this.stage(field, { text: this.spec(field).format(this.baseValue(field)), clear: true })
|
||||
},
|
||||
save: () => { void this.save() },
|
||||
discard: () => {
|
||||
if (this.staged.size === 0 && !this.failed) return
|
||||
this.staged.clear()
|
||||
this.failed = false
|
||||
this.publish()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write every staged edit, then re-seed from what the Host accepted.
|
||||
*
|
||||
* The Host is the only authority on whether a value was accepted — its
|
||||
* validators own the constraints no schema can express — so the outcome is
|
||||
* read back from the section rather than predicted here. A save that did not
|
||||
* land keeps its drafts, so the user can correct them instead of retyping.
|
||||
* @returns settlement after every write and the read-back.
|
||||
*/
|
||||
async save(): Promise<void> {
|
||||
const plan = this.plan()
|
||||
const writes = plan.flatMap(item => item.run === undefined ? [] : [item.run])
|
||||
if (plan.length === 0 || this.saving || writes.length !== plan.length) return
|
||||
this.saving = true
|
||||
this.failed = false
|
||||
this.publish()
|
||||
let landed = true
|
||||
for (const write of writes) {
|
||||
landed = await write() && landed
|
||||
}
|
||||
if (landed) this.staged.clear()
|
||||
this.saving = false
|
||||
this.failed = !landed
|
||||
this.publish()
|
||||
}
|
||||
|
||||
/**
|
||||
* Every staged edit a save would write. An entry whose draft is not a value
|
||||
* its field accepts carries no write: the form is still dirty, and the save
|
||||
* refuses rather than dropping the edit.
|
||||
* @returns the planned writes, in the order the fields were staged.
|
||||
*/
|
||||
private plan(): PlannedWrite[] {
|
||||
const plan: PlannedWrite[] = []
|
||||
for (const [field, staged] of this.staged) {
|
||||
const secret = this.secretSpecs.get(field)
|
||||
if (secret !== undefined) {
|
||||
const value = staged.text.trim()
|
||||
if (value !== '') plan.push({ field, run: () => secret.write(value) })
|
||||
continue
|
||||
}
|
||||
const spec = this.spec(field)
|
||||
if (staged.clear) {
|
||||
if (this.stored(field)) plan.push({ field, run: () => this.clear(field) })
|
||||
continue
|
||||
}
|
||||
if (staged.text === spec.format(this.sectionValue(field))) continue
|
||||
const write = spec.parse(staged.text)
|
||||
if (write === undefined) plan.push({ field, run: undefined })
|
||||
else if (write.kind === 'clear') plan.push({ field, run: () => this.clear(field) })
|
||||
else plan.push({ field, run: () => this.store(field, write.value) })
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
private async clear(field: string): Promise<boolean> {
|
||||
await this.scope.unset(field)
|
||||
return !this.stored(field)
|
||||
}
|
||||
|
||||
private async store(field: string, value: unknown): Promise<boolean> {
|
||||
await this.scope.set(field, value)
|
||||
return this.userLayer()?.[field] === value
|
||||
}
|
||||
|
||||
private stage(field: string, edit: StagedEdit): void {
|
||||
this.staged.set(field, edit)
|
||||
this.failed = false
|
||||
this.publish()
|
||||
}
|
||||
|
||||
private spec(field: string): CardFieldSpec {
|
||||
const spec = this.specs.get(field)
|
||||
// Every call site names a field this card declared; a missing one is a
|
||||
// wiring mistake that must not degrade into a silently inert control.
|
||||
if (spec === undefined) throw new Error(`plugin card has no field ${field}`)
|
||||
return spec
|
||||
}
|
||||
|
||||
private snapshotOf(): SettingsScopeSnapshot<T> {
|
||||
return this.scope.getSnapshot()
|
||||
}
|
||||
|
||||
private sectionValue(field: string): unknown {
|
||||
return (this.snapshotOf().value as Record<string, unknown> | undefined)?.[field]
|
||||
}
|
||||
|
||||
private baseValue(field: string): unknown {
|
||||
return (this.snapshotOf().base as Record<string, unknown> | undefined)?.[field]
|
||||
}
|
||||
|
||||
private userLayer(): Record<string, unknown> | undefined {
|
||||
return this.snapshotOf().user as Record<string, unknown> | undefined
|
||||
}
|
||||
|
||||
private stored(field: string): boolean {
|
||||
const user = this.userLayer()
|
||||
return user !== undefined && Object.hasOwn(user, field)
|
||||
}
|
||||
|
||||
private publish(): void {
|
||||
for (const listener of this.listeners) listener()
|
||||
}
|
||||
}
|
||||
113
packages/client/ui-plugin-config/src/client/fields.module.css
Normal file
113
packages/client/ui-plugin-config/src/client/fields.module.css
Normal file
@@ -0,0 +1,113 @@
|
||||
/* Plugin configuration fields: label, control, override badge, and hint. */
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.field + .field {
|
||||
border-top: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
border-radius: 999px;
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
line-height: 17px;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.badgeMuted {
|
||||
border-radius: 999px;
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
line-height: 17px;
|
||||
white-space: nowrap;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.reset {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reset:hover:not(:disabled) {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.reset:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.input {
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.input:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--dsw-alias-brand-primary);
|
||||
}
|
||||
|
||||
.input:disabled {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.inputInvalid {
|
||||
composes: input;
|
||||
border-color: var(--dsw-alias-label-error);
|
||||
}
|
||||
|
||||
.invalid {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--dsw-alias-label-error);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
123
packages/client/ui-plugin-config/src/client/fields.tsx
Normal file
123
packages/client/ui-plugin-config/src/client/fields.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Hand-written controls for the plugin configuration forms. Each renders one
|
||||
* field's label, its staged text, whether saving would leave an override, and
|
||||
* — when one stands — the reset that stages a clear back to the composition
|
||||
* layer. Nothing here writes: a control reports what the user typed, and the
|
||||
* card's save is the single point where a draft becomes a document mutation.
|
||||
*/
|
||||
|
||||
import css from './fields.module.css'
|
||||
|
||||
/** What every field control needs regardless of its value type. */
|
||||
export interface FieldProps {
|
||||
/** Stable id associating the label with its control. */
|
||||
id: string
|
||||
/** Visible label. */
|
||||
label: string
|
||||
/** One-line explanation rendered under the control. */
|
||||
hint: string
|
||||
/** Draft text this control renders. */
|
||||
text: string
|
||||
/** True when saving would leave a user-layer entry for this field. */
|
||||
overridden: boolean
|
||||
/** True when the draft is not a value this field accepts. */
|
||||
invalid: boolean
|
||||
/** Copy for the overridden badge. */
|
||||
overriddenLabel: string
|
||||
/** Copy for the reset control. */
|
||||
resetLabel: string
|
||||
/** Copy shown in place of the hint while the draft is invalid. */
|
||||
invalidLabel: string
|
||||
/** Disables every control (read-only document, or an unavailable namespace). */
|
||||
disabled: boolean
|
||||
/** Stage draft text. */
|
||||
onEdit: (text: string) => void
|
||||
/** Stage a clear so the field re-inherits the composition layer. */
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* A staged value field. `numeric` only hints the keypad: which drafts a field
|
||||
* accepts is decided by its spec, so the control never silently rewrites what
|
||||
* the user typed.
|
||||
* @param props - the field's copy, its staged text, and the edit actions.
|
||||
* @returns the labelled control.
|
||||
*/
|
||||
export function ValueField(props: FieldProps & {
|
||||
/** Hints a numeric keypad without narrowing what the control accepts. */
|
||||
numeric?: boolean
|
||||
/** Placeholder shown while the draft is empty. */
|
||||
placeholder?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={css.field}>
|
||||
<div className={css.head}>
|
||||
<label className={css.label} htmlFor={props.id}>{props.label}</label>
|
||||
{props.overridden
|
||||
? (
|
||||
<span className={css.badges}>
|
||||
<span className={css.badge}>{props.overriddenLabel}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.reset}
|
||||
disabled={props.disabled}
|
||||
onClick={props.onReset}
|
||||
>
|
||||
{props.resetLabel}
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
<input
|
||||
id={props.id}
|
||||
className={props.invalid ? css.inputInvalid : css.input}
|
||||
type="text"
|
||||
{...props.numeric === true ? { inputMode: 'numeric' as const } : {}}
|
||||
{...props.invalid ? { 'aria-invalid': true } : {}}
|
||||
value={props.text}
|
||||
placeholder={props.placeholder ?? ''}
|
||||
disabled={props.disabled}
|
||||
onChange={(event) => { props.onEdit(event.target.value) }}
|
||||
/>
|
||||
<p className={props.invalid ? css.invalid : css.hint}>
|
||||
{props.invalid ? props.invalidLabel : props.hint}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A write-only credential control. The value never rides a response, so the
|
||||
* control reports only whether one is configured and starts blank; a blank
|
||||
* draft writes nothing, which keeps the stored key rather than clearing it.
|
||||
* @param props - the field's copy, its staged text, and the configured state.
|
||||
* @returns the labelled control.
|
||||
*/
|
||||
export function SecretField(props: Pick<FieldProps, 'id' | 'label' | 'hint' | 'text' | 'disabled' | 'onEdit'> & {
|
||||
/** Whether the Host reports a configured credential for this reference. */
|
||||
configured: boolean
|
||||
/** Copy describing the configured state. */
|
||||
stateLabel: string
|
||||
}) {
|
||||
return (
|
||||
<div className={css.field}>
|
||||
<div className={css.head}>
|
||||
<label className={css.label} htmlFor={props.id}>{props.label}</label>
|
||||
<span className={css.badges}>
|
||||
<span className={props.configured ? css.badge : css.badgeMuted}>{props.stateLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
id={props.id}
|
||||
className={css.input}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={props.text}
|
||||
disabled={props.disabled}
|
||||
onChange={(event) => { props.onEdit(event.target.value) }}
|
||||
/>
|
||||
<p className={css.hint}>{props.hint}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
110
packages/client/ui-plugin-config/src/client/index.ts
Normal file
110
packages/client/ui-plugin-config/src/client/index.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Plugin configuration surface, browser half — one settings section holding
|
||||
* an expandable card per Host plugin whose configuration a user owns.
|
||||
*
|
||||
* The section owns no knowledge of any namespace: it declares the
|
||||
* `settings.plugin.item` slot and renders whatever cards were registered into
|
||||
* it, so a plugin that ships a browser half contributes its own card and its
|
||||
* own controls. The three cards this package registers are the host-plane
|
||||
* sections the deployment already exposes; each binds its namespace through
|
||||
* the client settings scope, which keeps them unaware of one another.
|
||||
*/
|
||||
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
// Type-only: the settings shell's SlotMap merge (the 'settings.section' entry)
|
||||
// and the ctx.settingsScope Context merge. Cross-plugin collaboration goes
|
||||
// through the service, never a value import (client bundle purity gate).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: the ctx.remote Context merge and the forwarded-event key face.
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { AgentLoopCard } from './AgentLoopCard.tsx'
|
||||
import { BashCard } from './BashCard.tsx'
|
||||
import { PluginConfigSection } from './PluginConfigSection.tsx'
|
||||
import { WebSearchCard } from './WebSearchCard.tsx'
|
||||
import { AGENT_LOOP_NS, AgentLoopCardController } from './agent-loop-store.ts'
|
||||
import { BASH_NS, BashCardController } from './bash-store.ts'
|
||||
import { WEB_SEARCH_NS, WebSearchCardController } from './web-search-store.ts'
|
||||
import { en, zh } from './locales.ts'
|
||||
|
||||
export type { PluginConfigSectionInjected, PluginConfigSectionProps } from './PluginConfigSection.tsx'
|
||||
export type { PluginCardProps } from './PluginCard.tsx'
|
||||
export type { SettingsPluginItemOwnerProps } from './slot-contract.ts'
|
||||
export type { FieldProps } from './fields.tsx'
|
||||
export type {
|
||||
CardActions, CardFieldSpec, CardFieldState, CardSecretSpec, CardShell,
|
||||
} from './card-store.ts'
|
||||
export type { AgentLoopCardFace, AgentLoopCardState } from './agent-loop-store.ts'
|
||||
export type { BashCardFace, BashCardState } from './bash-store.ts'
|
||||
export type { WebSearchCardFace, WebSearchCardState } from './web-search-store.ts'
|
||||
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
const NS = 'settings.pluginConfig'
|
||||
|
||||
/** Required services (cordis fiber inject). */
|
||||
export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope']
|
||||
|
||||
/**
|
||||
* Mount the plugin configuration section and the cards this package ships.
|
||||
* @param ctx - the browser plugin context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const { api } = ctx.get('connection') as ConnectionHandle
|
||||
const t = ctx.locale.bind(NS)
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plugin-config: section dictionaries')
|
||||
|
||||
const bash = new BashCardController(ctx.settingsScope.bind({ namespace: BASH_NS }))
|
||||
const agentLoop = new AgentLoopCardController(ctx.settingsScope.bind({ namespace: AGENT_LOOP_NS }))
|
||||
const webSearch = new WebSearchCardController(ctx.settingsScope.bind({ namespace: WEB_SEARCH_NS }), api)
|
||||
|
||||
// The credential a card reports is not part of any settings section, so its
|
||||
// scope publishes nothing when one is written. This is the only signal that
|
||||
// a key written on another surface reached the Host.
|
||||
ctx.effect(
|
||||
() => ctx.remote.$on('credentials/updated', (ref) => { webSearch.refreshCredential(ref) }),
|
||||
'ui-plugin-config: credential invalidations',
|
||||
)
|
||||
|
||||
// The section renders the empty line rather than an empty list when no plugin
|
||||
// contributed a card. The count is read once: the renderer caches a root
|
||||
// entry's inject face per registration, so this reports what was registered
|
||||
// when the section mounted, not what is visible now. Both gaps are bounded by
|
||||
// this deployment always registering the three cards below — a card that
|
||||
// arrives later would not raise the count, and a namespace this deployment
|
||||
// does not expose leaves its card rendering nothing inside a non-empty list.
|
||||
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
||||
name: 'settings.section',
|
||||
id: 'plugins',
|
||||
order: 30,
|
||||
label: () => t('nav'),
|
||||
locale: NS,
|
||||
inject: () => ({ cardCount: ctx.slots.entries('settings.plugin.item').length }),
|
||||
children: { 'settings.plugin.item': { kind: 'list', scope: 'root' } },
|
||||
}, PluginConfigSection))
|
||||
|
||||
ctx.slots.inject('settings.plugin.item', function* () {
|
||||
yield ctx.slots.register({
|
||||
name: 'settings.plugin.item',
|
||||
id: 'bash',
|
||||
order: 0,
|
||||
locale: NS,
|
||||
inject: () => bash.inject(),
|
||||
}, BashCard)
|
||||
yield ctx.slots.register({
|
||||
name: 'settings.plugin.item',
|
||||
id: 'agent-loop',
|
||||
order: 10,
|
||||
locale: NS,
|
||||
inject: () => agentLoop.inject(),
|
||||
}, AgentLoopCard)
|
||||
yield ctx.slots.register({
|
||||
name: 'settings.plugin.item',
|
||||
id: 'web-search',
|
||||
order: 20,
|
||||
locale: NS,
|
||||
inject: () => webSearch.inject(),
|
||||
}, WebSearchCard)
|
||||
})
|
||||
}
|
||||
91
packages/client/ui-plugin-config/src/client/locales.ts
Normal file
91
packages/client/ui-plugin-config/src/client/locales.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/** Locale bundles for the plugin configuration section and its plugin cards. */
|
||||
|
||||
/** Locale keys these surfaces render. */
|
||||
export type PluginConfigKey =
|
||||
| 'nav' | 'title' | 'intro' | 'empty'
|
||||
| 'overridden' | 'reset' | 'readOnly' | 'expand' | 'collapse'
|
||||
| 'save' | 'saving' | 'discard' | 'unsaved' | 'saveFailed' | 'invalidNumber'
|
||||
| 'bashTitle' | 'bashDescription' | 'bashTimeoutMs' | 'bashTimeoutMsHint'
|
||||
| 'bashMaxOutputBytes' | 'bashMaxOutputBytesHint'
|
||||
| 'agentLoopTitle' | 'agentLoopDescription' | 'agentLoopMaxParallel' | 'agentLoopMaxParallelHint'
|
||||
| 'webSearchTitle' | 'webSearchDescription'
|
||||
| 'webSearchApiKey' | 'webSearchApiKeyHint' | 'webSearchApiKeySet' | 'webSearchApiKeyUnset'
|
||||
| 'webSearchBaseUrl' | 'webSearchBaseUrlHint' | 'webSearchMaxUses' | 'webSearchMaxUsesHint'
|
||||
|
||||
/** English copy. */
|
||||
export const en: Record<PluginConfigKey, string> = {
|
||||
nav: 'Plugin config',
|
||||
title: 'Plugin configuration',
|
||||
intro: 'Configure the plugins this deployment installed.',
|
||||
empty: 'This deployment exposes no plugin settings.',
|
||||
overridden: 'Overridden',
|
||||
reset: 'Reset to default',
|
||||
readOnly: 'This deployment stores settings read-only.',
|
||||
expand: 'Show settings',
|
||||
collapse: 'Hide settings',
|
||||
save: 'Save',
|
||||
saving: 'Saving…',
|
||||
discard: 'Discard',
|
||||
unsaved: 'Unsaved',
|
||||
saveFailed: 'The deployment did not accept these values; they were left for you to correct.',
|
||||
invalidNumber: 'Enter a number, or leave blank to use the default.',
|
||||
bashTitle: 'Shell',
|
||||
bashDescription: 'Limits every command the agent runs.',
|
||||
bashTimeoutMs: 'Command timeout (ms)',
|
||||
bashTimeoutMsHint: 'How long one command may run before it is terminated.',
|
||||
bashMaxOutputBytes: 'Output cap per stream (bytes)',
|
||||
bashMaxOutputBytesHint: 'Output beyond this spills to a temporary file rather than being lost.',
|
||||
agentLoopTitle: 'Agent loop',
|
||||
agentLoopDescription: 'How the agent dispatches tool calls.',
|
||||
agentLoopMaxParallel: 'Parallel tool calls',
|
||||
agentLoopMaxParallelHint: 'Upper bound on parallel-safe calls running at once within one step.',
|
||||
webSearchTitle: 'Web search',
|
||||
webSearchDescription: 'The DeepSeek search provider.',
|
||||
webSearchApiKey: 'API key',
|
||||
webSearchApiKeyHint: 'Stored outside the settings file. Leave blank to keep the current key.',
|
||||
webSearchApiKeySet: 'A key is configured.',
|
||||
webSearchApiKeyUnset: 'No key is configured; search is unavailable until one is.',
|
||||
webSearchBaseUrl: 'Endpoint',
|
||||
webSearchBaseUrlHint: 'Leave blank to use the provider default.',
|
||||
webSearchMaxUses: 'Max searches per request',
|
||||
webSearchMaxUsesHint: 'How many times one request may search before it must answer.',
|
||||
}
|
||||
|
||||
/** Simplified Chinese copy. */
|
||||
export const zh: Record<PluginConfigKey, string> = {
|
||||
nav: '插件配置',
|
||||
title: '插件配置',
|
||||
intro: '配置本部署已安装的插件。',
|
||||
empty: '本部署没有开放任何插件设置。',
|
||||
overridden: '已覆盖',
|
||||
reset: '恢复默认',
|
||||
readOnly: '本部署的设置为只读。',
|
||||
expand: '展开设置',
|
||||
collapse: '收起设置',
|
||||
save: '保存',
|
||||
saving: '保存中…',
|
||||
discard: '放弃修改',
|
||||
unsaved: '未保存',
|
||||
saveFailed: '本部署没有接受这些值,已保留供你修改。',
|
||||
invalidNumber: '请填数字;留空表示使用默认值。',
|
||||
bashTitle: '终端',
|
||||
bashDescription: '限制 agent 运行的每一条命令。',
|
||||
bashTimeoutMs: '命令超时(毫秒)',
|
||||
bashTimeoutMsHint: '单条命令允许运行多久,超时即终止。',
|
||||
bashMaxOutputBytes: '单流输出上限(字节)',
|
||||
bashMaxOutputBytesHint: '超出部分会转存到临时文件,而不是被丢弃。',
|
||||
agentLoopTitle: 'Agent 循环',
|
||||
agentLoopDescription: 'Agent 如何派发工具调用。',
|
||||
agentLoopMaxParallel: '并行工具调用数',
|
||||
agentLoopMaxParallelHint: '同一步内最多同时运行多少个可并行的调用。',
|
||||
webSearchTitle: '网页搜索',
|
||||
webSearchDescription: 'DeepSeek 搜索提供方。',
|
||||
webSearchApiKey: 'API Key',
|
||||
webSearchApiKeyHint: '不写入设置文件。留空表示保持当前密钥。',
|
||||
webSearchApiKeySet: '已配置密钥。',
|
||||
webSearchApiKeyUnset: '未配置密钥;配置之前搜索不可用。',
|
||||
webSearchBaseUrl: '接口地址',
|
||||
webSearchBaseUrlHint: '留空则使用提供方默认地址。',
|
||||
webSearchMaxUses: '单次请求最多搜索次数',
|
||||
webSearchMaxUsesHint: '一次请求在必须作答前最多可以搜索多少次。',
|
||||
}
|
||||
24
packages/client/ui-plugin-config/src/client/slot-contract.ts
Normal file
24
packages/client/ui-plugin-config/src/client/slot-contract.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* The `settings.plugin.item` slot type — one plugin's card inside the plugin
|
||||
* configuration section. Options: `id` (card key), `order` (card position).
|
||||
* A card draws its own internals; the section only stacks them and reports
|
||||
* how many there are.
|
||||
*
|
||||
* TYPE HOME RATIONALE: unlike `settings.general.item`, whose registrants span
|
||||
* packages that cannot reference its declarer, every current registrant of
|
||||
* this slot ships in this package, and a plugin registering its own card
|
||||
* already depends on this package for the card chrome. The type therefore
|
||||
* lives with the section that declares it at runtime.
|
||||
*/
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
/** One plugin's card inside the plugin configuration section (see module JSDoc). */
|
||||
'settings.plugin.item': { kind: 'list'; scope: 'root'; owner: SettingsPluginItemOwnerProps }
|
||||
}
|
||||
}
|
||||
|
||||
/** Owner share of a plugin card (the section supplies nothing). */
|
||||
export interface SettingsPluginItemOwnerProps {
|
||||
/** Marker field: card owner props are intentionally empty. */
|
||||
children?: never
|
||||
}
|
||||
192
packages/client/ui-plugin-config/src/client/web-search-store.ts
Normal file
192
packages/client/ui-plugin-config/src/client/web-search-store.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* The web-search card's staged form over the `web-search-deepseek` settings
|
||||
* namespace.
|
||||
*
|
||||
* The key is the one control that does not live in the section: its literal
|
||||
* never rides a response, so the card learns only whether one is configured
|
||||
* and writes it through the credentials domain, addressed by the reference the
|
||||
* section names. It is still staged with the rest of the form, so one save
|
||||
* covers everything the card shows.
|
||||
*/
|
||||
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SettingsScope, SettingsScopeSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
CardForm, numberField, textField,
|
||||
type CardActions, type CardFieldState, type CardShell,
|
||||
} from './card-store.ts'
|
||||
|
||||
/**
|
||||
* Namespace of the DeepSeek search provider. Spelled here rather than
|
||||
* imported: a client package must not depend on a Host package.
|
||||
*/
|
||||
export const WEB_SEARCH_NS = 'web-search-deepseek'
|
||||
|
||||
/** Credential reference the provider resolves when the section names none. */
|
||||
const DEFAULT_API_KEY_REF = 'DEEPSEEK_API_KEY'
|
||||
|
||||
/** Form field the credential control stages under. */
|
||||
const API_KEY_FIELD = 'apiKey'
|
||||
|
||||
/** The search-provider fields this card edits. */
|
||||
export interface WebSearchSettings {
|
||||
/** Credential reference naming the environment key. */
|
||||
apiKeyEnv?: string
|
||||
/** Provider endpoint; blank inherits the provider default. */
|
||||
baseURL?: string
|
||||
/** Maximum searches served within one request. */
|
||||
maxUses?: number
|
||||
}
|
||||
|
||||
/** What the credentials domain last reported, and for which reference. */
|
||||
interface CredentialState {
|
||||
/** Reference this answer describes; a stale response for another one is dropped. */
|
||||
ref: string
|
||||
/** Whether any layer supplies a value for it. */
|
||||
configured: boolean
|
||||
/** Whether `credentials.set` can affect it; false disables the control. */
|
||||
writable: boolean
|
||||
}
|
||||
|
||||
/** What the web-search card renders. */
|
||||
export interface WebSearchCardState extends CardShell {
|
||||
/** Provider endpoint. */
|
||||
baseURL: CardFieldState
|
||||
/** Searches allowed per request. */
|
||||
maxUses: CardFieldState
|
||||
/** The staged credential, which starts blank on every load. */
|
||||
apiKey: CardFieldState
|
||||
/** Whether the Host reports a credential configured for the referenced key. */
|
||||
apiKeyConfigured: boolean
|
||||
/** Whether the credentials domain accepts a write for it; false disables the control. */
|
||||
apiKeyWritable: boolean
|
||||
}
|
||||
|
||||
/** The registration-side face the web-search card's slot entry injects. */
|
||||
export interface WebSearchCardFace extends CardActions {
|
||||
hooks: {
|
||||
/** Card snapshot bound by the renderer as useWebSearchCard. */
|
||||
webSearchCard: SnapshotStore<WebSearchCardState>
|
||||
}
|
||||
}
|
||||
|
||||
/** Bridges the `web-search-deepseek` scope and the credentials domain onto the card. */
|
||||
export class WebSearchCardController {
|
||||
private readonly form: CardForm<WebSearchSettings>
|
||||
private readonly store: SnapshotStore<WebSearchCardState>
|
||||
private credential: CredentialState = { ref: '', configured: false, writable: true }
|
||||
|
||||
/**
|
||||
* @param scope - the bound settings scope for the `web-search-deepseek` namespace.
|
||||
* @param api - wire face used for the credential the section references.
|
||||
*/
|
||||
constructor(
|
||||
private readonly scope: SettingsScope<WebSearchSettings>,
|
||||
private readonly api: Pick<IApiClient, 'credentials'>,
|
||||
) {
|
||||
this.form = new CardForm(
|
||||
scope,
|
||||
[textField('baseURL'), numberField('maxUses')],
|
||||
[{ field: API_KEY_FIELD, write: text => this.writeKey(text) }],
|
||||
)
|
||||
this.store = this.form.bind(() => this.projection())
|
||||
scope.subscribe(() => { void this.readCredential() })
|
||||
void this.readCredential()
|
||||
}
|
||||
|
||||
private projection(): WebSearchCardState {
|
||||
return {
|
||||
...this.form.shell(),
|
||||
baseURL: this.form.field('baseURL'),
|
||||
maxUses: this.form.field('maxUses'),
|
||||
apiKey: this.form.field(API_KEY_FIELD),
|
||||
apiKeyConfigured: this.credential.configured,
|
||||
apiKeyWritable: this.credential.writable,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the credentials domain about the reference the section currently names.
|
||||
*
|
||||
* The answer is stored with the reference it describes: `apiKeyEnv` can
|
||||
* change between the request and its response, and two reads can settle out
|
||||
* of order, so a response is published only while it still answers for the
|
||||
* reference in force.
|
||||
*/
|
||||
private async readCredential(): Promise<void> {
|
||||
const ref = refOf(this.scope.getSnapshot())
|
||||
if (ref !== this.credential.ref) {
|
||||
// A new reference knows nothing yet; keeping the old answer would claim
|
||||
// the key is configured under a name nobody has checked.
|
||||
this.credential = { ref, configured: false, writable: true }
|
||||
this.store.set(this.projection())
|
||||
}
|
||||
let response: Awaited<ReturnType<IApiClient['credentials']['describe']>>
|
||||
try {
|
||||
response = await this.api.credentials.describe({ refs: [ref] })
|
||||
} catch (_credentialReadFailure) {
|
||||
// The card stays usable without this: the key control simply reports the
|
||||
// last state it knew, and a write still reaches the Host.
|
||||
return
|
||||
}
|
||||
if (!response.result.ok || ref !== refOf(this.scope.getSnapshot())) return
|
||||
const view = response.result.value.credentials[ref]
|
||||
const next: CredentialState = {
|
||||
ref,
|
||||
configured: view?.configured ?? false,
|
||||
// An unknown reference is treated as writable: the control stays usable
|
||||
// and the Host is what refuses, rather than the card guessing a refusal.
|
||||
writable: view?.writable ?? true,
|
||||
}
|
||||
if (next.configured === this.credential.configured && next.writable === this.credential.writable) return
|
||||
this.credential = next
|
||||
this.store.set(this.projection())
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read after the Host reports a change to the reference this card watches.
|
||||
*
|
||||
* A key can be written from somewhere else — the Models page addresses the
|
||||
* same reference — and the settings section does not change when it is, so
|
||||
* without this the badge keeps reporting a state the Host already replaced.
|
||||
* @param ref - the reference the Host reports as changed.
|
||||
*/
|
||||
refreshCredential(ref: string): void {
|
||||
if (ref !== this.credential.ref) return
|
||||
void this.readCredential()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the face the card's slot registration injects.
|
||||
* @returns the card's snapshot and its form actions.
|
||||
*/
|
||||
inject(): WebSearchCardFace {
|
||||
return { hooks: { webSearchCard: this.store }, ...this.form.actions() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the staged key, then re-read whether the Host now holds one.
|
||||
* @param value - the staged credential literal.
|
||||
* @returns whether the Host reports a configured credential afterwards.
|
||||
*/
|
||||
private async writeKey(value: string): Promise<boolean> {
|
||||
try {
|
||||
await this.api.credentials.set({ ref: refOf(this.scope.getSnapshot()), value })
|
||||
} catch (_credentialWriteFailure) {
|
||||
// Refusals surface through the re-read below: the Host is the only
|
||||
// authority on whether the key now exists.
|
||||
}
|
||||
await this.readCredential()
|
||||
return this.credential.configured
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The credential reference the section names, or the provider's default.
|
||||
* @param snapshot - the current scope snapshot.
|
||||
* @returns the reference to address.
|
||||
*/
|
||||
function refOf(snapshot: SettingsScopeSnapshot<WebSearchSettings>): string {
|
||||
const declared = snapshot.value?.apiKeyEnv
|
||||
return declared !== undefined && declared.length > 0 ? declared : DEFAULT_API_KEY_REF
|
||||
}
|
||||
4
packages/client/ui-plugin-config/src/css-modules.d.ts
vendored
Normal file
4
packages/client/ui-plugin-config/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
11
packages/client/ui-plugin-config/src/index.ts
Normal file
11
packages/client/ui-plugin-config/src/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Plugin configuration surface, node half. The empty apply exists so the
|
||||
* plugin appears in the host cordis.yml / Loader; the browser half ships the
|
||||
* settings section through exports["./client"], discovered from the
|
||||
* package.json dsh.client declaration. Every section this page edits is owned
|
||||
* by the Host plugin that registered it, so this package registers no
|
||||
* namespace of its own.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for this surface plugin. */
|
||||
export function apply(): void {}
|
||||
31
packages/client/ui-plugin-config/src/invariant.ts
Normal file
31
packages/client/ui-plugin-config/src/invariant.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-plugin-config`.
|
||||
* @module @deepseek-ai/dsh-client-ui-plugin-config/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-plugin-config'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-plugin-config-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this is a browser-side settings surface whose node half owns no event
|
||||
* stream or mutable runtime data; the layering, write refusals, and exposure boundary are Host
|
||||
* contracts covered by the owning plugins and the api-proxy.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
134
packages/client/ui-plugin-config/tests/apply.spec.ts
Normal file
134
packages/client/ui-plugin-config/tests/apply.spec.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/** What the browser half registers, and that it all leaves with the fiber. */
|
||||
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { SettingsScopeService } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-plugin-config/client'
|
||||
|
||||
// The service reads its initial locale from the browser; these specs assert
|
||||
// the shipped Chinese copy, so they state the browser they assume.
|
||||
usePinnedBrowserLanguages('zh-CN')
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const locale = new LocaleService(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
const describeCredentials = vi.fn(() => Promise.resolve({ rpcId: 'c', result: { ok: false, error: {} } }))
|
||||
// The section binds its scopes through the Settings surface's service, and
|
||||
// forwarded Host events reach it through the same `$dispatch` handoff the
|
||||
// connection sink makes.
|
||||
new TestRemote(ctx)
|
||||
ctx.provide('connection', {
|
||||
isLoopback: true,
|
||||
api: {
|
||||
settings: { describe: vi.fn(() => Promise.resolve({ rpcId: 's', result: { ok: false, error: {} } })) },
|
||||
credentials: { describe: describeCredentials },
|
||||
},
|
||||
} as never)
|
||||
await ctx.plugin(SettingsScopeService).await()
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService, describeCredentials }
|
||||
}
|
||||
|
||||
function declareRoot(slots: SlotsService): () => void {
|
||||
return slots.register({
|
||||
name: 'root',
|
||||
children: { 'settings.section': { kind: 'list', scope: 'root' } },
|
||||
} as never, () => null)
|
||||
}
|
||||
|
||||
describe('ui-plugin-config apply', () => {
|
||||
it('declares the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope'])
|
||||
})
|
||||
|
||||
it('registers the section and declares the per-plugin card slot', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
|
||||
const section = slots.entries('settings.section')[0]!
|
||||
expect(section.options).toMatchObject({ id: 'plugins', order: 30 })
|
||||
// The nav label is a locale-following thunk; owners resolve it at read time.
|
||||
expect(resolveSlotLabel(section.options.label)).toBe('插件配置')
|
||||
expect(slots.spec('settings.plugin.item')).toMatchObject({ kind: 'list', scope: 'root' })
|
||||
})
|
||||
|
||||
it('registers one card per host-plane section it ships, in a stable order', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
|
||||
expect(slots.entries('settings.plugin.item').map(entry => entry.options.id))
|
||||
.toEqual(['bash', 'agent-loop', 'web-search'])
|
||||
})
|
||||
|
||||
it('injects a live card count and one business face per card', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
|
||||
const section = slots.entries('settings.section')[0]!
|
||||
expect((section as { inject?: () => unknown }).inject?.()).toEqual({ cardCount: 3 })
|
||||
for (const entry of slots.entries('settings.plugin.item')) {
|
||||
const face = (entry as { inject?: () => unknown }).inject?.() as { hooks: Record<string, unknown> }
|
||||
// Each card injects exactly one snapshot store plus its own actions.
|
||||
expect(Object.keys(face.hooks)).toHaveLength(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('re-reads the credential when the Host reports the watched reference changed', async () => {
|
||||
const { ctx, slots, describeCredentials } = await bench()
|
||||
declareRoot(slots)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() })
|
||||
describeCredentials.mockClear()
|
||||
|
||||
// A key written on another surface changes no settings section, so this
|
||||
// event is the only thing that reaches the card.
|
||||
ctx.remote.$dispatch('credentials/updated', ['DEEPSEEK_API_KEY'])
|
||||
|
||||
await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalledTimes(1) })
|
||||
})
|
||||
|
||||
it('ignores a credential change for a reference no card watches', async () => {
|
||||
const { ctx, slots, describeCredentials } = await bench()
|
||||
declareRoot(slots)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() })
|
||||
describeCredentials.mockClear()
|
||||
|
||||
ctx.remote.$dispatch('credentials/updated', ['SOME_OTHER_KEY'])
|
||||
await Promise.resolve()
|
||||
|
||||
expect(describeCredentials).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('registers into a declaration that arrives after apply', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
|
||||
declareRoot(slots)
|
||||
|
||||
await vi.waitFor(() => { expect(slots.entries('settings.section')).toHaveLength(1) })
|
||||
})
|
||||
|
||||
it('collapses every contribution on teardown', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(slots.entries('settings.plugin.item')).toHaveLength(3)
|
||||
|
||||
await fiber.dispose()
|
||||
|
||||
expect(slots.entries('settings.section')).toHaveLength(0)
|
||||
expect(slots.spec('settings.plugin.item')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
156
packages/client/ui-plugin-config/tests/fields.spec.tsx
Normal file
156
packages/client/ui-plugin-config/tests/fields.spec.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Field-control behavior: what a control renders for a staged draft, how an
|
||||
* overridden field offers its reset, and that a control never writes on its own.
|
||||
*/
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SecretField, ValueField } from '../src/client/fields.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const frame = {
|
||||
id: 'field',
|
||||
label: 'Command timeout',
|
||||
hint: 'How long one command may run.',
|
||||
overriddenLabel: 'Overridden',
|
||||
resetLabel: 'Reset to default',
|
||||
invalidLabel: 'Enter a number.',
|
||||
disabled: false,
|
||||
overridden: false,
|
||||
invalid: false,
|
||||
}
|
||||
|
||||
describe('ValueField', () => {
|
||||
it('stages every keystroke without writing', () => {
|
||||
const onEdit = vi.fn()
|
||||
render(<ValueField {...frame} text="60000" onEdit={onEdit} onReset={vi.fn()} />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Command timeout'), { target: { value: '9000' } })
|
||||
|
||||
expect(onEdit).toHaveBeenCalledWith('9000')
|
||||
})
|
||||
|
||||
it('renders the staged text it is given rather than a draft of its own', () => {
|
||||
const { rerender } = render(<ValueField {...frame} text="60000" onEdit={vi.fn()} onReset={vi.fn()} />)
|
||||
expect(screen.getByLabelText('Command timeout')).toHaveProperty('value', '60000')
|
||||
|
||||
rerender(<ValueField {...frame} text="9000" onEdit={vi.fn()} onReset={vi.fn()} />)
|
||||
|
||||
expect(screen.getByLabelText('Command timeout')).toHaveProperty('value', '9000')
|
||||
})
|
||||
|
||||
it('offers the reset only while an override would stand', () => {
|
||||
const onReset = vi.fn()
|
||||
const { rerender } = render(<ValueField {...frame} text="9000" onEdit={vi.fn()} onReset={onReset} />)
|
||||
expect(screen.queryByRole('button', { name: 'Reset to default' })).toBeNull()
|
||||
|
||||
rerender(<ValueField {...frame} overridden text="9000" onEdit={vi.fn()} onReset={onReset} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reset to default' }))
|
||||
|
||||
expect(screen.getByText('Overridden')).toBeTruthy()
|
||||
expect(onReset).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('replaces the hint with the reason an invalid draft cannot be saved', () => {
|
||||
render(<ValueField {...frame} invalid text="soon" onEdit={vi.fn()} onReset={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('Enter a number.')).toBeTruthy()
|
||||
expect(screen.queryByText('How long one command may run.')).toBeNull()
|
||||
expect(screen.getByLabelText('Command timeout').getAttribute('aria-invalid')).toBe('true')
|
||||
})
|
||||
|
||||
it('hints a numeric keypad and renders a placeholder when asked', () => {
|
||||
render(
|
||||
<ValueField
|
||||
{...frame}
|
||||
numeric
|
||||
placeholder="https://api.deepseek.com"
|
||||
text=""
|
||||
onEdit={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
const input = screen.getByLabelText('Command timeout')
|
||||
|
||||
expect(input.getAttribute('inputmode')).toBe('numeric')
|
||||
expect(input).toHaveProperty('placeholder', 'https://api.deepseek.com')
|
||||
})
|
||||
|
||||
it('disables the control and its reset while the document is read-only', () => {
|
||||
render(<ValueField {...frame} disabled overridden text="9000" onEdit={vi.fn()} onReset={vi.fn()} />)
|
||||
|
||||
expect(screen.getByLabelText('Command timeout')).toHaveProperty('disabled', true)
|
||||
expect(screen.getByRole('button', { name: 'Reset to default' })).toHaveProperty('disabled', true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SecretField', () => {
|
||||
const secret = {
|
||||
id: 'key',
|
||||
label: 'API key',
|
||||
hint: 'Stored outside the settings file.',
|
||||
disabled: false,
|
||||
}
|
||||
|
||||
it('stages the draft and never renders it', () => {
|
||||
const onEdit = vi.fn()
|
||||
render(
|
||||
<SecretField
|
||||
{...secret}
|
||||
text=""
|
||||
configured={false}
|
||||
stateLabel="No key is configured."
|
||||
onEdit={onEdit}
|
||||
/>,
|
||||
)
|
||||
const input = screen.getByLabelText('API key')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'ds-secret' } })
|
||||
|
||||
expect(onEdit).toHaveBeenCalledWith('ds-secret')
|
||||
expect(input).toHaveProperty('type', 'password')
|
||||
})
|
||||
|
||||
it('reports the configured state the Host holds', () => {
|
||||
const { rerender } = render(
|
||||
<SecretField
|
||||
{...secret}
|
||||
text=""
|
||||
configured={false}
|
||||
stateLabel="No key is configured."
|
||||
onEdit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('No key is configured.')).toBeTruthy()
|
||||
|
||||
rerender(
|
||||
<SecretField
|
||||
{...secret}
|
||||
text="ds-secret"
|
||||
configured
|
||||
stateLabel="A key is configured."
|
||||
onEdit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('A key is configured.')).toBeTruthy()
|
||||
expect(screen.getByLabelText('API key')).toHaveProperty('value', 'ds-secret')
|
||||
})
|
||||
|
||||
it('disables the control when it is told to', () => {
|
||||
render(
|
||||
<SecretField
|
||||
{...secret}
|
||||
disabled
|
||||
text=""
|
||||
configured
|
||||
stateLabel="A key is configured."
|
||||
onEdit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByLabelText('API key')).toHaveProperty('disabled', true)
|
||||
})
|
||||
})
|
||||
25
packages/client/ui-plugin-config/tests/invariant.spec.ts
Normal file
25
packages/client/ui-plugin-config/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/** The package's node half: an empty host body and an explained empty invariant companion. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as PluginConfigInvariant from '@deepseek-ai/dsh-client-ui-plugin-config/invariant'
|
||||
|
||||
describe('invariant companion', () => {
|
||||
it('reserves package ownership with an empty installer', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
|
||||
await expect(ctx.plugin(PluginConfigInvariant).await()).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('has an empty node half', async () => {
|
||||
const { apply } = await import('@deepseek-ai/dsh-client-ui-plugin-config')
|
||||
|
||||
// The host body exists only so the plugin appears in the host cordis.yml;
|
||||
// every surface this package ships lives in the browser half.
|
||||
apply()
|
||||
|
||||
expect(typeof apply).toBe('function')
|
||||
})
|
||||
})
|
||||
325
packages/client/ui-plugin-config/tests/section.spec.tsx
Normal file
325
packages/client/ui-plugin-config/tests/section.spec.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* What the section and its cards show: the empty line when no plugin
|
||||
* contributed one, a card that renders nothing while its namespace is
|
||||
* unavailable, and the save footer that decides when staged edits are written.
|
||||
*/
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { AgentLoopCard } from '../src/client/AgentLoopCard.tsx'
|
||||
import type { AgentLoopCardProps } from '../src/client/AgentLoopCard.tsx'
|
||||
import { BashCard } from '../src/client/BashCard.tsx'
|
||||
import type { BashCardProps } from '../src/client/BashCard.tsx'
|
||||
import { PluginConfigSection } from '../src/client/PluginConfigSection.tsx'
|
||||
import type { PluginConfigSectionProps } from '../src/client/PluginConfigSection.tsx'
|
||||
import { WebSearchCard } from '../src/client/WebSearchCard.tsx'
|
||||
import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx'
|
||||
import type { AgentLoopCardState } from '../src/client/agent-loop-store.ts'
|
||||
import type { BashCardState } from '../src/client/bash-store.ts'
|
||||
import type { CardFieldState, CardShell } from '../src/client/card-store.ts'
|
||||
import type { WebSearchCardState } from '../src/client/web-search-store.ts'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const t = (key: keyof typeof en) => en[key]
|
||||
|
||||
/** A settled form: nothing staged, everything served. */
|
||||
const settled: CardShell = {
|
||||
available: true,
|
||||
writable: true,
|
||||
dirty: false,
|
||||
invalid: false,
|
||||
saving: false,
|
||||
failed: false,
|
||||
}
|
||||
|
||||
/** One control's state, defaulting to an inherited value. */
|
||||
function field(text: string, rest: Partial<CardFieldState> = {}): CardFieldState {
|
||||
return { text, overridden: false, invalid: false, ...rest }
|
||||
}
|
||||
|
||||
function cardActions() {
|
||||
return { edit: vi.fn(), resetField: vi.fn(), save: vi.fn(), discard: vi.fn() }
|
||||
}
|
||||
|
||||
function renderSection(cardCount: number, cards = 'cards') {
|
||||
const props = {
|
||||
t,
|
||||
cardCount,
|
||||
renderSlot: () => <li>{cards}</li>,
|
||||
} as unknown as PluginConfigSectionProps
|
||||
render(<PluginConfigSection {...props} />)
|
||||
}
|
||||
|
||||
function renderBash(state: Partial<BashCardState> = {}) {
|
||||
const store = createSnapshotStore<BashCardState>({
|
||||
...settled,
|
||||
timeoutMs: field('60000'),
|
||||
maxOutputBytes: field('64000'),
|
||||
...state,
|
||||
})
|
||||
const actions = cardActions()
|
||||
const props = { ...actions, t, useBashCard: bindSnapshotSelector(store) } as unknown as BashCardProps
|
||||
render(<BashCard {...props} />)
|
||||
return actions
|
||||
}
|
||||
|
||||
describe('PluginConfigSection', () => {
|
||||
it('says so when no plugin contributed a card', () => {
|
||||
renderSection(0)
|
||||
|
||||
expect(screen.getByText(en.empty)).toBeTruthy()
|
||||
expect(screen.queryByText('cards')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the card list once a plugin contributed one', () => {
|
||||
renderSection(1)
|
||||
|
||||
expect(screen.getByText('cards')).toBeTruthy()
|
||||
expect(screen.queryByText(en.empty)).toBeNull()
|
||||
})
|
||||
|
||||
it('leads with its own heading and intro', () => {
|
||||
renderSection(1)
|
||||
|
||||
expect(screen.getByRole('heading', { name: en.title })).toBeTruthy()
|
||||
expect(screen.getByText(en.intro)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BashCard', () => {
|
||||
it('renders nothing while its namespace is unavailable', () => {
|
||||
const { container } = render(<div />)
|
||||
renderBash({ available: false })
|
||||
|
||||
expect(container.textContent).toBe('')
|
||||
expect(screen.queryByText(en.bashTitle)).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the plugin and reveals its fields only once expanded', () => {
|
||||
renderBash()
|
||||
expect(screen.getByText(en.bashTitle)).toBeTruthy()
|
||||
expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
|
||||
expect(screen.getByLabelText(en.bashTimeoutMs)).toBeTruthy()
|
||||
expect(screen.getByLabelText(en.bashMaxOutputBytes)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('stages an edit instead of writing it', () => {
|
||||
const actions = renderBash()
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.bashTimeoutMs), { target: { value: '9000' } })
|
||||
|
||||
expect(actions.edit).toHaveBeenCalledWith('timeoutMs', '9000')
|
||||
expect(actions.save).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('offers the reset for an overridden field only', () => {
|
||||
const actions = renderBash({ timeoutMs: field('9000', { overridden: true }) })
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
|
||||
// One badge and one reset: the output cap is still inherited.
|
||||
expect(screen.getAllByText(en.overridden)).toHaveLength(1)
|
||||
fireEvent.click(screen.getByRole('button', { name: en.reset }))
|
||||
|
||||
expect(actions.resetField).toHaveBeenCalledWith('timeoutMs')
|
||||
})
|
||||
|
||||
it('addresses each of its two fields separately', () => {
|
||||
const actions = renderBash({ maxOutputBytes: field('64000', { overridden: true }) })
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.bashMaxOutputBytes), { target: { value: '1024' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.reset }))
|
||||
|
||||
expect(actions.edit).toHaveBeenCalledWith('maxOutputBytes', '1024')
|
||||
expect(actions.resetField).toHaveBeenCalledWith('maxOutputBytes')
|
||||
})
|
||||
|
||||
it('keeps save and discard inert until something is staged', () => {
|
||||
renderBash()
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
|
||||
expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true)
|
||||
expect(screen.getByRole('button', { name: en.discard })).toHaveProperty('disabled', true)
|
||||
expect(screen.queryByText(en.unsaved)).toBeNull()
|
||||
})
|
||||
|
||||
it('writes the staged edits when saved, and drops them when discarded', () => {
|
||||
const actions = renderBash({ dirty: true, timeoutMs: field('9000', { overridden: true }) })
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: en.save }))
|
||||
fireEvent.click(screen.getByRole('button', { name: en.discard }))
|
||||
|
||||
expect(actions.save).toHaveBeenCalledOnce()
|
||||
expect(actions.discard).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('marks a card holding unsaved edits, collapsed or not', () => {
|
||||
renderBash({ dirty: true })
|
||||
|
||||
expect(screen.getByText(en.unsaved)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('blocks the save while a draft is invalid, and says why', () => {
|
||||
renderBash({ dirty: true, invalid: true, timeoutMs: field('soon', { invalid: true }) })
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
|
||||
expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true)
|
||||
expect(screen.getByRole('button', { name: en.discard })).toHaveProperty('disabled', false)
|
||||
expect(screen.getByText(en.invalidNumber)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('reports a save in flight and refuses another', () => {
|
||||
renderBash({ dirty: true, saving: true })
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
|
||||
expect(screen.getByRole('button', { name: en.saving })).toHaveProperty('disabled', true)
|
||||
expect(screen.getByRole('button', { name: en.discard })).toHaveProperty('disabled', true)
|
||||
})
|
||||
|
||||
it('reports a save the deployment did not accept', () => {
|
||||
renderBash({ dirty: true, failed: true })
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
|
||||
expect(screen.getByText(en.saveFailed)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('says the document is read-only and disables its controls', () => {
|
||||
renderBash({ writable: false })
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
|
||||
expect(screen.getByRole('status')).toHaveProperty('textContent', en.readOnly)
|
||||
expect(screen.getByLabelText(en.bashTimeoutMs)).toHaveProperty('disabled', true)
|
||||
})
|
||||
|
||||
it('collapses again on a second click', () => {
|
||||
renderBash()
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
expect(screen.getByLabelText(en.bashTimeoutMs)).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByText(en.bashTitle))
|
||||
|
||||
expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentLoopCard', () => {
|
||||
it('stages and saves the only field it owns', () => {
|
||||
const store = createSnapshotStore<AgentLoopCardState>({
|
||||
...settled,
|
||||
dirty: true,
|
||||
maxParallelToolCalls: field('10'),
|
||||
})
|
||||
const actions = cardActions()
|
||||
const props = {
|
||||
...actions,
|
||||
t,
|
||||
useAgentLoopCard: bindSnapshotSelector(store),
|
||||
} as unknown as AgentLoopCardProps
|
||||
render(<AgentLoopCard {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByText(en.agentLoopTitle))
|
||||
fireEvent.change(screen.getByLabelText(en.agentLoopMaxParallel), { target: { value: '2' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.save }))
|
||||
|
||||
expect(actions.edit).toHaveBeenCalledWith('maxParallelToolCalls', '2')
|
||||
expect(actions.save).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('stages a reset for the field it owns', () => {
|
||||
const store = createSnapshotStore<AgentLoopCardState>({
|
||||
...settled,
|
||||
maxParallelToolCalls: field('2', { overridden: true }),
|
||||
})
|
||||
const actions = cardActions()
|
||||
const props = {
|
||||
...actions,
|
||||
t,
|
||||
useAgentLoopCard: bindSnapshotSelector(store),
|
||||
} as unknown as AgentLoopCardProps
|
||||
render(<AgentLoopCard {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByText(en.agentLoopTitle))
|
||||
fireEvent.click(screen.getByRole('button', { name: en.reset }))
|
||||
|
||||
expect(actions.resetField).toHaveBeenCalledWith('maxParallelToolCalls')
|
||||
})
|
||||
})
|
||||
|
||||
describe('WebSearchCard', () => {
|
||||
function renderWebSearch(state: Partial<WebSearchCardState> = {}) {
|
||||
const store = createSnapshotStore<WebSearchCardState>({
|
||||
...settled,
|
||||
baseURL: field(''),
|
||||
maxUses: field('5'),
|
||||
apiKey: field(''),
|
||||
apiKeyConfigured: false,
|
||||
apiKeyWritable: true,
|
||||
...state,
|
||||
})
|
||||
const actions = cardActions()
|
||||
const props = { ...actions, t, useWebSearchCard: bindSnapshotSelector(store) } as unknown as WebSearchCardProps
|
||||
render(<WebSearchCard {...props} />)
|
||||
return actions
|
||||
}
|
||||
|
||||
it('reports whether a key is configured without ever showing one', () => {
|
||||
renderWebSearch({ apiKeyConfigured: true })
|
||||
fireEvent.click(screen.getByText(en.webSearchTitle))
|
||||
|
||||
expect(screen.getByText(en.webSearchApiKeySet)).toBeTruthy()
|
||||
expect(screen.getByLabelText(en.webSearchApiKey)).toHaveProperty('type', 'password')
|
||||
})
|
||||
|
||||
it('keeps the key control usable while the settings document is read-only', () => {
|
||||
const actions = renderWebSearch({ writable: false })
|
||||
fireEvent.click(screen.getByText(en.webSearchTitle))
|
||||
|
||||
const key = screen.getByLabelText(en.webSearchApiKey)
|
||||
expect(key).toHaveProperty('disabled', false)
|
||||
expect(screen.getByLabelText(en.webSearchBaseUrl)).toHaveProperty('disabled', true)
|
||||
|
||||
fireEvent.change(key, { target: { value: 'ds-secret' } })
|
||||
|
||||
expect(actions.edit).toHaveBeenCalledWith('apiKey', 'ds-secret')
|
||||
})
|
||||
|
||||
it('disables the key control when the reference itself is not writable', () => {
|
||||
// A key coming from the process environment: the settings document is
|
||||
// writable, the credential is not.
|
||||
renderWebSearch({ apiKeyConfigured: true, apiKeyWritable: false })
|
||||
fireEvent.click(screen.getByText(en.webSearchTitle))
|
||||
|
||||
expect(screen.getByLabelText(en.webSearchApiKey)).toHaveProperty('disabled', true)
|
||||
expect(screen.getByLabelText(en.webSearchBaseUrl)).toHaveProperty('disabled', false)
|
||||
})
|
||||
|
||||
it('stages the endpoint, the search budget, and their resets', () => {
|
||||
const actions = renderWebSearch({
|
||||
baseURL: field('https://search.test/v1', { overridden: true }),
|
||||
maxUses: field('3', { overridden: true }),
|
||||
})
|
||||
fireEvent.click(screen.getByText(en.webSearchTitle))
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.webSearchBaseUrl), { target: { value: 'https://other.test' } })
|
||||
fireEvent.change(screen.getByLabelText(en.webSearchMaxUses), { target: { value: '4' } })
|
||||
const resets = screen.getAllByRole('button', { name: en.reset })
|
||||
expect(resets).toHaveLength(2)
|
||||
for (const reset of resets) fireEvent.click(reset)
|
||||
|
||||
expect(actions.edit.mock.calls).toEqual([
|
||||
['baseURL', 'https://other.test'],
|
||||
['maxUses', '4'],
|
||||
])
|
||||
expect(actions.resetField.mock.calls).toEqual([['baseURL'], ['maxUses']])
|
||||
})
|
||||
})
|
||||
540
packages/client/ui-plugin-config/tests/stores.spec.ts
Normal file
540
packages/client/ui-plugin-config/tests/stores.spec.ts
Normal file
@@ -0,0 +1,540 @@
|
||||
/**
|
||||
* The staged card form: what a draft shows before it is written, which wire
|
||||
* call a save reaches, and what happens to drafts the Host did not accept.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { CardForm, numberField, textField } from '../src/client/card-store.ts'
|
||||
import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-store.ts'
|
||||
import { BashCardController, type BashSettings } from '../src/client/bash-store.ts'
|
||||
import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-store.ts'
|
||||
|
||||
/** Make the stub behave like a Host that accepts every write. */
|
||||
function acceptWrites<T>(host: StubSettingsScope<T>): void {
|
||||
const section = (): Record<string, unknown> => ({ ...host.scope.getSnapshot().value as object })
|
||||
const layer = (): Record<string, unknown> => ({ ...host.scope.getSnapshot().user as object })
|
||||
host.set.mockImplementation((field: string, value: unknown) => {
|
||||
host.publish({ value: { ...section(), [field]: value } as T, user: { ...layer(), [field]: value } })
|
||||
})
|
||||
host.unset.mockImplementation((field: string) => {
|
||||
const user = Object.fromEntries(Object.entries(layer()).filter(([key]) => key !== field))
|
||||
const base = host.scope.getSnapshot().base as Record<string, unknown> | undefined
|
||||
host.publish({ value: { ...section(), [field]: base?.[field] } as T, user })
|
||||
})
|
||||
}
|
||||
|
||||
function credentialsApi(configured: boolean) {
|
||||
const describe = vi.fn(() => Promise.resolve({
|
||||
rpcId: 'c-1' as never,
|
||||
result: { ok: true as const, value: { credentials: { DEEPSEEK_API_KEY: { configured, writable: true } } } },
|
||||
}))
|
||||
const set = vi.fn(() => Promise.resolve({ rpcId: 'c-2' as never, result: { ok: true as const, value: {} } }))
|
||||
return { api: { credentials: { describe, set } } as never, describe, set }
|
||||
}
|
||||
|
||||
describe('CardForm', () => {
|
||||
function form() {
|
||||
const host = stubSettingsScope<Record<string, unknown>>()
|
||||
const subject = new CardForm(host.scope, [numberField('timeoutMs'), textField('baseURL')])
|
||||
host.publish({
|
||||
status: 'ready',
|
||||
writable: true,
|
||||
value: { timeoutMs: 60_000, baseURL: 'https://search.test/v1' },
|
||||
base: { timeoutMs: 60_000, baseURL: 'https://search.test/v1' },
|
||||
user: {},
|
||||
})
|
||||
return { host, subject }
|
||||
}
|
||||
|
||||
it('shows the effective value and stays clean until something is staged', () => {
|
||||
const { subject } = form()
|
||||
|
||||
expect(subject.field('timeoutMs')).toEqual({ text: '60000', overridden: false, invalid: false })
|
||||
expect(subject.shell()).toMatchObject({ available: true, writable: true, dirty: false, invalid: false })
|
||||
})
|
||||
|
||||
it('marks a field the user layer carries as overridden', () => {
|
||||
const { host, subject } = form()
|
||||
|
||||
host.publish({ value: { timeoutMs: 60_000 }, user: { timeoutMs: 60_000 } })
|
||||
|
||||
// An override equal to the composition default is still an override.
|
||||
expect(subject.field('timeoutMs').overridden).toBe(true)
|
||||
})
|
||||
|
||||
it('writes nothing until the form is saved', async () => {
|
||||
const { host, subject } = form()
|
||||
acceptWrites(host)
|
||||
|
||||
subject.actions().edit('timeoutMs', '9000')
|
||||
|
||||
expect(subject.field('timeoutMs')).toEqual({ text: '9000', overridden: true, invalid: false })
|
||||
expect(subject.shell().dirty).toBe(true)
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
|
||||
await subject.save()
|
||||
|
||||
expect(host.set.mock.calls).toEqual([['timeoutMs', 9_000]])
|
||||
expect(subject.shell()).toMatchObject({ dirty: false, failed: false, saving: false })
|
||||
})
|
||||
|
||||
it('drops a draft that settles back on the value already shown', async () => {
|
||||
const { host, subject } = form()
|
||||
|
||||
subject.actions().edit('timeoutMs', '9000')
|
||||
subject.actions().edit('timeoutMs', '60000')
|
||||
|
||||
expect(subject.shell().dirty).toBe(false)
|
||||
await subject.save()
|
||||
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses to save while a draft is not a value the field accepts', async () => {
|
||||
const { host, subject } = form()
|
||||
|
||||
subject.actions().edit('timeoutMs', 'soon')
|
||||
|
||||
expect(subject.field('timeoutMs')).toEqual({ text: 'soon', overridden: false, invalid: true })
|
||||
expect(subject.shell()).toMatchObject({ dirty: true, invalid: true })
|
||||
|
||||
await subject.save()
|
||||
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
expect(subject.field('timeoutMs').text).toBe('soon')
|
||||
})
|
||||
|
||||
it('stages a reset that clears the field only once saved', async () => {
|
||||
const { host, subject } = form()
|
||||
acceptWrites(host)
|
||||
host.publish({ value: { timeoutMs: 9_000 }, user: { timeoutMs: 9_000 } })
|
||||
|
||||
subject.actions().resetField('timeoutMs')
|
||||
|
||||
// The badge previews the save: the field will no longer be overridden.
|
||||
expect(subject.field('timeoutMs')).toEqual({ text: '60000', overridden: false, invalid: false })
|
||||
expect(host.unset).not.toHaveBeenCalled()
|
||||
|
||||
await subject.save()
|
||||
|
||||
expect(host.unset.mock.calls).toEqual([['timeoutMs']])
|
||||
expect(subject.shell()).toMatchObject({ dirty: false, failed: false })
|
||||
})
|
||||
|
||||
it('treats resetting an inherited field as no change at all', async () => {
|
||||
const { host, subject } = form()
|
||||
|
||||
subject.actions().resetField('timeoutMs')
|
||||
|
||||
expect(subject.shell().dirty).toBe(false)
|
||||
await subject.save()
|
||||
|
||||
expect(host.unset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears a number field by emptying it', async () => {
|
||||
const { host, subject } = form()
|
||||
acceptWrites(host)
|
||||
host.publish({ user: { timeoutMs: 9_000 } })
|
||||
|
||||
subject.actions().edit('timeoutMs', '')
|
||||
|
||||
expect(subject.field('timeoutMs')).toEqual({ text: '', overridden: false, invalid: false })
|
||||
await subject.save()
|
||||
|
||||
expect(host.unset.mock.calls).toEqual([['timeoutMs']])
|
||||
})
|
||||
|
||||
it('clears a text field by emptying it', async () => {
|
||||
const { host, subject } = form()
|
||||
acceptWrites(host)
|
||||
host.publish({ user: { baseURL: 'https://search.test/v1' } })
|
||||
|
||||
subject.actions().edit('baseURL', ' ')
|
||||
await subject.save()
|
||||
|
||||
expect(host.unset.mock.calls).toEqual([['baseURL']])
|
||||
})
|
||||
|
||||
it('writes the trimmed text of a text field', async () => {
|
||||
const { host, subject } = form()
|
||||
acceptWrites(host)
|
||||
|
||||
subject.actions().edit('baseURL', ' https://other.test ')
|
||||
await subject.save()
|
||||
|
||||
expect(host.set.mock.calls).toEqual([['baseURL', 'https://other.test']])
|
||||
})
|
||||
|
||||
it('keeps the drafts a save did not land, and reports the failure', async () => {
|
||||
const { host, subject } = form()
|
||||
|
||||
subject.actions().edit('timeoutMs', '9000')
|
||||
await subject.save()
|
||||
|
||||
// The stub Host accepted the call without storing it, exactly as a
|
||||
// validator that refuses the value does.
|
||||
expect(host.set).toHaveBeenCalledWith('timeoutMs', 9_000)
|
||||
expect(subject.shell()).toMatchObject({ dirty: true, failed: true, saving: false })
|
||||
expect(subject.field('timeoutMs').text).toBe('9000')
|
||||
})
|
||||
|
||||
it('reports a reset the Host did not apply as a failure', async () => {
|
||||
const { host, subject } = form()
|
||||
host.publish({ user: { timeoutMs: 9_000 } })
|
||||
|
||||
subject.actions().resetField('timeoutMs')
|
||||
await subject.save()
|
||||
|
||||
expect(host.unset).toHaveBeenCalledWith('timeoutMs')
|
||||
expect(subject.shell().failed).toBe(true)
|
||||
})
|
||||
|
||||
it('clears the failure as soon as the user edits again', async () => {
|
||||
const { subject } = form()
|
||||
|
||||
subject.actions().edit('timeoutMs', '9000')
|
||||
await subject.save()
|
||||
expect(subject.shell().failed).toBe(true)
|
||||
|
||||
subject.actions().edit('timeoutMs', '9001')
|
||||
|
||||
expect(subject.shell().failed).toBe(false)
|
||||
})
|
||||
|
||||
it('discards every staged edit', async () => {
|
||||
const { host, subject } = form()
|
||||
|
||||
subject.actions().edit('timeoutMs', '9000')
|
||||
subject.actions().discard()
|
||||
|
||||
expect(subject.field('timeoutMs').text).toBe('60000')
|
||||
expect(subject.shell()).toMatchObject({ dirty: false, failed: false })
|
||||
|
||||
// A discard with nothing staged publishes nothing.
|
||||
const before = subject.shell()
|
||||
subject.actions().discard()
|
||||
expect(subject.shell()).toEqual(before)
|
||||
|
||||
await subject.save()
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a second save while one is in flight', async () => {
|
||||
const { host, subject } = form()
|
||||
acceptWrites(host)
|
||||
|
||||
subject.actions().edit('timeoutMs', '9000')
|
||||
const first = subject.save()
|
||||
expect(subject.shell().saving).toBe(true)
|
||||
const second = subject.save()
|
||||
await Promise.all([first, second])
|
||||
|
||||
expect(host.set).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('publishes a projection whenever the scope or a draft changes', () => {
|
||||
const { host, subject } = form()
|
||||
const store = subject.bind(() => subject.field('timeoutMs').text)
|
||||
expect(store.getSnapshot()).toBe('60000')
|
||||
|
||||
host.publish({ value: { timeoutMs: 1_000 } })
|
||||
expect(store.getSnapshot()).toBe('1000')
|
||||
|
||||
subject.actions().edit('timeoutMs', '2000')
|
||||
expect(store.getSnapshot()).toBe('2000')
|
||||
})
|
||||
|
||||
it('refuses to address a field the card never declared', () => {
|
||||
const { subject } = form()
|
||||
|
||||
expect(() => subject.field('nope')).toThrow('plugin card has no field nope')
|
||||
})
|
||||
|
||||
it('renders an absent section value as an empty draft', () => {
|
||||
const host = stubSettingsScope<Record<string, unknown>>()
|
||||
const subject = new CardForm(host.scope, [numberField('timeoutMs'), textField('baseURL')])
|
||||
|
||||
host.publish({ status: 'ready', writable: true, value: {}, base: {}, user: undefined })
|
||||
|
||||
expect(subject.field('timeoutMs').text).toBe('')
|
||||
expect(subject.field('baseURL').text).toBe('')
|
||||
expect(subject.shell().available).toBe(true)
|
||||
})
|
||||
|
||||
it('stays unavailable while the namespace is not served', () => {
|
||||
const host = stubSettingsScope<Record<string, unknown>>()
|
||||
const subject = new CardForm(host.scope, [numberField('timeoutMs')])
|
||||
|
||||
host.publish({ status: 'unavailable' })
|
||||
|
||||
expect(subject.shell()).toMatchObject({ available: false, writable: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('BashCardController', () => {
|
||||
it('projects both fields and saves them in one write pass', async () => {
|
||||
const host = stubSettingsScope<BashSettings>()
|
||||
acceptWrites(host)
|
||||
const controller = new BashCardController(host.scope)
|
||||
host.publish({
|
||||
status: 'ready',
|
||||
writable: true,
|
||||
value: { timeoutMs: 5_000, maxOutputBytes: 64_000 },
|
||||
base: { timeoutMs: 60_000, maxOutputBytes: 64_000 },
|
||||
user: { timeoutMs: 5_000 },
|
||||
})
|
||||
const face = controller.inject()
|
||||
|
||||
expect(face.hooks.bashCard.getSnapshot()).toMatchObject({
|
||||
available: true,
|
||||
writable: true,
|
||||
dirty: false,
|
||||
timeoutMs: { text: '5000', overridden: true },
|
||||
maxOutputBytes: { text: '64000', overridden: false },
|
||||
})
|
||||
|
||||
face.edit('timeoutMs', '9000')
|
||||
face.edit('maxOutputBytes', '1024')
|
||||
expect(face.hooks.bashCard.getSnapshot().dirty).toBe(true)
|
||||
|
||||
face.save()
|
||||
await vi.waitFor(() => { expect(host.set).toHaveBeenCalledTimes(2) })
|
||||
|
||||
expect(host.set.mock.calls).toEqual([['timeoutMs', 9_000], ['maxOutputBytes', 1_024]])
|
||||
expect(face.hooks.bashCard.getSnapshot().dirty).toBe(false)
|
||||
})
|
||||
|
||||
it('stages a reset and applies it on save', async () => {
|
||||
const host = stubSettingsScope<BashSettings>()
|
||||
acceptWrites(host)
|
||||
const controller = new BashCardController(host.scope)
|
||||
host.publish({
|
||||
status: 'ready',
|
||||
writable: true,
|
||||
value: { timeoutMs: 5_000 },
|
||||
base: { timeoutMs: 60_000 },
|
||||
user: { timeoutMs: 5_000 },
|
||||
})
|
||||
const face = controller.inject()
|
||||
|
||||
face.resetField('timeoutMs')
|
||||
expect(face.hooks.bashCard.getSnapshot().timeoutMs.text).toBe('60000')
|
||||
|
||||
face.save()
|
||||
await vi.waitFor(() => { expect(host.unset).toHaveBeenCalledWith('timeoutMs') })
|
||||
|
||||
expect(face.hooks.bashCard.getSnapshot()).toMatchObject({
|
||||
dirty: false,
|
||||
timeoutMs: { text: '60000', overridden: false },
|
||||
})
|
||||
})
|
||||
|
||||
it('discards staged edits without writing', () => {
|
||||
const host = stubSettingsScope<BashSettings>()
|
||||
const controller = new BashCardController(host.scope)
|
||||
host.publish({ status: 'ready', writable: true, value: { timeoutMs: 5_000 }, user: {} })
|
||||
const face = controller.inject()
|
||||
|
||||
face.edit('timeoutMs', '9000')
|
||||
face.discard()
|
||||
|
||||
expect(face.hooks.bashCard.getSnapshot().timeoutMs.text).toBe('5000')
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentLoopCardController', () => {
|
||||
it('saves the only field it owns', async () => {
|
||||
const host = stubSettingsScope<AgentLoopSettings>()
|
||||
acceptWrites(host)
|
||||
const controller = new AgentLoopCardController(host.scope)
|
||||
host.publish({
|
||||
status: 'ready',
|
||||
writable: true,
|
||||
value: { maxParallelToolCalls: 10 },
|
||||
base: { maxParallelToolCalls: 10 },
|
||||
user: {},
|
||||
})
|
||||
const face = controller.inject()
|
||||
|
||||
face.edit('maxParallelToolCalls', '4')
|
||||
face.save()
|
||||
await vi.waitFor(() => { expect(host.set).toHaveBeenCalledWith('maxParallelToolCalls', 4) })
|
||||
|
||||
expect(face.hooks.agentLoopCard.getSnapshot()).toMatchObject({
|
||||
dirty: false,
|
||||
maxParallelToolCalls: { text: '4', overridden: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a read-only document so the card can disable its controls', () => {
|
||||
const host = stubSettingsScope<AgentLoopSettings>()
|
||||
const controller = new AgentLoopCardController(host.scope)
|
||||
|
||||
host.publish({ status: 'ready', writable: false, value: { maxParallelToolCalls: 10 } })
|
||||
|
||||
expect(controller.inject().hooks.agentLoopCard.getSnapshot().writable).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('WebSearchCardController', () => {
|
||||
it('reads the credential state for the reference the section names', async () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
const credentials = credentialsApi(true)
|
||||
const controller = new WebSearchCardController(host.scope, credentials.api)
|
||||
const state = () => controller.inject().hooks.webSearchCard.getSnapshot()
|
||||
await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() })
|
||||
|
||||
host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' }, user: {} })
|
||||
await vi.waitFor(() => { expect(state().apiKeyConfigured).toBe(true) })
|
||||
|
||||
expect(state()).toMatchObject({
|
||||
baseURL: { text: 'https://search.test/v1', overridden: false },
|
||||
apiKey: { text: '', overridden: false },
|
||||
})
|
||||
})
|
||||
|
||||
it('writes the staged key through the credentials domain, never the settings section', async () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
const credentials = credentialsApi(false)
|
||||
const controller = new WebSearchCardController(host.scope, credentials.api)
|
||||
host.publish({ status: 'ready', writable: true, value: {}, user: {} })
|
||||
const face = controller.inject()
|
||||
|
||||
face.edit('apiKey', ' ds-secret ')
|
||||
expect(face.hooks.webSearchCard.getSnapshot().dirty).toBe(true)
|
||||
expect(credentials.set).not.toHaveBeenCalled()
|
||||
|
||||
credentials.describe.mockImplementation(() => Promise.resolve({
|
||||
rpcId: 'c-1' as never,
|
||||
result: { ok: true as const, value: { credentials: { DEEPSEEK_API_KEY: { configured: true, writable: true } } } },
|
||||
}))
|
||||
face.save()
|
||||
await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() })
|
||||
|
||||
expect(credentials.set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'ds-secret' })
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => {
|
||||
expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({ dirty: false, apiKeyConfigured: true })
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the stored key when the draft is left blank', () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
const credentials = credentialsApi(true)
|
||||
const controller = new WebSearchCardController(host.scope, credentials.api)
|
||||
host.publish({ status: 'ready', writable: true, value: {}, user: {} })
|
||||
const face = controller.inject()
|
||||
|
||||
face.edit('apiKey', ' ')
|
||||
|
||||
expect(face.hooks.webSearchCard.getSnapshot().dirty).toBe(false)
|
||||
face.save()
|
||||
|
||||
expect(credentials.set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-reads when the Host reports the watched reference changed', async () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
const credentials = credentialsApi(false)
|
||||
const controller = new WebSearchCardController(host.scope, credentials.api)
|
||||
host.publish({ status: 'ready', writable: true, value: {}, user: {} })
|
||||
await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() })
|
||||
credentials.describe.mockClear()
|
||||
|
||||
// Another reference is not this card's business.
|
||||
controller.refreshCredential('OTHER_KEY')
|
||||
expect(credentials.describe).not.toHaveBeenCalled()
|
||||
|
||||
// A key written on another surface reaches this card only through this signal.
|
||||
credentials.describe.mockImplementation(() => Promise.resolve({
|
||||
rpcId: 'c-1' as never,
|
||||
result: { ok: true as const, value: { credentials: { DEEPSEEK_API_KEY: { configured: true, writable: true } } } },
|
||||
}))
|
||||
controller.refreshCredential('DEEPSEEK_API_KEY')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(controller.inject().hooks.webSearchCard.getSnapshot().apiKeyConfigured).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('addresses the reference the section declares rather than the default', async () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
const credentials = credentialsApi(false)
|
||||
const controller = new WebSearchCardController(host.scope, credentials.api)
|
||||
host.publish({ status: 'ready', writable: true, value: { apiKeyEnv: 'SEARCH_KEY' }, user: {} })
|
||||
const face = controller.inject()
|
||||
|
||||
face.edit('apiKey', 'ds-secret')
|
||||
face.save()
|
||||
await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() })
|
||||
|
||||
expect(credentials.set).toHaveBeenCalledWith({ ref: 'SEARCH_KEY', value: 'ds-secret' })
|
||||
})
|
||||
|
||||
it('reports a key the Host did not store as a failed save', async () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
const credentials = credentialsApi(false)
|
||||
const controller = new WebSearchCardController(host.scope, credentials.api)
|
||||
host.publish({ status: 'ready', writable: true, value: {}, user: {} })
|
||||
const face = controller.inject()
|
||||
|
||||
face.edit('apiKey', 'ds-secret')
|
||||
face.save()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({ failed: true, dirty: true })
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the card usable when the credential read fails', async () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
const describe = vi.fn(() => Promise.reject(new Error('offline')))
|
||||
const set = vi.fn(() => Promise.reject(new Error('offline')))
|
||||
const controller = new WebSearchCardController(host.scope, { credentials: { describe, set } } as never)
|
||||
const face = controller.inject()
|
||||
await vi.waitFor(() => { expect(describe).toHaveBeenCalled() })
|
||||
|
||||
host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' }, user: {} })
|
||||
face.edit('apiKey', 'ds-secret')
|
||||
face.save()
|
||||
await vi.waitFor(() => { expect(set).toHaveBeenCalled() })
|
||||
|
||||
expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({
|
||||
available: true,
|
||||
apiKeyConfigured: false,
|
||||
baseURL: { text: 'https://search.test/v1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores a credential read the Host refused', async () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
const describe = vi.fn(() => Promise.resolve({
|
||||
rpcId: 'c-1' as never,
|
||||
result: { ok: false as const, error: { code: 'credentials-unavailable', message: 'no provider' } },
|
||||
}))
|
||||
const controller = new WebSearchCardController(host.scope, { credentials: { describe, set: vi.fn() } } as never)
|
||||
await vi.waitFor(() => { expect(describe).toHaveBeenCalled() })
|
||||
|
||||
expect(controller.inject().hooks.webSearchCard.getSnapshot().apiKeyConfigured).toBe(false)
|
||||
})
|
||||
|
||||
it('saves the endpoint and the search budget together', async () => {
|
||||
const host = stubSettingsScope<WebSearchSettings>()
|
||||
acceptWrites(host)
|
||||
const credentials = credentialsApi(true)
|
||||
const controller = new WebSearchCardController(host.scope, credentials.api)
|
||||
host.publish({ status: 'ready', writable: true, value: {}, base: {}, user: {} })
|
||||
const face = controller.inject()
|
||||
|
||||
face.edit('baseURL', 'https://other.test')
|
||||
face.edit('maxUses', '3')
|
||||
face.save()
|
||||
await vi.waitFor(() => { expect(host.set).toHaveBeenCalledTimes(2) })
|
||||
|
||||
expect(host.set.mock.calls).toEqual([['baseURL', 'https://other.test'], ['maxUses', 3]])
|
||||
expect(credentials.set).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
45
packages/client/ui-plugin-config/tsconfig.json
Normal file
45
packages/client/ui-plugin-config/tsconfig.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../test-runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../ui-settings"
|
||||
},
|
||||
{
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../web-react"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/ui-plugin-config/tsdown.config.ts
Normal file
3
packages/client/ui-plugin-config/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-ui-plugin-config', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
|
||||
README.md: a9c802c1e43cf06aa0492b39d5052e882a72d9e6
|
||||
README.zh.md: 9e67488ccc1d70263bd0b91d167c4c251ee95926
|
||||
README.md: f96e931472a0946bd023b97b034643f079a67e44
|
||||
README.zh.md: 0db2ac1bccbdc4793c488e1ce621ee5c674d9895
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Pure React atoms (zero cordis): StateDot, DisclosureRow, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the OnboardingSurface first-run takeover (body-portaled mask + opaque stage that holds `#root` inert for exactly its own lifetime), the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), TerminalBlock, DiffBlock, ReadBlock, SearchBlock, and WebBlock.
|
||||
Pure React atoms (zero cordis): StateDot, DisclosureRow, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the Toast transient banner, the OnboardingSurface first-run takeover (body-portaled mask + opaque stage that holds `#root` inert for exactly its own lifetime), the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), TerminalBlock, DiffBlock, ReadBlock, SearchBlock, and WebBlock.
|
||||
|
||||
## Hover cards
|
||||
|
||||
`HoverCard` keeps its portaled preview reachable across the anchor gap with a pointer-leave grace. A consumer may also pass `copyText`: the card then exposes button semantics for pointer and keyboard activation, includes that value after the `copyLabel` prefix in its accessible name, writes the exact value through the package clipboard helper, and temporarily replaces its content with `copiedLabel` only after the host accepts the write. A non-collapsed text selection intersecting the card suppresses pointer-click activation, while success feedback retains the original card height and clears when the card closes or after one second. `copyLabel` and `copiedLabel` are label props because this zero-cordis atom cannot read the application locale; omitting `copyText` preserves the read/select-only card. Historical rationale: [the archived hover-card copy note](../../../.agents/notes/archived/feature/2026-07-31-hover-card-click-copy.md).
|
||||
|
||||
## Toast
|
||||
|
||||
`Toast` is the transient top banner: it slides in, holds at full opacity for three seconds, fades over one second, then calls `onDone` so the owner can unmount it. It renders `role="alert"` with an optional leading icon slot and takes its copy as a required prop (zero-cordis: the owner localizes). It body-portals with `pointer-events: none`, sits 120px from the viewport top, and centers horizontally over the optional `anchor` element (re-measured on window resizes) — the composer passes its card so the banner centers over the chat column rather than the whole window — falling back to the viewport center without one. Re-showing the same message requires a remount — owners key the element by a per-show sequence so an identical repeated message restarts the hold-and-fade cycle instead of silently reusing the faded banner. Under `prefers-reduced-motion: reduce` the slide-in is dropped and only the delayed fade remains. It layers above the ui-attachment image lightbox so a failure reported during a preview stays readable.
|
||||
|
||||
## Markdown rendering
|
||||
|
||||
`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. An optional `fileMentions` resolver lets the owning view link inline code that names a real file: the token keeps code styling and gains a button wired to the resolved opener, with the resolver's accessible label and full-path `title`. The renderer never guesses at what looks like a path — an unresolved token stays inert, mentions apply to settled renders only (the streaming cache must not bake in handlers that could go stale), and a token inside an anchor stays inert because a button cannot nest there. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
纯 React 原子组件(零 cordis):StateDot、DisclosureRow、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、OnboardingSurface 首次使用接管层(portal 到 body 的遮罩加不透明展示层,在且仅在自身生命周期内保持 `#root` 为 `inert`)、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` 钩子(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量)、TerminalBlock、DiffBlock、ReadBlock、SearchBlock,以及 WebBlock。
|
||||
纯 React 原子组件(零 cordis):StateDot、DisclosureRow、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、Toast 短时横幅、OnboardingSurface 首次使用接管层(portal 到 body 的遮罩加不透明展示层,在且仅在自身生命周期内保持 `#root` 为 `inert`)、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` 钩子(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量)、TerminalBlock、DiffBlock、ReadBlock、SearchBlock,以及 WebBlock。
|
||||
|
||||
## 悬浮卡片
|
||||
|
||||
`HoverCard` 通过指针离开宽限期,使采用 portal 渲染的预览在跨过与锚点之间的间隙时仍可触及。消费方还可传入 `copyText`:此时卡片为指针与键盘激活提供按钮语义,其无障碍名称会在 `copyLabel` 前缀后包含该值,通过包内剪贴板辅助函数原样写入该值,并且只有宿主接受写入后,才会临时将内容替换为 `copiedLabel`。与卡片相交的非折叠文本选区会阻止指针点击激活;成功反馈保持卡片原有高度,并随卡片关闭或在一秒后清除。`copyLabel` 和 `copiedLabel` 采用 label prop,是因为这个 zero-cordis 原子组件无法读取应用 locale;省略 `copyText` 时,卡片维持只读且可选择文本的行为。历史依据见[已归档的悬浮卡片复制 Agent Note](../../../.agents/notes/archived/feature/2026-07-31-hover-card-click-copy.md)。
|
||||
|
||||
## Toast
|
||||
|
||||
`Toast` 是顶部的短时横幅:滑入后满不透明度停留三秒,再用一秒淡出,随后调用 `onDone` 由持有方卸载。它渲染 `role="alert"`,带可选的前置图标插槽,文案是必填 prop(零 cordis,由持有方本地化)。它经 body portal 渲染且 `pointer-events: none`,距视口顶部 120px,水平中心跟随可选的 `anchor` 元素(窗口尺寸变化时重测)——composer 传入自己的卡片,横幅因此在聊天列而非整个窗口上居中——不传则回退到视口居中。重复展示同一条消息需要重新挂载,持有方用每次展示递增的序号作为 key,让相同文案重新走完停留与淡出,而不是静默复用已淡出的横幅。`prefers-reduced-motion: reduce` 下去掉滑入,只保留延迟淡出。它的层级高于 ui-attachment 的图片灯箱,预览打开时报出的失败仍然可读。
|
||||
|
||||
## Markdown 渲染
|
||||
|
||||
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。可选的 `fileMentions` 解析器让持有该组件的视图为命名真实文件的行内代码添加可点击入口:token 保留代码样式,并获得一个连接到解析所得 opener 的按钮,按钮带有解析器提供的无障碍标签和以完整路径为值的 `title`。渲染器绝不猜测哪些内容像路径:未解析的 token 保持不可交互;文件提及仅应用于已定稿的渲染(流式缓存不得固化可能过期的 handler);锚点内的 token 也保持不可交互,因为按钮不能嵌套其中。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性约定](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。
|
||||
|
||||
70
packages/client/ui-primitives/src/Toast.module.css
Normal file
70
packages/client/ui-primitives/src/Toast.module.css
Normal file
@@ -0,0 +1,70 @@
|
||||
/* Transient top-center banner (DeepSeek Chat toast look): contrast fill,
|
||||
inverted label, slide-in, then hold-and-fade. The fade delay/duration MUST
|
||||
agree with HOLD_MS/FADE_MS in Toast.tsx: the component unmounts at their
|
||||
sum, so a mismatched sheet either cuts the fade or leaves an invisible
|
||||
banner blocking nothing. */
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 120px;
|
||||
left: 50%;
|
||||
/* Above the 1000 the image lightbox backdrop uses: a failure reported while
|
||||
a preview is open must stay readable. */
|
||||
z-index: 1100;
|
||||
/* Purely an announcement: it must never intercept clicks — in particular
|
||||
after the CSS fade finished while a throttled background-tab timer has
|
||||
not yet unmounted the still-hit-testable fixed element. */
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
max-width: min(560px, calc(100vw - 48px));
|
||||
padding: 12px 16px;
|
||||
border-radius: 14px;
|
||||
background: var(--dsw-alias-button-contrast-fill);
|
||||
color: var(--dsw-alias-label-primary-inverted);
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
box-shadow: var(--dsw-shadow-lv3);
|
||||
transform: translateX(-50%);
|
||||
animation:
|
||||
dsh-toast-in 160ms ease-out,
|
||||
dsh-toast-fade 1000ms ease 3000ms forwards;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
color: var(--dsw-alias-state-warn-label);
|
||||
}
|
||||
|
||||
.text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@keyframes dsh-toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -6px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dsh-toast-fade {
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion drops the slide-in; the delayed fade (an opacity change,
|
||||
not movement) still ends the banner before the timed unmount. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast {
|
||||
animation: dsh-toast-fade 1000ms ease 3000ms forwards;
|
||||
}
|
||||
}
|
||||
59
packages/client/ui-primitives/src/Toast.tsx
Normal file
59
packages/client/ui-primitives/src/Toast.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useLayoutEffect, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import css from './Toast.module.css'
|
||||
|
||||
/** Full-opacity hold before the fade starts. Must agree with the stylesheet's
|
||||
* toast-fade delay (Toast.module.css) or the banner unmounts mid-fade. */
|
||||
const HOLD_MS = 3000
|
||||
/** Fade duration. Must agree with the stylesheet's toast-fade duration. */
|
||||
const FADE_MS = 1000
|
||||
|
||||
/**
|
||||
* Transient top-center banner: slides in, holds at full opacity, fades out,
|
||||
* then reports done so the owner can unmount it. Re-showing the same text
|
||||
* restarts the cycle when the owner remounts the component (key it by a
|
||||
* per-show sequence). Rendered through a body portal so an owner inside a
|
||||
* transformed or filtered ancestor cannot trap the fixed banner in that
|
||||
* ancestor's box.
|
||||
*
|
||||
* @param props.text - resolved banner copy; the owner passes localized text.
|
||||
* @param props.icon - optional leading glyph (e.g. a warning icon).
|
||||
* @param props.anchor - optional element whose horizontal center the banner
|
||||
* follows (e.g. the composer card, so the banner centers over the chat column
|
||||
* rather than the whole window); omitted, it centers on the viewport.
|
||||
* @param props.onDone - called once the fade completes; unmount the toast here.
|
||||
* @returns the floating banner.
|
||||
*/
|
||||
export function Toast({ text, icon, anchor, onDone }: {
|
||||
text: string
|
||||
icon?: ReactNode
|
||||
anchor?: HTMLElement | null
|
||||
onDone: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(onDone, HOLD_MS + FADE_MS)
|
||||
return () => { clearTimeout(timer) }
|
||||
}, [onDone])
|
||||
// Anchor-centered placement re-measures on window resizes; the banner lives
|
||||
// four seconds, so sub-window layout drift within that span stays out of
|
||||
// scope.
|
||||
const [left, setLeft] = useState<number | null>(null)
|
||||
useLayoutEffect(() => {
|
||||
if (anchor == null) return
|
||||
const measure = (): void => {
|
||||
const rect = anchor.getBoundingClientRect()
|
||||
setLeft(rect.left + rect.width / 2)
|
||||
}
|
||||
measure()
|
||||
window.addEventListener('resize', measure)
|
||||
return () => { window.removeEventListener('resize', measure) }
|
||||
}, [anchor])
|
||||
return createPortal(
|
||||
<div className={css.toast} role="alert" style={left === null ? undefined : { left }}>
|
||||
{icon !== undefined && <span className={css.icon} aria-hidden>{icon}</span>}
|
||||
<span className={css.text}>{text}</span>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user