Merge remote-tracking branch 'origin/worktree-llm-web-config' into worktree/onboarding-deepseek-config
# Conflicts: # packages/client/ui-models/README.i18n.yaml # packages/client/ui-models/README.md # packages/client/ui-models/README.zh.md # packages/client/ui-models/src/client/store.ts # packages/client/ui-models/tests/components.spec.tsx
This commit is contained in:
@@ -76,8 +76,8 @@ The GUI test structure (three tiers, lane map) is settled in the [GUI testing sy
|
||||
Run the narrowest rung that covers what you touched; escalate only when the change surface demands it.
|
||||
|
||||
1. **Every GUI code change** — `pnpm run test:gui` (seconds; no browser, no server): the client suites plus the host-side GUI packages. This is the inner loop; run it as freely as a typecheck.
|
||||
2. **Changes to the build surface, boot wiring, static serving, or the wire carriage** (`apps/web`, vite config, `dsh-host-webserver`, connection/handler/SSE) — additionally `pnpm run test:web`: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without `DEEPSEEK_API_KEY`) plus the keyless replayed e2e scenarios (`DSH_SNAPSHOT=refresh` rewrites their aria goldens after an intentional conversation-UI change; `DSH_SNAPSHOT=record` re-records fixtures with a key).
|
||||
3. **Before a PR** — `pnpm run check:pre-push` (the repo-wide gate ladder). Between PR windows this rung is not expected on every commit.
|
||||
2. **Any change that can alter the assembled browser or visible conversation/UI output** (client components or copy, `apps/web`, Vite, `dsh-host-webserver`, connection/handler/SSE) — additionally `DSH_SNAPSHOT=replay pnpm run test:web`: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without `DEEPSEEK_API_KEY`) plus the keyless replayed e2e scenarios. Linux PR CI uses the same read-only replay mode. Use `DSH_SNAPSHOT=refresh` only after confirming an intentional output change, or `DSH_SNAPSHOT=record` with a key to re-record fixtures.
|
||||
3. **Before a PR** — use [dsh-pre-push-checks](../../.agents/skills/dsh-pre-push-checks/SKILL.md) to select the narrow checks for the outgoing diff; there is no repo-wide pre-push aggregate.
|
||||
|
||||
If `test:gui` is red on code you did not touch, neither silently fix nor ignore it: note it in your handoff so it lands in the next PR window's sweep.
|
||||
|
||||
@@ -86,7 +86,7 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore
|
||||
Bringing up a new `packages/client/<name>` plugin package (ui-workspace is the latest walked example; ui-sidebar/ui-question are good skeletons to copy):
|
||||
|
||||
1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dshClient` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section.
|
||||
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `apps/cli/cordis.yml`; an `apps/cli/package.json` dependency (Loader resolves each config-tree package against the composing app's URL — a row whose package is not an `apps/cli` dependency fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
|
||||
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `apps/cli/config/web.cordis.yml`; an `apps/cli/package.json` dependency (Loader resolves each config-tree package against the composing app's URL — a row whose package is not an `apps/cli` dependency fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
|
||||
3. **dshClient manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else.
|
||||
4. **Registering into another package's slot**: if the declaring host provides no waitable service, your apply's order relative to the host's is unconstrained — a bare `slots.register` into its slot races boot (intermittent `slot "..." is not declared` page failures). Register with declaration-aware deferral: check `ctx.slots.spec(name)`, otherwise `ctx.slots.subscribe(name)` and register on the declaration event (SlotCore supports subscribing ahead of declaration); make the registration idempotent, and unsubscribe + dispose in the effect disposer. Only take a service edge in `inject` when the host actually provides one (ui-question → `'conversation'` is that case).
|
||||
5. Rebuild the bundle (`pnpm --filter <pkg> bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources.
|
||||
@@ -97,5 +97,5 @@ Bringing up a new `packages/client/<name>` plugin package (ui-workspace is the l
|
||||
2. Type the props as the four shares (`PropsRuntime` & `PropsRenderSlots` & `PropsStore` & inject face) — derive, don't hand-write. Shared/surviving state goes in a `createXXXStore()` factory declared at register; component-private state stays local.
|
||||
3. Component tests feed props directly (`createXXXStore().create()` for the store share; plain stubs for framework hooks) — behavior-shaped assertions, no render machinery.
|
||||
4. Tokens only in CSS; Chinese product copy; English comments.
|
||||
5. `pnpm run test:gui` green (plus `test:web` if you touched the build surface).
|
||||
5. `pnpm run test:gui` green; if the component changes visible assembled output, also run `DSH_SNAPSHOT=replay pnpm run test:web`.
|
||||
6. Non-trivial change? It needs an Agent Note in the same PR (repo-wide rule) — the GUI notes above are the precedents to extend.
|
||||
|
||||
@@ -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/connection/README.md
|
||||
README.md: 6aa4daa6d4440996e8037f658e50e954278a9e6d
|
||||
README.zh.md: 12f83f7c1757cbff5613865ac75232b4ab1e0cf6
|
||||
README.md: d2fda9f15125915594259e01e5b153609ceb21bb
|
||||
README.zh.md: 669ae760693b4d98ee873ee5fe323554f58e7ca5
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, `settings.update`, `settings.replace`, `credentials.set`, `credentials.unset`) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
|
||||
|
||||
## /api browser-trust fence
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`、`settings.update`、`settings.replace`、`credentials.set`、`credentials.unset`)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。
|
||||
|
||||
## /api 浏览器信任栅栏
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export type {
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
InboxItemId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
|
||||
GoalsApi, GoalRef,
|
||||
SettingsApi, SettingsNamespaceView, SettingsSecretView,
|
||||
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
|
||||
CredentialsApi, CredentialView, ConfigurableProviderView, LlmApi,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
|
||||
|
||||
@@ -1545,6 +1545,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
value: { apiKeyEnv: 'DEEPSEEK_API_KEY' },
|
||||
applies: 'live',
|
||||
secrets: [{ path: ['apiKey'], set: false }],
|
||||
revision: 0,
|
||||
}],
|
||||
}),
|
||||
update: request => err(request, {
|
||||
@@ -1557,6 +1558,11 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
message: 'fixture: the minimal readiness settings descriptor is read-only',
|
||||
details: { ns: request.payload.ns },
|
||||
}),
|
||||
mutate: request => err(request, {
|
||||
code: 'settings-rejected',
|
||||
message: 'fixture: no settings namespaces are registered',
|
||||
details: { ns: request.payload.ns },
|
||||
}),
|
||||
},
|
||||
credentials: {
|
||||
describe: request => ok(request, {
|
||||
@@ -1680,6 +1686,7 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'settings.describe': return this.api.settings.describe(request)
|
||||
case 'settings.update': return this.api.settings.update(request)
|
||||
case 'settings.replace': return this.api.settings.replace(request)
|
||||
case 'settings.mutate': return this.api.settings.mutate(request)
|
||||
case 'credentials.describe': return this.api.credentials.describe(request)
|
||||
case 'credentials.set': return this.api.credentials.set(request)
|
||||
case 'credentials.unset': return this.api.credentials.unset(request)
|
||||
|
||||
@@ -22,7 +22,7 @@ export type {
|
||||
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
|
||||
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
|
||||
GoalsApi, GoalRef,
|
||||
SettingsApi, SettingsNamespaceView, SettingsSecretView,
|
||||
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
|
||||
CredentialsApi, CredentialView, ConfigurableProviderView, LlmApi,
|
||||
} from './api.ts'
|
||||
export { RpcId, AbstractApiClient, transportError } from './api.ts'
|
||||
|
||||
@@ -35,16 +35,26 @@ export const Config: z<ConnectionConfig> = z.object({
|
||||
|
||||
/**
|
||||
* Methods gated to loopback even on a trusted-host deployment. Native dialogs
|
||||
* act on the host machine; settings and credential writes mutate the user's
|
||||
* configuration and secret store. A declared `trustedHosts` authority reaches
|
||||
* every other method, but these stay loopback-same-origin until a real
|
||||
* authentication layer exists.
|
||||
* act on the host machine; the settings and credential domains mutate the
|
||||
* user's configuration and secret store, and READING them is equally
|
||||
* privileged — `settings.describe` returns every exposed namespace's
|
||||
* configuration and `credentials.describe` reports whether an arbitrary
|
||||
* environment-variable name is configured and where from, which is
|
||||
* reconnaissance no anonymous caller should have. `trustedHosts` is a
|
||||
* DNS-rebinding fence, explicitly not authentication, so the whole
|
||||
* configuration plane stays loopback-same-origin until a real authentication
|
||||
* layer exists. The model catalog (`llm.providers`, `llm.models`) is
|
||||
* deliberately NOT here: it carries provider ids, display names, and model
|
||||
* lists — no endpoints, keys, or key state — and a LAN client's model picker
|
||||
* legitimately needs it.
|
||||
*/
|
||||
const PRIVILEGED_METHODS = new Set([
|
||||
'host.pickDirectory',
|
||||
'host.openPath',
|
||||
'settings.describe',
|
||||
'settings.update',
|
||||
'settings.replace',
|
||||
'credentials.describe',
|
||||
'credentials.set',
|
||||
'credentials.unset',
|
||||
])
|
||||
|
||||
@@ -156,8 +156,9 @@ export class FakeApiClient implements IApiClient {
|
||||
|
||||
readonly settings: IApiClient['settings'] = {
|
||||
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))),
|
||||
update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [] }))),
|
||||
replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [] }))),
|
||||
update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
|
||||
replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
|
||||
mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
|
||||
}
|
||||
|
||||
readonly credentials: IApiClient['credentials'] = {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/** Node half: registers the /api prefix route bridging to the api gateway. */
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { createServer, request as httpRequest } from 'node:http'
|
||||
import { Readable } from 'node:stream'
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
@@ -99,14 +101,14 @@ describe('connection node half', () => {
|
||||
|
||||
it('pins privileged methods to loopback even for a declared trusted authority', async () => {
|
||||
const { routes, dispose } = await mounted({ trustedHosts: ['harness.example'] })
|
||||
// The privileged set: native dialogs plus every settings/credential write.
|
||||
// The same declared authority reaches ordinary reads (carrier-level 404
|
||||
// from the empty proxy proves the fence passed), but each privileged
|
||||
// method stays loopback-only and short-circuits 403.
|
||||
// The privileged set: native dialogs plus the whole settings/credential
|
||||
// configuration plane, reads included. The same declared authority reaches
|
||||
// ordinary reads (carrier-level 404 from the empty proxy proves the fence
|
||||
// passed), but each privileged method stays loopback-only and 403s.
|
||||
for (const method of [
|
||||
'host.pickDirectory', 'host.openPath',
|
||||
'settings.update', 'settings.replace',
|
||||
'credentials.set', 'credentials.unset',
|
||||
'settings.describe', 'settings.update', 'settings.replace',
|
||||
'credentials.describe', 'credentials.set', 'credentials.unset',
|
||||
]) {
|
||||
const denied = fakeResponse()
|
||||
await routes[0]!.handler(
|
||||
@@ -143,3 +145,69 @@ describe('connection node half', () => {
|
||||
await dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('connection node half over a real HTTP server', () => {
|
||||
/** Serve the registered prefix route from a real server and return its port. */
|
||||
async function serve(routes: WebRoute[]): Promise<{ port: number; close: () => Promise<void> }> {
|
||||
const server = createServer((request, response) => {
|
||||
void routes[0]!.handler(request, response)
|
||||
})
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address() as AddressInfo
|
||||
return {
|
||||
port: address.port,
|
||||
close: () => new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error === undefined || error === null) resolve()
|
||||
else reject(error)
|
||||
})
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/** One real request; `host` spoofs the authority the way a LAN client's browser would send it. */
|
||||
function call(port: number, method: string, host: string): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = httpRequest(
|
||||
{ host: '127.0.0.1', port, path: `${API_PATH}/${method}`, method: 'GET', headers: { host } },
|
||||
(response) => {
|
||||
response.resume()
|
||||
response.on('end', () => { resolve(response.statusCode ?? 0) })
|
||||
},
|
||||
)
|
||||
request.on('error', reject)
|
||||
request.end()
|
||||
})
|
||||
}
|
||||
|
||||
it('answers a declared LAN authority with 403 on every configuration method, over real HTTP', async () => {
|
||||
// The fence's input is a real IncomingMessage parsed by Node from the
|
||||
// wire, not a hand-assembled object: the Host header a LAN browser sends
|
||||
// is exactly what decides loopback-only here, so the boundary is asserted
|
||||
// against the parse the server actually performs.
|
||||
const { routes, dispose } = await mounted({ trustedHosts: ['harness.example'] })
|
||||
const { port, close } = await serve(routes)
|
||||
try {
|
||||
// Reads are as privileged as writes: describe returns the exposed
|
||||
// configuration, and credentials.describe probes arbitrary env-var names.
|
||||
for (const method of [
|
||||
'settings.describe', 'settings.update', 'settings.replace',
|
||||
'credentials.describe', 'credentials.set', 'credentials.unset',
|
||||
'host.pickDirectory', 'host.openPath',
|
||||
]) {
|
||||
expect([method, await call(port, method, 'harness.example')]).toEqual([method, 403])
|
||||
}
|
||||
// The model catalog stays reachable for the same authority: a LAN
|
||||
// client's model picker needs it, and it carries no key or endpoint
|
||||
// state (404 is the empty proxy's carrier answer — the fence passed).
|
||||
for (const method of ['llm.providers', 'llm.models']) {
|
||||
expect([method, await call(port, method, 'harness.example')]).toEqual([method, 404])
|
||||
}
|
||||
// Loopback reaches everything, configuration included.
|
||||
expect(await call(port, 'settings.describe', `127.0.0.1:${String(port)}`)).toBe(404)
|
||||
} finally {
|
||||
await close()
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -183,8 +183,9 @@ export class FakeApiClient implements IApiClient {
|
||||
|
||||
readonly settings: IApiClient['settings'] = {
|
||||
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))),
|
||||
update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [] }))),
|
||||
replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [] }))),
|
||||
update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
|
||||
replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
|
||||
mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
|
||||
}
|
||||
|
||||
readonly credentials: IApiClient['credentials'] = {
|
||||
|
||||
@@ -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/schema-form/README.md
|
||||
README.md: 23e69f80914b400a77c036192f564d32bc148310
|
||||
README.zh.md: b26593d971d0c53d1fd8d0778200914a90b9b891
|
||||
README.md: 5dcef89cbffc8b03c3f2d874e870fa9767360d3c
|
||||
README.zh.md: a82acb7d85005da25858fb17cf42b49f06ae59db
|
||||
|
||||
@@ -18,5 +18,6 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Rehydration executes the served envelope** — `rehydrateSchema` reconstructs a live schemastery validator, and schemastery revives serialized callbacks through `new Function`, so the schema envelope is executable content rather than inert data. That is acceptable only because the envelope comes from the same host that serves the page; a browser schema protocol should carry a description the client cannot execute, which is deferred with the settings seam's [wire-boundary work](../../settings/settings/README.md#known-limitations-and-deferred-work).
|
||||
- **Validation is draft-level, not per-field** — `validateDraft` reports schemastery's first failure message (which names the `$.path`); per-field error mapping is deferred until a consumer needs it.
|
||||
- **No generic renderer** — a schema-driven form component was built and then replaced by the hand-written Models editor ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)); if a future page needs to edit arbitrary sections, it starts from these helpers, not from a resurrected generic renderer, unless the note's trade-off changes.
|
||||
|
||||
@@ -18,5 +18,6 @@
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **重建 schema 会执行所收到的信封**——`rehydrateSchema` 会重建一个活的 schemastery 校验器,而 schemastery 通过 `new Function` 复活序列化过的 callback,因此 schema 信封是可执行内容,而非惰性数据。这只有在信封来自提供该页面的同一 host 时才可接受;面向浏览器的 schema 协议应当传递客户端无法执行的描述,此项与 settings seam 的[协议边界工作](../../settings/settings/README.md#known-limitations-and-deferred-work)一并暂缓。
|
||||
- **校验是草稿级的,而非逐字段**——`validateDraft` 报告 schemastery 的第一条失败消息(其中会点名 `$.path`);逐字段的报错映射延后到出现需要它的消费方再做。
|
||||
- **没有通用渲染器**——一个 schema 驱动的表单组件曾被构建出来,随后被手写的 Models 编辑器取代([Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md));若未来有页面需要编辑任意分节,起点是这些辅助函数,而不是复活后的通用渲染器——除非该 note 的权衡发生变化。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||
README.md: 3973c14f2b8fe746549bb74af85a7a60a7d66aea
|
||||
README.zh.md: a6bb15c4cdd53d05bf28147b97d9d64d1c59da2b
|
||||
README.md: 855c42b3377e80b0d8f21a418da0a591782439e1
|
||||
README.zh.md: 31cf2c7b5a9a0740c2be9079ce55d897d175a6d0
|
||||
|
||||
@@ -16,11 +16,11 @@ A tool call declaring the `terminal` render intent renders its command output in
|
||||
|
||||
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
|
||||
|
||||
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
|
||||
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 10` — between Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
|
||||
|
||||
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.
|
||||
|
||||
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `command.hint` locale namespace this package registers and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats.
|
||||
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `command.hint` locale namespace this package registers and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists.
|
||||
|
||||
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
|
||||
|
||||
|
||||
@@ -16,11 +16,11 @@
|
||||
|
||||
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,kebab-case 预设名渲染为 Title Case 标签(与 `/permission` popup 的显示变换孪生),选中会经由输入栏注入的 `command` 回调提交 `/permission <preset>` 命令行。
|
||||
|
||||
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 10` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 和 Queue 之间),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
|
||||
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。
|
||||
|
||||
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。
|
||||
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar 坑位本身为 `session-maybe`:没有当前会话时,同一个 bar 以惰性态渲染(machine face 缺席、`disabled` owner prop),而不是换入一棵平行的 disabled 树,因此 textarea DOM 在选定 workspace 的切换中得以存活;严格会话作用域的控件 seat 在会话存在之前保持为空。
|
||||
|
||||
`src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
ApprovalWait, ChatViewInjected, ComposerBarInjected, ComposerChainProps, ConversationInjected,
|
||||
ConversationSessionInjected, DetailsInjected,
|
||||
} from './contract/slots.ts'
|
||||
import type { InputNotice } from './input/contract.ts'
|
||||
import { resolveToolPath } from './contract/tool-call-model.ts'
|
||||
import { createChatStore } from './stores.ts'
|
||||
import { ConversationService } from './service.ts'
|
||||
@@ -21,6 +22,7 @@ import { StatsLine } from './chat/StatsLine.tsx'
|
||||
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
|
||||
import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
|
||||
import { todoToolview } from './toolviews/todo-row.tsx'
|
||||
import { askQuestionToolview } from './toolviews/ask-question-row.tsx'
|
||||
import { todoDockEntry } from './skeleton/TodoPanel.tsx'
|
||||
import { queueDockEntry } from './queue/QueueDock.tsx'
|
||||
import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
|
||||
@@ -30,6 +32,19 @@ import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
|
||||
/** Services required by the conversation plugin. */
|
||||
export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale']
|
||||
|
||||
// Static no-session sources for the composer-bar hooks compartment: module
|
||||
// constants so the render side's per-source hook cache (observableHook) keeps
|
||||
// one identity across every no-session render.
|
||||
const ABSENT_NOTICES = {
|
||||
getSnapshot: (): InputNotice | null => null,
|
||||
subscribe: () => () => {},
|
||||
}
|
||||
const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map()
|
||||
const ABSENT_LEXICON = {
|
||||
getSnapshot: () => EMPTY_LEXICON,
|
||||
subscribe: () => () => {},
|
||||
}
|
||||
|
||||
/** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */
|
||||
function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
|
||||
const scoped = sessions.scope(id)
|
||||
@@ -120,7 +135,7 @@ export function apply(ctx: Context): void {
|
||||
children: {
|
||||
'conversation.session': { kind: 'single', scope: 'session' },
|
||||
'conversation.composer': { kind: 'chain', scope: 'session' },
|
||||
'conversation.composer.bar': { kind: 'single', scope: 'session' },
|
||||
'conversation.composer.bar': { kind: 'single', scope: 'session-maybe' },
|
||||
'conversation.input.overlay': { kind: 'list', scope: 'session' },
|
||||
'conversation.input.dock': { kind: 'list', scope: 'session' },
|
||||
'conversation.composer.dock': { kind: 'list', scope: 'session' },
|
||||
@@ -165,6 +180,9 @@ export function apply(ctx: Context): void {
|
||||
// chain's fallback (decision 20). Public machine surface arrives via the
|
||||
// provide channel above; the keyboard command face and the stop/retry
|
||||
// verbs ride this inject (package-internal — hub and bar are one plugin).
|
||||
// Session-maybe: with no current session the machine faces are absent and
|
||||
// the hooks compartment binds static empty sources (module constants, so
|
||||
// observableHook caching and hook order stay stable across transitions).
|
||||
slots.register({
|
||||
name: 'conversation.composer.bar',
|
||||
// The two named control seats in the bar's tool row (plan beside the
|
||||
@@ -174,7 +192,16 @@ export function apply(ctx: Context): void {
|
||||
'conversation.input.plan': { kind: 'single', scope: 'session' },
|
||||
'conversation.input.model': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
inject: (sessionId: SessionId): ComposerBarInjected => {
|
||||
inject: (sessionId: SessionId | undefined): ComposerBarInjected => {
|
||||
if (sessionId === undefined) {
|
||||
return {
|
||||
keyboard: undefined,
|
||||
stop: undefined,
|
||||
command: undefined,
|
||||
translateHint,
|
||||
hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON },
|
||||
}
|
||||
}
|
||||
const shell = inputHub.shell(sessionId)
|
||||
return {
|
||||
keyboard: shell,
|
||||
@@ -257,6 +284,9 @@ export function apply(ctx: Context): void {
|
||||
// The todo_write row rides the same seam (a product registration, not a sample).
|
||||
ctx.plugin(todoToolview)
|
||||
|
||||
// The ask_user_question row: waiting/answered/cancelled interaction outcome.
|
||||
ctx.plugin(askQuestionToolview)
|
||||
|
||||
// The plan strip rides the input dock above the queue rows (same posture).
|
||||
ctx.plugin(todoDockEntry)
|
||||
|
||||
|
||||
@@ -61,12 +61,6 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* The others-variant sparkle glyph is one gray step darker than the icon
|
||||
family in the source design. */
|
||||
.root[data-variant='others'] .leading {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Cordis lifecycle tools retain their generic row mechanics while carrying a
|
||||
shared product accent and tool-owned action title. */
|
||||
.root[data-tool^='cordis_'] .leading,
|
||||
@@ -86,10 +80,6 @@ button.leading {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Hover preview on expandable rows: the idle tool icon crossfades (100ms)
|
||||
into a down chevron before the row is opened. The chevron overlays the
|
||||
icon cell absolutely so both can stay mounted for the opacity transition. */
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
// through the host; the row itself is not a details-panel control.
|
||||
|
||||
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { CodeBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { CHAT_TERMINAL_MAX_LINES, type TerminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
@@ -106,12 +105,12 @@ export function ToolRow({
|
||||
? (
|
||||
<>
|
||||
<span className={css.iconIdle}>{icon}</span>
|
||||
<IconChevronDownOutline14 className={clsx(css.chevron, css.chevronHover)} />
|
||||
<IconChevronDownOutline14 className={css.chevronHover} />
|
||||
</>
|
||||
)
|
||||
: icon
|
||||
const leading = open
|
||||
? <IconChevronDownOutline14 className={css.chevron} />
|
||||
? <IconChevronDownOutline14 />
|
||||
: leadingFor(state, collapsedIcon)
|
||||
return (
|
||||
<div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>
|
||||
|
||||
@@ -77,11 +77,15 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* The default composer body: a single slot rendered as the composer
|
||||
* chain's fallback (decision 20 — a real entry, not a chain rider, so a
|
||||
* takeover election hides rather than unmounts it and the textarea DOM
|
||||
* survives). InputBar registers here from this package's apply; its
|
||||
* machine state arrives through the standard provide channel (useInput +
|
||||
* inputActions), the keyboard command face through its own inject.
|
||||
* survives). Session-maybe: the bar stays mounted across the
|
||||
* no-session/session transition — the no-workspace hero renders the SAME
|
||||
* textarea DOM disabled instead of a parallel inert tree — with the
|
||||
* machine hooks absent until a session is current. InputBar registers
|
||||
* here from this package's apply; its machine state arrives through the
|
||||
* standard provide channel (useInput + inputActions), the keyboard
|
||||
* command face through its own inject.
|
||||
*/
|
||||
'conversation.composer.bar': { kind: 'single'; scope: 'session'; owner: ComposerBarOwnerProps }
|
||||
'conversation.composer.bar': { kind: 'single'; scope: 'session-maybe'; owner: ComposerBarOwnerProps }
|
||||
/**
|
||||
* The Plan-mode status seat in the composer tool row (left group,
|
||||
* right of the access-mode control). Declared by the composer-bar
|
||||
@@ -244,6 +248,12 @@ export interface ConversationSessionInjected {
|
||||
export interface ComposerBarOwnerProps {
|
||||
/** Hero = empty-state centered card; composer = resident bottom bar. */
|
||||
variant: 'hero' | 'composer'
|
||||
/**
|
||||
* Inert no-workspace state: the bar renders its normal DOM fully disabled
|
||||
* (textarea, add, send) so the workspace pick transitions in place instead
|
||||
* of swapping component trees.
|
||||
*/
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
/** Optional content rendered above the textarea. */
|
||||
accessory?: ReactNode
|
||||
@@ -261,19 +271,24 @@ export interface ComposerBarOwnerProps {
|
||||
|
||||
/** Injected share of the composer-bar entry (package-internal faces). */
|
||||
export interface ComposerBarInjected {
|
||||
/** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane). */
|
||||
keyboard: ComposerKeyboard
|
||||
/** Cancel the in-flight turn. */
|
||||
stop: () => void
|
||||
/** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane); absent with the session. */
|
||||
keyboard: ComposerKeyboard | undefined
|
||||
/** Cancel the in-flight turn; absent with the session. */
|
||||
stop: (() => void) | undefined
|
||||
/**
|
||||
* Submit one slash-command line against this session's agent (the chrome
|
||||
* controls' write path — the permission chip submits `/permission <preset>`).
|
||||
* controls' write path — the permission chip submits `/permission <preset>`);
|
||||
* absent with the session.
|
||||
* Resolves admission: false = rejected/unmatched/transport failure.
|
||||
*/
|
||||
command: (line: string) => Promise<boolean>
|
||||
/** Locale-aware hint translator for claimed command placeholders. */
|
||||
command: ((line: string) => Promise<boolean>) | undefined
|
||||
/** Locale-aware hint translator for claimed command placeholders (session-independent — always present). */
|
||||
translateHint: (key: string) => string
|
||||
/** Registrant hooks compartment: the renderer binds these to useNotices/useLexicon. */
|
||||
/**
|
||||
* Registrant hooks compartment: the renderer binds these to
|
||||
* useNotices/useLexicon (static absent sources without a session — hook
|
||||
* order stays constant).
|
||||
*/
|
||||
hooks: {
|
||||
/** Latest surfaced notice (null after none; seq keys re-render of repeats). */
|
||||
notices: ObservableSnapshot<InputNotice | null>
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
flex: none;
|
||||
width: 100%;
|
||||
max-width: 776px;
|
||||
/* Eat InputBar's 6px top padding and tuck the panel 2px under the card;
|
||||
the later composer sibling paints its surface and shadow over this edge. */
|
||||
margin: 0 auto -10px;
|
||||
/* Flex gap still applies after this item; subtract it together with the
|
||||
design's overlap so the later composer paints over the queue edge. */
|
||||
margin: 0 auto calc(
|
||||
0px - var(--dsh-composer-stack-gap) - var(--dsh-queue-composer-overlap)
|
||||
);
|
||||
padding: 2px 12px;
|
||||
}
|
||||
|
||||
|
||||
@@ -162,14 +162,14 @@ export const queueDockEntry = {
|
||||
name: 'conversation-queue-dock',
|
||||
inject: ['slots', 'conversation', 'sessions'],
|
||||
/**
|
||||
* Register the queue strip into the input dock (list entry, order 0).
|
||||
* Register the queue strip as the terminal input-dock entry (order 20).
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.register({
|
||||
name: 'conversation.input.dock',
|
||||
id: 'queue',
|
||||
order: 0,
|
||||
order: 20,
|
||||
inject: (sessionId: SessionId): QueueDockInjected => {
|
||||
const actx = ctx.sessions.scope(sessionId)
|
||||
if (actx === undefined) throw new Error(`queue dock: session "${sessionId}" resolved no scope`)
|
||||
|
||||
@@ -127,14 +127,15 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Composer stack: dock strips above the input card (design §6 MIX order).
|
||||
The stack owns the vertical rhythm: one gap here, entries carry no outer
|
||||
margins — an entry that renders null costs nothing, so spacing stays
|
||||
correct for any dock combination. */
|
||||
/* Composer context stack (Figma 9:937): standalone dock cards share one
|
||||
rhythm; the terminal queue strip additionally tucks under the input card. */
|
||||
.composerStack {
|
||||
--dsh-composer-stack-gap: 6px;
|
||||
--dsh-queue-composer-overlap: 5px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: var(--dsh-composer-stack-gap);
|
||||
}
|
||||
|
||||
/* Common seat for the composer chain (fallback + elected overlay siblings). */
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
// Resident conversation skeleton. Hero chrome, composer positioning, and the
|
||||
// chain stay mounted across no-session/session transitions. Only the inert
|
||||
// input body swaps for the strict session InputBar.
|
||||
// Resident conversation skeleton. Hero chrome, composer positioning, the
|
||||
// chain, AND the composer bar (session-maybe slot) stay mounted across
|
||||
// no-session/session transitions — the bar renders inert via owner props.
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSlotProps, InputZone } from '../contract/slots.ts'
|
||||
import { HeroGlow, HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx'
|
||||
import { DisabledInputBar } from './DisabledInputBar.tsx'
|
||||
import css from './ConversationRoot.module.css'
|
||||
|
||||
/** Full props composed from the slot contract. */
|
||||
@@ -113,20 +112,23 @@ export function ConversationRoot({
|
||||
)
|
||||
|
||||
// The placeholder chip ("Choose workspace") and the inert input travel
|
||||
// together: a blank session whose workspace vanished (deleted from the
|
||||
// sidebar) reverts to the same disabled bar as the initial no-session state.
|
||||
const inputBar = sessionId === undefined || (hero && chipTitle === undefined)
|
||||
? <DisabledInputBar />
|
||||
: renderSlot('conversation.composer.bar', {
|
||||
variant: hero ? 'hero' : 'composer',
|
||||
...(hero ? { placeholder: 'Describe what you want to build' } : {}),
|
||||
overlay: renderSlot('conversation.input.overlay', {}),
|
||||
leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
|
||||
rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
|
||||
// Stats band under the card, inside the bar's width column so both
|
||||
// share one constraint (composer.dock = stats-line family).
|
||||
footer: !hero && zone !== undefined ? renderSlot('conversation.composer.dock', zone) : null,
|
||||
})
|
||||
// together: no workspace picked yet (cold start, no session at all), or a
|
||||
// blank session whose workspace vanished (deleted from the sidebar). The
|
||||
// bar is ONE session-maybe slot rendered unconditionally — inert is a prop,
|
||||
// not a different tree, so the textarea DOM survives the transition.
|
||||
const inert = sessionId === undefined || (hero && chipTitle === undefined)
|
||||
const inputBar = renderSlot('conversation.composer.bar', {
|
||||
variant: hero ? 'hero' : 'composer',
|
||||
...(inert
|
||||
? { disabled: true, placeholder: 'Choose a workspace to start' }
|
||||
: hero ? { placeholder: 'Describe what you want to build' } : {}),
|
||||
overlay: renderSlot('conversation.input.overlay', {}),
|
||||
leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
|
||||
rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
|
||||
// Stats band under the card, inside the bar's width column so both
|
||||
// share one constraint (composer.dock = stats-line family).
|
||||
footer: !hero && zone !== undefined ? renderSlot('conversation.composer.dock', zone) : null,
|
||||
})
|
||||
|
||||
const composerBar = (
|
||||
<div className={clsx(css.composerStack, hero && css.composerHero)}>
|
||||
@@ -176,7 +178,7 @@ export function ConversationRoot({
|
||||
'conversation.session',
|
||||
{ wrapActiveBody },
|
||||
)}
|
||||
{sessionId === undefined ? composerSeat : null}
|
||||
{sessionId === undefined ? wrapActiveBody(null) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
/** Inert no-session input body; the resident Hero shell renders around it. */
|
||||
|
||||
import clsx from 'clsx'
|
||||
import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import css from './InputBar.module.css'
|
||||
|
||||
/** Disabled visual twin of the session-bound InputBar. */
|
||||
export function DisabledInputBar() {
|
||||
return (
|
||||
<div className={clsx(css.root, css.hero)}>
|
||||
<div className={css.card}>
|
||||
<div className={css.grow}>
|
||||
<textarea
|
||||
className={css.input}
|
||||
value=""
|
||||
disabled
|
||||
placeholder="Choose a workspace to start"
|
||||
rows={2}
|
||||
readOnly
|
||||
/>
|
||||
<div aria-hidden className={css.mirror}>{'\n'}</div>
|
||||
</div>
|
||||
<div className={css.row}>
|
||||
<div className={css.tools}>
|
||||
<button type="button" className={css.add} aria-label="Add attachment" disabled>
|
||||
<IconPlusOutline16 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className={css.trailing}>
|
||||
<button type="button" className={css.primary} aria-label="Send message" disabled>
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
|
||||
<path d="M8.3125 0.980183C8.66767 1.0531 8.97902 1.20418 9.2627 1.43233C9.48724 1.61297 9.73029 1.85793 9.97949 2.10714L14.707 6.83468L13.293 8.24874L9 3.95577V15.0417H7V3.95577L2.70703 8.24874L1.29297 6.83468L6.02051 2.10714C6.26971 1.85793 6.51277 1.61297 6.7373 1.43233C6.97662 1.23986 7.28445 1.04402 7.6875 0.980183C7.8973 0.947006 8.1031 0.95516 8.3125 0.980183Z" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -17,9 +17,13 @@ import type {} from '@deepseek-ai/dsh-plan-mode/client'
|
||||
import type {} from '@deepseek-ai/dsh-goal/client'
|
||||
import type { ComposerBarProps } from '../contract/slots.ts'
|
||||
import { deriveDecorations } from '../input/decorations.ts'
|
||||
import type { DraftDecorations } from '../input/decorations.ts'
|
||||
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'
|
||||
@@ -29,15 +33,16 @@ export interface InputBarError {
|
||||
export type InputBarProps = ComposerBarProps
|
||||
|
||||
export function InputBar({
|
||||
useSession, useInput, inputActions, keyboard, stop, command, translateHint, renderSlot, useNotices, useLexicon, useProjection,
|
||||
variant, placeholder, accessory, overlay, leftItems, rightItems, footer, onAdd, addLabel = 'Add attachment',
|
||||
useSession, useInput, inputActions, keyboard, stop, command, translateHint, renderSlot, useNotices, useLexicon,
|
||||
useProjection, sessionId, variant, disabled: inert = false, placeholder, accessory, overlay, leftItems, rightItems, footer,
|
||||
onAdd, addLabel = 'Add attachment',
|
||||
}: InputBarProps) {
|
||||
const input = useInput(s => s)
|
||||
const notice = useNotices(s => s)
|
||||
const lexicon = useLexicon(s => s)
|
||||
const promptError = useSession(s => s.promptError)
|
||||
const running = useSession(s => s.running)
|
||||
const disabled = useSession(s => s.removed)
|
||||
const promptError = useSession(s => s.promptError) ?? null
|
||||
const running = useSession(s => s.running) ?? false
|
||||
const removed = useSession(s => s.removed) ?? false
|
||||
// Plan mode swaps the textarea placeholder (the projection is the folded
|
||||
// host value; owner-prop placeholders — hero, session-unavailable — win).
|
||||
const planActive = useProjection('plan', plan => plan !== undefined && (plan.pending ? !plan.active : plan.active))
|
||||
@@ -49,7 +54,10 @@ export function InputBar({
|
||||
const error: InputBarError | null = promptError === null
|
||||
? null
|
||||
: { op: promptError.op, message: `${promptError.error.message} (${promptError.error.code})` }
|
||||
const draft = input.draft
|
||||
// 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
|
||||
const draft = input?.draft ?? ''
|
||||
const empty = draft.trim() === ''
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
// IME guard: composition Enter picks a candidate, it must not send. The ref outlives renders;
|
||||
@@ -68,16 +76,18 @@ export function InputBar({
|
||||
// (undefined = capability absent → the chip renders nothing).
|
||||
const permissions = useProjection('permissions')
|
||||
|
||||
// Queue cut 1: running input stays free; locked = session disabled only.
|
||||
// The transient machine locks (adjudicating pending / submitting) render
|
||||
// Queue cut 1: running input stays free; locked = session removed, the
|
||||
// inert no-workspace state, or the machine faces absent (no session). The
|
||||
// transient machine locks (adjudicating pending / submitting) render
|
||||
// read-only — the draft stays visible and focused, keystrokes drop.
|
||||
const disabled = removed || inert || !live
|
||||
const locked = disabled
|
||||
const machineBusy = input.phase === 'adjudicating' || input.phase === 'submitting'
|
||||
const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting'
|
||||
|
||||
// Unlock (mount / session switch) returns focus to the box.
|
||||
useEffect(() => {
|
||||
if (!locked) inputRef.current?.focus()
|
||||
}, [locked])
|
||||
}, [locked, sessionId])
|
||||
|
||||
// Active conversation scrollport: chain the wheel. While the textarea (capped
|
||||
// at 14 lines with overflow-y:auto) can still move in this direction, keep
|
||||
@@ -101,6 +111,9 @@ export function InputBar({
|
||||
}, [])
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
|
||||
// Absent machine (no session): the textarea is disabled so events cannot
|
||||
// fire; the guard narrows the faces for the paths below.
|
||||
if (keyboard === undefined || inputActions === undefined) return
|
||||
// Shift+Enter is the native newline UNCONDITIONALLY — decided before the
|
||||
// IME guard so a composition-closing Shift+Enter still breaks the line.
|
||||
if (e.key === 'Enter' && e.shiftKey) return
|
||||
@@ -162,6 +175,7 @@ export function InputBar({
|
||||
}
|
||||
|
||||
const onChange = (e: ChangeEvent<HTMLTextAreaElement>): void => {
|
||||
if (keyboard === undefined) return // absent machine: disabled textarea, no events
|
||||
if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock
|
||||
const next = e.target.value
|
||||
keyboard.setDraft(next)
|
||||
@@ -187,6 +201,7 @@ export function InputBar({
|
||||
/* oxlint-enable typescript/no-unnecessary-condition */
|
||||
|
||||
const onCopyOrCut = (e: React.ClipboardEvent<HTMLTextAreaElement>, cut: boolean): void => {
|
||||
if (input === undefined || keyboard === undefined) return // absent machine: disabled textarea, no events
|
||||
const el = e.currentTarget
|
||||
const { start, end } = selectionOf(el)
|
||||
if (start === end) return
|
||||
@@ -211,6 +226,7 @@ export function InputBar({
|
||||
}
|
||||
|
||||
const onPaste = (e: React.ClipboardEvent<HTMLTextAreaElement>): void => {
|
||||
if (keyboard === undefined) return // absent machine: disabled textarea, no events
|
||||
if (machineBusy || locked) return
|
||||
const text = e.clipboardData.getData('text/plain')
|
||||
if (text === '') return
|
||||
@@ -230,7 +246,7 @@ export function InputBar({
|
||||
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.
|
||||
if (keyboard.snapshot.paste !== undefined) keyboard.invalidatePaste()
|
||||
if (keyboard !== undefined && keyboard.snapshot.paste !== undefined) keyboard.invalidatePaste()
|
||||
void e
|
||||
}
|
||||
|
||||
@@ -242,6 +258,7 @@ export function InputBar({
|
||||
|
||||
const primaryLabel = running ? 'Stop generating' : 'Send message'
|
||||
const onPrimary = (): void => {
|
||||
if (inputActions === undefined || stop === undefined) return // absent machine: the button is disabled
|
||||
if (running) {
|
||||
stop()
|
||||
return
|
||||
@@ -251,16 +268,17 @@ export function InputBar({
|
||||
}
|
||||
|
||||
// The Access seat: the projection-fed permission chip (renders nothing
|
||||
// while the permissions key is absent — permission-less host or Draft).
|
||||
const accessSelect: ReactNode = (
|
||||
<PermissionSelect value={permissions} locked={locked} command={command} />
|
||||
)
|
||||
// while the permissions key is absent — permission-less host or Draft —
|
||||
// or while the command face is absent with the session).
|
||||
const accessSelect: ReactNode = command === undefined
|
||||
? null
|
||||
: <PermissionSelect value={permissions} locked={locked} command={command} />
|
||||
|
||||
// Mirror-layer decorations: a visible backdrop with transparent text. The
|
||||
// claim token highlights through behind the textarea glyphs; each U+FFFC
|
||||
// placeholder renders as a chip (the textarea's own glyph is invisible, the
|
||||
// backdrop chip supplies the visual); the claim hint is ghost text.
|
||||
const deco = deriveDecorations(input, lexicon)
|
||||
const deco = input === undefined ? INERT_DECORATIONS : deriveDecorations(input, lexicon)
|
||||
const backdrop: ReactNode[] = []
|
||||
{
|
||||
// Segment boundaries: the token range end, every chip offset, and every
|
||||
@@ -322,7 +340,7 @@ export function InputBar({
|
||||
pushPlain(draft.length)
|
||||
if (deco.hint !== null) {
|
||||
// Claim tokens are shaped `/name ` (trailing space); trim to the bare name.
|
||||
const commandName = input.claim?.token.slice(1).trim() ?? ''
|
||||
const commandName = input?.claim?.token.slice(1).trim() ?? ''
|
||||
const hintKey = commandName === 'goal' && hasGoal ? 'goal.active' : commandName
|
||||
const translated = translateHint(hintKey)
|
||||
const displayHint = translated !== hintKey ? translated : deco.hint
|
||||
@@ -356,7 +374,7 @@ export function InputBar({
|
||||
value={draft}
|
||||
disabled={locked}
|
||||
readOnly={machineBusy}
|
||||
data-phase={input.phase}
|
||||
data-phase={input?.phase ?? 'inert'}
|
||||
placeholder={placeholder ?? (disabled
|
||||
? 'Session unavailable'
|
||||
: planActive ? translateHint('placeholder.plan') : translateHint('placeholder.default'))}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/* Todo strip above the composer (figma 772:51905 / 772:52972 / 772:53419):
|
||||
tip surface, 14px radius, status icons + secondary item labels. Column is
|
||||
calc(100% - 88px) / max 752 (GoalBar's column), centered; the composer
|
||||
stack owns the gap. */
|
||||
/* Todo strip in the composer context stack (Figma 9:959): tip surface,
|
||||
14px radius, status icons + secondary item labels. */
|
||||
|
||||
.root {
|
||||
box-sizing: border-box;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
margin: 0 auto;
|
||||
@@ -21,13 +20,11 @@
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
/* Compact scale (GoalBar reference): collapsed header totals the goal
|
||||
strip's 38px (8+8 pad + 20 line + 2 border). */
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
padding: 9px 15px;
|
||||
}
|
||||
|
||||
.header {
|
||||
@@ -44,8 +41,8 @@
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
@@ -135,10 +135,10 @@ export const todoDockEntry = {
|
||||
name: 'conversation-todo-dock',
|
||||
inject: ['slots', 'conversation'],
|
||||
/**
|
||||
* Register the plan strip into the input dock (list entry, above the queue rows).
|
||||
* Register the plan strip between the goal and queue entries (order 10).
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: -1 }, TodoDock)
|
||||
ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: 10 }, TodoDock)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// ask_user_question toolview: question-flavored summary row replacing the
|
||||
// generic "Tool call" card, registered into the keyed
|
||||
// 'conversation.chat.toolview' hole like todo-row. The row composes ToolRow
|
||||
// (chrome, running sweep, leading expansion) and swaps in the interaction
|
||||
// outcome — `waiting` while pending, answered-count once settled, `cancelled`
|
||||
// when the user dismissed the whole set — because the questions themselves
|
||||
// render in the composer takeover.
|
||||
|
||||
import { IconQuestionOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from 'cordis'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { toolRowModel } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from '../chat/ToolRow.tsx'
|
||||
|
||||
/** One parsed answer entry, shape-checked (result JSON crosses the wire). */
|
||||
interface AnswerEntry { selected?: unknown; custom?: unknown }
|
||||
|
||||
function isAnswer(value: unknown): value is AnswerEntry {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
/** `${answered}/${total} answered` off the result JSON (a skipped question has
|
||||
* empty `selected` and no `custom`); null on unexpected shape (generic fallback). */
|
||||
function answeredSummary(text: string): string | null {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null) return null
|
||||
const answers = (parsed as { answers?: unknown }).answers
|
||||
if (!Array.isArray(answers) || !answers.every(isAnswer)) return null
|
||||
const answered = answers.filter(a =>
|
||||
(Array.isArray(a.selected) && a.selected.length > 0)
|
||||
|| (typeof a.custom === 'string' && a.custom !== '')).length
|
||||
return `${answered}/${answers.length} answered`
|
||||
}
|
||||
|
||||
/** One-line question-interaction row (leading toggle expands the raw args). */
|
||||
export function AskQuestionRow({ toolName, block }: ToolRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
// Composer verdicts settle the call as specific UserInteractionErrors
|
||||
// (apiproxy ask_user_question handler): 'ASK_CANCELLED' is the user's own
|
||||
// dismissal of the set, 'ASK_ABORTED' is a turn interrupt landing while the
|
||||
// question was pending. Both name their verdict instead of the generic
|
||||
// failed shape, and the abort keeps the shared stopped (amber) semantics of
|
||||
// any other interrupted tool call.
|
||||
const code = 'kind' in block ? block.error?.code : undefined
|
||||
let summary = model.summary
|
||||
let state = model.state
|
||||
if (code === 'ASK_CANCELLED') {
|
||||
summary = 'cancelled'
|
||||
} else if (code === 'ASK_ABORTED') {
|
||||
summary = 'interrupted'
|
||||
state = 'stopped'
|
||||
} else if (model.state === 'running') {
|
||||
summary = 'waiting'
|
||||
} else if ('kind' in block && model.state === 'ok') {
|
||||
const text = block.content.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||
summary = answeredSummary(text) ?? model.summary
|
||||
}
|
||||
return (
|
||||
<ToolRow
|
||||
variant={model.variant}
|
||||
toolName={toolName}
|
||||
icon={<IconQuestionOutline14 />}
|
||||
title="Ask question"
|
||||
summary={summary}
|
||||
body={model.body}
|
||||
state={state}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The ask-question row as a plain registrant plugin, riding the same
|
||||
* load-order seam as todo-toolview: `inject: ['conversation']` guarantees the
|
||||
* chat entry (and with it the 'conversation.chat.toolview' declaration) is on
|
||||
* the ledger.
|
||||
*/
|
||||
export const askQuestionToolview = {
|
||||
name: 'ask-question-toolview',
|
||||
inject: ['slots', 'conversation'],
|
||||
/**
|
||||
* Register the ask-question row into the chat view's keyed toolview hole.
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'ask_user_question' }, AskQuestionRow)
|
||||
},
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
/* todo_write plan-update row: ToolRow chrome (figma 780:53675) —
|
||||
[16 checklist] gap6 [title 14/24] gap8 [2x2 dot] gap8 [summary FILL truncate]. */
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.leading {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 6px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
font-weight: 500; /* figma wt510, rendered 500 */
|
||||
color: var(--dsw-alias-label-primary-dimmed);
|
||||
}
|
||||
|
||||
.sep {
|
||||
flex: none;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
margin: 0 8px;
|
||||
background: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.summary {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.err {
|
||||
flex: none;
|
||||
margin-left: 8px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
// todo_write toolview: plan-flavored summary row replacing the generic
|
||||
// "Tool call" card, registered into the keyed 'conversation.chat.toolview'
|
||||
// hole like the bash sample (a product registration, not a sample). The row
|
||||
// summarizes the written list (counts + active item) from the call args; the
|
||||
// composes ToolRow (chrome, running sweep, leading expansion) and swaps in a
|
||||
// summary of the written list (counts + active item) from the call args; the
|
||||
// durable list itself renders in the TodoPanel above the composer, so the
|
||||
// row stays one line. Chrome matches ToolRow (figma 780:53675).
|
||||
// row stays one line.
|
||||
|
||||
import { IconChecklistOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from 'cordis'
|
||||
import { IconChecklistOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
|
||||
import css from './todo-row.module.css'
|
||||
import { toolRowModel } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from '../chat/ToolRow.tsx'
|
||||
|
||||
/** One parsed args item, shape-checked (model JSON: any field may be missing or mistyped). */
|
||||
interface TodoWriteItem { content?: unknown; status?: unknown }
|
||||
@@ -39,37 +40,23 @@ function summarize(argsRaw: string): string | null {
|
||||
: head
|
||||
}
|
||||
|
||||
/** Leading-slot state substitution matches ToolRow / bash: icon yields to the
|
||||
* state semantic while running or failed; ok keeps the checklist glyph. */
|
||||
function leadingFor(state: ToolRowState) {
|
||||
switch (state) {
|
||||
case 'running': return <StateDot state="ongoing" />
|
||||
case 'error': return <StateDot state="error" />
|
||||
case 'stopped': return <StateDot state="warning" />
|
||||
default: return <IconChecklistOutline16 />
|
||||
}
|
||||
}
|
||||
|
||||
/** One-line plan update row. Non-ok execution states keep the generic row's
|
||||
* dot semantics — a cancelled call wrote no todo/write, so it must not read
|
||||
* as a completed update. */
|
||||
/** One-line plan update row (leading toggle expands the raw args). Non-ok
|
||||
* execution states keep the shared row's dot semantics — a cancelled call
|
||||
* wrote no todo/write, so it must not read as a completed update. */
|
||||
export function TodoRow({ toolName, block }: ToolRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? ''
|
||||
const summary = summarize(argsRaw) ?? model.summary
|
||||
return (
|
||||
<div
|
||||
className={css.row}
|
||||
data-sample="todo-row"
|
||||
data-state={model.state}
|
||||
>
|
||||
<span className={css.leading} aria-hidden>{leadingFor(model.state)}</span>
|
||||
<span className={css.title}>更新任务清单</span>
|
||||
<span className={css.sep} aria-hidden />
|
||||
<span className={css.summary}>{summary}</span>
|
||||
{model.state === 'error' && <span className={css.err}>failed</span>}
|
||||
{model.state === 'stopped' && <span className={css.err}>已中断</span>}
|
||||
</div>
|
||||
<ToolRow
|
||||
variant={model.variant}
|
||||
toolName={toolName}
|
||||
icon={<IconChecklistOutline14 />}
|
||||
title="更新任务清单"
|
||||
summary={summary}
|
||||
body={model.body}
|
||||
state={model.state}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ describe('conversation slot inject surface', () => {
|
||||
unbind()
|
||||
// Stop failure is swallowed (promptError owns the surface).
|
||||
b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x', details: {} } })
|
||||
b.composerSurface(ROOT).stop()
|
||||
b.composerSurface(ROOT).stop!()
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
expect(b.sessionFake.cancel).toHaveBeenCalledTimes(1)
|
||||
await b.runtime.dispose()
|
||||
@@ -172,12 +172,19 @@ describe('conversation slot inject surface', () => {
|
||||
it('inject fails loud when the session resolves no binding or the scope lacks the service', async () => {
|
||||
const b = await bench()
|
||||
const entry = b.entryOf('conversation.composer.bar')
|
||||
const injectFn = entry.inject as unknown as (sessionId: SessionId) => ComposerBarInjected
|
||||
const injectFn = entry.inject as unknown as (sessionId: SessionId | undefined) => ComposerBarInjected
|
||||
// Unknown session: the keyboard face's binding resolution answers nothing.
|
||||
expect(() => { injectFn('ghost' as SessionId).stop() }).toThrow(/resolved no binding/)
|
||||
expect(() => { injectFn('ghost' as SessionId).stop!() }).toThrow(/resolved no binding/)
|
||||
// No session (session-maybe absent side): machine faces absent, static
|
||||
// hooks compartment still present so the render side's hook order holds.
|
||||
const absent = injectFn(undefined)
|
||||
expect(absent.keyboard).toBeUndefined()
|
||||
expect(absent.stop).toBeUndefined()
|
||||
expect(absent.hooks.notices.getSnapshot()).toBeNull()
|
||||
expect(absent.hooks.lexicon.getSnapshot().size).toBe(0)
|
||||
// A scope whose service tree lost 'conversation' (the feature fiber
|
||||
// unloaded while a retained inject closure re-runs): fails loud too.
|
||||
const stop = injectFn(ROOT).stop
|
||||
const stop = injectFn(ROOT).stop!
|
||||
await b.feature.dispose()
|
||||
expect(() => { stop() }).toThrow(/unavailable through the session scope/)
|
||||
await b.runtime.dispose()
|
||||
|
||||
129
packages/client/ui-conversation/tests/ask-question-row.spec.tsx
Normal file
129
packages/client/ui-conversation/tests/ask-question-row.spec.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* ask_user_question toolview acceptance: `waiting` summary while running,
|
||||
* answered-count from the result JSON once settled (skipped answers
|
||||
* excluded), the cancelled/interrupted verdicts off ASK_CANCELLED and
|
||||
* ASK_ABORTED, shared ToolRow state
|
||||
* semantics for interrupted/failed calls, and generic fallbacks on
|
||||
* malformed results.
|
||||
*/
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
import { AskQuestionRow, askQuestionToolview } from '../src/client/toolviews/ask-question-row.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const ARGS = JSON.stringify({ questions: [{ id: 'a' }, { id: 'b' }, { id: 'c' }] })
|
||||
|
||||
const resultNode = (argsRaw: string, resultText: string | null, over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1',
|
||||
call: { name: 'ask_user_question', argsRaw },
|
||||
content: resultText === null ? [] : [{ type: 'text', text: resultText }],
|
||||
isError: false, callView: null, resultView: null, ...over,
|
||||
})
|
||||
|
||||
const runningCall = (argsRaw: string) =>
|
||||
({ callId: 'c1', name: 'ask_user_question', argsRaw, turn: 1, step: 1, time: 1_000, callView: null })
|
||||
|
||||
function rowProps(block: unknown): ToolRowProps {
|
||||
return {
|
||||
callId: 'c1', toolName: 'ask_user_question', block,
|
||||
openFile: vi.fn(),
|
||||
sessionId: 's1',
|
||||
useSessions: () => undefined,
|
||||
} as unknown as ToolRowProps
|
||||
}
|
||||
|
||||
const answers = (entries: unknown[]): string => JSON.stringify({ answers: entries })
|
||||
|
||||
describe('AskQuestionRow', () => {
|
||||
it('running call reads waiting (args-independent: the composer takeover shows the questions)', () => {
|
||||
const view = render(<AskQuestionRow {...rowProps(runningCall(ARGS))} />)
|
||||
expect(screen.getByText('Ask question')).toBeTruthy()
|
||||
expect(screen.getByText('waiting')).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('settled result counts answered entries (selected choices or custom text)', () => {
|
||||
render(<AskQuestionRow {...rowProps(resultNode(ARGS, answers([
|
||||
{ id: 'a', selected: ['x'] },
|
||||
{ id: 'b', selected: [], custom: 'freeform' },
|
||||
{ id: 'c', selected: ['y', 'z'], custom: '' },
|
||||
])))} />)
|
||||
expect(screen.getByText('3/3 answered')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('skipped questions (no selection, no custom) stay out of the answered count', () => {
|
||||
const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, answers([
|
||||
{ id: 'a', selected: ['x'] },
|
||||
{ id: 'b', selected: [], custom: '' },
|
||||
{ id: 'c' },
|
||||
])))} />)
|
||||
expect(screen.getByText('1/3 answered')).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ label: 'non-JSON result text', text: 'oops' },
|
||||
{ label: 'non-object result root', text: '"str"' },
|
||||
{ label: 'null result root', text: 'null' },
|
||||
{ label: 'missing answers array', text: '{"other":1}' },
|
||||
{ label: 'null answer entries', text: '{"answers":[null]}' },
|
||||
{ label: 'empty result content', text: null },
|
||||
])('settled result falls back to the generic summary on $label', ({ text }) => {
|
||||
render(<AskQuestionRow {...rowProps(resultNode(ARGS, text))} />)
|
||||
expect(screen.getByText(`ask_user_question · ${ARGS}`)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('user cancellation names the verdict instead of the generic failed shape', () => {
|
||||
// ASK_CANCELLED: the apiproxy ask_user_question handler's cancel error.
|
||||
const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, null,
|
||||
{ isError: true, error: { name: 'UserInteractionError', code: 'ASK_CANCELLED' } }))} />)
|
||||
expect(screen.getByText('cancelled')).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('a turn abort while pending reads interrupted with stopped semantics', () => {
|
||||
// ASK_ABORTED: the apiproxy ask handler's turn-abort settlement.
|
||||
const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, null,
|
||||
{ isError: true, error: { name: 'UserInteractionError', code: 'ASK_ABORTED' } }))} />)
|
||||
expect(screen.getByText('interrupted')).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('an interrupted turn reads as stopped, not cancelled', () => {
|
||||
const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, null,
|
||||
{ isError: true, error: { name: 'Interrupted', code: 'interrupted' } }))} />)
|
||||
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
|
||||
expect(screen.queryByText('cancelled')).toBeNull()
|
||||
expect(screen.getByText(`ask_user_question · ${ARGS}`)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('other tool errors keep the generic summary with the error state', () => {
|
||||
const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, null, { isError: true }))} />)
|
||||
expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
|
||||
expect(screen.getByText(`ask_user_question · ${ARGS}`)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('window-truncated result (call head lost) falls back to the callId summary', () => {
|
||||
render(<AskQuestionRow {...rowProps(resultNode('', null, { call: null }))} />)
|
||||
expect(screen.getByText('ask_user_question · c1')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('leading toggle expands the raw args body', () => {
|
||||
render(<AskQuestionRow {...rowProps(resultNode(ARGS, answers([])))} />)
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
expect(screen.getByRole('button', { expanded: true })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('askQuestionToolview is a plain registrant riding the conversation load-order seam', () => {
|
||||
expect(askQuestionToolview.name).toBe('ask-question-toolview')
|
||||
expect(askQuestionToolview.inject).toEqual(['slots', 'conversation'])
|
||||
const register = vi.fn()
|
||||
askQuestionToolview.apply({ slots: { register } } as never)
|
||||
expect(register).toHaveBeenCalledWith({ name: 'conversation.chat.toolview', key: 'ask_user_question' }, AskQuestionRow)
|
||||
})
|
||||
})
|
||||
@@ -108,7 +108,7 @@ describe('todo_write assembly (product registrations, no outlet twins)', () => {
|
||||
const view = runtime.renderRoot()
|
||||
|
||||
// Keyed toolview registration took the row (summary derived from args).
|
||||
const row = view.container.querySelector('[data-sample="todo-row"]')
|
||||
const row = view.container.querySelector('[data-tool="todo_write"]')
|
||||
expect(row).not.toBeNull()
|
||||
expect(row!.textContent).toContain('1/3 已完成 · 实现 fixture 样本')
|
||||
|
||||
@@ -128,7 +128,7 @@ describe('todo_write assembly (product registrations, no outlet twins)', () => {
|
||||
await waitFor(() => {
|
||||
expect(view.container.querySelector('[data-testid="todo-panel"]')).toBeNull()
|
||||
})
|
||||
expect(view.container.querySelector('[data-sample="todo-row"]')).not.toBeNull()
|
||||
expect(view.container.querySelector('[data-tool="todo_write"]')).not.toBeNull()
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -80,12 +80,12 @@ describe('apply wiring', () => {
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('mounts the bash sample and the todo row as keyed entries through the load-order seam', async () => {
|
||||
it('mounts the bash sample and the product rows as keyed entries through the load-order seam', async () => {
|
||||
const b = await bench()
|
||||
// Both registrant plugins' inject: ['slots', 'conversation'] resolved — the
|
||||
// Every registrant plugin's inject: ['slots', 'conversation'] resolved — the
|
||||
// service being present implies the chat entry declared the hole first.
|
||||
const entries = b.slots.entries('conversation.chat.toolview')
|
||||
expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write'])
|
||||
expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write', 'ask_user_question'])
|
||||
// Stats stick with the composer (not inside ChatView).
|
||||
expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats'])
|
||||
await b.runtime.dispose()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
// Branch tails the acceptance specs do not reach: ToolRow stopped-state dot,
|
||||
// bash sample state dots, the node-half empty
|
||||
// apply, and AssistantMarkdown reasoning/unknown block arms.
|
||||
// bash sample state dots, the node-half empty apply, and AssistantMarkdown
|
||||
// reasoning/unknown block arms.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
|
||||
@@ -197,9 +197,14 @@ describe('QueueDock', () => {
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('ships the session-scoped registrant plugin shape', () => {
|
||||
it('registers as the terminal composer-context entry', () => {
|
||||
expect(queueDockEntry.name).toBe('conversation-queue-dock')
|
||||
expect(queueDockEntry.inject).toEqual(['slots', 'conversation', 'sessions'])
|
||||
expect(typeof queueDockEntry.apply).toBe('function')
|
||||
const register = vi.fn()
|
||||
queueDockEntry.apply({ slots: { register } } as never)
|
||||
expect(register).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'conversation.input.dock', id: 'queue', order: 20 }),
|
||||
QueueDock,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* Todo display acceptance: the TodoPanel plan strip (empty-hidden, status
|
||||
* rows, collapse), its TodoDock adapter (selects the plan off the session
|
||||
* snapshot and follows changes), and the todo_write toolview row (progress
|
||||
* summary from args, generic fallback on malformed JSON, error badge,
|
||||
* keyboard activation).
|
||||
* summary from args, generic fallback on malformed JSON, shared ToolRow
|
||||
* state dots and leading expansion).
|
||||
*/
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -92,12 +92,12 @@ describe('TodoDock', () => {
|
||||
expect(screen.queryByTestId('todo-panel')).toBeNull()
|
||||
})
|
||||
|
||||
it('ships the registrant plugin shape (list entry above the queue rows)', () => {
|
||||
it('registers between the goal and queue entries', () => {
|
||||
expect(todoDockEntry.name).toBe('conversation-todo-dock')
|
||||
expect(todoDockEntry.inject).toEqual(['slots', 'conversation'])
|
||||
const register = vi.fn()
|
||||
todoDockEntry.apply({ slots: { register } } as never)
|
||||
expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: -1 }, TodoDock)
|
||||
expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: 10 }, TodoDock)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -131,8 +131,8 @@ describe('TodoRow', () => {
|
||||
expect(screen.getByText('1/1 已完成')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the non-ok execution states visible: running dot, interrupted marker', () => {
|
||||
// A running call (no result yet) shows the ongoing dot, never the ok badge.
|
||||
it('keeps the non-ok execution states visible through the shared row states', () => {
|
||||
// A running call (no result yet) carries the running state (row sweep).
|
||||
const args = JSON.stringify({ todos: LIST })
|
||||
const running = render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null })} />)
|
||||
expect(running.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||
@@ -141,20 +141,26 @@ describe('TodoRow', () => {
|
||||
// A cancelled call wrote no todo/write: the row must not read as a completed update.
|
||||
const stopped = render(<TodoRow {...rowProps(resultNode(args, { isError: true, error: { name: 'Interrupted', code: 'interrupted' } }))} />)
|
||||
expect(stopped.container.querySelector('[data-state="stopped"]')).not.toBeNull()
|
||||
expect(stopped.getByText('已中断')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('falls back to the generic summary on malformed args and flags errors', () => {
|
||||
render(<TodoRow {...rowProps(resultNode('not json', { isError: true }))} />)
|
||||
expect(screen.getByText('failed')).toBeTruthy()
|
||||
it('falls back to the generic summary on malformed args and marks the error state', () => {
|
||||
const view = render(<TodoRow {...rowProps(resultNode('not json', { isError: true }))} />)
|
||||
expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
|
||||
// Generic others summary: "<tool> · <raw>".
|
||||
expect(screen.getByText('todo_write · not json')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('falls back when parsed args carry no todos array and stays non-interactive', () => {
|
||||
it('falls back when parsed args carry no todos array', () => {
|
||||
render(<TodoRow {...rowProps(resultNode('{"other":1}'))} />)
|
||||
expect(screen.getByText('todo_write · {"other":1}')).toBeTruthy()
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('leading toggle expands the raw args body', () => {
|
||||
render(<TodoRow {...rowProps(resultNode(ARGS))} />)
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
expect(screen.getByRole('button', { expanded: true })).toBeTruthy()
|
||||
// The expanded body is the pretty-printed args, not the tool output.
|
||||
expect(screen.getByText(/搭骨架/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -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-goal/README.md
|
||||
README.md: fed4870f73277b22760417297d668853b8afb2db
|
||||
README.zh.md: cc607edc856e04c6ee42cc8f596aa658679a02ca
|
||||
README.md: 2c109ab1fbe0b566b8749a6af44ec5e0055fe3b2
|
||||
README.zh.md: b81113c67566fd834b3ddb10931d4ecc630aa2f9
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Goal surface plugin, browser half: the `GoalBar` strip in the `conversation.input.dock` list (order 1, tucked against the composer). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no store, no refresh chain, and no event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear over the `goal.*` wire domain — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline (the RPC's compare-and-set is the staleness guard — there is no client fence). Goal creation stays on the `/goal` host command; loading, absent, and completed goals render nothing.
|
||||
Goal surface plugin, browser half: the `GoalBar` strip is the first standalone card in the `conversation.input.dock` composer-context stack (order 0, before Todo and Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no store, no refresh chain, and no event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear over the `goal.*` wire domain — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline (the RPC's compare-and-set is the staleness guard — there is no client fence). Goal creation stays on the `/goal` host command; loading, absent, and completed goals render nothing.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Goal 表面插件(浏览器半件):`conversation.input.dock` 列表中的 `GoalBar` 条带(order 1,紧贴 composer)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,走 `goal.*` 协议域——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并把结算后的 RPC 错误内联呈现(RPC 的 compare-and-set 即陈旧性防护——客户端没有任何栅栏)。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成三种状态一律不渲染。
|
||||
Goal 表面插件(浏览器半件):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第一张独立卡片(order 0,位于 Todo 和 Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,走 `goal.*` 协议域——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并把结算后的 RPC 错误内联呈现(RPC 的 compare-and-set 即陈旧性防护——客户端没有任何栅栏)。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成三种状态一律不渲染。
|
||||
|
||||
`/client` 出口面为插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。
|
||||
|
||||
|
||||
@@ -1,29 +1,24 @@
|
||||
/* GoalBar: the goal strip docked above the composer card. The dock's 44px
|
||||
side padding and the bar's 752px cap match the todo strip's column
|
||||
(TodoPanel.module.css), 24px inside the composer card's edges. The
|
||||
negative bottom margin cancels the composer stack's 8px gap and tucks the
|
||||
bar's square bottom edge 2px under the composer card's top edge (the
|
||||
card, later in DOM order, paints over it). Surface matches the todo
|
||||
strip: tip fill, l1 border — no bottom edge where it disappears under the
|
||||
card. All states share one fixed 38px height so switching between them
|
||||
never resizes the strip. */
|
||||
/* GoalBar: the first standalone card in the composer context stack (Figma
|
||||
9:939). Its 752px column matches Todo and the Queue panel. */
|
||||
|
||||
.dock {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 0 44px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
box-sizing: border-box;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
max-width: 752px;
|
||||
height: 38px;
|
||||
margin: 0 auto -10px;
|
||||
padding: 0 14px;
|
||||
height: 36px;
|
||||
margin: 0 auto;
|
||||
padding: 4px 5px 4px 12px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-bottom: none;
|
||||
border-radius: 14px 14px 0 0;
|
||||
border-radius: 14px;
|
||||
background: var(--dsw-specific-tip);
|
||||
}
|
||||
|
||||
@@ -37,8 +32,8 @@
|
||||
flex: none;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary-dimmed);
|
||||
}
|
||||
|
||||
.objective {
|
||||
@@ -47,7 +42,7 @@
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
color: var(--dsw-alias-label-primary-dimmed);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -92,7 +87,7 @@
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
@@ -100,11 +95,11 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
|
||||
@@ -59,7 +59,7 @@ export function apply(ctx: ClientContext): void {
|
||||
scope.effect(() => scope.slots.register({
|
||||
name: 'conversation.input.dock',
|
||||
id: 'goal',
|
||||
order: 1,
|
||||
order: 0,
|
||||
inject: (sessionId): GoalBarActions => ({
|
||||
onEdit: async (objective) => {
|
||||
const ref = refOf(sessionId)
|
||||
|
||||
@@ -92,7 +92,7 @@ describe('ui-goal browser plugin', () => {
|
||||
it('registers the GoalBar dock entry with the documented id and order', async () => {
|
||||
const b = bench()
|
||||
await b.fiber.await()
|
||||
expect(b.entry()).toMatchObject({ id: 'goal', order: 1 })
|
||||
expect(b.entry()).toMatchObject({ id: 'goal', order: 0 })
|
||||
expect(b.entry()?.inject).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-layout/README.md
|
||||
README.md: 9354f4b79f7b1af7d8a20a295e77913ff443c2e4
|
||||
README.zh.md: c949236557e7eb3eed0c698566fb5aa9e9cdd18a
|
||||
README.md: 0e92958c9b088071ab58f7e87e8af68f6c2df68d
|
||||
README.zh.md: 0fb3b1cd85bbf6e070a2a1dd01b25981e5990df0
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, plus the theme's alias tokens as inline variables on body).
|
||||
|
||||
AppFrame always mounts the conversation and details columns; a connected Session renders through `SessionProvider`. The transient layout store starts both panels at their default widths and never reads or writes `localStorage`. Hero and other unselected states derive a zero rendered details width without changing that stored preference. AppFrame retains the last non-blank Session id across those states: the first Session opens at the default width, returning to the same Session restores its unchanged width, and selecting a different Session closes details before paint. The conversation owner share is empty, while the sidebar owner share contains only `collapsed` and `width`; registrants obtain business data from standard hooks and actions from their own inject faces.
|
||||
AppFrame always mounts the conversation and details columns; a connected Session renders through `SessionProvider`. The transient layout store starts the sidebar at its default width and details closed, and it never reads or writes `localStorage`. Hero and other unselected states also derive a zero rendered details width without changing that stored preference. AppFrame retains the last non-blank Session id across those states: the first Session remains closed, an explicit details action opens the contract default width, returning to the same Session restores its unchanged width, and selecting a different Session closes details before paint. The conversation owner share is empty, while the sidebar owner share contains only `collapsed` and `width`; registrants obtain business data from standard hooks and actions from their own inject faces.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`), `LayoutService`, and the four owner-share interfaces. AppFrame, the panel store, and the concession solver remain package-internal; tests import internals through `/src`.
|
||||
|
||||
@@ -18,6 +18,6 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Panel geometry is transient** — reload restores both panels to their defaults; switching between distinct Session ids closes details and forgets its dragged width, while unselected surfaces render details at zero width without modifying geometry.
|
||||
- **Panel geometry is transient** — reload restores the sidebar default and details closed; switching between distinct Session ids also closes details and forgets its dragged width, while unselected surfaces render details at zero width without modifying geometry.
|
||||
- **Concession-chain auto-close derives a zero width without touching the preferred width** — the panel restores itself when the window widens; consumers must not read the stored details width as the rendered truth.
|
||||
- **Scroll anchoring during squeeze reflow is not implemented** — deferred with the virtualized-list project.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
外壳插件:三栏 AppFrame(拖动手柄与让步链)加 `ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot,并声明 `sidebar`、`conversation`、`details` 和 `conversation.empty`。侧边栏宽度固定(只会收缩详情栏,然后将其自动关闭);关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document(用 `html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。
|
||||
|
||||
AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionProvider` 渲染。布局 store 是瞬时状态,两个面板均以默认宽度启动,且从不读写 `localStorage`。hero 和其他未选中状态会将详情栏的渲染宽度派生为零,但不会改变存储的首选宽度。AppFrame 会跨越这些状态保留最后一个非 blank 会话 id:首个会话以默认宽度打开;返回同一会话时恢复其未改变的宽度;选择不同会话时,详情栏会在绘制前关闭。会话 owner share 为空,侧边栏 owner share 只包含 `collapsed` 和 `width`;注册方通过标准钩子获取业务数据,并从各自的 inject 表层获取操作。
|
||||
AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionProvider` 渲染。布局 store 是瞬时状态,侧边栏以默认宽度启动,详情栏则保持关闭,且该 store 从不读写 `localStorage`。hero 和其他未选中状态也会将详情栏的渲染宽度派生为零,但不会改变存储的首选宽度。AppFrame 会跨越这些状态保留最后一个非 blank 会话 id:首个会话保持关闭;显式打开详情栏的操作会使用契约默认宽度;返回同一会话时恢复其未改变的宽度;选择不同会话时,详情栏会在绘制前关闭。会话 owner share 为空,侧边栏 owner share 只包含 `collapsed` 和 `width`;注册方通过标准钩子获取业务数据,并从各自的 inject 表层获取操作。
|
||||
|
||||
`/client` 导出表层包含插件主体(`apply`/`inject`)、`LayoutService` 和四个 owner-share 接口。AppFrame、面板 store 与让步求解器仍属于包内部;测试通过 `/src` 导入内部实现。
|
||||
|
||||
@@ -18,6 +18,6 @@ AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionPr
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **面板几何信息是瞬时状态**:重新加载会将两个面板恢复为默认值;在不同会话 id 之间切换会关闭详情栏,并忘记拖动后的宽度,而未选中表面会以零宽度渲染详情栏,但不会修改几何信息。
|
||||
- **面板几何信息是瞬时状态**:重新加载会恢复侧边栏默认值,并使详情栏保持关闭;在不同会话 id 之间切换同样会关闭详情栏,并忘记拖动后的宽度,而未选中表面会以零宽度渲染详情栏,但不会修改几何信息。
|
||||
- **让步链自动关闭通过推导零宽度实现,不会改动首选宽度**:窗口变宽时面板会自行恢复;消费方禁止把 store 中的详情宽度当作实际渲染状态。
|
||||
- **挤压重排期间尚未实现滚动锚定**:与虚拟化列表项目一并暂缓。
|
||||
|
||||
@@ -38,7 +38,7 @@ type LayoutActions = {
|
||||
*/
|
||||
export function createLayoutStore(): EngineStoreHandle<LayoutState, LayoutActions> {
|
||||
const handle = defineStore({
|
||||
init: (): LayoutState => ({ sidebar: SIDEBAR_DEFAULT, details: DETAILS_DEFAULT }),
|
||||
init: (): LayoutState => ({ sidebar: SIDEBAR_DEFAULT, details: 0 }),
|
||||
actions: {
|
||||
setSidebar: (d, px: number) => { d.sidebar = clampWidth(px, SIDEBAR_MIN, SIDEBAR_MAX) },
|
||||
setDetails: (d, px: number) => { d.details = clampWidth(px, DETAILS_MIN, DETAILS_MAX) },
|
||||
|
||||
@@ -139,7 +139,7 @@ afterEach(() => {
|
||||
describe('AppFrame', () => {
|
||||
it('renders three tracks from store state', () => {
|
||||
const { frame } = mountFrame()
|
||||
expect(tracks(frame)).toEqual([280, 360])
|
||||
expect(tracks(frame)).toEqual([280, 0])
|
||||
})
|
||||
|
||||
it('renders the session pair with empty owner shares (sessionId is framework-standard)', () => {
|
||||
@@ -174,6 +174,9 @@ describe('AppFrame', () => {
|
||||
|
||||
it('ignores unselected states and closes only when the Session id changes', () => {
|
||||
const { frame, instance, rerenderFrame } = mountFrame()
|
||||
expect(tracks(frame)).toEqual([280, 0])
|
||||
|
||||
act(() => { instance.actions.openDetails() })
|
||||
expect(tracks(frame)).toEqual([280, 360])
|
||||
|
||||
selectedSession.current = 's-next' as SessionId
|
||||
@@ -200,15 +203,15 @@ describe('AppFrame', () => {
|
||||
expect(tracks(frame)).toEqual([280, 0])
|
||||
})
|
||||
|
||||
it('keeps the default details width when the first Session materializes', () => {
|
||||
it('keeps details closed when the first Session materializes', () => {
|
||||
selectedSession.current = undefined
|
||||
const { frame, instance, rerenderFrame } = mountFrame()
|
||||
expect(tracks(frame)).toEqual([280, 0])
|
||||
expect(instance.getSnapshot().details).toBe(360)
|
||||
expect(instance.getSnapshot().details).toBe(0)
|
||||
|
||||
selectedSession.current = 's-first' as SessionId
|
||||
act(() => { rerenderFrame() })
|
||||
expect(tracks(frame)).toEqual([280, 360])
|
||||
expect(tracks(frame)).toEqual([280, 0])
|
||||
})
|
||||
|
||||
it('sidebar slot receives live concession output as owner props', () => {
|
||||
@@ -224,7 +227,8 @@ describe('AppFrame', () => {
|
||||
})
|
||||
|
||||
it('details drag widens leftward (negative dx grows the panel)', () => {
|
||||
const { frame } = mountFrame()
|
||||
const { frame, instance } = mountFrame()
|
||||
act(() => { instance.actions.openDetails() })
|
||||
const handles = frame.querySelectorAll('[class*="handle"]')
|
||||
drag(handles[1]!, 1560, 1500)
|
||||
expect(tracks(frame)[1]).toBe(420)
|
||||
@@ -233,6 +237,7 @@ describe('AppFrame', () => {
|
||||
it('drag base is the rendered (concession-clamped) width, not the preference', () => {
|
||||
frameWidth = 1250 // step-2 squeeze: details renders 330 while preference is 360
|
||||
const { frame, instance } = mountFrame()
|
||||
act(() => { instance.actions.openDetails() })
|
||||
expect(tracks(frame)).toEqual([280, 330])
|
||||
const handles = frame.querySelectorAll('[class*="handle"]')
|
||||
drag(handles[1]!, 920, 930) // shrink by 10 from the rendered width
|
||||
@@ -240,8 +245,7 @@ describe('AppFrame', () => {
|
||||
})
|
||||
|
||||
it('details column stays mounted at zero width', () => {
|
||||
const { frame, instance, getByTestId } = mountFrame()
|
||||
act(() => { instance.actions.closeDetails() })
|
||||
const { frame, getByTestId } = mountFrame()
|
||||
expect(tracks(frame)).toEqual([280, 0])
|
||||
expect(getByTestId('details-content')).toBeTruthy()
|
||||
expect(frame.hasAttribute('data-details-collapsed')).toBe(true)
|
||||
@@ -250,7 +254,7 @@ describe('AppFrame', () => {
|
||||
it('closed sidebar keeps its compact rail with mounted slot content and collapsed owner props', () => {
|
||||
const { frame, instance, slotCalls, getByTestId } = mountFrame()
|
||||
act(() => { instance.actions.toggleSidebar() })
|
||||
expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 360])
|
||||
expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 0])
|
||||
expect(getByTestId('sidebar-content')).toBeTruthy()
|
||||
expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(true)
|
||||
const lastSidebarCall = slotCalls.filter(c => c.key === 'sidebar').at(-1)!
|
||||
@@ -258,7 +262,8 @@ describe('AppFrame', () => {
|
||||
})
|
||||
|
||||
it('viewport shrink triggers the concession chain via ResizeObserver', () => {
|
||||
const { frame } = mountFrame()
|
||||
const { frame, instance } = mountFrame()
|
||||
act(() => { instance.actions.openDetails() })
|
||||
frameWidth = 1250
|
||||
act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
|
||||
expect(tracks(frame)).toEqual([280, 330])
|
||||
@@ -269,6 +274,8 @@ describe('AppFrame', () => {
|
||||
|
||||
it('drag handles disappear for collapsed columns', () => {
|
||||
const { frame, instance } = mountFrame()
|
||||
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(1)
|
||||
act(() => { instance.actions.openDetails() })
|
||||
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(2)
|
||||
act(() => { instance.actions.closeDetails() })
|
||||
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(1)
|
||||
@@ -323,7 +330,7 @@ describe('AppFrame — guard branches', () => {
|
||||
frameWidth = 0
|
||||
act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
|
||||
// Track template still reflects the last non-zero viewport.
|
||||
expect(tracks(frame)).toEqual([280, 360])
|
||||
expect(tracks(frame)).toEqual([280, 0])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -338,7 +345,8 @@ describe('AppFrame — unmount with an in-flight resize frame', () => {
|
||||
})
|
||||
|
||||
it('double resize inside one frame rides the pending rAF (??= guard)', () => {
|
||||
const { frame } = mountFrame()
|
||||
const { frame, instance } = mountFrame()
|
||||
act(() => { instance.actions.openDetails() })
|
||||
frameWidth = 1250
|
||||
act(() => { fireResize?.(); fireResize?.(); vi.advanceTimersByTime(20) })
|
||||
expect(tracks(frame)).toEqual([280, 330])
|
||||
|
||||
@@ -17,9 +17,9 @@ const PERSIST_KEY = 'dsh.layout.panels'
|
||||
beforeEach(() => { localStorage.clear() })
|
||||
|
||||
describe('createLayoutStore', () => {
|
||||
it('initializes both panels at their default widths', () => {
|
||||
it('initializes the sidebar at its default width and details closed', () => {
|
||||
const { store } = createLayoutStore().create()
|
||||
expect(store.getSnapshot()).toEqual({ sidebar: SIDEBAR_DEFAULT, details: DETAILS_DEFAULT })
|
||||
expect(store.getSnapshot()).toEqual({ sidebar: SIDEBAR_DEFAULT, details: 0 })
|
||||
})
|
||||
|
||||
it('each create() is an independent instance (factory is not a singleton)', () => {
|
||||
@@ -50,9 +50,8 @@ describe('createLayoutStore', () => {
|
||||
expect(store.getSnapshot().sidebar).toBe(SIDEBAR_DEFAULT)
|
||||
})
|
||||
|
||||
it('openDetails is a no-op when already open; closeDetails zeroes', () => {
|
||||
it('openDetails uses the contract default, preserves an open width, and closeDetails zeroes', () => {
|
||||
const { store, actions } = createLayoutStore().create()
|
||||
actions.closeDetails()
|
||||
actions.openDetails()
|
||||
expect(store.getSnapshot().details).toBe(DETAILS_DEFAULT)
|
||||
actions.setDetails(500)
|
||||
@@ -65,13 +64,14 @@ describe('createLayoutStore', () => {
|
||||
it('does not persist panel geometry', () => {
|
||||
const first = createLayoutStore().create()
|
||||
first.actions.setSidebar(400)
|
||||
first.actions.closeDetails()
|
||||
first.actions.openDetails()
|
||||
first.actions.setDetails(500)
|
||||
expect(localStorage.getItem(PERSIST_KEY)).toBeNull()
|
||||
|
||||
const second = createLayoutStore().create()
|
||||
expect(second.store.getSnapshot()).toEqual({
|
||||
sidebar: SIDEBAR_DEFAULT,
|
||||
details: DETAILS_DEFAULT,
|
||||
details: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,7 +11,7 @@ export const zh = {
|
||||
'menu.aria': '模型与推理等级',
|
||||
'menu.model': '模型',
|
||||
'menu.effort': '推理等级',
|
||||
'effort.providerDefault': '服务商默认',
|
||||
'effort.providerDefault': 'Default',
|
||||
'status.loading': '正在刷新模型列表…',
|
||||
'error.action': '模型操作失败:{message}',
|
||||
'action.reload': '重新加载',
|
||||
@@ -35,7 +35,7 @@ export const en = {
|
||||
'menu.aria': 'Model and reasoning effort',
|
||||
'menu.model': 'Model',
|
||||
'menu.effort': 'Effort',
|
||||
'effort.providerDefault': 'Provider default',
|
||||
'effort.providerDefault': 'Default',
|
||||
'status.loading': 'Refreshing model list…',
|
||||
'error.action': 'Model operation failed: {message}',
|
||||
'action.reload': 'Reload',
|
||||
|
||||
@@ -48,9 +48,9 @@ afterEach(cleanup)
|
||||
|
||||
describe('ModelSelect reasoning effort', () => {
|
||||
it('renders adapter metadata and submits the effort as part of the session target', async () => {
|
||||
const directory = createSnapshotStore(state())
|
||||
const directory = createSnapshotStore<ModelDirectoryState>(state())
|
||||
const select = vi.fn(async (target: ModelTarget) => {
|
||||
directory.update((snapshot) => { snapshot.current = target })
|
||||
directory.set(state({ current: target }))
|
||||
return true
|
||||
})
|
||||
render(<ModelSelect
|
||||
@@ -102,10 +102,10 @@ describe('ModelSelect reasoning effort', () => {
|
||||
/>)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {
|
||||
name: '选择模型,当前 Model,推理等级 服务商默认',
|
||||
name: '选择模型,当前 Model,推理等级 Default',
|
||||
}))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
|
||||
expect(screen.getAllByRole('menuitemradio').map(item => item.textContent))
|
||||
.toEqual(['服务商默认', 'Standard'])
|
||||
.toEqual(['Default', 'Standard'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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-models/README.md
|
||||
README.md: eea761859a187b13e08e3cd48e2120cc0cdead15
|
||||
README.zh.md: 8fd0f7af3625bfe9cf34425c651b41a8115b74aa
|
||||
README.md: aac437a13f6465196fcf1f8908b1d9ea2ccef401
|
||||
README.zh.md: 468537ac217a46395f2ec78174efa1f5d75a679d
|
||||
|
||||
@@ -8,7 +8,7 @@ Rows are the *configured* providers (their profile resolves in the owning namesp
|
||||
|
||||
The first-run overlay projects `deepseek-official` readiness from that same joined snapshot. A configured literal `apiKey` secret sidecar or configured credential reference suppresses the prompt, including a read-only launch-environment credential. A mounted adapter with a missing writable reference shows one action that opens Settings on the Models section, whose existing setup card exclusively owns key input and `credentials.set`; the overlay never holds a secret. An absent adapter is skipped because browser navigation cannot mount Cordis plugins, while an unusable settings or credential capability produces a deployment diagnostic with the same route to Models.
|
||||
|
||||
Apply semantics mirror the settings seam: an edit without removals lands as a minimal `settings.update` merge patch, while clearing a fold field back to inherited or deleting a row lands through `settings.replace` of the whole user section so removals actually take effect — safe wholesale, because the section stores key references, never key values. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
|
||||
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -20,8 +20,7 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **A reset can drop a stored literal secret in the same subtree** — a replace-carried removal cannot re-supply secrets the wire never returned; store keys behind `credentials.*` references (the product default) and the case cannot arise.
|
||||
- **Only the API key and the curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)); advanced fields (`models`, retry policy, timeouts…) are edited in `settings.yaml`, which the fold points at. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name.
|
||||
- **Deleting a row leaves its stored key in `.env`** — removal replaces the settings profile but deliberately does not unset the derived credential; re-adding the provider finds the key already configured. An explicit key-removal control is deferred.
|
||||
- **Deleting a row leaves its stored key in `.env`** — removal unsets the settings profile but deliberately does not unset the derived credential; re-adding the provider finds the key already configured. An explicit key-removal control is deferred.
|
||||
- **No per-provider model listing on the page** — the picker surfaces models; this page shows route state only. A models preview per row is deferred until a consumer needs it.
|
||||
- **Undeclared live routes render nowhere** — a route registered without a configurable-provider declaration has no settings address; it stays visible in pickers but not on this page's rows.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
首次使用浮层从同一个联接快照得出 `deepseek-official` 的就绪状态。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,浮层就不再显示,其中包括来自启动环境且只读的凭据。适配器已挂载、引用可写但尚未配置时,浮层只显示一个操作按钮,用于打开「设置」的 Models 分区;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,浮层绝不持有 secret。适配器缺失时直接跳过,因为浏览器导航无法挂载 Cordis 插件;设置或凭据能力不可用时则显示部署诊断,并提供同一个前往 Models 的入口。
|
||||
|
||||
「应用」语义与 settings seam 呈镜像:不含删除的编辑以最小的 `settings.update` 合并 patch 落地,把折叠区字段清回继承值或删除整行则经对整个用户分节的 `settings.replace` 落地,使删除真正生效——整体替换是安全的,因为该分节存的是密钥引用,从不存密钥值。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
|
||||
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除整行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -20,8 +20,7 @@
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **重置可能丢弃同一子树中已存储的字面 secret**:经 replace 承载的删除无法重新提供协议从未返回过的 secret;把密钥放在 `credentials.*` 引用背后(产品默认做法),该情形便不会出现。
|
||||
- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md));进阶字段(`models`、重试策略、超时……)在 `settings.yaml` 中编辑,折叠区会指向它。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。
|
||||
- **删除一行会把它已存储的密钥留在 `.env` 里**:删除替换的是 settings profile,却刻意不清除那条派生凭据;重新添加该提供方时会发现密钥已配置。显式的密钥移除控件暂缓。
|
||||
- **删除一行会把它已存储的密钥留在 `.env` 里**:删除取消设置的是 settings profile,却刻意不清除那条派生凭据;重新添加该提供方时会发现密钥已配置。显式的密钥移除控件暂缓。
|
||||
- **页面上没有逐提供方的模型列表**:模型由选择器呈现;本页只展示路由状态。逐行的模型预览暂缓,待有消费方需要时再实现。
|
||||
- **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { deletePath } from '@deepseek-ai/dsh-client-schema-form'
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { messageOf } from './store.ts'
|
||||
import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts'
|
||||
import { ProviderEditor } from './ProviderEditor.tsx'
|
||||
import type { en } from './locales.ts'
|
||||
@@ -45,24 +45,35 @@ interface EditorTarget {
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove one user-added provider profile from its namespace's user section
|
||||
* (wholesale replace — merge cannot express a removal) and reload on success.
|
||||
* Remove one user-added provider profile by unsetting its path in the stored
|
||||
* user section, then reload. The removal names the profile rather than
|
||||
* rebuilding the section: this page only ever holds the redacted descriptor,
|
||||
* so a rebuilt section would drop every literal secret stored elsewhere in
|
||||
* the namespace along with the profile being removed.
|
||||
* @param api - settings wire face.
|
||||
* @param controller - the page store to refresh.
|
||||
* @param target - the provider's settings address.
|
||||
* @param namespace - the owning namespace view.
|
||||
* @returns settles when the write and any reload finished.
|
||||
* @returns the failure message, or undefined once the write and reload landed.
|
||||
*/
|
||||
export async function removeProviderProfile(
|
||||
api: Pick<IApiClient, 'settings'>,
|
||||
controller: ModelsSettingsStore,
|
||||
target: { settingsNs: string; settingsPath: readonly string[] },
|
||||
namespace: SettingsNamespaceView,
|
||||
): Promise<void> {
|
||||
const user = structuredClone((namespace.user ?? {}) as Record<string, unknown>)
|
||||
const next = deletePath(user, [...target.settingsPath])
|
||||
const response = await api.settings.replace({ ns: target.settingsNs, section: next })
|
||||
if (response.result.ok) await controller.load()
|
||||
): Promise<string | undefined> {
|
||||
let response
|
||||
try {
|
||||
response = await api.settings.mutate({
|
||||
ns: target.settingsNs,
|
||||
ops: [{ op: 'unset', path: [...target.settingsPath] }],
|
||||
})
|
||||
} catch (error) {
|
||||
// The transport rejected rather than answering; the caller must be able
|
||||
// to say so instead of the row silently staying put.
|
||||
return messageOf(error)
|
||||
}
|
||||
if (!response.result.ok) return response.result.error.message
|
||||
await controller.load()
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,7 +193,11 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
type="button"
|
||||
className={styles['dangerButton']}
|
||||
disabled={!state.writable}
|
||||
onClick={() => { void removeProviderProfile(api, controller, target, namespace) }}
|
||||
onClick={() => {
|
||||
void removeProviderProfile(api, controller, target).then((failure) => {
|
||||
if (failure !== undefined) controller.fail(failure)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{t('remove')}
|
||||
</button>
|
||||
|
||||
@@ -6,19 +6,19 @@
|
||||
* has none, and the pi-ai profile records that derivation as `apiKeyEnv`);
|
||||
* the collapsed 自定义设置 area carries the per-family extras (`baseURL` for
|
||||
* both families, plus `reasoningEffort` for deepseek / `reasoning` for
|
||||
* pi-ai). Everything else stays owned by `settings.yaml`. Profile edits land as a
|
||||
* minimal `settings.update` merge patch; clearing a field back to inherited
|
||||
* removes its key, so that apply replaces the user section (safe: the section
|
||||
* stores references, never key values).
|
||||
* pi-ai). Everything else stays owned by `settings.yaml`. Profile edits land as
|
||||
* minimal `settings.mutate` path ops against the stored section — the card
|
||||
* reads the redacted descriptor, so it names only the fields it can see and a
|
||||
* stored literal secret is never collaterally removed.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { CredentialView, IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { CredentialView, IApiClient, SettingsNamespaceView, SettingsPathOpView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import {
|
||||
deletePath, getPath, nodeAtPath, rehydrateSchema, setPath, validateDraft,
|
||||
} from '@deepseek-ai/dsh-client-schema-form'
|
||||
import { deriveKeyRef } from './store.ts'
|
||||
import { deriveKeyRef, messageOf } from './store.ts'
|
||||
import type { en } from './locales.ts'
|
||||
import styles from './ModelsSection.module.css'
|
||||
|
||||
@@ -70,21 +70,33 @@ function draftAt(namespace: SettingsNamespaceView, path: readonly string[]): Rec
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any key present in `before` is absent from `after` (a reset
|
||||
* happened somewhere in the draft, so the apply must replace, not merge).
|
||||
* @param before - the user-layer subtree the draft started from.
|
||||
* @param after - the edited draft.
|
||||
* @returns whether a removal exists at any depth.
|
||||
* The minimal path ops carrying `after` over `before`, both as the card sees
|
||||
* them (that is, redacted). Only keys the card observed are named: a stored
|
||||
* `role('secret')` field appears in neither side, so it produces no op and
|
||||
* survives the write — the whole reason edits are path-addressed rather than
|
||||
* a rebuilt section.
|
||||
* @param base - path of the edited subtree inside the user section.
|
||||
* @param before - the subtree as loaded, or undefined when it is new.
|
||||
* @param after - the subtree as edited.
|
||||
* @returns ordered set/unset ops; empty when nothing changed.
|
||||
*/
|
||||
export function removedAny(before: unknown, after: unknown): boolean {
|
||||
if (typeof before !== 'object' || before === null) return false
|
||||
/* v8 ignore next -- the editor edits containers in place; a container cannot become a primitive */
|
||||
if (typeof after !== 'object' || after === null) return true
|
||||
for (const [key, value] of Object.entries(before)) {
|
||||
if (!(key in (after as Record<string, unknown>))) return true
|
||||
if (removedAny(value, (after as Record<string, unknown>)[key])) return true
|
||||
export function pathOps(
|
||||
base: readonly string[],
|
||||
before: unknown,
|
||||
after: Record<string, unknown>,
|
||||
): SettingsPathOpView[] {
|
||||
const previous = typeof before === 'object' && before !== null && !Array.isArray(before)
|
||||
? before as Record<string, unknown>
|
||||
: {}
|
||||
const ops: SettingsPathOpView[] = []
|
||||
for (const [key, value] of Object.entries(after)) {
|
||||
if (JSON.stringify(previous[key]) === JSON.stringify(value)) continue
|
||||
ops.push({ op: 'set', path: [...base, key], value })
|
||||
}
|
||||
return false
|
||||
for (const key of Object.keys(previous)) {
|
||||
if (!(key in after)) ops.push({ op: 'unset', path: [...base, key] })
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
/** The editor layout the owning namespace selects. */
|
||||
@@ -115,6 +127,10 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
const [keyState, setKeyState] = useState<CredentialView | undefined>(undefined)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [failure, setFailure] = useState<string | undefined>(undefined)
|
||||
// The revision this card opened at. A write carrying it is refused if
|
||||
// anything else — another tab, an external edit of settings.yaml — moved the
|
||||
// namespace meanwhile, instead of silently overwriting that change.
|
||||
const [openedAt] = useState(() => namespace.revision)
|
||||
const root = useMemo(() => rehydrateSchema(namespace.schema), [namespace.schema])
|
||||
const node = useMemo(() => nodeAtPath(root, settingsPath), [root, settingsPath])
|
||||
const fallback = getPath(namespace.value, settingsPath)
|
||||
@@ -125,10 +141,17 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
useEffect(() => {
|
||||
let stale = false
|
||||
setKeyState(undefined)
|
||||
void api.credentials.describe({ refs: [keyRef] }).then((response) => {
|
||||
if (stale || !response.result.ok) return
|
||||
setKeyState(response.result.value.credentials[keyRef])
|
||||
})
|
||||
// The key state is a placeholder hint, not a precondition for editing:
|
||||
// neither a business rejection nor a transport failure may reach the
|
||||
// browser as an unhandled rejection, so the card simply renders without
|
||||
// the "already configured" hint.
|
||||
void api.credentials.describe({ refs: [keyRef] }).then(
|
||||
(response) => {
|
||||
if (stale || !response.result.ok) return
|
||||
setKeyState(response.result.value.credentials[keyRef])
|
||||
},
|
||||
() => undefined,
|
||||
)
|
||||
return () => { stale = true }
|
||||
}, [api.credentials, keyRef])
|
||||
|
||||
@@ -140,9 +163,14 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
setDraft(current => next === undefined ? deletePath(current, [key]) : setPath(current, [key], next))
|
||||
}
|
||||
|
||||
const apply = async (): Promise<void> => {
|
||||
setBusy(true)
|
||||
setFailure(undefined)
|
||||
/**
|
||||
* The write for this card, or a failure message. Every edit travels as
|
||||
* path ops against the STORED section: the draft comes from the redacted
|
||||
* descriptor, so a wholesale replace rebuilt from it would delete the
|
||||
* literal secrets the wire never returned. Ops name only the fields this
|
||||
* card can see, so a stored secret is untouched by construction.
|
||||
*/
|
||||
const applyOnce = async (): Promise<string | undefined> => {
|
||||
const ns = namespace.ns
|
||||
const original = getPath(namespace.user, settingsPath)
|
||||
// The pi-ai profile must name the reference the key stores under, so a
|
||||
@@ -151,45 +179,46 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
&& stringAt(fallback, 'apiKeyEnv') === undefined
|
||||
? setPath(draft, ['apiKeyEnv'], keyRef)
|
||||
: draft
|
||||
const settingsChanged = JSON.stringify(next) !== JSON.stringify(original ?? {})
|
||||
if (settingsChanged) {
|
||||
const needsReplace = removedAny(original, next)
|
||||
// Merge patches stay minimal (just this profile); a replace must carry
|
||||
// the complete next user section because it lands wholesale.
|
||||
const patch = settingsPath.length === 0 ? next : setPath({}, [...settingsPath], next)
|
||||
/* v8 ignore next 3 -- a subtree apply implies the join served this namespace's user layer */
|
||||
const nextSection = settingsPath.length === 0
|
||||
? next
|
||||
: setPath(structuredClone((namespace.user ?? {}) as Record<string, unknown>), [...settingsPath], next)
|
||||
/* v8 ignore next -- apply is only reachable from the rendered card, which required a resolved node */
|
||||
if (node !== undefined) {
|
||||
const sectionError = settingsPath.length === 0 ? validateDraft(node, next) : undefined
|
||||
if (sectionError !== undefined) {
|
||||
setBusy(false)
|
||||
setFailure(sectionError)
|
||||
return
|
||||
}
|
||||
}
|
||||
const response = needsReplace
|
||||
? await api.settings.replace({ ns, section: nextSection })
|
||||
: await api.settings.update({ ns, patch })
|
||||
/* v8 ignore next -- apply is only reachable from the rendered card, which required a resolved node */
|
||||
if (node !== undefined && settingsPath.length === 0) {
|
||||
const sectionError = validateDraft(node, next)
|
||||
if (sectionError !== undefined) return sectionError
|
||||
}
|
||||
const ops = pathOps(settingsPath, original, next)
|
||||
if (ops.length > 0) {
|
||||
const response = await api.settings.mutate({ ns, ops, expectedRevision: openedAt })
|
||||
if (!response.result.ok) {
|
||||
setBusy(false)
|
||||
setFailure(response.result.error.message)
|
||||
return
|
||||
return response.result.error.code === 'settings-conflict'
|
||||
? t('conflict')
|
||||
: response.result.error.message
|
||||
}
|
||||
}
|
||||
if (keyDraft.length > 0) {
|
||||
const stored = await api.credentials.set({ ref: keyRef, value: keyDraft })
|
||||
if (!stored.result.ok) {
|
||||
setBusy(false)
|
||||
setFailure(stored.result.error.message)
|
||||
if (!stored.result.ok) return stored.result.error.message
|
||||
}
|
||||
setKeyDraft('')
|
||||
return undefined
|
||||
}
|
||||
|
||||
const apply = async (): Promise<void> => {
|
||||
setBusy(true)
|
||||
setFailure(undefined)
|
||||
try {
|
||||
const failure = await applyOnce()
|
||||
if (failure !== undefined) {
|
||||
setFailure(failure)
|
||||
return
|
||||
}
|
||||
setKeyDraft('')
|
||||
props.onClose(true)
|
||||
} catch (error) {
|
||||
// A transport failure (disconnect, a request the host refuses) rejects
|
||||
// rather than answering; without this the card would stay busy forever
|
||||
// with no error shown.
|
||||
setFailure(messageOf(error))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
setBusy(false)
|
||||
props.onClose(true)
|
||||
}
|
||||
|
||||
if (node === undefined) {
|
||||
|
||||
@@ -16,6 +16,7 @@ export const en = {
|
||||
applying: 'Applying…',
|
||||
readOnly: 'The settings document is read-only in this deployment.',
|
||||
loadFailed: 'Loading the provider directory failed',
|
||||
conflict: 'Someone else changed these settings while this card was open. Close it and reopen to edit the current values.',
|
||||
retry: 'Retry',
|
||||
keyInput: 'API key',
|
||||
keyPlaceholder: 'Enter your API key',
|
||||
@@ -54,6 +55,7 @@ export const zh: typeof en = {
|
||||
applying: '保存中…',
|
||||
readOnly: '当前部署的设置文档为只读。',
|
||||
loadFailed: '加载提供方目录失败',
|
||||
conflict: '这张卡片打开期间,这些设置已被其他地方改动。请关闭后重新打开,在当前值上编辑。',
|
||||
retry: '重试',
|
||||
keyInput: 'API 密钥',
|
||||
keyPlaceholder: '输入 API 密钥',
|
||||
|
||||
@@ -44,6 +44,17 @@ export interface ModelsSettingsState {
|
||||
namespaces: ReadonlyMap<string, SettingsNamespaceView>
|
||||
}
|
||||
|
||||
/**
|
||||
* Human text for a rejected wire call. A transport failure rejects with an
|
||||
* Error; a host or a runtime can reject with anything, and the page still has
|
||||
* to say something.
|
||||
* @param error - the rejection value.
|
||||
* @returns the message to show.
|
||||
*/
|
||||
export function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the conventional credential reference for a provider route: the v1
|
||||
* page never asks for an environment-variable name, so a typed key stores
|
||||
@@ -77,11 +88,6 @@ function literalApiKeyConfigured(
|
||||
&& secret.path.every((key, index) => key === secretPath[index]))
|
||||
}
|
||||
|
||||
/** Safe display text for a rejected transport or business response. */
|
||||
function errorText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
/** The models settings page controller (one per settings surface). */
|
||||
export class ModelsSettingsStore {
|
||||
/** The snapshot the section renders from (uSES-safe store). */
|
||||
@@ -97,6 +103,18 @@ export class ModelsSettingsStore {
|
||||
*/
|
||||
constructor(private readonly api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>) {}
|
||||
|
||||
/**
|
||||
* Surface a failure from an operation the page ran outside {@link load} —
|
||||
* a row removal — on the same banner a load failure uses.
|
||||
* @param message - the failure text to show.
|
||||
*/
|
||||
fail(message: string): void {
|
||||
this.store.update((s) => {
|
||||
s.status = 'error'
|
||||
s.error = message
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the whole page snapshot: directory and namespaces in parallel,
|
||||
* then one batched credential describe over every referenced ref. A
|
||||
@@ -151,12 +169,13 @@ export class ModelsSettingsStore {
|
||||
if (refs.length > 0) {
|
||||
try {
|
||||
const response = await this.api.credentials.describe({ refs })
|
||||
// Credential state is an enrichment for the Models page, while the
|
||||
// onboarding readiness projection below reports its failure.
|
||||
// Credential state is an enrichment for the Models page: neither a
|
||||
// business rejection nor a transport failure fails the load. The
|
||||
// onboarding projection below retains the failure distinction.
|
||||
if (response.result.ok) credentials = response.result.value.credentials
|
||||
else credentialError = response.result.error.message
|
||||
} catch (error) {
|
||||
credentialError = errorText(error)
|
||||
credentialError = messageOf(error)
|
||||
}
|
||||
}
|
||||
if (generation !== this.generation) return
|
||||
|
||||
@@ -7,7 +7,7 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { ModelsSection, needsSetup, removeProviderProfile } from '../src/client/ModelsSection.tsx'
|
||||
import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx'
|
||||
import { removedAny } from '../src/client/ProviderEditor.tsx'
|
||||
import { pathOps } from '../src/client/ProviderEditor.tsx'
|
||||
import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts'
|
||||
import type { ProviderRow } from '../src/client/store.ts'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
@@ -44,6 +44,7 @@ function wireNamespaces(): SettingsNamespaceView[] {
|
||||
user: { reasoningEffort: 'high' },
|
||||
applies: 'live',
|
||||
secrets: [{ path: ['apiKey'], set: false }],
|
||||
revision: 0,
|
||||
},
|
||||
{
|
||||
ns: 'llm-plain',
|
||||
@@ -53,6 +54,7 @@ function wireNamespaces(): SettingsNamespaceView[] {
|
||||
value: {},
|
||||
applies: 'live',
|
||||
secrets: [],
|
||||
revision: 0,
|
||||
},
|
||||
{
|
||||
ns: 'llm-pi-ai',
|
||||
@@ -61,6 +63,7 @@ function wireNamespaces(): SettingsNamespaceView[] {
|
||||
user: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } },
|
||||
applies: 'live',
|
||||
secrets: [{ path: ['token'], set: false }, { path: ['providers', 'openai', 'apiKey'], set: false }],
|
||||
revision: 0,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -79,10 +82,12 @@ function fail<T>(message: string, code = 'settings-rejected'): RpcResponse<T> {
|
||||
function scriptedFace(overrides: {
|
||||
update?: ReturnType<typeof vi.fn>
|
||||
replace?: ReturnType<typeof vi.fn>
|
||||
mutate?: ReturnType<typeof vi.fn>
|
||||
set?: ReturnType<typeof vi.fn>
|
||||
} = {}) {
|
||||
const update = overrides.update ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2])))
|
||||
const replace = overrides.replace ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2])))
|
||||
const mutate = overrides.mutate ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2])))
|
||||
const set = overrides.set ?? vi.fn(() => Promise.resolve(ok({})))
|
||||
const face = {
|
||||
llm: {
|
||||
@@ -102,6 +107,7 @@ function scriptedFace(overrides: {
|
||||
describe: vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: wireNamespaces() }))),
|
||||
update,
|
||||
replace,
|
||||
mutate,
|
||||
},
|
||||
credentials: {
|
||||
describe: vi.fn((payload: { refs: string[] }) => Promise.resolve(ok({
|
||||
@@ -115,13 +121,13 @@ function scriptedFace(overrides: {
|
||||
unset: vi.fn(() => Promise.resolve(ok({}))),
|
||||
},
|
||||
}
|
||||
return { face, update, replace, set }
|
||||
return { face, update, replace, mutate, set }
|
||||
}
|
||||
|
||||
type WireFace = ConstructorParameters<typeof ModelsSettingsStore>[0]
|
||||
|
||||
async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {}) {
|
||||
const { face, update, replace, set } = scriptedFace(overrides)
|
||||
const { face, update, replace, mutate, set } = scriptedFace(overrides)
|
||||
const controller = new ModelsSettingsStore(face as unknown as WireFace)
|
||||
await controller.load()
|
||||
const injected: ModelsSectionInjected = {
|
||||
@@ -131,7 +137,7 @@ async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {})
|
||||
t,
|
||||
}
|
||||
const view = render(<ModelsSection {...injected} />)
|
||||
return { view, face, update, replace, set, controller }
|
||||
return { view, face, update, replace, mutate, set, controller }
|
||||
}
|
||||
|
||||
describe('ModelsSection', () => {
|
||||
@@ -197,10 +203,15 @@ describe('ModelsSection', () => {
|
||||
expect(deriveKeyRef('minimax-cn')).toBe('MINIMAX_CN_API_KEY')
|
||||
})
|
||||
|
||||
it('detects removals at any draft depth', () => {
|
||||
expect(removedAny({ a: { b: 1, c: 2 } }, { a: { b: 1 } })).toBe(true)
|
||||
expect(removedAny({ a: { b: 1 } }, { a: { b: 2 }, d: 3 })).toBe(false)
|
||||
expect(removedAny(undefined, {})).toBe(false)
|
||||
it('names only the fields the card can see, so an unseen secret survives', () => {
|
||||
// `before` is the REDACTED subtree: a stored literal apiKey is in neither
|
||||
// side, so no op mentions it and the seam leaves it alone.
|
||||
expect(pathOps(['providers', 'openai'], { baseURL: 'https://old', reasoning: 'high' }, { reasoning: 'high' }))
|
||||
.toEqual([{ op: 'unset', path: ['providers', 'openai', 'baseURL'] }])
|
||||
expect(pathOps([], { b: 1 }, { b: 2, d: 3 }))
|
||||
.toEqual([{ op: 'set', path: ['b'], value: 2 }, { op: 'set', path: ['d'], value: 3 }])
|
||||
expect(pathOps([], undefined, {})).toEqual([])
|
||||
expect(pathOps([], { a: 1 }, { a: 1 })).toEqual([])
|
||||
})
|
||||
|
||||
it('stores a typed key write-only from the setup card without touching settings', async () => {
|
||||
@@ -213,9 +224,9 @@ describe('ModelsSection', () => {
|
||||
await waitFor(() => { expect(face.settings.describe.mock.calls.length).toBeGreaterThan(1) })
|
||||
})
|
||||
|
||||
it('applies customized deepseek fields as a merge patch', async () => {
|
||||
const { update } = await mountSection({
|
||||
update: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
|
||||
it('applies customized deepseek fields as path ops', async () => {
|
||||
const { mutate } = await mountSection({
|
||||
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
|
||||
})
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
const baseURL = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
|
||||
@@ -224,23 +235,33 @@ describe('ModelsSection', () => {
|
||||
expect(baseURL.placeholder).toBe('https://api.deepseek.com')
|
||||
fireEvent.change(baseURL, { target: { value: 'https://next2' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(update).toHaveBeenCalledTimes(1) })
|
||||
expect(update.mock.calls[0]?.[0]).toEqual({
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
// Only the field that actually changed: reasoningEffort was already
|
||||
// 'high' in the loaded profile, so it produces no op.
|
||||
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-deepseek',
|
||||
patch: { reasoningEffort: 'high', baseURL: 'https://next2' },
|
||||
ops: [{ op: 'set', path: ['baseURL'], value: 'https://next2' }],
|
||||
expectedRevision: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('clears an inherited override through replace so the removal lands', async () => {
|
||||
const { replace, update } = await mountSection()
|
||||
it('clears an inherited override with an unset op, never a whole-section replace', async () => {
|
||||
// The data-loss shape: the old path rebuilt the section from the REDACTED
|
||||
// user layer and replaced it wholesale, deleting any stored literal key.
|
||||
const { replace, update, mutate } = await mountSection()
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
const effort = screen.getByLabelText<HTMLSelectElement>(en.effort)
|
||||
expect(effort.value).toBe('high')
|
||||
fireEvent.change(effort, { target: { value: '' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(replace).toHaveBeenCalledTimes(1) })
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
expect(replace.mock.calls[0]?.[0]).toEqual({ ns: 'llm-deepseek', section: {} })
|
||||
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-deepseek',
|
||||
ops: [{ op: 'unset', path: ['reasoningEffort'] }],
|
||||
expectedRevision: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('pins the deepseek placeholder and clears typed input back to inherited', async () => {
|
||||
@@ -251,6 +272,7 @@ describe('ModelsSection', () => {
|
||||
value: {},
|
||||
applies: 'live',
|
||||
secrets: [],
|
||||
revision: 0,
|
||||
}
|
||||
const { ProviderEditor } = await import('../src/client/ProviderEditor.tsx')
|
||||
render(<ProviderEditor
|
||||
@@ -282,7 +304,7 @@ describe('ModelsSection', () => {
|
||||
})
|
||||
|
||||
it('edits a pi-ai profile with the curated fields only', async () => {
|
||||
const { update } = await mountSection()
|
||||
const { mutate } = await mountSection()
|
||||
fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
|
||||
// The configured credential shows as the stored placeholder.
|
||||
const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
|
||||
@@ -297,19 +319,20 @@ describe('ModelsSection', () => {
|
||||
const effort = screen.getAllByLabelText<HTMLSelectElement>(en.effort)
|
||||
fireEvent.change(effort[effort.length - 1] as HTMLSelectElement, { target: { value: 'xhigh' } })
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
await waitFor(() => { expect(update).toHaveBeenCalledTimes(1) })
|
||||
expect(update.mock.calls[0]?.[0]).toEqual({
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
// Only the edited field travels: apiKeyEnv, baseURL and headers were
|
||||
// already stored with these values, so no op restates them — and the
|
||||
// profile's stored literal apiKey, absent from the redacted view the card
|
||||
// read, is named by nothing at all.
|
||||
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-pi-ai',
|
||||
patch: {
|
||||
providers: {
|
||||
openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' }, reasoning: 'xhigh' },
|
||||
},
|
||||
},
|
||||
ops: [{ op: 'set', path: ['providers', 'openai', 'reasoning'], value: 'xhigh' }],
|
||||
expectedRevision: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('adds a dormant provider with a derived reference and stores its key', async () => {
|
||||
const { update, set } = await mountSection()
|
||||
const { mutate, set } = await mountSection()
|
||||
fireEvent.click(screen.getByText(`+ ${en.add}`))
|
||||
const pick = await screen.findByLabelText<HTMLSelectElement>(en.provider)
|
||||
expect([...pick.options].map(option => option.value)).toEqual(['anthropic', 'broken', 'plain'])
|
||||
@@ -323,10 +346,11 @@ describe('ModelsSection', () => {
|
||||
const addKey = keys[keys.length - 1] as HTMLInputElement
|
||||
fireEvent.change(addKey, { target: { value: 'sk-ant' } })
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
await waitFor(() => { expect(update).toHaveBeenCalledTimes(1) })
|
||||
expect(update.mock.calls[0]?.[0]).toEqual({
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-pi-ai',
|
||||
patch: { providers: { anthropic: { apiKeyEnv: 'ANTHROPIC_API_KEY' } } },
|
||||
ops: [{ op: 'set', path: ['providers', 'anthropic', 'apiKeyEnv'], value: 'ANTHROPIC_API_KEY' }],
|
||||
expectedRevision: 0,
|
||||
})
|
||||
await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'ANTHROPIC_API_KEY', value: 'sk-ant' }) })
|
||||
})
|
||||
@@ -349,7 +373,7 @@ describe('ModelsSection', () => {
|
||||
|
||||
it('surfaces a rejected settings write and never stores the key after it', async () => {
|
||||
const { set } = await mountSection({
|
||||
update: vi.fn(() => Promise.resolve(fail('llm-pi-ai: unknown pi-ai provider "bogus"'))),
|
||||
mutate: vi.fn(() => Promise.resolve(fail('llm-pi-ai: unknown pi-ai provider "bogus"'))),
|
||||
})
|
||||
fireEvent.click(screen.getByText(`+ ${en.add}`))
|
||||
await screen.findByLabelText(en.provider)
|
||||
@@ -360,6 +384,57 @@ describe('ModelsSection', () => {
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders the card without the stored-key hint when the credential probe rejects', async () => {
|
||||
// The probe is a placeholder hint, not a precondition: an escaping
|
||||
// rejection would surface in the browser as an unhandled rejection.
|
||||
const { face } = scriptedFace()
|
||||
face.credentials.describe = vi.fn(() => Promise.reject(new Error('connection lost')))
|
||||
const unhandled = vi.fn()
|
||||
process.on('unhandledRejection', unhandled)
|
||||
try {
|
||||
const controller = new ModelsSettingsStore(face as unknown as WireFace)
|
||||
await controller.load()
|
||||
render(<ModelsSection
|
||||
controller={controller}
|
||||
useSnapshot={bindSnapshotSelector(controller.store)}
|
||||
api={face as never}
|
||||
t={t}
|
||||
/>)
|
||||
const key = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
|
||||
expect(key.placeholder).toBe(en.keyPlaceholder)
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
expect(unhandled).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
process.off('unhandledRejection', unhandled)
|
||||
}
|
||||
})
|
||||
|
||||
it('tells the user to reopen when another writer moved the namespace first', async () => {
|
||||
// The stale-draft overwrite: two tabs open the same card, the other saves,
|
||||
// and this one must be refused rather than replay its opening snapshot.
|
||||
const { set } = await mountSection({
|
||||
mutate: vi.fn(() => Promise.resolve(fail('changed since it was read', 'settings-conflict'))),
|
||||
})
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
fireEvent.change(screen.getByLabelText<HTMLInputElement>(en.baseUrl), { target: { value: 'https://mine' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await screen.findByText(en.conflict)
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the card usable when the write rejects instead of answering', async () => {
|
||||
// A transport failure (disconnect, or the 403 a non-loopback browser now
|
||||
// gets on the whole configuration plane) rejects rather than returning a
|
||||
// failed envelope: without a catch the card would stay busy forever.
|
||||
await mountSection({ mutate: vi.fn(() => Promise.reject(new Error('connection lost'))) })
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
fireEvent.change(screen.getByLabelText<HTMLInputElement>(en.baseUrl), { target: { value: 'https://next' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await screen.findByText('connection lost')
|
||||
// Not stuck in `applying…`: the finally cleared busy, so Apply is live again.
|
||||
expect(screen.getByText(en.apply)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('surfaces a shadowed credential write on the card', async () => {
|
||||
await mountSection({
|
||||
set: vi.fn(() => Promise.resolve(fail('credentials: DEEPSEEK_API_KEY is shadowed by the read-only environment', 'credential-rejected'))),
|
||||
@@ -396,11 +471,15 @@ describe('ModelsSection', () => {
|
||||
await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) })
|
||||
})
|
||||
|
||||
it('removes a user-added provider through replace', async () => {
|
||||
const { replace } = await mountSection()
|
||||
it('removes a user-added provider by unsetting its path', async () => {
|
||||
const { replace, mutate } = await mountSection()
|
||||
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
|
||||
await waitFor(() => { expect(replace).toHaveBeenCalledTimes(1) })
|
||||
expect(replace.mock.calls[0]?.[0]).toEqual({ ns: 'llm-pi-ai', section: { providers: { zombie: {} } } })
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-pi-ai',
|
||||
ops: [{ op: 'unset', path: ['providers', 'openai'] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the load failure with a retry control', async () => {
|
||||
@@ -474,30 +553,54 @@ describe('ModelsSection', () => {
|
||||
await screen.findByText('DeepSeek')
|
||||
})
|
||||
|
||||
it('removes against a namespace with no user layer as an empty-section replace', async () => {
|
||||
const { face, replace, controller } = await mountSection()
|
||||
const namespace = controller.store.getSnapshot().namespaces.get('llm-plain')
|
||||
it('removes by unsetting the profile path, never by rebuilding the section', async () => {
|
||||
// The section rebuild is what dropped stored literal secrets: this page
|
||||
// only ever holds the redacted descriptor, so the removal names the path.
|
||||
const { face, mutate, replace, controller } = await mountSection()
|
||||
await removeProviderProfile(
|
||||
face as unknown as Parameters<typeof removeProviderProfile>[0],
|
||||
controller,
|
||||
{ settingsNs: 'llm-plain', settingsPath: ['ghost-profile'] },
|
||||
namespace as NonNullable<typeof namespace>,
|
||||
)
|
||||
expect(replace.mock.calls[0]?.[0]).toEqual({ ns: 'llm-plain', section: {} })
|
||||
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-plain',
|
||||
ops: [{ op: 'unset', path: ['ghost-profile'] }],
|
||||
})
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the snapshot untouched when a removal write is refused', async () => {
|
||||
it('keeps the snapshot untouched and reports the message when a removal write is refused', async () => {
|
||||
const { face, controller } = await mountSection({
|
||||
replace: vi.fn(() => Promise.resolve(fail('read-only'))),
|
||||
mutate: vi.fn(() => Promise.resolve(fail('read-only'))),
|
||||
})
|
||||
const namespace = controller.store.getSnapshot().namespaces.get('llm-pi-ai')
|
||||
const before = controller.store.getSnapshot().rows
|
||||
await removeProviderProfile(
|
||||
const failure = await removeProviderProfile(
|
||||
face as unknown as Parameters<typeof removeProviderProfile>[0],
|
||||
controller,
|
||||
{ settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] },
|
||||
namespace as NonNullable<typeof namespace>,
|
||||
)
|
||||
expect(failure).toBe('read-only')
|
||||
expect(controller.store.getSnapshot().rows).toBe(before)
|
||||
})
|
||||
|
||||
it('shows a failed removal on the page banner, including a non-Error rejection', async () => {
|
||||
// The whole click path: the row's Remove button, the transport rejecting
|
||||
// with a non-Error value, and the store surfacing it where a load failure
|
||||
// would appear — rather than the row silently staying put.
|
||||
await mountSection({ mutate: vi.fn(() => Promise.reject(new Error('the host refused'))) })
|
||||
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
|
||||
await screen.findByText(`${en.loadFailed}: the host refused`)
|
||||
})
|
||||
|
||||
it('reports a transport rejection instead of failing the removal silently', async () => {
|
||||
const { face, controller } = await mountSection({
|
||||
mutate: vi.fn(() => Promise.reject(new Error('connection lost'))),
|
||||
})
|
||||
const failure = await removeProviderProfile(
|
||||
face as unknown as Parameters<typeof removeProviderProfile>[0],
|
||||
controller,
|
||||
{ settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] },
|
||||
)
|
||||
expect(failure).toBe('connection lost')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -70,6 +70,7 @@ function harness(options: {
|
||||
: { apiKeyEnv: options.apiKeyEnv ?? 'DEEPSEEK_API_KEY' },
|
||||
applies: 'live' as const,
|
||||
secrets: [{ path: ['apiKey'], set: options.literal === true }],
|
||||
revision: 0,
|
||||
}],
|
||||
})),
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Page-store join: directory × namespaces × credentials, with last-good rows on failure. */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RpcResponse } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { ModelsSettingsStore } from '../src/client/store.ts'
|
||||
import { messageOf, ModelsSettingsStore } from '../src/client/store.ts'
|
||||
|
||||
let nextRpc = 0
|
||||
function ok<T>(value: T): RpcResponse<T> {
|
||||
@@ -26,6 +26,7 @@ const NAMESPACES = [
|
||||
base: { baseURL: 'https://base' },
|
||||
applies: 'live' as const,
|
||||
secrets: [{ path: ['apiKey'], set: false }],
|
||||
revision: 0,
|
||||
},
|
||||
{
|
||||
ns: 'llm-pi-ai',
|
||||
@@ -34,6 +35,7 @@ const NAMESPACES = [
|
||||
user: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } },
|
||||
applies: 'live' as const,
|
||||
secrets: [],
|
||||
revision: 0,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -121,7 +123,7 @@ describe('ModelsSettingsStore', () => {
|
||||
|
||||
it('stringifies a non-Error credential transport rejection', async () => {
|
||||
const { face } = api({
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
||||
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario
|
||||
describeCredentials: () => Promise.reject('credential transport refusal'),
|
||||
})
|
||||
const store = new ModelsSettingsStore(face)
|
||||
@@ -199,6 +201,7 @@ describe('edge joins', () => {
|
||||
value: { providers: { weird: 'oops' } },
|
||||
applies: 'live' as const,
|
||||
secrets: [],
|
||||
revision: 0,
|
||||
}] as never,
|
||||
})),
|
||||
providers: () => Promise.resolve(ok({
|
||||
@@ -218,7 +221,7 @@ describe('edge joins', () => {
|
||||
const { face, seenRefs } = api({
|
||||
describeSettings: () => Promise.resolve(ok({
|
||||
writable: true,
|
||||
namespaces: [{ ns: 'llm-pi-ai', schema: {}, value: { providers: {} }, applies: 'live' as const, secrets: [] }] as never,
|
||||
namespaces: [{ ns: 'llm-pi-ai', schema: {}, value: { providers: {} }, applies: 'live' as const, secrets: [], revision: 0 }] as never,
|
||||
})),
|
||||
providers: () => Promise.resolve(ok({
|
||||
providers: [
|
||||
@@ -241,7 +244,7 @@ describe('edge joins', () => {
|
||||
|
||||
it('stringifies a non-Error load failure', async () => {
|
||||
// The wire can surface non-Error throwables; the store must stringify them.
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
||||
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario
|
||||
const { face } = api({ providers: () => Promise.reject('plain refusal') })
|
||||
const store = new ModelsSettingsStore(face)
|
||||
await store.load()
|
||||
@@ -272,3 +275,13 @@ describe('edge joins', () => {
|
||||
expect(store.store.getSnapshot().rows).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('messageOf', () => {
|
||||
it('reads an Error message, and stringifies anything else a rejection may carry', () => {
|
||||
// The wire layer rejects with an Error, but a host or a runtime can reject
|
||||
// with any value, and the page still has to render something.
|
||||
expect(messageOf(new Error('connection lost'))).toBe('connection lost')
|
||||
expect(messageOf('the host refused')).toBe('the host refused')
|
||||
expect(messageOf(undefined)).toBe('undefined')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Read-only plan status badge: quiet chip; the × affordance appears on
|
||||
hover/focus and the whole chip is the /plan off button. */
|
||||
/* Plan-mode toggle chip: quiet while off; the pressed state takes the
|
||||
business accent pair (same token pairing as the trajectory user badge). */
|
||||
|
||||
.wrap {
|
||||
display: inline-flex;
|
||||
@@ -10,8 +10,7 @@
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
@@ -25,6 +24,14 @@
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Hovering keeps the pressed accent: the higher-specificity hover rule above
|
||||
would otherwise swap it back to the neutral hover wash. */
|
||||
.chip[aria-pressed='true'],
|
||||
.chip[aria-pressed='true']:hover:not(:disabled) {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
background: var(--dsw-alias-state-business-tertiary);
|
||||
}
|
||||
|
||||
.chip:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-label-secondary);
|
||||
outline-offset: 2px;
|
||||
@@ -35,17 +42,6 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.chip:hover .close,
|
||||
.chip:focus-visible .close {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
font-size: 12px;
|
||||
|
||||
@@ -11,16 +11,16 @@ export type PlanChipProps =
|
||||
PropsRuntime<'conversation.input.plan'> & InjectFace<PlanChipInjected>
|
||||
|
||||
/**
|
||||
* Read-only status badge over the host-computed `plan` projection. Plan mode
|
||||
* is entered through the /plan command only; the chip appears while the
|
||||
* effective target is plan mode and its hover × executes /plan off. The
|
||||
* displayed state follows the target (`pending ? !active : active`) — a
|
||||
* folded host value, not client optimism, so an arriving frame corrects it.
|
||||
* Plan-mode toggle over the host-computed `plan` projection. The chip renders
|
||||
* whenever the capability is present and reflects the effective target as its
|
||||
* pressed state (`pending ? !active : active` — a folded host value, not
|
||||
* client optimism, so an arriving frame corrects it). Clicking executes
|
||||
* /plan or /plan off toward the opposite target.
|
||||
*/
|
||||
export function PlanChip({ useProjection, locked, exitPlanMode }: PlanChipProps) {
|
||||
export function PlanChip({ useProjection, locked, setPlanMode }: PlanChipProps) {
|
||||
const plan = useProjection('plan')
|
||||
const [leaving, setLeaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<{ text: string; detail: string } | null>(null)
|
||||
const aliveRef = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -30,24 +30,25 @@ export function PlanChip({ useProjection, locked, exitPlanMode }: PlanChipProps)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Absent capability (no plan-mode host plugin / no session yet) or the
|
||||
// default mode: no seat content.
|
||||
// Absent capability (no plan-mode host plugin / no session yet): no seat
|
||||
// content — without the capability there is nothing to toggle.
|
||||
if (plan === undefined) return null
|
||||
const target = plan.pending ? !plan.active : plan.active
|
||||
if (!target) return null
|
||||
|
||||
const off = (): void => {
|
||||
// No leaving/locked guard: both disable the button, so no click arrives.
|
||||
setLeaving(true)
|
||||
const toggle = (): void => {
|
||||
// No busy/locked guard: both disable the button, so no click arrives.
|
||||
const on = !target
|
||||
const failText = on ? '进入 plan mode 失败' : '退出 plan mode 失败'
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
void exitPlanMode().then((failure) => {
|
||||
void setPlanMode(on).then((failure) => {
|
||||
if (!aliveRef.current) return
|
||||
setLeaving(false)
|
||||
setError(failure)
|
||||
setBusy(false)
|
||||
setError(failure === null ? null : { text: failText, detail: failure })
|
||||
}, (reason: unknown) => {
|
||||
if (!aliveRef.current) return
|
||||
setLeaving(false)
|
||||
setError(reason instanceof Error ? reason.message : String(reason))
|
||||
setBusy(false)
|
||||
setError({ text: failText, detail: reason instanceof Error ? reason.message : String(reason) })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -56,19 +57,17 @@ export function PlanChip({ useProjection, locked, exitPlanMode }: PlanChipProps)
|
||||
<button
|
||||
type="button"
|
||||
className={css.chip}
|
||||
aria-label="Plan mode on, press to turn off"
|
||||
title="Plan mode on — click × to turn off (/plan off)"
|
||||
disabled={locked || leaving}
|
||||
onClick={off}
|
||||
aria-pressed={target}
|
||||
aria-label={target ? 'Plan mode on, press to turn off' : 'Plan mode off, press to turn on'}
|
||||
title={target
|
||||
? 'Plan mode on — click to turn off (/plan off)'
|
||||
: 'Plan mode off — click to turn on (/plan)'}
|
||||
disabled={locked || busy}
|
||||
onClick={toggle}
|
||||
>
|
||||
Plan
|
||||
<span className={css.close} aria-hidden>
|
||||
<svg viewBox="0 0 12 12" width="10" height="10">
|
||||
<path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" fill="none" />
|
||||
</svg>
|
||||
</span>
|
||||
Plan { target ? 'on' : 'off' }
|
||||
</button>
|
||||
{error !== null && <span className={css.error} role="status" title={error}>退出 plan mode 失败</span>}
|
||||
{error !== null && <span className={css.error} role="status" title={error.detail}>{error.text}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Plan control plugin, browser half: occupies the composer's named
|
||||
* `conversation.input.plan` seat with a read-only status chip. Plan mode is
|
||||
* entered through the /plan command only; while the projection's effective
|
||||
* target is plan mode the chip renders (hover × executes /plan off through
|
||||
* `command.execute`), otherwise the seat stays empty. Reads ride the generic
|
||||
* projection pair through the standard-kit `useProjection` (an absent key is
|
||||
* capability absence); zero client-side plan state.
|
||||
* `conversation.input.plan` seat with a plan-mode toggle chip. While the
|
||||
* `plan` projection is present the chip renders in both states and executes
|
||||
* /plan or /plan off through `command.execute` toward the opposite target;
|
||||
* an absent projection (no capability) leaves the seat empty. Reads ride the
|
||||
* generic projection pair through the standard-kit `useProjection` (an absent
|
||||
* key is capability absence); zero client-side plan state.
|
||||
*/
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -18,10 +18,11 @@ import { PlanChip } from './PlanModeControl.tsx'
|
||||
/** Injected business face of the composer plan seat. */
|
||||
export interface PlanChipInjected {
|
||||
/**
|
||||
* Leave plan mode by executing /plan off.
|
||||
* Switch plan mode by executing /plan (on) or /plan off.
|
||||
* @param on - desired target: true enters plan mode, false leaves it.
|
||||
* @returns null on admitted execution; a user-visible failure line otherwise.
|
||||
*/
|
||||
exitPlanMode: () => Promise<string | null>
|
||||
setPlanMode: (on: boolean) => Promise<string | null>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,11 +39,12 @@ export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => ctx.slots.register({
|
||||
name: 'conversation.input.plan',
|
||||
inject: (sessionId: SessionId): PlanChipInjected => ({
|
||||
exitPlanMode: async () => {
|
||||
setPlanMode: async (on) => {
|
||||
const line = on ? '/plan' : '/plan off'
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const { result } = await connection.api.commands.execute({ sessionId, line: '/plan off' })
|
||||
const { result } = await connection.api.commands.execute({ sessionId, line })
|
||||
if (!result.ok) return `${result.error.message}(${result.error.code})`
|
||||
if (!result.value.matched) return '未知命令:/plan off'
|
||||
if (!result.value.matched) return `未知命令:${line}`
|
||||
return null
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* ui-plan browser half on a real SlotsService: the plugin occupies the
|
||||
* conversation-declared `conversation.input.plan` single seat with the plan
|
||||
* status chip; the injected face executes /plan off and folds admission
|
||||
* outcomes into null (admitted) or a user-visible failure line; teardown
|
||||
* empties the seat (HMR safety).
|
||||
* toggle chip; the injected face executes /plan or /plan off by direction and
|
||||
* folds admission outcomes into null (admitted) or a user-visible failure
|
||||
* line; teardown empties the seat (HMR safety).
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
@@ -49,7 +49,7 @@ describe('ui-plan browser apply', () => {
|
||||
.rejects.toThrow(/slot "conversation.input.plan" is not declared/)
|
||||
})
|
||||
|
||||
it('registers the chip, executes /plan off, and unregisters on teardown', async () => {
|
||||
it('registers the chip, executes /plan by direction, and unregisters on teardown', async () => {
|
||||
const b = await bench()
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
@@ -57,20 +57,22 @@ describe('ui-plan browser apply', () => {
|
||||
expect(entry.component).toBe(PlanChip)
|
||||
const injected = (entry.inject as unknown as (id: SessionId) => PlanChipInjected)(SID)
|
||||
|
||||
await expect(injected.exitPlanMode()).resolves.toBeNull()
|
||||
await expect(injected.setPlanMode(false)).resolves.toBeNull()
|
||||
expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan off' })
|
||||
await expect(injected.setPlanMode(true)).resolves.toBeNull()
|
||||
expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan' })
|
||||
|
||||
// Business failure folds to the composer-visible line.
|
||||
b.execute.mockResolvedValueOnce({
|
||||
result: { ok: false as const, error: { code: 'session-not-found', message: 'gone', details: {} } },
|
||||
} as never)
|
||||
await expect(injected.exitPlanMode()).resolves.toBe('gone(session-not-found)')
|
||||
await expect(injected.setPlanMode(false)).resolves.toBe('gone(session-not-found)')
|
||||
|
||||
// Unmatched admission (plan-mode not composed host-side) is also a failure line.
|
||||
b.execute.mockResolvedValueOnce({
|
||||
result: { ok: true as const, value: { matched: false as const } },
|
||||
} as never)
|
||||
await expect(injected.exitPlanMode()).resolves.toBe('未知命令:/plan off')
|
||||
await expect(injected.setPlanMode(true)).resolves.toBe('未知命令:/plan')
|
||||
|
||||
await fiber.dispose()
|
||||
expect(b.slots.entries('conversation.input.plan')).toHaveLength(0)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* PlanChip over the `plan` projection: nothing renders while the capability
|
||||
* is absent or the effective target is the default mode; the chip renders
|
||||
* while the target is plan mode (pending follows the target — /plan shows it
|
||||
* immediately, /plan off hides it immediately); the chip button executes
|
||||
* /plan off and surfaces failures without hiding until the projection says so.
|
||||
* is absent; with the capability present the chip renders in both states with
|
||||
* aria-pressed following the effective target (pending folds — /plan shows
|
||||
* pressed immediately, /plan off unpressed immediately); clicking executes
|
||||
* the command toward the opposite target and surfaces direction-specific
|
||||
* failures while the projection still owns the displayed state.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
@@ -17,78 +18,98 @@ afterEach(cleanup)
|
||||
|
||||
function setup(
|
||||
plan: PlanProjection | undefined,
|
||||
exitPlanMode = vi.fn(() => Promise.resolve<string | null>(null)),
|
||||
setPlanMode = vi.fn((_on: boolean) => Promise.resolve<string | null>(null)),
|
||||
locked = false,
|
||||
) {
|
||||
const store = createSnapshotStore<{ value: PlanProjection | undefined }>({ value: plan })
|
||||
const useProjection = (_key: string, selector?: (v: unknown) => unknown) =>
|
||||
bindSnapshotSelector(store)(s => (selector ?? (v => v))(s.value))
|
||||
const props = { useProjection, locked, exitPlanMode } as unknown as PlanChipProps
|
||||
const props = { useProjection, locked, setPlanMode } as unknown as PlanChipProps
|
||||
const view = render(<PlanChip {...props} />)
|
||||
return { store, exitPlanMode, view }
|
||||
return { store, setPlanMode, view }
|
||||
}
|
||||
|
||||
const chip = () => screen.getByRole('button', { name: 'Plan mode on, press to turn off' })
|
||||
const onChip = () => screen.getByRole('button', { name: 'Plan mode on, press to turn off' })
|
||||
const offChip = () => screen.getByRole('button', { name: 'Plan mode off, press to turn on' })
|
||||
|
||||
describe('PlanChip', () => {
|
||||
it('renders nothing for absent capability or the default mode', () => {
|
||||
it('renders nothing while the capability is absent', () => {
|
||||
const absent = setup(undefined)
|
||||
expect(absent.view.container.innerHTML).toBe('')
|
||||
cleanup()
|
||||
const inactive = setup({ active: false, pending: false })
|
||||
expect(inactive.view.container.innerHTML).toBe('')
|
||||
cleanup()
|
||||
// Active with a pending exit: the target is default — chip already gone.
|
||||
const leaving = setup({ active: true, pending: true })
|
||||
expect(leaving.view.container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('renders while the effective target is plan mode, including the pending entry window', () => {
|
||||
it('reflects the effective target as the pressed state, folding pending', () => {
|
||||
setup({ active: false, pending: false })
|
||||
expect(offChip().getAttribute('aria-pressed')).toBe('false')
|
||||
cleanup()
|
||||
setup({ active: true, pending: false })
|
||||
expect(chip()).toBeTruthy()
|
||||
expect(onChip().getAttribute('aria-pressed')).toBe('true')
|
||||
cleanup()
|
||||
// /plan just ran (command/run folded, plan/mode not yet): target is plan.
|
||||
setup({ active: false, pending: true })
|
||||
expect(chip()).toBeTruthy()
|
||||
expect(onChip().getAttribute('aria-pressed')).toBe('true')
|
||||
cleanup()
|
||||
// Active with a pending exit: the target is default — already unpressed.
|
||||
setup({ active: true, pending: true })
|
||||
expect(offChip().getAttribute('aria-pressed')).toBe('false')
|
||||
})
|
||||
|
||||
it('the chip executes /plan off once and follows the projection down', async () => {
|
||||
it('unpressed chip executes /plan (on) once and follows the projection up', async () => {
|
||||
let resolve!: (value: string | null) => void
|
||||
const exitPlanMode = vi.fn(() => new Promise<string | null>((done) => { resolve = done }))
|
||||
const { store } = setup({ active: true, pending: false }, exitPlanMode)
|
||||
fireEvent.click(chip())
|
||||
expect(exitPlanMode).toHaveBeenCalledTimes(1)
|
||||
const setPlanMode = vi.fn((_on: boolean) => new Promise<string | null>((done) => { resolve = done }))
|
||||
const { store } = setup({ active: false, pending: false }, setPlanMode)
|
||||
fireEvent.click(offChip())
|
||||
expect(setPlanMode).toHaveBeenCalledTimes(1)
|
||||
expect(setPlanMode).toHaveBeenLastCalledWith(true)
|
||||
// Busy while its own call is in flight.
|
||||
fireEvent.click(chip())
|
||||
expect(exitPlanMode).toHaveBeenCalledTimes(1)
|
||||
fireEvent.click(offChip())
|
||||
expect(setPlanMode).toHaveBeenCalledTimes(1)
|
||||
resolve(null)
|
||||
// The off command's run record folds: target flips, the chip unmounts.
|
||||
// The command's run record folds: target flips, the chip presses.
|
||||
store.set({ value: { active: false, pending: true } })
|
||||
await waitFor(() => {
|
||||
expect(onChip().getAttribute('aria-pressed')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
it('pressed chip executes /plan off and follows the projection down', async () => {
|
||||
const setPlanMode = vi.fn((_on: boolean) => Promise.resolve<string | null>(null))
|
||||
const { store } = setup({ active: true, pending: false }, setPlanMode)
|
||||
fireEvent.click(onChip())
|
||||
expect(setPlanMode).toHaveBeenLastCalledWith(false)
|
||||
store.set({ value: { active: true, pending: true } })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('button', { name: 'Plan mode on, press to turn off' })).toBeNull()
|
||||
expect(offChip().getAttribute('aria-pressed')).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
it('disables under the locked owner prop', () => {
|
||||
setup({ active: true, pending: false }, vi.fn(), true)
|
||||
expect((chip() as HTMLButtonElement).disabled).toBe(true)
|
||||
expect((onChip() as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('surfaces admission and transport failures while staying visible', async () => {
|
||||
const exitPlanMode = vi.fn()
|
||||
it('surfaces direction-specific admission and transport failures while staying visible', async () => {
|
||||
const exitFailing = vi.fn()
|
||||
.mockResolvedValueOnce('host said no')
|
||||
.mockRejectedValueOnce(new Error('network down'))
|
||||
.mockRejectedValueOnce('socket closed')
|
||||
setup({ active: true, pending: false }, exitPlanMode)
|
||||
fireEvent.click(chip())
|
||||
setup({ active: true, pending: false }, exitFailing)
|
||||
fireEvent.click(onChip())
|
||||
expect((await screen.findByText('退出 plan mode 失败')).getAttribute('title')).toBe('host said no')
|
||||
expect(chip()).toBeTruthy()
|
||||
expect(onChip()).toBeTruthy()
|
||||
|
||||
fireEvent.click(chip())
|
||||
fireEvent.click(onChip())
|
||||
expect(await screen.findByTitle('network down')).toBeTruthy()
|
||||
|
||||
fireEvent.click(chip())
|
||||
fireEvent.click(onChip())
|
||||
expect(await screen.findByTitle('socket closed')).toBeTruthy()
|
||||
cleanup()
|
||||
|
||||
const enterFailing = vi.fn().mockResolvedValueOnce('agent busy')
|
||||
setup({ active: false, pending: false }, enterFailing)
|
||||
fireEvent.click(offChip())
|
||||
expect((await screen.findByText('进入 plan mode 失败')).getAttribute('title')).toBe('agent busy')
|
||||
expect(offChip()).toBeTruthy()
|
||||
})
|
||||
|
||||
it('ignores in-flight fulfillment and rejection after unmount', () => {
|
||||
@@ -97,14 +118,14 @@ describe('PlanChip', () => {
|
||||
{ active: true, pending: false },
|
||||
vi.fn(() => new Promise<string | null>((done) => { resolve = done })),
|
||||
)
|
||||
fireEvent.click(chip())
|
||||
fireEvent.click(onChip())
|
||||
successful.view.unmount()
|
||||
expect(() => { resolve(null) }).not.toThrow()
|
||||
|
||||
let reject!: (reason: unknown) => void
|
||||
const exitPlanMode = vi.fn(() => new Promise<string | null>((_done, fail) => { reject = fail }))
|
||||
const { view } = setup({ active: true, pending: false }, exitPlanMode)
|
||||
fireEvent.click(chip())
|
||||
const setPlanMode = vi.fn(() => new Promise<string | null>((_done, fail) => { reject = fail }))
|
||||
const { view } = setup({ active: true, pending: false }, setPlanMode)
|
||||
fireEvent.click(onChip())
|
||||
view.unmount()
|
||||
expect(() => { reject(new Error('late')) }).not.toThrow()
|
||||
})
|
||||
|
||||
@@ -123,6 +123,16 @@ export const IconCheckOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** ic_ds_check_outline_14 */
|
||||
export const IconCheckOutline14 = ({ size = 14, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M11.5635 4.58984L7.61426 9.07715C7.35154 9.37561 7.11346 9.64812 6.89453 9.84668C6.66593 10.054 6.38519 10.2506 6.01465 10.3164C5.82079 10.3508 5.62207 10.3529 5.42773 10.3213C5.0561 10.2609 4.77266 10.0674 4.54102 9.86328C4.31926 9.66791 4.07752 9.39911 3.81055 9.10449L2.44531 7.59863L3.55664 6.59082L4.92188 8.09766C5.21256 8.41844 5.38878 8.61191 5.53223 8.73828C5.61022 8.80699 5.65253 8.83192 5.66895 8.83984C5.69648 8.84429 5.72449 8.84467 5.75195 8.83984C5.72657 8.84451 5.75564 8.85422 5.88672 8.73535C6.02833 8.60692 6.20225 8.41088 6.48828 8.08594L10.4385 3.59961L11.5635 4.58984Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** ic_ds_branch_outline_16 */
|
||||
export const IconBranchOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -665,13 +675,13 @@ export const IconDataOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** ic_checklist_outline_16 (figma extract): two rings + two list bars. */
|
||||
export const IconChecklistOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path transform="translate(1.736 2.0752)" d="M12.5279 8.64648V9.92617H6.48105V8.64648H12.5279Z" fill="currentColor" />
|
||||
<path transform="translate(1.736 2.0752)" d="M12.5279 1.92275V3.20244H6.48105V1.92275H12.5279Z" fill="currentColor" />
|
||||
<path transform="translate(1.736 2.0752)" d="M3.84531 9.28623C3.84525 8.57774 3.271 8.00342 2.5625 8.00342C1.85405 8.00348 1.27975 8.57778 1.27969 9.28623C1.27969 9.99474 1.85401 10.569 2.5625 10.569C3.27105 10.569 3.84531 9.99478 3.84531 9.28623ZM5.12578 9.28623C5.12578 10.7017 3.97797 11.8495 2.5625 11.8495C1.14709 11.8494 0 10.7017 0 9.28623C6.59755e-05 7.87086 1.14713 6.7238 2.5625 6.72373C3.97793 6.72373 5.12572 7.87082 5.12578 9.28623Z" fill="currentColor" />
|
||||
<path transform="translate(1.736 2.0752)" d="M3.84551 2.5625C3.84549 1.85402 3.27118 1.27969 2.5627 1.27969C1.85422 1.2797 1.2799 1.85403 1.27988 2.5625C1.27988 3.27098 1.85422 3.8453 2.5627 3.84531C3.27119 3.84531 3.84551 3.27099 3.84551 2.5625ZM5.1252 2.5625C5.1252 3.97792 3.97811 5.125 2.5627 5.125C1.14729 5.12499 0.000195313 3.97791 0.000195313 2.5625C0.000208508 1.1471 1.1473 1.31957e-05 2.5627 0C3.9781 0 5.12518 1.1471 5.1252 2.5625Z" fill="currentColor" />
|
||||
/** ic_checklist_outline_14 (figma extract): two rings + two list bars. */
|
||||
export const IconChecklistOutline14 = ({ size = 14, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.3277 9.69629V10.976H7.28086V9.69629H13.3277Z" fill="currentColor" />
|
||||
<path d="M13.3277 2.97256V4.25225H7.28086V2.97256H13.3277Z" fill="currentColor" />
|
||||
<path d="M4.64512 10.336C4.64505 9.62755 4.07081 9.05322 3.3623 9.05322C2.65386 9.05329 2.07956 9.62759 2.07949 10.336C2.07949 11.0445 2.65382 11.6188 3.3623 11.6188C4.07085 11.6188 4.64512 11.0446 4.64512 10.336ZM5.92559 10.336C5.92559 11.7515 4.77777 12.8993 3.3623 12.8993C1.94689 12.8993 0.799805 11.7515 0.799805 10.336C0.799871 8.92066 1.94693 7.7736 3.3623 7.77354C4.77773 7.77354 5.92552 8.92062 5.92559 10.336Z" fill="currentColor" />
|
||||
<path d="M4.64531 3.6123C4.6453 2.90382 4.07098 2.32949 3.3625 2.32949C2.65403 2.32951 2.0797 2.90383 2.07969 3.6123C2.07969 4.32079 2.65402 4.8951 3.3625 4.89512C4.07099 4.89512 4.64531 4.3208 4.64531 3.6123ZM5.925 3.6123C5.925 5.02772 4.77792 6.1748 3.3625 6.1748C1.9471 6.17479 0.8 5.02771 0.8 3.6123C0.800013 2.19691 1.9471 1.04982 3.3625 1.0498C4.77791 1.0498 5.92499 2.1969 5.925 3.6123Z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
@@ -703,3 +713,18 @@ export const IconSparkle16 = ({ size = 16, className }: IconProps) => (
|
||||
<path d="M12.5 9.4Q12.7 11.4 14.7 11.6Q12.7 11.8 12.5 13.8Q12.3 11.8 10.3 11.6Q12.3 11.4 12.5 9.4Z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** ic_ds_question_outline_14 (figma extract): ring + question glyph. */
|
||||
export const IconQuestionOutline14 = ({ size = 14, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M12.5757 7.00012C12.5757 3.92085 10.0794 1.42463 7.00012 1.42456C3.9208 1.42456 1.42456 3.9208 1.42456 7.00012C1.42463 10.0794 3.92085 12.5757 7.00012 12.5757C10.0793 12.5756 12.5756 10.0793 12.5757 7.00012ZM13.8002 7.00012C13.8001 10.7559 10.7559 13.8001 7.00012 13.8002C3.2443 13.8002 0.199291 10.7559 0.199219 7.00012C0.199219 3.24426 3.24426 0.199219 7.00012 0.199219C10.7559 0.199291 13.8002 3.2443 13.8002 7.00012Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M6.18042 8.68184C6.18043 8.09153 6.32893 7.34655 6.92127 6.8481C7.28566 6.54148 7.76104 6.27318 8.0022 6.10811C8.28964 5.91137 8.42234 5.76562 8.48328 5.58944C8.57774 5.31609 8.53121 5.00904 8.34912 4.76741C8.17409 4.53522 7.83879 4.32222 7.28186 4.32222C5.99668 4.32225 5.46969 5.11832 5.46949 5.78939H4.24414C4.24436 4.39942 5.36327 3.09691 7.28186 3.09688C8.17773 3.09688 8.89489 3.45606 9.32752 4.02999C9.75287 4.59438 9.86938 5.32775 9.64026 5.99019C9.44847 6.5444 9.04722 6.87743 8.69434 7.11898C8.29506 7.39226 8.02318 7.52192 7.70996 7.78548C7.51943 7.94582 7.40577 8.24899 7.40577 8.68184V8.75533H6.18042V8.68184Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path d="M7.39455 9.44026V10.8109H6.16921V9.44026H7.39455Z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
@@ -14,8 +14,8 @@ const icons = Object.fromEntries(
|
||||
const iconNames = Object.keys(icons)
|
||||
|
||||
describe('ic_ds_ icon set', () => {
|
||||
it('exports the full P-I set (44 deepsuite + 13 figma extracts + the hand-authored sparkle)', () => {
|
||||
expect(iconNames.length).toBe(58)
|
||||
it('exports the full P-I set (45 deepsuite + 14 figma extracts + the hand-authored sparkle)', () => {
|
||||
expect(iconNames.length).toBe(60)
|
||||
})
|
||||
|
||||
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => {
|
||||
|
||||
@@ -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-question/README.md
|
||||
README.md: 3a3cd639fc2834685230aca7c8087583e0a48c71
|
||||
README.zh.md: 1330578577da7ed7d0890595f675fd272fd5ebc7
|
||||
README.md: 0700375758774610fcd897b9a3e16484206a871d
|
||||
README.zh.md: d9e5eb22cef13e16ab1ce2cebba9e563bd9d08d9
|
||||
|
||||
@@ -8,6 +8,8 @@ The component renders one question at a time with progress navigation, single- a
|
||||
|
||||
Selection state is local to a component keyed by the request rpcId. A replay with the same id preserves a still-mounted draft, while `question/resolved` from the host removes the composer. The host remains authoritative: successful HTTP delivery does not remove pending state locally.
|
||||
|
||||
Composer chrome copy (pager, buttons, placeholders, validation feedback) is bilingual: the plugin registers zh/en dictionaries under the `question` namespace of `dsh-client-locale` and hands the entry its bound translator plus the locale snapshot source through the inject face, so a locale switch re-renders a mounted composer. Question and option text arrives from the model and renders verbatim; carrier failure messages also display untranslated.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through `dsh-tool-ask-user`; that package owns the model-visible tool schema and structured result.
|
||||
|
||||
@@ -8,6 +8,8 @@ Web `ask_user_question` 功能插件。只有选择 Web 功能时,其主机侧
|
||||
|
||||
选择状态只存在于以请求 rpcId 为 key 的组件本地。使用相同 id 回放时,只要组件仍挂载,就会保留草稿;主机发出的 `question/resolved` 则会移除编辑器。主机仍具有最终决定权:HTTP 交付成功不会在本地移除待处理状态。
|
||||
|
||||
编辑器外框文案(翻页器、按钮、占位符、校验提示)是双语的:插件在 `dsh-client-locale` 的 `question` 命名空间下注册 zh/en 词典,并通过 inject face 把绑定的翻译函数和 locale 快照源交给该配置项,因此切换语言会重新渲染已挂载的编辑器。问题与选项文本来自模型并原样渲染;载体失败消息也不经翻译直接显示。
|
||||
|
||||
## 模型体验
|
||||
|
||||
通过 `dsh-tool-ask-user` 间接影响;该包拥有模型可见的工具 schema 和结构化结果。
|
||||
|
||||
@@ -1,28 +1,33 @@
|
||||
/* The takeover seats where the input card sits, so the frame mirrors the
|
||||
InputBar geometry (side pad 32, card cap 800) to keep both edges flush. */
|
||||
.frame {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 6px 24px 10px;
|
||||
padding: 6px 32px 10px;
|
||||
}
|
||||
|
||||
/* Figma Input 973:36348 body over the 1019:36938 header: no banner strip —
|
||||
the card keeps zero padding and sections carry their own insets. */
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
max-width: 800px;
|
||||
/* Composer seat sits in a fixed-height conversation column (overflow
|
||||
hidden): cap the card against the viewport and scroll the option list
|
||||
so header and footer actions stay reachable on long batches. */
|
||||
max-height: min(60vh, 520px);
|
||||
padding: 14px 16px 12px;
|
||||
padding: 0 0 10px;
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
border-radius: 18px;
|
||||
border-radius: 20px;
|
||||
background: var(--dsw-specific-input-major);
|
||||
box-shadow: var(--dsw-shadow-lv1-blur);
|
||||
box-shadow: var(--dsw-shadow-lv2);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
/* Elevated surface in dark, same as the menus: the option list inside scrolls
|
||||
overflow: hidden;
|
||||
/* Elevated surface in dark, same as the menus: the body inside scrolls
|
||||
once the card hits the cap above, so the thumb takes the l2 pair. Declared
|
||||
on the card because the elevation belongs to the surface, and the custom
|
||||
properties inherit down to `.options` (see ui-theme styles/scrollbar.css
|
||||
properties inherit down to `.body` (see ui-theme styles/scrollbar.css
|
||||
for the rebinding contract). */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
@@ -33,64 +38,63 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Figma 1019:36938 header, user-tuned: heading block left, close right; the
|
||||
pager sits in the footer to balance the card. */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 8px;
|
||||
padding: 20px 16px 0 24px;
|
||||
}
|
||||
|
||||
.headingBlock {
|
||||
min-width: 0;
|
||||
padding: 1px 2px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin-bottom: 2px;
|
||||
/* Eyebrow-to-title gap widened from the figma 2px (user-tuned). */
|
||||
margin-bottom: 5px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.multiSelectHint {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail {
|
||||
margin: 0 2px 8px;
|
||||
}
|
||||
|
||||
.headerActions,
|
||||
.footerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.progress {
|
||||
padding: 0 6px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
padding: 0 4px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
/* Narrow the plain spaces around the slash without touching glyph tracking. */
|
||||
word-spacing: -2px;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
@@ -128,21 +132,23 @@
|
||||
.options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 1px;
|
||||
margin: 8px 0 0;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
.option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
min-height: 40px;
|
||||
/* Rows are the scroll content, never the slack absorber: a shrinkable row
|
||||
collapses to min-height while its wrapped copy keeps the taller
|
||||
intrinsic height, and centered content then paints outside the row box —
|
||||
over the title and the next row. Overflow belongs to .options. */
|
||||
flex-shrink: 0;
|
||||
padding: 5px 8px;
|
||||
padding: 6px 12px 6px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
@@ -161,25 +167,63 @@
|
||||
border-color: var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.option:disabled,
|
||||
.customTrigger:disabled {
|
||||
.option:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Leading indicator (figma 20×20, radius 6): single-select shows the option
|
||||
number, multi-select swaps in a checkbox; the custom-answer row follows —
|
||||
its checkbox mirrors the typed draft (styling only, exclusivity holds). */
|
||||
.number {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 999px;
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
flex: 0 0 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-bg-overlay);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
/* Multi-select box (figma 1055:41594, user-tuned down to 14×14): a radius-4
|
||||
box centered in the 20px indicator seat; the box itself is the ::before
|
||||
layer so the check icon stacks over it in the same grid cell. */
|
||||
.checkbox {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.checkbox::before {
|
||||
content: '';
|
||||
grid-area: 1 / 1;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1px solid var(--dsw-alias-border-l4);
|
||||
border-radius: 4px;
|
||||
transition: background-color 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
|
||||
.checkbox > svg {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
|
||||
/* Checked: label-primary fill with a primary-foreground check — the pair
|
||||
inverts with the theme (dark fill in light mode, light fill in dark mode). */
|
||||
.checkboxChecked {
|
||||
color: var(--dsw-alias-label-primary-foreground);
|
||||
}
|
||||
|
||||
.checkboxChecked::before {
|
||||
border-color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.optionCopy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
@@ -194,103 +238,101 @@
|
||||
|
||||
.optionLabel {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
padding: 0 4px;
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-specific-sidebar-nav-item-active-accent);
|
||||
color: var(--dsw-alias-button-info-fill);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.choiceIcon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 20px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.custom {
|
||||
/* Same reason as .option: the custom block is scroll content, and shrinking
|
||||
it pushes its trigger row (and the open textarea) past the footer. */
|
||||
flex-shrink: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.customOpen {
|
||||
border-color: var(--dsw-alias-border-l2);
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
}
|
||||
|
||||
.customOptionless {
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.customTrigger {
|
||||
/* Custom answer row (figma 973:36427): an option-shaped row whose copy is an
|
||||
inline text input; focus or a typed draft lifts it to the selected look. */
|
||||
.customRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
padding: 5px 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
min-height: 40px;
|
||||
/* Same reason as .option: the custom row is scroll content, and shrinking
|
||||
it pushes the inline input past the footer. */
|
||||
flex-shrink: 0;
|
||||
padding: 6px 12px 6px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 12px;
|
||||
transition: background-color 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
|
||||
.customTrigger:hover:not(:disabled) {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
.customRow:hover,
|
||||
.customRow:focus-within,
|
||||
.customRowActive {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.customRow:focus-within,
|
||||
.customRowActive {
|
||||
border-color: var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.customInput {
|
||||
display: block;
|
||||
width: calc(100% - 20px);
|
||||
min-height: 54px;
|
||||
max-height: 140px;
|
||||
margin: 0 10px 10px;
|
||||
padding: 7px 10px;
|
||||
resize: vertical;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: var(--dsw-specific-input-major);
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
caret-color: var(--dsw-alias-state-business-primary);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.customInput:focus {
|
||||
border-color: var(--dsw-alias-state-business-primary);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.customInput::placeholder {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.customOptionless .customInput {
|
||||
width: 100%;
|
||||
min-height: 58px;
|
||||
margin: 0;
|
||||
/* Optionless question: the free-form answer is the whole body. The 12px side
|
||||
margins add to the .options 12px padding so both edges align with the
|
||||
title's 24px inset; type matches the option rows, no resize handle. */
|
||||
.customTextarea {
|
||||
display: block;
|
||||
min-height: 64px;
|
||||
max-height: 140px;
|
||||
flex-shrink: 0;
|
||||
margin: 0 12px;
|
||||
padding: 8px 12px;
|
||||
resize: none;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 10px;
|
||||
outline: none;
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
caret-color: var(--dsw-alias-state-business-primary);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.customTextarea:focus {
|
||||
border-color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.customTextarea::placeholder {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.footer {
|
||||
@@ -299,15 +341,17 @@
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 8px;
|
||||
padding: 0 2px;
|
||||
margin-top: 12px;
|
||||
padding: 0 10px 0 18px;
|
||||
}
|
||||
|
||||
.feedback {
|
||||
flex: 1;
|
||||
min-height: 16px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
@@ -316,21 +360,15 @@
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 12px 10px 10px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: block;
|
||||
padding: 10px 12px 0 18px;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.headingBlock {
|
||||
padding: 0 2px;
|
||||
.options {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
@@ -339,18 +377,15 @@
|
||||
}
|
||||
|
||||
.option,
|
||||
.customTrigger {
|
||||
.customRow {
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.choiceIcon {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
align-items: flex-end;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
@@ -359,7 +394,8 @@
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.option {
|
||||
.option,
|
||||
.customRow {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo, useState, type KeyboardEvent } from 'react'
|
||||
import { useMemo, useState, type ChangeEvent, type KeyboardEvent } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
Button, IconCheckOutline16, IconChevronLeftOutline14, IconChevronRightOutline14,
|
||||
Button, IconCheckOutline14, IconChevronLeftOutline14, IconChevronRightOutline14,
|
||||
IconCloseOutline16, IconEditOutline16, MarkdownText,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import {
|
||||
@@ -13,10 +13,17 @@ import css from './QuestionComposer.module.css'
|
||||
interface DraftAnswer {
|
||||
selected: string[]
|
||||
custom: string
|
||||
customOpen: boolean
|
||||
skipped: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Displayed feedback: validation feedback is stored as a dictionary KEY and
|
||||
* translated at render, so already-shown feedback follows a locale switch;
|
||||
* runtime failure messages (finished strings from the wire) pass through
|
||||
* verbatim.
|
||||
*/
|
||||
type Feedback = { key: 'error.incomplete' | 'error.unanswered' } | { text: string }
|
||||
|
||||
/**
|
||||
* Split the conventional recommendation suffix without changing the answer value.
|
||||
* @param label - Original option label returned if selected.
|
||||
@@ -29,17 +36,8 @@ export function parseRecommendedLabel(label: string): { label: string; recommend
|
||||
: { label, recommended: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a conventional multi-select suffix so the hint can be styled separately.
|
||||
* @param title - Question title supplied by the interaction request.
|
||||
* @returns Question title without a trailing multi-select marker.
|
||||
*/
|
||||
export function parseQuestionTitle(title: string): string {
|
||||
return title.replace(/\s*[((]可多选[))]\s*$/, '')
|
||||
}
|
||||
|
||||
/** Return whether a textarea key event belongs to an active IME composition. */
|
||||
function isComposing(event: KeyboardEvent<HTMLTextAreaElement>): boolean {
|
||||
/** Return whether a text-field key event belongs to an active IME composition. */
|
||||
function isComposing(event: KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>): boolean {
|
||||
// keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
|
||||
// oxlint-disable-next-line typescript/no-deprecated
|
||||
return event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229
|
||||
@@ -61,14 +59,11 @@ export function QuestionComposer(props: QuestionComposerProps) {
|
||||
function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<QuestionComposerProps, 't'>) {
|
||||
const questions = pending.questions
|
||||
const [index, setIndex] = useState(0)
|
||||
const [drafts, setDrafts] = useState<DraftAnswer[]>(() => questions.map(question => ({
|
||||
selected: [], custom: '', customOpen: (question.options?.length ?? 0) === 0, skipped: false,
|
||||
const [drafts, setDrafts] = useState<DraftAnswer[]>(() => questions.map(() => ({
|
||||
selected: [], custom: '', skipped: false,
|
||||
})))
|
||||
const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
|
||||
// Validation feedback is stored as a dictionary KEY and translated at
|
||||
// render, so already-shown feedback follows a locale switch; runtime
|
||||
// failure messages (finished strings from the wire) pass through verbatim.
|
||||
const [error, setError] = useState<{ key: 'error.incomplete' | 'error.unanswered' } | { text: string } | null>(null)
|
||||
const [error, setError] = useState<Feedback | null>(null)
|
||||
// index stays in bounds (every setIndex site clamps) and drafts mirrors questions 1:1.
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
const question = questions[index]!
|
||||
@@ -97,17 +92,13 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
|
||||
? current.selected.filter(item => item !== label)
|
||||
: [...current.selected, label]
|
||||
: [label]
|
||||
return { selected, custom: '', customOpen: false, skipped: false }
|
||||
return { selected, custom: '', skipped: false }
|
||||
})
|
||||
if (question.multiSelect !== true && index < questions.length - 1) {
|
||||
setIndex(current => current + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const openCustom = (): void => {
|
||||
updateDraft(current => ({ ...current, selected: [], customOpen: true, skipped: false }))
|
||||
}
|
||||
|
||||
const answered = (item: DraftAnswer): boolean =>
|
||||
item.selected.length > 0 || item.custom.trim() !== ''
|
||||
|
||||
@@ -153,13 +144,26 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
|
||||
submitDrafts(drafts)
|
||||
}
|
||||
|
||||
// Shared by the inline custom input and the optionless textarea: typing a
|
||||
// custom draft clears any selection, and Enter continues the flow
|
||||
// (Shift+Enter stays a newline in the textarea; on the single-line input it
|
||||
// is inert either way).
|
||||
const draftCustom = (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
|
||||
const value = event.target.value
|
||||
updateDraft(current => ({
|
||||
...current, selected: [], custom: value, skipped: false,
|
||||
}))
|
||||
}
|
||||
|
||||
const continueFromCustom = (event: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
|
||||
if (event.key !== 'Enter' || event.shiftKey || isComposing(event)) return
|
||||
event.preventDefault()
|
||||
continueFlow()
|
||||
}
|
||||
|
||||
const skipQuestion = (): void => {
|
||||
const nextDrafts = drafts.map((item, itemIndex) => itemIndex === index
|
||||
? {
|
||||
selected: [], custom: '',
|
||||
customOpen: (question.options?.length ?? 0) === 0,
|
||||
skipped: true,
|
||||
}
|
||||
? { selected: [], custom: '', skipped: true }
|
||||
: item)
|
||||
setDrafts(nextDrafts)
|
||||
setError(null)
|
||||
@@ -177,38 +181,16 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
|
||||
<div className={css.headingBlock}>
|
||||
{question.header !== undefined && <div className={css.eyebrow}>{question.header}</div>}
|
||||
<h2 className={css.title} id={`question-${pending.key}-${String(index)}`}>
|
||||
<span>{question.multiSelect === true
|
||||
? parseQuestionTitle(question.question)
|
||||
: question.question}</span>
|
||||
{question.multiSelect === true && (
|
||||
<span className={css.multiSelectHint}>{t('title.multi')}</span>
|
||||
)}
|
||||
{question.question}
|
||||
</h2>
|
||||
</div>
|
||||
<div className={css.headerActions}>
|
||||
<span className={css.progress}>{index + 1} / {questions.length}</span>
|
||||
<button
|
||||
type="button" className={css.iconButton} aria-label={t('nav.prev')}
|
||||
disabled={index === 0 || busy !== null}
|
||||
onClick={() => { setIndex(index - 1); setError(null) }}
|
||||
>
|
||||
<IconChevronLeftOutline14 />
|
||||
</button>
|
||||
<button
|
||||
type="button" className={css.iconButton} aria-label={t('nav.next')}
|
||||
disabled={index === questions.length - 1 || busy !== null}
|
||||
onClick={() => { setIndex(index + 1); setError(null) }}
|
||||
>
|
||||
<IconChevronRightOutline14 />
|
||||
</button>
|
||||
<button
|
||||
type="button" className={css.iconButton} aria-label={t('nav.cancel')}
|
||||
title={t('nav.cancel')}
|
||||
disabled={busy !== null} onClick={cancelFlow}
|
||||
>
|
||||
<IconCloseOutline16 />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button" className={css.iconButton} aria-label={t('nav.cancel')}
|
||||
title={t('nav.cancel')}
|
||||
disabled={busy !== null} onClick={cancelFlow}
|
||||
>
|
||||
<IconCloseOutline16 />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className={css.body} data-question-scroll>
|
||||
@@ -222,7 +204,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
|
||||
return (
|
||||
<button
|
||||
type="button" key={`${option.label}-${String(optionIndex)}`}
|
||||
className={clsx(css.option, selected && css.optionSelected)}
|
||||
className={clsx(css.option, selected && question.multiSelect !== true && css.optionSelected)}
|
||||
role={question.multiSelect === true ? 'checkbox' : 'radio'}
|
||||
aria-checked={selected}
|
||||
aria-label={display.label}
|
||||
@@ -234,7 +216,13 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
|
||||
submitDrafts(drafts)
|
||||
}}
|
||||
>
|
||||
<span className={css.number}>{optionIndex + 1}</span>
|
||||
{question.multiSelect === true
|
||||
? (
|
||||
<span className={clsx(css.checkbox, selected && css.checkboxChecked)} aria-hidden="true">
|
||||
{selected && <IconCheckOutline14 size={12} />}
|
||||
</span>
|
||||
)
|
||||
: <span className={css.number}>{optionIndex + 1}</span>}
|
||||
<span className={css.optionCopy}>
|
||||
<span className={css.optionLine}>
|
||||
<span className={css.optionLabel}>{display.label}</span>
|
||||
@@ -246,62 +234,80 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span className={css.choiceIcon}>
|
||||
{selected ? <IconCheckOutline16 /> : <IconChevronRightOutline14 />}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className={clsx(
|
||||
css.custom,
|
||||
draft.customOpen && css.customOpen,
|
||||
!hasOptions && css.customOptionless,
|
||||
)}>
|
||||
{hasOptions && (
|
||||
<button
|
||||
type="button" className={css.customTrigger}
|
||||
disabled={busy !== null} onClick={openCustom}
|
||||
aria-expanded={draft.customOpen}
|
||||
>
|
||||
<span className={css.number}><IconEditOutline16 /></span>
|
||||
<span>{t('option.custom')}</span>
|
||||
</button>
|
||||
)}
|
||||
{draft.customOpen && (
|
||||
{hasOptions
|
||||
? (
|
||||
<div className={clsx(css.customRow, draft.custom !== '' && css.customRowActive)}>
|
||||
{question.multiSelect === true
|
||||
? (
|
||||
<span
|
||||
className={clsx(css.checkbox, draft.custom !== '' && css.checkboxChecked)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{draft.custom !== '' && <IconCheckOutline14 size={12} />}
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<span className={css.number} aria-hidden="true">
|
||||
<IconEditOutline16 size={12} />
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
className={css.customInput}
|
||||
value={draft.custom}
|
||||
disabled={busy !== null}
|
||||
placeholder={t('custom.placeholder')}
|
||||
onChange={draftCustom}
|
||||
onKeyDown={continueFromCustom}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<textarea
|
||||
autoFocus
|
||||
className={css.customInput}
|
||||
className={css.customTextarea}
|
||||
value={draft.custom}
|
||||
disabled={busy !== null}
|
||||
rows={2}
|
||||
placeholder={t('custom.placeholder')}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value
|
||||
updateDraft(current => ({
|
||||
...current, selected: [], custom: value, customOpen: true, skipped: false,
|
||||
}))
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey && !isComposing(event)) {
|
||||
event.preventDefault()
|
||||
continueFlow()
|
||||
}
|
||||
}}
|
||||
onChange={draftCustom}
|
||||
onKeyDown={continueFromCustom}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className={css.footer}>
|
||||
<div className={css.feedback} role="status">{error === null ? null : 'key' in error ? t(error.key) : error.text}</div>
|
||||
<div className={css.pager}>
|
||||
<button
|
||||
type="button" className={css.iconButton} aria-label={t('nav.prev')}
|
||||
disabled={index === 0 || busy !== null}
|
||||
onClick={() => { setIndex(index - 1); setError(null) }}
|
||||
>
|
||||
<IconChevronLeftOutline14 />
|
||||
</button>
|
||||
<span className={css.progress}>{index + 1} / {questions.length}</span>
|
||||
<button
|
||||
type="button" className={css.iconButton} aria-label={t('nav.next')}
|
||||
disabled={index === questions.length - 1 || busy !== null}
|
||||
onClick={() => { setIndex(index + 1); setError(null) }}
|
||||
>
|
||||
<IconChevronRightOutline14 />
|
||||
</button>
|
||||
</div>
|
||||
<div className={css.feedback} role="status">
|
||||
{error === null ? null : 'key' in error ? t(error.key) : error.text}
|
||||
</div>
|
||||
<div className={css.footerActions}>
|
||||
<Button variant="ghost" size="sm" disabled={busy !== null} onClick={skipQuestion}>
|
||||
<Button variant="outline" disabled={busy !== null} onClick={skipQuestion}>
|
||||
{t('action.skip')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary" size="sm"
|
||||
variant="primary"
|
||||
disabled={busy !== null || !answered(draft)} onClick={continueFlow}
|
||||
>
|
||||
{busy === 'answer'
|
||||
|
||||
@@ -4,12 +4,10 @@
|
||||
export const zh = {
|
||||
'error.incomplete': '请先完成这道问题。',
|
||||
'error.unanswered': '请选择一个选项或填写自定义答案。',
|
||||
'title.multi': '可多选',
|
||||
'nav.prev': '上一题',
|
||||
'nav.next': '下一题',
|
||||
'nav.cancel': '放弃整组问题',
|
||||
'option.recommended': '推荐',
|
||||
'option.custom': '其他,请填写自定义答案',
|
||||
'custom.placeholder': '输入你的答案',
|
||||
'action.skip': '跳过本题',
|
||||
'action.next': '下一题',
|
||||
@@ -22,12 +20,10 @@ export type QuestionKey = keyof typeof zh
|
||||
export const en = {
|
||||
'error.incomplete': 'Please complete this question first.',
|
||||
'error.unanswered': 'Please select an option or enter a custom answer.',
|
||||
'title.multi': 'Multi-select',
|
||||
'nav.prev': 'Previous question',
|
||||
'nav.next': 'Next question',
|
||||
'nav.cancel': 'Dismiss all questions',
|
||||
'option.recommended': 'Recommended',
|
||||
'option.custom': 'Other — enter a custom answer',
|
||||
'custom.placeholder': 'Type your answer',
|
||||
'action.skip': 'Skip this question',
|
||||
'action.next': 'Next',
|
||||
|
||||
@@ -9,19 +9,22 @@ import type { RpcReceipt } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { PendingQuestion, type QuestionComposerProps } from '../src/client/contract/slots.ts'
|
||||
import {
|
||||
QuestionComposer, parseQuestionTitle, parseRecommendedLabel,
|
||||
} from '../src/client/QuestionComposer.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { QuestionComposer, parseRecommendedLabel } from '../src/client/QuestionComposer.tsx'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
/** Framework standard-kit stubs: the composer consumes none of them, the
|
||||
* composed props type mandates their delivery (framework hooks are plain
|
||||
* stubs per the client testing discipline). */
|
||||
/** Seat stub over a dictionary pair mirroring the real lookup chain: package dictionary, then common vocabulary, then the key. */
|
||||
const seatOver = (dict: Record<string, string>, common: Record<string, string>): QuestionComposerProps['t'] =>
|
||||
(key => dict[key] ?? common[key] ?? key)
|
||||
|
||||
/** Framework standard-kit stubs: the composer consumes only the locale seat;
|
||||
* the composed props type mandates delivery of the rest (framework hooks are
|
||||
* plain stubs per the client testing discipline). */
|
||||
const kit = {
|
||||
sessionId: SID,
|
||||
useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<ConversationSnapshot>,
|
||||
@@ -30,11 +33,8 @@ const kit = {
|
||||
useProjection: (() => undefined) as never,
|
||||
useInput: (() => { throw new Error('unused') }) as never,
|
||||
inputActions: { setDraft: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') } } as never,
|
||||
// The seat's key domain is question ∪ common; the stub mirrors the real
|
||||
// lookup chain: package dictionary, then common vocabulary, then the key.
|
||||
t: (key => (zh as Record<string, string>)[key]
|
||||
?? (commonZh as Record<string, string>)[key]
|
||||
?? key) as QuestionComposerProps['t'],
|
||||
// The seat's key domain is question ∪ common.
|
||||
t: seatOver(zh, commonZh),
|
||||
}
|
||||
|
||||
const QUESTIONS = [
|
||||
@@ -75,6 +75,7 @@ describe('QuestionComposer', () => {
|
||||
const { carrier, respond } = wait()
|
||||
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
|
||||
|
||||
expect(screen.getByText('偏好')).toBeTruthy()
|
||||
expect(screen.getByText('1 / 3')).toBeTruthy()
|
||||
expect(screen.getByText('推荐')).toBeTruthy()
|
||||
expect(screen.getByText('工程落地型')).toBeTruthy()
|
||||
@@ -96,9 +97,8 @@ describe('QuestionComposer', () => {
|
||||
fireEvent.keyDown(custom, { key: 'Enter' })
|
||||
|
||||
expect(screen.getByText('3 / 3')).toBeTruthy()
|
||||
expect(screen.getByText('选择重要信号')).toBeTruthy()
|
||||
expect(screen.getByText('可多选')).toBeTruthy()
|
||||
expect(screen.queryByText('(可多选)')).toBeNull()
|
||||
// The model's question text renders verbatim — no marker filtering.
|
||||
expect(screen.getByText('选择重要信号(可多选)')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
|
||||
@@ -175,11 +175,10 @@ describe('QuestionComposer', () => {
|
||||
expect(screen.getByText('3 / 3')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('opens custom input, reports missing skipped answers, and supports header navigation', () => {
|
||||
it('shows the inline custom input, reports missing answers, and supports pager navigation', () => {
|
||||
const { carrier, respond } = wait()
|
||||
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '其他,请填写自定义答案' }))
|
||||
expect(screen.getByPlaceholderText('输入你的答案')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('radio', { name: '工程落地型' }))
|
||||
const emptyCustom = screen.getByPlaceholderText('输入你的答案')
|
||||
@@ -240,6 +239,16 @@ describe('QuestionComposer', () => {
|
||||
expect(await screen.findByText('字符串错误')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders chrome copy through the English dictionary', () => {
|
||||
const respond = vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: true }))
|
||||
const carrier = new PendingWait(
|
||||
'question', RpcId('solo'), SID, { questions: [{ id: 'detail', question: '补充你的要求' }] }, respond)
|
||||
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} t={seatOver(en, commonEn)} />)
|
||||
expect(screen.getByLabelText('Dismiss all questions')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Skip this question' })).toBeTruthy()
|
||||
expect(screen.getByPlaceholderText('Type your answer')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('same-key carrier replacement (baseline replay) keeps drafts', () => {
|
||||
const first = wait('same-id')
|
||||
const view = render(<QuestionComposer matched={first.carrier} interactions={[first.carrier]} {...kit} />)
|
||||
@@ -295,11 +304,3 @@ describe('parseRecommendedLabel', () => {
|
||||
expect(parseRecommendedLabel('Plain')).toEqual({ label: 'Plain', recommended: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseQuestionTitle', () => {
|
||||
it('removes Chinese and ASCII multi-select suffixes', () => {
|
||||
expect(parseQuestionTitle('选择信号(可多选)')).toBe('选择信号')
|
||||
expect(parseQuestionTitle('选择信号 (可多选)')).toBe('选择信号')
|
||||
expect(parseQuestionTitle('选择信号')).toBe('选择信号')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -103,6 +103,11 @@
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.table tbody tr[data-request-only='true']:last-child td {
|
||||
/* Retain the lower half of the 16px boundary marker at the table's end. */
|
||||
height: 9px;
|
||||
}
|
||||
|
||||
.table tbody tr[data-request-only='true'] .turnRail {
|
||||
top: -15px;
|
||||
bottom: 0;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
.root {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
isolation: isolate;
|
||||
flex: none;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
user-select: none;
|
||||
|
||||
@@ -13,11 +13,17 @@
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
}
|
||||
|
||||
/* Under the active conversation column (`[data-conversation-scroll]`) the
|
||||
* parent owns overflow so the sticky composer stays in the same scrollport. */
|
||||
:global([data-conversation-scroll]) .root {
|
||||
overflow: visible;
|
||||
height: auto;
|
||||
/* Trajectory keeps the ledger and details panel inside the remaining
|
||||
* conversation height. Only the ledger pane scrolls; the composer remains
|
||||
* the fixed flex sibling below this view. */
|
||||
:global([data-conversation-scroll]):has(.root) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global([data-conversation-scroll]):has(.root) > :first-child {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ledger {
|
||||
|
||||
@@ -192,6 +192,13 @@
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.hoverPath {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: #CFD3D6;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.hoverTime {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Workspace browser tree row components (figma Cell set 14:3080): pure presentational —
|
||||
* all data and callbacks arrive via props. Hover swaps (folder->chevron,
|
||||
* time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only
|
||||
* except workspace Rename/Delete and session Rename; the session hover card is
|
||||
* suppressed while a menu is open.
|
||||
* except workspace Rename/Delete and session Rename; the session and workspace
|
||||
* hover cards are suppressed while a menu is open.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
@@ -30,10 +30,26 @@ const WORKSPACE_MENU_ITEMS = [
|
||||
{ id: 'delete', label: 'Delete workspace', icon: <IconTrashOutline16 />, danger: true },
|
||||
]
|
||||
|
||||
/** Hover-card body: workspace title, full directory path, absolute creation time. */
|
||||
function WorkspaceHoverContent({ label, cwd, createdAt }: {
|
||||
label: string
|
||||
cwd: string | undefined
|
||||
createdAt: number
|
||||
}) {
|
||||
return (
|
||||
<div className={css.hoverContent}>
|
||||
<div className={css.hoverTitle}>{label}</div>
|
||||
<div className={css.hoverPath}>{cwd}</div>
|
||||
<div className={css.hoverTime}>{`Created ${new Date(createdAt).toLocaleString()}`}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Project (workspace) header row: 54px, folder + title + session count;
|
||||
* hover reveals the chevron and create button. `containsCurrent` arrives on
|
||||
* the node (derivation fact, no renderer scan).
|
||||
* hover reveals the chevron and create button, and dwelling on a real
|
||||
* Workspace shows its hover card (the ungrouped bucket has none).
|
||||
* `containsCurrent` arrives on the node (derivation fact, no renderer scan).
|
||||
* @param props.group - derived group node.
|
||||
* @param props.onToggle - expand/collapse the group.
|
||||
* @param props.onCreate - start a frontend Session inside this Workspace.
|
||||
@@ -50,7 +66,7 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions }: {
|
||||
const active = group.expanded && group.containsCurrent
|
||||
const count = `${row.sessionCount} ${row.sessionCount === 1 ? 'session' : 'sessions'}`
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
return (
|
||||
const ownRow = (
|
||||
<div
|
||||
className={clsx(css.projectRow, menuOpen && css.menuOpen)}
|
||||
role="treeitem"
|
||||
@@ -107,6 +123,15 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions }: {
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
// The ungrouped bucket has no backing Workspace: no card to show.
|
||||
if (row.createdAt === undefined) return ownRow
|
||||
return (
|
||||
<HoverCard
|
||||
anchor={ownRow}
|
||||
content={<WorkspaceHoverContent label={row.label} cwd={row.cwd} createdAt={row.createdAt} />}
|
||||
disabled={menuOpen}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -31,6 +31,8 @@ export interface GroupNode {
|
||||
/** Backing Workspace id; absent only for the ungrouped bucket. */
|
||||
workspaceId: WorkspaceId | undefined
|
||||
cwd: string | undefined
|
||||
/** Workspace creation time (epoch ms); absent only for the ungrouped bucket. */
|
||||
createdAt: number | undefined
|
||||
label: string
|
||||
/** Total visible sessions in the group. */
|
||||
sessionCount: number
|
||||
@@ -52,6 +54,7 @@ interface Group {
|
||||
key: string
|
||||
workspaceId: WorkspaceId | undefined
|
||||
cwd: string | undefined
|
||||
createdAt: number | undefined
|
||||
label: string
|
||||
summaries: Map<SessionId, SessionSummary>
|
||||
roots: SessionId[]
|
||||
@@ -91,6 +94,7 @@ function buildGroup(
|
||||
key: string,
|
||||
workspaceId: WorkspaceId | undefined,
|
||||
cwd: string | undefined,
|
||||
createdAt: number | undefined,
|
||||
label: string,
|
||||
members: readonly SessionSummary[],
|
||||
order: 'account' | 'recency',
|
||||
@@ -142,7 +146,7 @@ function buildGroup(
|
||||
for (const m of members) {
|
||||
if (!reachable.has(m.id)) rootIds.push(m.id)
|
||||
}
|
||||
return { key, workspaceId, cwd, label, summaries, roots: rootIds, children }
|
||||
return { key, workspaceId, cwd, createdAt, label, summaries, roots: rootIds, children }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,7 +167,8 @@ function groupByWorkspace(list: SessionListState, workspaces: readonly Workspace
|
||||
members.push(summary)
|
||||
}
|
||||
groups.push(buildGroup(
|
||||
workspace.workspaceId, workspace.workspaceId, workspace.path, workspace.title, members, 'account',
|
||||
workspace.workspaceId, workspace.workspaceId, workspace.path,
|
||||
Date.parse(workspace.createdAt), workspace.title, members, 'account',
|
||||
))
|
||||
}
|
||||
const stray = list.ids
|
||||
@@ -171,7 +176,7 @@ function groupByWorkspace(list: SessionListState, workspaces: readonly Workspace
|
||||
.filter((s): s is SessionSummary =>
|
||||
s !== undefined && !accounted.has(s.id) && sessionVisible(s, list.current))
|
||||
if (stray.length > 0) {
|
||||
groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, UNGROUPED_LABEL, stray, 'recency'))
|
||||
groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, undefined, UNGROUPED_LABEL, stray, 'recency'))
|
||||
}
|
||||
return groups
|
||||
}
|
||||
@@ -267,6 +272,7 @@ export function deriveGroups(
|
||||
key: g.key,
|
||||
workspaceId: g.workspaceId,
|
||||
cwd: g.cwd,
|
||||
createdAt: g.createdAt,
|
||||
label: g.label,
|
||||
sessionCount: g.summaries.size,
|
||||
expanded,
|
||||
@@ -280,6 +286,7 @@ export function deriveGroups(
|
||||
key: g.key,
|
||||
workspaceId: g.workspaceId,
|
||||
cwd: g.cwd,
|
||||
createdAt: g.createdAt,
|
||||
label: g.label,
|
||||
sessionCount: g.summaries.size,
|
||||
expanded: visible.size > 0,
|
||||
|
||||
@@ -42,7 +42,7 @@ describe('workspace browser rows', () => {
|
||||
const onToggle = vi.fn()
|
||||
const onCreate = vi.fn()
|
||||
const group: GroupNode = {
|
||||
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project',
|
||||
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project',
|
||||
sessionCount: 1, expanded: true, containsCurrent: true, sessions: [],
|
||||
}
|
||||
render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={onCreate} />)
|
||||
@@ -103,7 +103,7 @@ describe('workspace browser rows', () => {
|
||||
const onDelete = vi.fn()
|
||||
const onToggle = vi.fn()
|
||||
const group: GroupNode = {
|
||||
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project',
|
||||
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project',
|
||||
sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
|
||||
}
|
||||
render(<ProjectRowItem
|
||||
@@ -128,9 +128,28 @@ describe('workspace browser rows', () => {
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
|
||||
it('workspace hover card shows title, directory path, and creation time after the dwell', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const group: GroupNode = {
|
||||
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project',
|
||||
sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
|
||||
}
|
||||
render(<ProjectRowItem group={group} onToggle={vi.fn()} onCreate={vi.fn()} />)
|
||||
fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
// Card body: full title + cwd + absolute creation time.
|
||||
expect(screen.getAllByText('Project')).toHaveLength(2)
|
||||
expect(screen.getByText('/projects/project')).toBeTruthy()
|
||||
expect(screen.getByText(/^Created /)).toBeTruthy()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('ungrouped bucket renders no workspace menu', () => {
|
||||
const group: GroupNode = {
|
||||
key: '', workspaceId: undefined, cwd: undefined, label: 'Ungrouped',
|
||||
key: '', workspaceId: undefined, cwd: undefined, createdAt: undefined, label: 'Ungrouped',
|
||||
sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
|
||||
}
|
||||
render(<ProjectRowItem group={group} onToggle={vi.fn()} onCreate={vi.fn()} />)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* React renderer for declarative slots. Per-entry bindings enforce child
|
||||
* authorization, and entry boundaries contain registrant failures.
|
||||
*/
|
||||
import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
|
||||
import { Component, useState, useSyncExternalStore, type FC, type ReactNode } from 'react'
|
||||
import {
|
||||
SlotOwnershipError, StaleAuthorizationError,
|
||||
type ChainRenderOpts, type HostObservable, type LocaleFace, type RenderOpts,
|
||||
@@ -366,15 +366,69 @@ function SessionEntry({ entry, ownerProps, info }: {
|
||||
return <Comp {...kit} {...injected} {...ownerProps} />
|
||||
}
|
||||
|
||||
function SessionMaybeEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: object }) {
|
||||
function SessionMaybeEntryBody({ entry, ownerProps, info }: {
|
||||
entry: StoredEntry
|
||||
ownerProps: object
|
||||
info: SessionMaybeProvideInfo
|
||||
}) {
|
||||
const host = useHost()
|
||||
const info = useSessionMaybeProvideInfo()
|
||||
const Comp = entry.component as FC<InjectedProps>
|
||||
const { kit, actions } = standardKit(host, entry, 'session-maybe', info)
|
||||
const injected = cachedSessionMaybeInject(entry, info, actions)
|
||||
return <Comp {...kit} {...injected} {...ownerProps} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Session-maybe identity: adoption — the ONLY behavior (there is no
|
||||
* hold-identity-forever mode). An incarnation born session-less ADOPTS the
|
||||
* first session that arrives: identity holds across that one transition
|
||||
* (undefined → first id), so a blank shell's DOM survives the moment a
|
||||
* session appears. From then on the entry behaves exactly like a strict
|
||||
* session entry: switching to a DIFFERENT session remounts (component-local
|
||||
* state must not leak between sessions), and dropping back to no-session
|
||||
* remounts into a fresh blank incarnation, which will adopt again.
|
||||
* Component-local per-session state therefore clears by construction; state
|
||||
* that must SURVIVE a switch belongs in session-bound sources (machine,
|
||||
* store, hooks) — the existing layering rule, now load-bearing.
|
||||
*/
|
||||
function SessionMaybeEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: object }) {
|
||||
const info = useSessionMaybeProvideInfo()
|
||||
// The child key is an incarnation counter, NOT the session id: adoption
|
||||
// must keep the key constant across undefined → first id. Bookkeeping
|
||||
// lives in this stable (unkeyed) wrapper via the render-phase setState
|
||||
// form (React's sanctioned derived-state pattern: setState during render
|
||||
// of the same component re-renders once before children mount, and the
|
||||
// guard conditions make it convergent — StrictMode-safe).
|
||||
const [state, setState] = useState<MaybeIncarnation>(FIRST_INCARNATION)
|
||||
let { adopted, epoch } = state
|
||||
if (info.sessionId !== undefined && adopted === undefined) {
|
||||
// Adoption: same epoch — no remount.
|
||||
adopted = info.sessionId
|
||||
setState({ adopted, epoch })
|
||||
} else if (adopted !== undefined && info.sessionId !== undefined && info.sessionId !== adopted) {
|
||||
// Post-adoption session switch: next incarnation, born already adopted.
|
||||
adopted = info.sessionId
|
||||
epoch += 1
|
||||
setState({ adopted, epoch })
|
||||
} else if (adopted !== undefined && info.sessionId === undefined) {
|
||||
// Back to no-session: next incarnation, born blank (adopts anew later).
|
||||
adopted = undefined
|
||||
epoch += 1
|
||||
setState({ adopted, epoch })
|
||||
}
|
||||
return <SessionMaybeEntryBody key={epoch} entry={entry} ownerProps={ownerProps} info={info} />
|
||||
}
|
||||
|
||||
/** Adoption bookkeeping of one session-maybe outlet (see SessionMaybeEntry). */
|
||||
interface MaybeIncarnation {
|
||||
/** Session this incarnation adopted; undefined while born blank and unadopted. */
|
||||
readonly adopted: string | undefined
|
||||
/** Incarnation counter — the child key; bumps exactly when an incarnation dies. */
|
||||
readonly epoch: number
|
||||
}
|
||||
|
||||
const FIRST_INCARNATION: MaybeIncarnation = { adopted: undefined, epoch: 0 }
|
||||
|
||||
function RootEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: object }) {
|
||||
const host = useHost()
|
||||
const Comp = entry.component as FC<InjectedProps>
|
||||
|
||||
@@ -117,9 +117,10 @@ const projectionHookCache = new WeakMap<SessionMaybeProvideInfo, (
|
||||
) => unknown>()
|
||||
|
||||
/**
|
||||
* Root-level binding provider. It follows current selection without a key, so
|
||||
* session-maybe entries retain their React identity while the context value
|
||||
* moves between absent and definite session bundles.
|
||||
* Root-level binding provider. It follows current selection without a key;
|
||||
* per-entry identity is the outlet's adoption bookkeeping (SessionMaybeEntry):
|
||||
* a blank-born incarnation adopts the first session without remounting, and
|
||||
* every later transition (switch or loss) remounts like a strict entry.
|
||||
*/
|
||||
export function SessionMaybeProvider({ children }: { children: ReactNode }) {
|
||||
const host = useHost()
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { act, fireEvent, render } from '@testing-library/react'
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import type { ActionsDecl, SlotEntryDef, SlotSpec, StoreHandle, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
@@ -863,3 +863,70 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
|
||||
expect(props['shared']).toBe('owner') // owner overrides inject
|
||||
})
|
||||
})
|
||||
|
||||
describe('session-maybe adoption identity', () => {
|
||||
const SINGLE_MAYBE: DeclaredSpec = { kind: 'single', scope: 'session-maybe' }
|
||||
|
||||
/** Mount a maybe entry that records its mount count and local state. */
|
||||
function mountMaybeCounter(h: Fake) {
|
||||
let mounts = 0
|
||||
const seen: { sessionId: string | undefined; mount: number }[] = []
|
||||
h.declare('k.maybe', SINGLE_MAYBE)
|
||||
h.add('k.maybe', {
|
||||
component: ({ sessionId }: { sessionId?: string }) => {
|
||||
// Local mount marker: useState initializer runs once per incarnation.
|
||||
const [mount] = useState(() => ++mounts)
|
||||
seen.push({ sessionId, mount })
|
||||
return <b>{`${sessionId ?? 'blank'}#${mount}`}</b>
|
||||
},
|
||||
})
|
||||
const { view } = mountRoot(h, { 'k.maybe': SINGLE_MAYBE }, renderSlot => renderSlot('k.maybe', {}))
|
||||
return { view, seen }
|
||||
}
|
||||
|
||||
it('adopts the first session: blank → first id keeps the incarnation (no remount)', () => {
|
||||
const h = makeHost()
|
||||
h.addSession('s1')
|
||||
const { view } = mountMaybeCounter(h)
|
||||
expect(view.container.textContent).toBe('blank#1')
|
||||
act(() => { h.current.set('s1') })
|
||||
// Same incarnation (#1): the blank shell adopted s1.
|
||||
expect(view.container.textContent).toBe('s1#1')
|
||||
})
|
||||
|
||||
it('remounts on a post-adoption session switch (local state must not leak across sessions)', () => {
|
||||
const h = makeHost()
|
||||
h.addSession('s1')
|
||||
h.addSession('s2')
|
||||
const { view } = mountMaybeCounter(h)
|
||||
act(() => { h.current.set('s1') })
|
||||
expect(view.container.textContent).toBe('s1#1')
|
||||
act(() => { h.current.set('s2') })
|
||||
// New incarnation (#2): strict-session behavior after adoption.
|
||||
expect(view.container.textContent).toBe('s2#2')
|
||||
})
|
||||
|
||||
it('remounts into a fresh blank incarnation on session loss, then adopts anew', () => {
|
||||
const h = makeHost()
|
||||
h.addSession('s1')
|
||||
h.addSession('s2')
|
||||
const { view } = mountMaybeCounter(h)
|
||||
act(() => { h.current.set('s1') })
|
||||
expect(view.container.textContent).toBe('s1#1')
|
||||
act(() => { h.current.set(undefined) })
|
||||
// The adopted incarnation dies with its session; blank state is fresh.
|
||||
expect(view.container.textContent).toBe('blank#2')
|
||||
act(() => { h.current.set('s2') })
|
||||
// The fresh blank adopts again — still incarnation #2, no flash.
|
||||
expect(view.container.textContent).toBe('s2#2')
|
||||
})
|
||||
|
||||
it('keeps the incarnation across a no-op republish of the same session', () => {
|
||||
const h = makeHost()
|
||||
h.addSession('s1')
|
||||
const { view } = mountMaybeCounter(h)
|
||||
act(() => { h.current.set('s1') })
|
||||
act(() => { h.current.set('s1') })
|
||||
expect(view.container.textContent).toBe('s1#1')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user