docs: state the Host-face rule for the browser e2e and settle the follow-ups
apps/web/tests/README.md records why these e2e type-check in the Host aggregate and why importing a Client package there pulls its project tree into the Host build graph, with mirroring as the standing answer. The Agent Note drops the directory-picker face split (assessed and declined) and the grep-level gate in favour of that README. docs: regenerate the catalogs and retarget the moved declarations The forwarded-event change moved three owner packages' cordis `Events` declarations and their branded types into client-safe `./types` modules, and the settings-scope split moves the shell spec into ui-settings-general. Point the type-equivalence manifest and the affected Agent Note at those homes, register the new `remote/*` event scope and the `ctx.settingsScope` service in the catalog partition, and re-run the generators. `$on` joins the documented `TypeRTClientRemote` surface, and the two Agent Note fences that quote a bare member signature are marked `ignore-check`: they are declaration fragments, not compilable units. refactor(client): make ui-settings the settings domain's base layer The settings-namespace transport lived in client/runtime, where every feature could value-import it because runtime is a platform module. It belongs to the settings domain, but moving it into ui-settings as a shared function fails twice: the client bundle purity gate forbids cross-plugin value imports, and ui-settings reached ui-sidebar for its shell, so any feature depending on it closed a cycle through ui-layout and ui-theme. Both halves move. `ctx.settingsScope` is now a cordis service — the collaboration shape the purity gate prescribes, and the service proxy binds `this.ctx` to the caller, so a bound scope's disposer belongs to the calling fiber. The shell ui-settings used to own (the `sidebar.settings` occupant, its navigation, and the nav-row projection) moves to ui-settings-general, which already owns the chrome and the General section. What stays in ui-settings is what carries no `ui-*` dependency: the scope service and the canonical settings slot types, `settings.general.item` included. That type was parked in the locale package precisely because the declarer was unreachable without a cycle; every registrant now depends on this base layer, so it comes home. The scope CONTRACT stays in client/runtime: a feature service accepts a scope through its own signature without depending on the surface that binds it. The forwarded settings invalidation replaces the deleted client-side `settings/changed` event, so the transport reads `ctx.remote.$on`. It reaches `$on` through the gateway's Client half plus the allowlist's type-only subpath rather than api-remotes' Client face: that face imports a Host-tsdown-generated artifact, and this package is reachable from the Host build graph through its callers. refactor(client): reach the settings transport through ctx.settingsScope Every feature that owns a preference row switches from value-importing a shared binder to the settings domain's service, and declares the two injections that binding needs: `settingsScope` for the transport and `remote` for the forwarded invalidation it subscribes to on the caller's own context. The rows stay with the features that own the preferences — Language with locale, Appearance with ui-theme, Composer Enter with ui-conversation. Only their route to the transport changes, so no settings surface moves and no feature gains a dependency on the shell. The `settings.general.item` slot type now arrives from ui-settings, the base layer every registrant already depends on, which retires the re-export outlet ui-theme kept and the parked declaration in the locale package. client/runtime drops its settings-form and schemastery dependencies with the transport that used them. test(client): bind the settings transport in the specs that boot a preference row Every bench that activates a plugin owning a preference row now supplies the two services that plugin injects: the forwarded-event port and the scope service. Specs that exercise no settings path get the minimal doubles; the ones that do drive their refresh chains through `remote/host-event`, the same signal client/runtime republishes from a forwarded frame, replacing the deleted client-side `settings/changed` event. Also fixes a publication defect the built-invariant gate catches once it runs: api-remotes' invariant companion shared the allowlist module with the package index, so rolldown hoisted it into a third chunk beside the two bundled entries — a file the mechanically derived publication list does not carry, leaving an installed companion unable to import it. The companion now reads the allowlist through this package's own published `./types` subpath, which the bundle keeps external, so each entry stays self-contained. The dynamic-subscription cast in apiproxy is gone: after the vendored cordis rescope, `on` accepts the rest-parameter handler directly, and the allowlist's shape assertion still carries the safety argument. fix(client): carry the settings-scope move across the release manifests Rebasing onto the publishable release set replaced every manifest's dependency block, so the packages this change touches restate their additions in the workspace-protocol form: the base layer's own transport dependencies, and the `ui-settings` plus `remote` edges each preference-row owner now needs. ui-settings-general takes clsx with the shell it received, and client/runtime drops the settings-form and schemastery dependencies that left with the transport. fix(api-gateway): give each $on subscription its own registration and containment Two defects in the forwarded-event subscription table, both raised in review: A set keyed on listener identity stored one entry when two callers subscribed the same function object to the same event, so the first frame reached it once instead of twice and either disposer silenced the surviving registration. Subscriptions are now records addressed by registration, which is what "the disposer belongs to the calling fiber" requires. A listener declared void may still be `async`, and the synchronous `try/catch` could not see its rejection: the promise was dropped and surfaced as an unhandled rejection outside the documented containment. Delivery now attaches a rejection handler when a listener returns a promise, so both failure modes are logged and isolated alike. Delivery also iterates a snapshot, so a listener that subscribes or disposes during a frame no longer changes who receives that frame, and production matches the TestRemote double instead of relying on live Set iteration order. Both fixes are pinned by tests that fail against the previous implementation. The double gains its own spec for the `$mount` refusal and the unsubscribed-name drop — per-file coverage reaches it — plus a note that it propagates a throwing listener where production contains one, so no spec mistakes it for the containment guarantee. Three prose corrections: `assertJsonArgs` states where its throw actually surfaces (the emitter's listener containment, not load or emit time), the browser e2e README names every standing Client import rather than claiming one exception, and two comments and a test title state the forwarded event instead of the deleted client-side one. refactor(remote): deliver forwarded frames through ctx.remote.$dispatch The carrier used to relay each decoded frame over an internal `remote/host-event` cordis event so the delivery port could stay off the Remote contract. The relay was the wrong shape twice over: it put a client-face event into a scan whose subject is the Host vocabulary, forcing a walk exemption for something that is not a Host event at all, and it made a direct handoff between two Client plugins look like a broadcast any plugin participates in. `TypeRTClientRemote` now carries both roles of one surface — consumers subscribe with `$on`, and whoever owns the Host frame sink hands frames over with `$dispatch` — so client/runtime calls the Remote service directly and the event declaration is gone. A cordis service method is the collaboration shape the client bundle purity gate prescribes, and it needs no relay to satisfy it. The trade is that the handoff is now developer-visible: any plugin holding `ctx.remote` can synthesize a forwarded event. That is the exposure the relay already had — `ctx.emit` was equally reachable — stated in the contract instead of hidden behind a private subscriber. runtime reaches `ctx.remote` through the gateway's Client face rather than api-remotes': that face imports a Host-tsdown-generated artifact, and this project sits in the Host build graph. refactor(api-remotes): keep the allowlist value out of types.ts `src/types.ts` carries only types by package convention, but it held the forwarded-event array, so the type-only subpath published runtime code. The array moves to `src/remote-events.ts` and `types.ts` derives its projection from it; both compiler faces list both files, so the Host forwarding loop and the consumer key face still read one declaration and the package's exports are unchanged. The invariant companion returns to an empty installer. Its dispatch-shape check was the only reason the companion imported the allowlist, which made the two bundled entries share a module: rolldown hoisted it into a third chunk that the mechanically derived publication list does not carry, so an installed companion could not import it. Dropping the check retires that coupling along with the subpath-import and bundle-external workarounds it needed, and the shape the check enforced at runtime is the part the Host face's `TypeRTForwardableEvent` assertion already refuses at compile time. test(ui-task): bind the locale plugin's new injections in its bench The bench boots the real locale plugin, which now injects the settings-scope service and the forwarded-event port, so it stayed pending and left `ctx.locale` undefined. Supplies both doubles like the other benches that boot a plugin owning a preference row. docs: close the documentation gates for the forwarded-event surface Regenerates the two graph catalogs and re-records every bilingual pair this branch edited. Several pairs needed real work beyond the record: - The generators write only the English side, so the Chinese sides of `event-producer-consumer` and `module-graph` had drifted: the former still listed the three deleted client-face events and pointed at declaration sites this branch moved into `types.ts` modules, and the latter carried a stale dependency graph. - `TypeRTClientRemote`'s documented declaration gains `$dispatch` on both sides. - The pairing contract requires both sides to link the same target, so the apiproxy README and the design note now link the English note from both languages, and the note's code blocks are byte-identical across the pair (a translated comment inside a fence counts as divergence). - `apps/web/tests/README.md` gains its Chinese counterpart; the browser e2e lane documents a discipline reviewers apply, so it belongs in the bilingual corpus rather than in the pairing exemption list. - Four fences in the design note are marked `ignore-check`: each quotes a member signature, a union arm, or a snippet that names symbols it does not import, so none is a compilable unit. docs(agent-note): transition the forwarded-event note to implemented The design shipped in this PR, so the pair moves into `implemented/` and takes that folder's skeleton: `## Proposal` becomes a present-tense `## Decision`, and `## Acceptance criteria` plus `## Risks` fold into `## Verification` (what pins the behavior) and `## Consequences` (what the shipped shape costs). Facts that moved after the proposal are corrected rather than preserved: the allowlist value now lives in `remote-events.ts` beside a type-only `types.ts`, the delivery port is `$dispatch` rather than an internal cordis event, and the invariant companion is an explained empty installer. `Verification` states the two `$on` defects the review found — independent registration identity and async-rejection containment — since those are now the properties tests pin. Supersession is partial, so five active notes stay active and gain a cross-link each: `web-config-plane`, `web-client-session-scope`, `config-plane-boundaries`, `versioned-gui-welcome-onboarding`, and `permission-default-for-new-sessions` each described a frame this change replaced. Only the mechanism sentence is annotated; every conclusion those notes own is untouched, and `host/models-changed` remains apiproxy's own derived frame in all of them. Also pins the disposer's idempotence: calling one `$on` disposer twice must not splice a surviving twin registration out from under its owner. fix: docs fix: test
This commit is contained in:
@@ -33,7 +33,9 @@
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-runtime"
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-settings",
|
||||
"@deepseek-ai/dsh-api-remotes"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
@@ -41,17 +43,22 @@
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { useState } from 'react'
|
||||
import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {} from './settings-contract.ts'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { createLanguageRowStore } from './settings-store.ts'
|
||||
import css from './LanguageRow.module.css'
|
||||
|
||||
|
||||
@@ -13,9 +13,11 @@ import type { Context } from '@deepseek-ai/cordis'
|
||||
import {
|
||||
type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
bindSettingsScope, type ClientContext, type SettingsScope,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ClientContext, SettingsScope } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: the ctx.settingsScope Context merge and the settings slot types.
|
||||
// Cross-plugin collaboration goes through the service, never a value import
|
||||
// (client bundle purity gate).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import {
|
||||
LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, type LocaleSettings,
|
||||
} from '../locale-settings.ts'
|
||||
@@ -29,7 +31,6 @@ import { createLanguageRowStore } from './settings-store.ts'
|
||||
|
||||
export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageRow.tsx'
|
||||
export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts'
|
||||
export type { SettingsGeneralItemOwnerProps } from './settings-contract.ts'
|
||||
export type { CommonKey } from '../locales/index.ts'
|
||||
export type { LocaleId, LocaleSettings } from '../locale-settings.ts'
|
||||
|
||||
@@ -343,7 +344,7 @@ function detectBrowserLocale(): LocaleId | undefined {
|
||||
}
|
||||
|
||||
/** Required services: slot registration plus the settings transport. */
|
||||
export const inject = ['slots', 'connection']
|
||||
export const inject = ['slots', 'connection', 'remote', 'settingsScope']
|
||||
|
||||
/**
|
||||
* Client plugin body: provide the locale service with base dictionaries and
|
||||
@@ -352,7 +353,7 @@ export const inject = ['slots', 'connection']
|
||||
* @param ctx - client cordis context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const host = bindSettingsScope<LocaleSettings>(ctx, { namespace: LOCALE_SETTINGS_NAMESPACE })
|
||||
const host = ctx.settingsScope.bind<LocaleSettings>({ namespace: LOCALE_SETTINGS_NAMESPACE })
|
||||
const locale = new LocaleService(ctx, host)
|
||||
locale.register(COMMON_NS, { zh, en })
|
||||
locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn })
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* The `settings.general.item` slot type — one preference row inside the
|
||||
* settings General section, contributed by the feature plugin that owns the
|
||||
* preference (locale → Language, ui-theme → Appearance). Options: `id` (row
|
||||
* key), `order` (row position). Rows draw their own internals (row layout,
|
||||
* separators via CSS); the section column only stacks them.
|
||||
*
|
||||
* TYPE HOME RATIONALE: the slot is declared at runtime by
|
||||
* ui-settings-general's General entry, but its type lives here — this
|
||||
* package is the common dependency of every item registrant (any settings
|
||||
* row carries copy, so every registrant already depends on locale), whereas
|
||||
* the declarer's own contract is unreachable for locale/ui-theme without a
|
||||
* reference cycle.
|
||||
*/
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
/** One preference row inside the settings General section (see module JSDoc). */
|
||||
'settings.general.item': { kind: 'list'; scope: 'root'; owner: SettingsGeneralItemOwnerProps }
|
||||
}
|
||||
}
|
||||
|
||||
/** Owner share of a General preference row (the section supplies nothing). */
|
||||
export interface SettingsGeneralItemOwnerProps {
|
||||
/** Marker field: item owner props are intentionally empty. */
|
||||
children?: never
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SettingsScopeService } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import {
|
||||
apply, inject, SETTINGS_NS,
|
||||
} from '@deepseek-ai/dsh-client-locale/client'
|
||||
@@ -43,6 +45,9 @@ async function bench() {
|
||||
}
|
||||
})
|
||||
ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback: true } as never)
|
||||
// The settings transport and the forwarded-event port the plugin injects.
|
||||
new TestRemote(ctx)
|
||||
await ctx.plugin(SettingsScopeService).await()
|
||||
return {
|
||||
ctx, slots: ctx.get('slots') as SlotsService, describe, mutate,
|
||||
setHostPreference: (next: string | undefined) => { preference = next; revision += 1 },
|
||||
@@ -79,7 +84,7 @@ describe('locale apply', () => {
|
||||
})
|
||||
|
||||
it('declares the slot service', () => {
|
||||
expect(inject).toEqual(['slots', 'connection'])
|
||||
expect(inject).toEqual(['slots', 'connection', 'remote', 'settingsScope'])
|
||||
})
|
||||
|
||||
it('provides the service with base + settings dictionaries and registers the row (declaration before or after apply)', async () => {
|
||||
@@ -134,10 +139,10 @@ describe('locale apply', () => {
|
||||
const locale = b.ctx.get('locale') as LocaleService
|
||||
await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') })
|
||||
b.setHostPreference(undefined)
|
||||
b.ctx.emit('settings/changed', LOCALE_SETTINGS_NAMESPACE)
|
||||
b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0])
|
||||
await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') })
|
||||
b.setHostPreference('en')
|
||||
b.ctx.emit('settings/changed', LOCALE_SETTINGS_NAMESPACE)
|
||||
b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0])
|
||||
await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') })
|
||||
expect(b.describe).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import { apply as clientApply, COMMON_NS, LocaleService, inject } from '@deepsee
|
||||
import * as LocaleInvariant from '@deepseek-ai/dsh-client-locale/invariant'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
|
||||
describe('invariant companion', () => {
|
||||
it('registers under the package name with an empty installer', async () => {
|
||||
@@ -20,10 +21,13 @@ describe('invariant companion', () => {
|
||||
|
||||
it('client apply provides ctx.locale seeded with the zh/en common namespace', async () => {
|
||||
// The feature registers its own Language settings row, hence the slots edge.
|
||||
expect(inject).toEqual(['slots', 'connection'])
|
||||
expect(inject).toEqual(['slots', 'connection', 'remote', 'settingsScope'])
|
||||
const ctx = new Context()
|
||||
new SlotsService(ctx)
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
// The settings row's transport and the forwarded-event port.
|
||||
ctx.provide('remote', { $on: () => () => {} } as never)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
await ctx.plugin({ inject, apply: clientApply }).await()
|
||||
const locale = ctx.get('locale')
|
||||
expect(locale).toBeInstanceOf(LocaleService)
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../ui-settings"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-typert-registry"
|
||||
"@deepseek-ai/dsh-typert-registry",
|
||||
"@deepseek-ai/dsh-api-gateway"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
@@ -44,7 +45,6 @@
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
@@ -59,19 +59,20 @@
|
||||
"zustand": "~4.4.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
"@types/react": "~18.3.1"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* The settings-namespace scope contract. The type lives here, in the common
|
||||
* dependency of every feature that owns a preference, while the implementation
|
||||
* and its Host transport live with the Settings surface
|
||||
* (`dsh-client-ui-settings`): a feature service accepts a scope through
|
||||
* `attachSettings` without depending on the surface that binds it, which would
|
||||
* otherwise close a reference cycle.
|
||||
*/
|
||||
|
||||
/** Client-side sync state of one settings namespace. */
|
||||
export interface SettingsScopeSnapshot<T> {
|
||||
/**
|
||||
* `loading` until the first accepted section, `ready` while one stands, and
|
||||
* `unavailable` when the namespace is not exposed to this client or the
|
||||
* connection keeps preferences process-local (memory mode).
|
||||
*/
|
||||
status: 'loading' | 'ready' | 'unavailable'
|
||||
/** Last accepted schema-resolved section; undefined before the first acceptance. */
|
||||
value: T | undefined
|
||||
/** Namespace revision fencing the next write; undefined before the first Host view. */
|
||||
revision: number | undefined
|
||||
/** Whether the Host document accepts writes; memory mode never does. */
|
||||
writable: boolean
|
||||
/** `host` syncs with the Host document; `memory` keeps a remote browser process-local. */
|
||||
mode: 'host' | 'memory'
|
||||
}
|
||||
|
||||
/** Domain-owned description of one settings namespace consumed by a browser plugin. */
|
||||
export interface SettingsScopeSpec<T> {
|
||||
/** Settings namespace registered by the owning Host plugin. */
|
||||
namespace: string
|
||||
/**
|
||||
* Narrow one wire section; undefined keeps the last accepted value. The
|
||||
* default validates the section against the namespace's own serialized wire
|
||||
* schema, so domains add a decoder only to narrow beyond that schema.
|
||||
*/
|
||||
decode?: (section: unknown) => T | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive owner handle over one namespace's durable section — the browser
|
||||
* mirror of the Host-side `SettingsScope` owner seam. Domain services read
|
||||
* and observe the snapshot and route explicit user choices through `set`.
|
||||
*/
|
||||
export interface SettingsScope<T> {
|
||||
/** @returns the current sync snapshot (stable reference until the next change). */
|
||||
getSnapshot(): SettingsScopeSnapshot<T>
|
||||
/**
|
||||
* Observe snapshot replacements.
|
||||
* @param listener - invoked after each snapshot change.
|
||||
* @returns the disposer removing this listener.
|
||||
*/
|
||||
subscribe(listener: () => void): () => void
|
||||
/**
|
||||
* Queue one field write. Rapid writes preserve mutation order, each carries
|
||||
* the latest known namespace revision, and only the latest settlement may
|
||||
* publish; a rejected or failed latest write reloads Host state instead.
|
||||
* @param field - scalar field inside the namespace section.
|
||||
* @param value - JSON-shaped value selected by the user.
|
||||
* @returns settlement after the write and any latest-write recovery read.
|
||||
*/
|
||||
set(field: string, value: unknown): Promise<void>
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
/** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Type-only: the ctx.remote merge. Deliberately the gateway's Client half rather
|
||||
// than api-remotes': that face imports a Host-tsdown-generated artifact, and this
|
||||
// project sits in the Host build graph.
|
||||
import type {} from '@deepseek-ai/dsh-api-gateway/client'
|
||||
import type { TypeRTContext } from '@deepseek-ai/dsh-type-meta'
|
||||
import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotsService } from './slots.ts'
|
||||
@@ -42,9 +46,12 @@ export type { SessionProvideChannelHost } from './sessions/provide.ts'
|
||||
export { createScope } from './agents/scope.ts'
|
||||
export type { AgentScopeHandle } from './agents/scope.ts'
|
||||
export { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts'
|
||||
export { bindSettingsScope, SettingsScopeController } from './settings-scope.ts'
|
||||
export type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from './settings-scope.ts'
|
||||
export { resolveWorkspacePath } from './workspaces/path.ts'
|
||||
// Contract only: the scope implementation and its Host transport belong to
|
||||
// dsh-client-ui-settings (see that package's settings-scope.ts).
|
||||
export type {
|
||||
SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec,
|
||||
} from './contract/settings-scope.ts'
|
||||
export type { Session } from './sessions/session.ts'
|
||||
export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts'
|
||||
export type { AgentContext, ISessions } from './contract/sessions.ts'
|
||||
@@ -190,7 +197,7 @@ declare module '@deepseek-ai/cordis' {
|
||||
}
|
||||
|
||||
/** Required services: the wire handle and Client TypeRT registry. */
|
||||
export const inject = ['connection', 'typert']
|
||||
export const inject = ['connection', 'typert', 'remote']
|
||||
|
||||
/** Mounts the browser runtime services and connection stream.
|
||||
* @param ctx - Client Cordis context.
|
||||
@@ -219,11 +226,11 @@ export function apply(ctx: Context): void {
|
||||
sessions.handleHostEnvelope(envelope)
|
||||
workspaces.handleHostEnvelope(envelope)
|
||||
// Forwarded-event bridge: the session layer ignores registry frames (no
|
||||
// session routing). This plugin only carries the frame onto the internal
|
||||
// `remote/host-event` plumbing event; the Remote service subscribes there
|
||||
// and fans out to `ctx.remote.$on`, so no consumer reads a frame.
|
||||
// session routing). This plugin owns the frame sink, so it hands the
|
||||
// decoded frame straight to the Remote service, which fans it out to
|
||||
// `ctx.remote.$on` subscribers; no consumer reads a frame.
|
||||
const frame = envelope.payload
|
||||
if (frame.type === 'host/remote-event') ctx.emit('remote/host-event', frame.event, frame.args)
|
||||
if (frame.type === 'host/remote-event') ctx.remote.$dispatch(frame.event, frame.args)
|
||||
else if (frame.type === 'host/session-preset-changed') {
|
||||
ctx.emit('session/preset-changed', frame.sessionId, frame.agentPreset)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Wire-to-typed-event bridge: a `host/remote-event` frame is republished
|
||||
* verbatim on the internal `remote/host-event` plumbing event (the Remote
|
||||
* service's fan-out to `ctx.remote.$on` is api-gateway's own coverage);
|
||||
* Wire-to-typed-event bridge: a `host/remote-event` frame is handed verbatim to
|
||||
* the Remote service's `$dispatch` (its fan-out to `ctx.remote.$on` is
|
||||
* api-gateway's own coverage);
|
||||
* host/session-preset-changed → ctx 'session/preset-changed';
|
||||
* `host/models-changed` still broadcasts the typed `models/changed`; each
|
||||
* established connection generation → ctx 'connection/reset' (the forced
|
||||
@@ -43,13 +43,20 @@ void forwardedEventContracts
|
||||
interface Bench {
|
||||
ctx: Context
|
||||
sinks: ConnectionSinks | undefined
|
||||
/** Every `$dispatch` the runtime made, as `[event, ...args]`. */
|
||||
dispatched: unknown[][]
|
||||
}
|
||||
|
||||
async function mount(): Promise<Bench> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
const api = new FakeApiClient()
|
||||
const bench: Bench = { ctx, sinks: undefined }
|
||||
const bench: Bench = { ctx, sinks: undefined, dispatched: [] }
|
||||
// Stands in for api-gateway's Remote service: this spec owns the carrier's
|
||||
// handoff, not the fan-out behind it.
|
||||
ctx.reflect.provide('remote', {
|
||||
$dispatch: (event: string, args: readonly unknown[]) => { bench.dispatched.push([event, ...args]) },
|
||||
})
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
isLoopback: true,
|
||||
@@ -69,8 +76,7 @@ async function mount(): Promise<Bench> {
|
||||
describe('wire event bridge', () => {
|
||||
it('republishes a forwarded host event verbatim, and routes no other host frame there', async () => {
|
||||
const bench = await mount()
|
||||
const seen: unknown[][] = []
|
||||
bench.ctx.on('remote/host-event', (event, args) => { seen.push([event, ...args]) })
|
||||
const seen = bench.dispatched
|
||||
bench.sinks?.onHostEnvelope?.({
|
||||
rpcId: 'r1' as never,
|
||||
payload: { type: 'host/remote-event', event: 'commands/change', args: [] },
|
||||
@@ -86,8 +92,7 @@ describe('wire event bridge', () => {
|
||||
|
||||
it('carries each forwarded event name with its own argument list, unfiltered', async () => {
|
||||
const bench = await mount()
|
||||
const seen: unknown[][] = []
|
||||
bench.ctx.on('remote/host-event', (event, args) => { seen.push([event, ...args]) })
|
||||
const seen = bench.dispatched
|
||||
|
||||
bench.sinks?.onHostEnvelope?.({
|
||||
rpcId: 'r3' as never,
|
||||
|
||||
@@ -23,9 +23,6 @@
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../schema-form"
|
||||
},
|
||||
{
|
||||
"path": "../../host/apiproxy"
|
||||
},
|
||||
@@ -61,6 +58,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../typert/registry"
|
||||
},
|
||||
{
|
||||
"path": "../../api/gateway"
|
||||
}
|
||||
],
|
||||
"exclude": [
|
||||
|
||||
@@ -7,29 +7,40 @@ import type { Context } from '@deepseek-ai/cordis'
|
||||
* host events to reach those subscribers, but not the generated namespaces or
|
||||
* the wire — so this double implements subscription and dispatch only.
|
||||
*
|
||||
* Dispatch is driven the same way production drives it: by the internal
|
||||
* `remote/host-event` event the connection sink emits. A spec therefore
|
||||
* exercises its refresh chains with `ctx.emit('remote/host-event', name, args)`,
|
||||
* the exact signal `client/runtime` republishes from a `host/remote-event`
|
||||
* frame, rather than reaching into this double.
|
||||
* Dispatch is driven the same way production drives it: `client/runtime` owns the
|
||||
* host frame sink and hands each decoded `host/remote-event` frame to
|
||||
* `$dispatch`. A spec therefore exercises its refresh chains by calling
|
||||
* `$dispatch(name, args)` on this double.
|
||||
*
|
||||
* `$mount` rejects: a spec that reaches a generated namespace through this
|
||||
* double has outgrown it and needs the real Client Remote service.
|
||||
*
|
||||
* One deliberate asymmetry with production: a throwing listener propagates out
|
||||
* of the emit instead of being contained and logged, so a spec cannot lean on
|
||||
* this double for the containment guarantee `$on` documents — assert that
|
||||
* against the real service.
|
||||
*/
|
||||
export class TestRemote {
|
||||
private readonly subscriptions = new Map<string, Set<(...args: never[]) => void>>()
|
||||
|
||||
/**
|
||||
* Register the double as `ctx.remote` and bind its dispatch to the plumbing event.
|
||||
* Register the double as `ctx.remote`.
|
||||
* @param ctx - the spec's root Context.
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
ctx.provide('remote', this)
|
||||
ctx.on('remote/host-event', (event, args) => {
|
||||
const listeners = this.subscriptions.get(event)
|
||||
if (listeners === undefined) return
|
||||
for (const listener of [...listeners]) listener(...args as never[])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver one forwarded host event to its subscribers, standing in for the
|
||||
* carrier that owns the frame sink.
|
||||
* @param event - forwarded host event name.
|
||||
* @param args - the Host argument list, verbatim.
|
||||
*/
|
||||
$dispatch(event: string, args: readonly unknown[]): void {
|
||||
const listeners = this.subscriptions.get(event)
|
||||
if (listeners === undefined) return
|
||||
for (const listener of [...listeners]) listener(...args as never[])
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
43
packages/client/test-runtime/tests/remote.spec.ts
Normal file
43
packages/client/test-runtime/tests/remote.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* TestRemote's own contract: subscription and disposal, dispatch driven by the
|
||||
* internal plumbing event, the silent drop for an unsubscribed name, and the
|
||||
* `$mount` refusal that sends a spec to the real Client Remote service.
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { TestRemote } from '../src/remote.ts'
|
||||
|
||||
describe('TestRemote', () => {
|
||||
it('delivers a forwarded event to its subscribers and stops after disposal', async () => {
|
||||
const ctx = new Context()
|
||||
const remote = new TestRemote(ctx)
|
||||
const seen: string[] = []
|
||||
const off = remote.$on('settings/document-updated', (ns: string) => {
|
||||
seen.push(ns)
|
||||
})
|
||||
|
||||
ctx.remote.$dispatch('settings/document-updated', ['ui-theme', 1])
|
||||
expect(seen).toEqual(['ui-theme'])
|
||||
|
||||
off()
|
||||
ctx.remote.$dispatch('settings/document-updated', ['ui-theme', 2])
|
||||
expect(seen).toEqual(['ui-theme'])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('drops a forwarded event nobody subscribed to', async () => {
|
||||
const ctx = new Context()
|
||||
new TestRemote(ctx)
|
||||
// No subscriber for this name: the emit must be inert rather than throwing,
|
||||
// because the wire carries whatever the Host allowlist selected.
|
||||
expect(() => { ctx.remote.$dispatch('credentials/updated', ['DEEPSEEK_API_KEY']) }).not.toThrow()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('refuses $mount, which needs the real Client Remote service', async () => {
|
||||
const ctx = new Context()
|
||||
const remote = new TestRemote(ctx)
|
||||
await expect(remote.$mount()).rejects.toThrow('needs the real Client Remote service')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -137,9 +137,10 @@ export class AgentPresetSectionController {
|
||||
/**
|
||||
* Called after this page changes the roster DIRECTORY, so the other
|
||||
* surfaces reading the same roster re-read it. A settings field moving is
|
||||
* already announced by the host through `settings/changed`; a directory
|
||||
* copied or deleted here is not, and the new-session chip has no other
|
||||
* way to learn a preset it should offer now exists.
|
||||
* already announced by the host through the forwarded
|
||||
* `settings/document-updated`; a directory copied or deleted here is not,
|
||||
* and the new-session chip has no other way to learn a preset it should
|
||||
* offer now exists.
|
||||
*/
|
||||
private readonly rosterChanged: () => void = () => {},
|
||||
) {}
|
||||
|
||||
@@ -79,7 +79,7 @@ async function bench() {
|
||||
const locale = new LocaleService(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
// The plugins inject `remote`; forwarded events reach them through the
|
||||
// same `remote/host-event` signal the connection sink republishes.
|
||||
// same `$dispatch` handoff the connection sink makes.
|
||||
new TestRemote(ctx)
|
||||
const calls: string[] = []
|
||||
ctx.provide('connection', {
|
||||
@@ -253,11 +253,11 @@ describe('ui-agent-preset apply', () => {
|
||||
await section.load()
|
||||
const before = calls.length
|
||||
|
||||
ctx.emit('remote/host-event', 'settings/document-updated', ['agent-presets', 1])
|
||||
ctx.remote.$dispatch('settings/document-updated', ['agent-presets', 1])
|
||||
await vi.waitFor(() => { expect(calls.length).toBe(before + 2) })
|
||||
const afterRelevant = calls.length
|
||||
|
||||
ctx.emit('remote/host-event', 'settings/document-updated', ['llm-deepseek', 1])
|
||||
ctx.remote.$dispatch('settings/document-updated', ['llm-deepseek', 1])
|
||||
await Promise.resolve()
|
||||
|
||||
// Both surfaces re-read on their own namespace; an unrelated one moves
|
||||
@@ -285,7 +285,7 @@ describe('ui-agent-preset apply', () => {
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const before = calls.length
|
||||
|
||||
ctx.emit('remote/host-event', 'settings/document-updated', ['agent-presets', 1])
|
||||
ctx.remote.$dispatch('settings/document-updated', ['agent-presets', 1])
|
||||
await vi.waitFor(() => { expect(calls.length).toBeGreaterThan(before) })
|
||||
|
||||
// Only the General row reloads: a section nobody opened has nothing to
|
||||
@@ -336,11 +336,11 @@ describe('ui-agent-preset apply', () => {
|
||||
// An unrelated namespace moves nothing: the chip re-reads on its own
|
||||
// setting, not on every settings write in the process.
|
||||
moveDefault()
|
||||
ctx.emit('remote/host-event', 'settings/document-updated', ['llm-deepseek', 1])
|
||||
ctx.remote.$dispatch('settings/document-updated', ['llm-deepseek', 1])
|
||||
await Promise.resolve()
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('standard')
|
||||
|
||||
ctx.emit('remote/host-event', 'settings/document-updated', ['agent-presets', 1])
|
||||
ctx.remote.$dispatch('settings/document-updated', ['agent-presets', 1])
|
||||
await vi.waitFor(() => {
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('minimal')
|
||||
})
|
||||
|
||||
@@ -80,7 +80,7 @@ async function bench(opts: BenchOptions = {}) {
|
||||
})
|
||||
ctx.provide('connection', { api })
|
||||
// CommandService injects `remote`; the directory invalidation arrives on the
|
||||
// same `remote/host-event` signal the connection sink republishes.
|
||||
// same `$dispatch` handoff the connection sink makes.
|
||||
new TestRemote(ctx)
|
||||
/** Notices the fake conversation face collected (runDetached routing). */
|
||||
const notices: Array<{ scope: SessionId | undefined; level: 'info' | 'error'; text: string }> = []
|
||||
@@ -602,7 +602,7 @@ describe('popupFor', () => {
|
||||
})
|
||||
|
||||
describe('directory invalidation events', () => {
|
||||
it('commands/changed repulls in the background while the old snapshot serves', async () => {
|
||||
it('commands/change repulls in the background while the old snapshot serves', async () => {
|
||||
let round = 0
|
||||
const { ctx, source, warm } = await bench({
|
||||
commands: () => {
|
||||
@@ -615,7 +615,7 @@ describe('directory invalidation events', () => {
|
||||
},
|
||||
})
|
||||
await warm(proj('s1'))
|
||||
ctx.emit('remote/host-event', 'commands/change', [])
|
||||
ctx.remote.$dispatch('commands/change', [])
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined()
|
||||
expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()
|
||||
|
||||
@@ -35,6 +35,8 @@
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-settings",
|
||||
"@deepseek-ai/dsh-api-remotes",
|
||||
"@deepseek-ai/dsh-client-ui-layout"
|
||||
],
|
||||
"platform": "web"
|
||||
@@ -51,47 +53,52 @@
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-plan-mode": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-permission": "workspace:^",
|
||||
"@deepseek-ai/dsh-plan-mode": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
bindSettingsScope, resolveWorkspacePath, type ISessions, type SessionId,
|
||||
resolveWorkspacePath, type ISessions, type SessionId,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: the ctx.settingsScope Context merge. Cross-plugin collaboration
|
||||
// goes through the service, never a value import (client bundle purity gate).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
@@ -46,7 +49,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
|
||||
/** Services required by the conversation plugin. */
|
||||
export const inject = [
|
||||
'slots', 'layout', 'sessions', 'workspaces', 'locale', 'connection',
|
||||
'slots', 'layout', 'sessions', 'workspaces', 'locale', 'connection', 'remote', 'settingsScope',
|
||||
'conversationEvents', 'conversationViews',
|
||||
]
|
||||
|
||||
@@ -128,7 +131,7 @@ export function apply(ctx: Context): void {
|
||||
// Apply-time construction keeps store identity bound to this fiber.
|
||||
const chatStore = createChatStore()
|
||||
const submissionPolicy = new ComposerSubmissionPolicy(
|
||||
bindSettingsScope<ConversationSettings>(ctx, { namespace: CONVERSATION_SETTINGS_NAMESPACE }),
|
||||
ctx.settingsScope.bind<ConversationSettings>({ namespace: CONVERSATION_SETTINGS_NAMESPACE }),
|
||||
)
|
||||
|
||||
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
// chat-toolview-slot.spec.tsx.
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -48,6 +48,9 @@ function sessionFakeFor() {
|
||||
async function bench() {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
// The plugin injects both; these specs exercise no settings path.
|
||||
runtime.provide('remote', { $on: () => () => {} })
|
||||
runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
const sessionFake = sessionFakeFor()
|
||||
await runtime.sessions.add({
|
||||
id: ROOT,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useState } from 'react'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply, inject, type EmptyWorkspaceOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
usePinnedBrowserLanguages('zh-CN')
|
||||
@@ -51,6 +51,9 @@ function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) {
|
||||
async function bench(opts?: { blank?: boolean }) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
// The plugin injects both; these specs exercise no settings path.
|
||||
runtime.provide('remote', { $on: () => () => {} })
|
||||
runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
@@ -76,6 +79,9 @@ describe('resident composer', () => {
|
||||
it('renders the locked view state while no session exists at all', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
// The plugin injects both; these specs exercise no settings path.
|
||||
runtime.provide('remote', { $on: () => () => {} })
|
||||
runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
@@ -103,6 +109,9 @@ describe('resident composer', () => {
|
||||
it('keeps the complete Hero tree mounted when the first Workspace session appears', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
// The plugin injects both; these specs exercise no settings path.
|
||||
runtime.provide('remote', { $on: () => () => {} })
|
||||
runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
@@ -169,6 +178,9 @@ describe('prompt rejection through the assembled composer', () => {
|
||||
it('renders the promptError alert strip and keeps the draft in the machine', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
// The plugin injects both; these specs exercise no settings path.
|
||||
runtime.provide('remote', { $on: () => () => {} })
|
||||
runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// entries. Tool composition belongs to ui-tool and its machinery spec.
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -22,6 +22,9 @@ const CHILD = 'child-1' as SessionId
|
||||
async function bench() {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
// The plugin injects both; these specs exercise no settings path.
|
||||
runtime.provide('remote', { $on: () => () => {} })
|
||||
runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
await runtime.sessions.add({ id: ROOT, summary: { title: 'R', displayTitle: 'R' } }, { current: false })
|
||||
await runtime.sessions.add(
|
||||
{ id: CHILD, summary: { title: 'C', displayTitle: 'C', parentId: ROOT } }, { current: false })
|
||||
|
||||
@@ -76,6 +76,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../interaction/permission"
|
||||
},
|
||||
{
|
||||
"path": "../ui-settings"
|
||||
}
|
||||
],
|
||||
"exclude": [
|
||||
|
||||
@@ -18,7 +18,7 @@ import type {
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { ProducedFiles } from '../src/client/ProducedFiles.tsx'
|
||||
import {
|
||||
basename, deliverablesDefinition, producedFileMentions, producedForClosing, selectProducedFiles,
|
||||
@@ -341,6 +341,9 @@ describe('plugin registration', () => {
|
||||
children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } },
|
||||
} as never, () => null)
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
// ui-theme's Appearance row binds a durable scope through these two.
|
||||
ctx.provide('remote', { $on: () => () => {} } as never)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await()
|
||||
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
// coverage gate still requires exercised.
|
||||
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
@@ -26,6 +27,9 @@ async function bench() {
|
||||
// seam for persistence; model this bench as a remote, memory-only browser.
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
// ui-theme's Appearance row binds a durable scope through these two.
|
||||
ctx.provide('remote', { $on: () => () => {} } as never)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
await ctx.plugin({ inject: themeInject, apply: themeApply }).await()
|
||||
await slotsFiber.await()
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService }
|
||||
|
||||
@@ -19,7 +19,7 @@ async function bench() {
|
||||
const locale = new LocaleService(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
// The plugins inject `remote`; forwarded events reach them through the
|
||||
// same `remote/host-event` signal the connection sink republishes.
|
||||
// same `$dispatch` handoff the connection sink makes.
|
||||
new TestRemote(ctx)
|
||||
// The apply path only captures the wire face; no call leaves this fake
|
||||
// until a section actually loads.
|
||||
@@ -138,8 +138,8 @@ describe('pushed invalidations', () => {
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
// The fake wire face has no methods: a fetch attempt would throw.
|
||||
b.ctx.emit('remote/host-event', 'settings/document-updated', ['llm-pi-ai', 1])
|
||||
b.ctx.emit('remote/host-event', 'credentials/updated', ['OPENAI_API_KEY'])
|
||||
b.ctx.remote.$dispatch('settings/document-updated', ['llm-pi-ai', 1])
|
||||
b.ctx.remote.$dispatch('credentials/updated', ['OPENAI_API_KEY'])
|
||||
b.ctx.emit('models/changed')
|
||||
b.ctx.emit('connection/reset')
|
||||
})
|
||||
@@ -170,7 +170,7 @@ describe('pushed invalidations', () => {
|
||||
)()
|
||||
injected.controller.store.update((state) => { state.status = 'ready' })
|
||||
const load = vi.spyOn(injected.controller, 'load').mockResolvedValue()
|
||||
b.ctx.emit('remote/host-event', 'credentials/updated', ['DEEPSEEK_API_KEY'])
|
||||
b.ctx.remote.$dispatch('credentials/updated', ['DEEPSEEK_API_KEY'])
|
||||
expect(load).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-command",
|
||||
"@deepseek-ai/dsh-api-remotes"
|
||||
"@deepseek-ai/dsh-api-remotes",
|
||||
"@deepseek-ai/dsh-client-ui-settings"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
@@ -55,6 +56,7 @@
|
||||
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-command": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
@@ -62,6 +64,7 @@
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
@@ -70,6 +73,7 @@
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-command": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
// Type-only: the settings slot types (this package registers a General row).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
// Type-only: pulls the ctx.remote merge and the forwarded-event key face
|
||||
// (the settings invalidation rides the allowlist) into this program.
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
|
||||
@@ -39,7 +39,7 @@ async function bench() {
|
||||
locale.setLocale('en')
|
||||
ctx.provide('locale', locale)
|
||||
// The plugin injects `remote`; forwarded events reach it through the same
|
||||
// `remote/host-event` signal the connection sink republishes.
|
||||
// `$dispatch` handoff the connection sink makes.
|
||||
new TestRemote(ctx)
|
||||
ctx.slots.register({
|
||||
name: 'root',
|
||||
@@ -162,8 +162,8 @@ describe('ui-permission browser plugin', () => {
|
||||
it('disposal removes the decoration (HMR safety)', async () => {
|
||||
const b = await bench()
|
||||
expect(b.decoration()).toBeDefined()
|
||||
b.ctx.emit('remote/host-event', 'settings/document-updated', ['another', 1])
|
||||
b.ctx.emit('remote/host-event', 'settings/document-updated', ['permission', 1])
|
||||
b.ctx.remote.$dispatch('settings/document-updated', ['another', 1])
|
||||
b.ctx.remote.$dispatch('settings/document-updated', ['permission', 1])
|
||||
b.ctx.emit('connection/reset')
|
||||
await b.fiber.dispose()
|
||||
expect(b.decoration()).toBeUndefined()
|
||||
|
||||
@@ -46,6 +46,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../ui-settings"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md
|
||||
README.md: ab27e073dc76335efc619f56365d1705007f7ef2
|
||||
README.zh.md: 16ff5604bee5425569b783e27a29699344f630e3
|
||||
README.md: 93bb2b04982a0e7bc8a248c1127428aec509773f
|
||||
README.zh.md: 5cf5da2f92a0b5a1f53f3306665c3c84381a0e85
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
"@deepseek-ai/dsh-client-ui-settings",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-api-remotes"
|
||||
"@deepseek-ai/dsh-api-remotes",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
@@ -48,19 +49,21 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
"@deepseek-ai/schemastery": "workspace:^",
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -71,11 +74,12 @@
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -16,7 +16,7 @@ import clsx from 'clsx'
|
||||
import {
|
||||
IconAgentPresetOutline16, IconCloseOutline16, IconDataOutline16, IconSettingsOutline16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts'
|
||||
import type { SettingsRootComponentProps, SettingsSectionRow } from './shell-contract.ts'
|
||||
import css from './SettingsRoot.module.css'
|
||||
|
||||
/** Nav glyph by section id; unknown ids fall back to the settings gear. */
|
||||
@@ -7,14 +7,21 @@
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
// Type-only: pulls the shell's SlotMap merges (trigger/header/section/item).
|
||||
// Type-only: the settings slot declarations plus the ctx.settingsScope Context
|
||||
// merge. Cross-plugin collaboration goes through the service, never a value
|
||||
// import (client bundle purity gate).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
// Type-only: pulls ctx.locale and the 'settings.general.item' SlotMap merge.
|
||||
// Type-only: pulls ctx.locale into this program.
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
// Type-only: pulls the ctx.remote merge and the forwarded-event key face
|
||||
// (the settings invalidation rides the allowlist) into this program.
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type {
|
||||
SettingsOnboardingStep, SettingsRootInjected, SettingsSectionRow,
|
||||
} from './shell-contract.ts'
|
||||
import { SettingsRoot } from './SettingsRoot.tsx'
|
||||
import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx'
|
||||
import { GeneralSection } from './GeneralSection.tsx'
|
||||
import { SettingsDocumentAction } from './SettingsDocumentAction.tsx'
|
||||
@@ -99,6 +106,77 @@ export function apply(ctx: ClientContext): void {
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'ui-settings-general: metadata invalidations')
|
||||
// The settings shell: this package occupies the sidebar-owned hole and
|
||||
// declares the settings slots. Ledger → nav-row projection as an observable
|
||||
// source (uSES contract: getSnapshot returns the cached rows until the
|
||||
// ledger version moves). Labels may be locale-following thunks, so the cache
|
||||
// key includes the locale revision and subscribers ride both sources.
|
||||
let rowsVersion = -1
|
||||
let rowsRevision = -1
|
||||
let rows: readonly SettingsSectionRow[] = []
|
||||
let onboardingVersion = -1
|
||||
let onboardingSteps: readonly SettingsOnboardingStep[] = []
|
||||
const shellInjected = (): SettingsRootInjected => ({
|
||||
hooks: {
|
||||
sections: {
|
||||
getSnapshot: () => {
|
||||
const version = ctx.slots.getVersion('settings.section')
|
||||
const revision = ctx.locale.getSnapshot().revision
|
||||
if (version !== rowsVersion || revision !== rowsRevision) {
|
||||
rowsVersion = version
|
||||
rowsRevision = revision
|
||||
rows = ctx.slots.entries('settings.section')
|
||||
.map(e => ({
|
||||
/* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */
|
||||
id: e.options.id ?? '',
|
||||
order: e.options.order ?? 0,
|
||||
label: resolveSlotLabel(e.options.label) ?? '',
|
||||
}))
|
||||
.sort((a, b) => a.order - b.order)
|
||||
}
|
||||
return rows
|
||||
},
|
||||
subscribe: (listener) => {
|
||||
const offLedger = ctx.slots.subscribe('settings.section', listener)
|
||||
const offLocale = ctx.locale.subscribe(listener)
|
||||
return () => {
|
||||
offLedger()
|
||||
offLocale()
|
||||
}
|
||||
},
|
||||
},
|
||||
onboardingSteps: {
|
||||
getSnapshot: () => {
|
||||
const version = ctx.slots.getVersion('settings.onboarding')
|
||||
if (version !== onboardingVersion) {
|
||||
onboardingVersion = version
|
||||
onboardingSteps = ctx.slots.entries('settings.onboarding')
|
||||
.map(e => ({
|
||||
/* v8 ignore next -- list-slot registration requires id */
|
||||
id: e.options.id ?? '',
|
||||
order: e.options.order ?? 0,
|
||||
}))
|
||||
.sort((a, b) => a.order - b.order)
|
||||
}
|
||||
return onboardingSteps
|
||||
},
|
||||
subscribe: listener => ctx.slots.subscribe('settings.onboarding', listener),
|
||||
},
|
||||
},
|
||||
})
|
||||
ctx.slots.inject('sidebar.settings', () => ctx.slots.register({
|
||||
name: 'sidebar.settings',
|
||||
children: {
|
||||
'settings.trigger': { kind: 'single', scope: 'root' },
|
||||
'settings.header': { kind: 'single', scope: 'root' },
|
||||
'settings.action': { kind: 'list', scope: 'root' },
|
||||
'settings.close': { kind: 'single', scope: 'root' },
|
||||
'settings.section': { kind: 'list', scope: 'root' },
|
||||
'settings.onboarding': { kind: 'list', scope: 'root' },
|
||||
},
|
||||
inject: shellInjected,
|
||||
}, SettingsRoot))
|
||||
|
||||
ctx.slots.inject('settings.trigger', () =>
|
||||
ctx.slots.register({ name: 'settings.trigger', locale: NS }, TriggerContent))
|
||||
ctx.slots.inject('settings.header', () =>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Settings shell contract — the types of the `sidebar.settings` occupant this
|
||||
* package renders. They live here rather than in ui-settings because they
|
||||
* reference the sidebar's own slot type: ui-settings is the settings domain's
|
||||
* base layer and must not depend on any `ui-*` presentation package, or the
|
||||
* reference graph closes a cycle through ui-sidebar → ui-layout → ui-theme.
|
||||
* The settings SLOT types (what registrants contribute) stay in ui-settings.
|
||||
*/
|
||||
import type { HostObservable, InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// Type-only: pulls ui-sidebar's SlotMap merge (the 'sidebar.settings' entry)
|
||||
// into every program that sees this contract.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
// Type-only: pulls the settings slot declarations the shell renders into.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
|
||||
/** One nav row projected from a settings.section registration's options. */
|
||||
export interface SettingsSectionRow {
|
||||
id: string
|
||||
order: number
|
||||
label: string
|
||||
}
|
||||
|
||||
/** One ordered onboarding step projected from a slot registration. */
|
||||
export interface SettingsOnboardingStep {
|
||||
id: string
|
||||
order: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Registrant-private injected share of the settings shell (assembled in
|
||||
* apply): the ledger's nav-row projection as a hooks-compartment source —
|
||||
* the shell reads no locale state and subscribes through the bound hook.
|
||||
*/
|
||||
export type SettingsRootInjected = {
|
||||
hooks: {
|
||||
/** settings.section ledger projected into ordered nav rows. */
|
||||
sections: HostObservable<readonly SettingsSectionRow[]>
|
||||
/** settings.onboarding ledger projected into coordinator order. */
|
||||
onboardingSteps: HostObservable<readonly SettingsOnboardingStep[]>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full component props of the settings shell root: the sidebar owner share
|
||||
* (wide/rail state) plus the declared render shares and the injected face
|
||||
* (hooks compartment bound to useSections). No store is registered — modal
|
||||
* open state and active section id are component-local viewing state.
|
||||
*/
|
||||
export type SettingsRootComponentProps =
|
||||
PropsRuntime<'sidebar.settings'>
|
||||
& PropsRenderSlots<
|
||||
| 'settings.trigger'
|
||||
| 'settings.header'
|
||||
| 'settings.action'
|
||||
| 'settings.close'
|
||||
| 'settings.section'
|
||||
| 'settings.onboarding'
|
||||
>
|
||||
& InjectFace<SettingsRootInjected>
|
||||
@@ -34,7 +34,7 @@ async function bench(isLoopback = true) {
|
||||
const locale = new LocaleService(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
// The plugins inject `remote`; forwarded events reach them through the
|
||||
// same `remote/host-event` signal the connection sink republishes.
|
||||
// same `$dispatch` handoff the connection sink makes.
|
||||
new TestRemote(ctx)
|
||||
const settingsDescribe = vi.fn(() => Promise.resolve({
|
||||
rpcId: 'settings-general' as never,
|
||||
@@ -170,9 +170,9 @@ describe('ui-settings-general apply', () => {
|
||||
const { controller } = (entry.inject as unknown as () => WelcomeNoticeInjected)()
|
||||
await controller.load()
|
||||
expect(b.settingsDescribe).toHaveBeenCalledOnce()
|
||||
b.ctx.emit('remote/host-event', 'settings/document-updated', ['unrelated', 1])
|
||||
b.ctx.remote.$dispatch('settings/document-updated', ['unrelated', 1])
|
||||
expect(b.settingsDescribe).toHaveBeenCalledOnce()
|
||||
b.ctx.emit('remote/host-event', 'settings/document-updated', [WELCOME_NOTICE_SETTINGS_NAMESPACE, 1])
|
||||
b.ctx.remote.$dispatch('settings/document-updated', [WELCOME_NOTICE_SETTINGS_NAMESPACE, 1])
|
||||
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) })
|
||||
b.ctx.emit('connection/reset')
|
||||
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(3) })
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { SettingsRootComponentProps } from '../src/client/contract/slots.ts'
|
||||
import type { SettingsRootComponentProps } from '../src/client/shell-contract.ts'
|
||||
import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
@@ -2,13 +2,26 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { SettingsRootInjected } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import type { SettingsRootInjected } from '../src/client/shell-contract.ts'
|
||||
import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
// Copy machinery the shell only reads a revision from; the real locale
|
||||
// plugin would drag its own settings-row dependencies into this bench.
|
||||
ctx.provide('locale', {
|
||||
register: () => () => {},
|
||||
bind: () => (key: string) => key,
|
||||
getSnapshot: () => ({ active: 'zh', locales: [], revision: 0 }),
|
||||
subscribe: () => () => {},
|
||||
} as never)
|
||||
ctx.provide('connection', {
|
||||
api: { settings: { describe: async () => ({ result: { ok: false } }) } },
|
||||
isLoopback: false,
|
||||
} as never)
|
||||
ctx.provide('remote', { $on: () => () => {} } as never)
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService }
|
||||
}
|
||||
|
||||
@@ -36,7 +49,7 @@ const CHILD_SPECS = {
|
||||
|
||||
describe('ui-settings apply', () => {
|
||||
it('declares only the slot registry (a pure composition face, no locale)', () => {
|
||||
expect(inject).toEqual(['slots'])
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote'])
|
||||
})
|
||||
|
||||
it('registers the shell and declares every child slot, before or after the declaration', async () => {
|
||||
@@ -63,13 +76,16 @@ describe('ui-settings apply', () => {
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const { sections } = injectedOf(b.slots).hooks
|
||||
// The shell ships no sections of its own — registrants fill the ledger.
|
||||
expect(sections.getSnapshot()).toEqual([])
|
||||
// This package registers the General section itself; every other section
|
||||
// arrives from a feature registrant.
|
||||
const GENERAL = { id: 'general', order: 0, label: 'general.nav' }
|
||||
expect(sections.getSnapshot()).toEqual([GENERAL])
|
||||
b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null)
|
||||
// No order and no label: both projection defaults apply.
|
||||
b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null)
|
||||
const rows = sections.getSnapshot()
|
||||
expect(rows).toEqual([
|
||||
GENERAL,
|
||||
{ id: 'a', order: 0, label: '' },
|
||||
{ id: 'z', order: 20, label: 'Z' },
|
||||
])
|
||||
@@ -94,6 +110,8 @@ describe('ui-settings apply', () => {
|
||||
b.slots.register({ name: 'settings.onboarding', id: 'default-order' } as never, () => null)
|
||||
const steps = onboardingSteps.getSnapshot()
|
||||
expect(steps).toEqual([
|
||||
// This package's own onboarding page, registered by the same apply.
|
||||
{ id: 'welcome-notice', order: -100 },
|
||||
{ id: 'welcome', order: -100 },
|
||||
{ id: 'credential', order: 0 },
|
||||
{ id: 'default-order', order: 0 },
|
||||
@@ -40,6 +40,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../ui-sidebar"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-settings",
|
||||
"description": "Settings shell plugin: sidebar trigger, modal panel, feature sections, and an ordered full-page onboarding stage",
|
||||
"description": "Settings domain base plugin: the settings-namespace scope service and the canonical settings slot-type contract",
|
||||
"version": "0.0.1-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
@@ -32,8 +32,9 @@
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar"
|
||||
"@deepseek-ai/dsh-api-remotes"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
@@ -43,30 +44,32 @@
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react-dom": "~18.3.0",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
@@ -7,10 +7,7 @@
|
||||
* setting never means editing the shell; copy that belongs to no single
|
||||
* feature (chrome, the General section) is owned by ui-settings-general.
|
||||
*/
|
||||
import type { HostObservable, InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// Type-only: pulls ui-sidebar's SlotMap merge (the 'sidebar.settings' entry)
|
||||
// into every program that sees this contract.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
@@ -66,9 +63,26 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* would render without mask or stage).
|
||||
*/
|
||||
'settings.onboarding': { kind: 'list'; scope: 'root'; owner: SettingsOnboardingOwnerProps }
|
||||
/**
|
||||
* One preference row inside the General section, contributed by the
|
||||
* feature plugin that owns the preference (locale → Language, ui-theme →
|
||||
* Appearance, ui-conversation → Composer Enter). Options: `id` (row key),
|
||||
* `order` (row position). Rows draw their own internals; the section
|
||||
* column only stacks them. Declared at runtime by ui-settings-general's
|
||||
* General entry — the type lives here with every other settings slot type,
|
||||
* because this package is the settings domain's base layer and every
|
||||
* registrant already depends on it for `ctx.settingsScope`.
|
||||
*/
|
||||
'settings.general.item': { kind: 'list'; scope: 'root'; owner: SettingsGeneralItemOwnerProps }
|
||||
}
|
||||
}
|
||||
|
||||
/** Owner share of a General preference row (the section supplies nothing). */
|
||||
export interface SettingsGeneralItemOwnerProps {
|
||||
/** Marker field: item owner props are intentionally empty. */
|
||||
children?: never
|
||||
}
|
||||
|
||||
/** Owner share of the trigger content seat: the sidebar column state. */
|
||||
export interface SettingsTriggerOwnerProps {
|
||||
/** Whether the sidebar renders wide content (false = 56px rail, icon only). */
|
||||
@@ -103,47 +117,3 @@ export interface SettingsOnboardingOwnerProps {
|
||||
openSection: (id: string) => void
|
||||
}
|
||||
|
||||
/** One nav row projected from a settings.section registration's options. */
|
||||
export interface SettingsSectionRow {
|
||||
id: string
|
||||
order: number
|
||||
label: string
|
||||
}
|
||||
|
||||
/** One ordered onboarding step projected from a slot registration. */
|
||||
export interface SettingsOnboardingStep {
|
||||
id: string
|
||||
order: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Registrant-private injected share of the settings shell (assembled in
|
||||
* apply): the ledger's nav-row projection as a hooks-compartment source —
|
||||
* the shell reads no locale state and subscribes through the bound hook.
|
||||
*/
|
||||
export type SettingsRootInjected = {
|
||||
hooks: {
|
||||
/** settings.section ledger projected into ordered nav rows. */
|
||||
sections: HostObservable<readonly SettingsSectionRow[]>
|
||||
/** settings.onboarding ledger projected into coordinator order. */
|
||||
onboardingSteps: HostObservable<readonly SettingsOnboardingStep[]>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full component props of the settings shell root: the sidebar owner share
|
||||
* (wide/rail state) plus the declared render shares and the injected face
|
||||
* (hooks compartment bound to useSections). No store is registered — modal
|
||||
* open state and active section id are component-local viewing state.
|
||||
*/
|
||||
export type SettingsRootComponentProps =
|
||||
PropsRuntime<'sidebar.settings'>
|
||||
& PropsRenderSlots<
|
||||
| 'settings.trigger'
|
||||
| 'settings.header'
|
||||
| 'settings.action'
|
||||
| 'settings.close'
|
||||
| 'settings.section'
|
||||
| 'settings.onboarding'
|
||||
>
|
||||
& InjectFace<SettingsRootInjected>
|
||||
|
||||
@@ -1,111 +1,32 @@
|
||||
/**
|
||||
* Settings shell plugin, browser half. A pure composition face: occupies the
|
||||
* sidebar-owned `sidebar.settings` hole with the trigger chrome + modal
|
||||
* panel, declares its chrome, section, and onboarding slots, and projects the
|
||||
* section ledger into panel navigation. The shell ships no copy; it reads the
|
||||
* optional locale revision only to resolve registrant-owned nav-label thunks.
|
||||
* ui-settings-general owns the chrome and General content; features own their
|
||||
* rows, sections, and onboarding pages. Export discipline: packages/client/AGENTS.md.
|
||||
* Settings domain base plugin, browser half. Provides `ctx.settingsScope`, the
|
||||
* settings-namespace Host transport every preference row binds its durable
|
||||
* section through, and owns the canonical slot-type contract for the settings
|
||||
* surface. It depends on no `ui-*` presentation package, so any feature that
|
||||
* owns a preference can reach it: the settings SHELL — the `sidebar.settings`
|
||||
* occupant, its navigation, and the chrome — lives in ui-settings-general,
|
||||
* because a shell dependency on ui-sidebar would close a reference cycle
|
||||
* through ui-layout and ui-theme. Export discipline: packages/client/AGENTS.md.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: the ctx.locale Context merge for the optional ctx.get('locale')
|
||||
// read (nav labels may be locale-following thunks; the shell still ships no
|
||||
// copy of its own and takes no hard locale dependency).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {
|
||||
SettingsOnboardingStep, SettingsRootInjected, SettingsSectionRow,
|
||||
} from './contract/slots.ts'
|
||||
import { SettingsRoot } from './SettingsRoot.tsx'
|
||||
import { SettingsScopeService } from './settings-scope.ts'
|
||||
|
||||
export type {
|
||||
SettingsHeaderOwnerProps, SettingsRootComponentProps, SettingsRootInjected,
|
||||
SettingsOnboardingOwnerProps, SettingsOnboardingStep, SettingsSectionOwnerProps,
|
||||
SettingsSectionRow, SettingsTriggerOwnerProps,
|
||||
SettingsGeneralItemOwnerProps, SettingsHeaderOwnerProps, SettingsOnboardingOwnerProps,
|
||||
SettingsSectionOwnerProps, SettingsTriggerOwnerProps,
|
||||
} from './contract/slots.ts'
|
||||
export { SettingsScopeController, SettingsScopeService } from './settings-scope.ts'
|
||||
|
||||
/**
|
||||
* Required services (cordis fiber inject). The target slot is declared by
|
||||
* ui-sidebar's apply, whose activation order relative to this one is NOT
|
||||
* constrained (dsh.client.inject edges are informational); registration
|
||||
* depends on the slot through `slots.inject()`.
|
||||
* Required services: none. The transport is resolved per caller through
|
||||
* `this.ctx` at `bind` time, so this plugin waits for nothing.
|
||||
*/
|
||||
export const inject = ['slots']
|
||||
export const inject = []
|
||||
|
||||
/**
|
||||
* Register the settings shell into `sidebar.settings` once the declaration is
|
||||
* on the ledger.
|
||||
* Provide the settings-namespace scope service.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
// Ledger → nav-row projection as an observable source (uSES contract:
|
||||
// getSnapshot returns the cached rows until the ledger version moves).
|
||||
// Labels may be locale-following thunks, so the cache key includes the
|
||||
// locale revision and subscribers ride both sources.
|
||||
let rowsVersion = -1
|
||||
let rowsRevision = -1
|
||||
let rows: readonly SettingsSectionRow[] = []
|
||||
let onboardingVersion = -1
|
||||
let onboardingSteps: readonly SettingsOnboardingStep[] = []
|
||||
const localeRevision = (): number => ctx.get('locale')?.getSnapshot().revision ?? 0
|
||||
const injected = (): SettingsRootInjected => ({
|
||||
hooks: {
|
||||
sections: {
|
||||
getSnapshot: () => {
|
||||
const version = ctx.slots.getVersion('settings.section')
|
||||
const revision = localeRevision()
|
||||
if (version !== rowsVersion || revision !== rowsRevision) {
|
||||
rowsVersion = version
|
||||
rowsRevision = revision
|
||||
rows = ctx.slots.entries('settings.section')
|
||||
.map(e => ({
|
||||
/* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */
|
||||
id: e.options.id ?? '',
|
||||
order: e.options.order ?? 0,
|
||||
label: resolveSlotLabel(e.options.label) ?? '',
|
||||
}))
|
||||
.sort((a, b) => a.order - b.order)
|
||||
}
|
||||
return rows
|
||||
},
|
||||
subscribe: (listener) => {
|
||||
const offLedger = ctx.slots.subscribe('settings.section', listener)
|
||||
const offLocale = ctx.get('locale')?.subscribe(listener)
|
||||
return () => {
|
||||
offLedger()
|
||||
offLocale?.()
|
||||
}
|
||||
},
|
||||
},
|
||||
onboardingSteps: {
|
||||
getSnapshot: () => {
|
||||
const version = ctx.slots.getVersion('settings.onboarding')
|
||||
if (version !== onboardingVersion) {
|
||||
onboardingVersion = version
|
||||
onboardingSteps = ctx.slots.entries('settings.onboarding')
|
||||
.map(e => ({
|
||||
/* v8 ignore next -- list-slot registration requires id */
|
||||
id: e.options.id ?? '',
|
||||
order: e.options.order ?? 0,
|
||||
}))
|
||||
.sort((a, b) => a.order - b.order)
|
||||
}
|
||||
return onboardingSteps
|
||||
},
|
||||
subscribe: listener => ctx.slots.subscribe('settings.onboarding', listener),
|
||||
},
|
||||
},
|
||||
})
|
||||
ctx.slots.inject('sidebar.settings', () => ctx.slots.register({
|
||||
name: 'sidebar.settings',
|
||||
children: {
|
||||
'settings.trigger': { kind: 'single', scope: 'root' },
|
||||
'settings.header': { kind: 'single', scope: 'root' },
|
||||
'settings.action': { kind: 'list', scope: 'root' },
|
||||
'settings.close': { kind: 'single', scope: 'root' },
|
||||
'settings.section': { kind: 'list', scope: 'root' },
|
||||
'settings.onboarding': { kind: 'list', scope: 'root' },
|
||||
},
|
||||
inject: injected,
|
||||
}, SettingsRoot))
|
||||
ctx.provide('settingsScope', new SettingsScopeService(ctx))
|
||||
}
|
||||
|
||||
@@ -1,66 +1,36 @@
|
||||
/** Host-backed settings-namespace synchronization for browser plugins. */
|
||||
/**
|
||||
* Host transport for the settings-namespace scope contract. The contract types
|
||||
* live in `dsh-client-runtime` (the common dependency of every feature that
|
||||
* owns a preference); this file owns the wire behavior and the invalidation
|
||||
* subscription, both of which are Settings-surface concerns.
|
||||
*/
|
||||
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ConnectionHandle, IApiClient, SettingsNamespaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { rehydrateSchema, validateDraft } from '@deepseek-ai/dsh-client-schema-form'
|
||||
import { createSnapshotStore, type SnapshotStore } from './contract/store.ts'
|
||||
|
||||
/** Client-side sync state of one settings namespace. */
|
||||
export interface SettingsScopeSnapshot<T> {
|
||||
/**
|
||||
* `loading` until the first accepted section, `ready` while one stands, and
|
||||
* `unavailable` when the namespace is not exposed to this client or the
|
||||
* connection keeps preferences process-local (memory mode).
|
||||
*/
|
||||
status: 'loading' | 'ready' | 'unavailable'
|
||||
/** Last accepted schema-resolved section; undefined before the first acceptance. */
|
||||
value: T | undefined
|
||||
/** Namespace revision fencing the next write; undefined before the first Host view. */
|
||||
revision: number | undefined
|
||||
/** Whether the Host document accepts writes; memory mode never does. */
|
||||
writable: boolean
|
||||
/** `host` syncs with the Host document; `memory` keeps a remote browser process-local. */
|
||||
mode: 'host' | 'memory'
|
||||
}
|
||||
|
||||
/** Domain-owned description of one settings namespace consumed by a browser plugin. */
|
||||
export interface SettingsScopeSpec<T> {
|
||||
/** Settings namespace registered by the owning Host plugin. */
|
||||
namespace: string
|
||||
/**
|
||||
* Narrow one wire section; undefined keeps the last accepted value. The
|
||||
* default validates the section against the namespace's own serialized wire
|
||||
* schema, so domains add a decoder only to narrow beyond that schema.
|
||||
*/
|
||||
decode?: (section: unknown) => T | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive owner handle over one namespace's durable section — the browser
|
||||
* mirror of the Host-side `SettingsScope` owner seam. Domain services read
|
||||
* and observe the snapshot and route explicit user choices through `set`.
|
||||
*/
|
||||
export interface SettingsScope<T> {
|
||||
/** @returns the current sync snapshot (stable reference until the next change). */
|
||||
getSnapshot(): SettingsScopeSnapshot<T>
|
||||
/**
|
||||
* Observe snapshot replacements.
|
||||
* @param listener - invoked after each snapshot change.
|
||||
* @returns the disposer removing this listener.
|
||||
*/
|
||||
subscribe(listener: () => void): () => void
|
||||
/**
|
||||
* Queue one field write. Rapid writes preserve mutation order, each carries
|
||||
* the latest known namespace revision, and only the latest settlement may
|
||||
* publish; a rejected or failed latest write reloads Host state instead.
|
||||
* @param field - scalar field inside the namespace section.
|
||||
* @param value - JSON-shaped value selected by the user.
|
||||
* @returns settlement after the write and any latest-write recovery read.
|
||||
*/
|
||||
set(field: string, value: unknown): Promise<void>
|
||||
}
|
||||
import {
|
||||
createSnapshotStore, type SettingsScope, type SettingsScopeSnapshot,
|
||||
type SettingsScopeSpec, type SnapshotStore,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only, and deliberately NOT `@deepseek-ai/dsh-api-remotes/client`: this
|
||||
// package is reachable from the Host build graph through its feature-package
|
||||
// callers, and api-remotes' Client face imports a Host-tsdown-generated
|
||||
// `/remote` artifact, which would deadlock the Host tsc phase. The gateway's
|
||||
// Client half declares `ctx.remote` with no generated import, and the
|
||||
// allowlist's `types` subpath is a pure-type source file, so the pair supplies
|
||||
// `$on` and its key face without dragging a build artifact in. The runtime
|
||||
// `remote` injection belongs to whoever calls bindSettingsScope: the
|
||||
// subscription is registered on the caller's own context.
|
||||
import type {} from '@deepseek-ai/dsh-api-gateway/client'
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/types'
|
||||
// The forwarded event's own declaration: `$on`'s key face is
|
||||
// `Extract<keyof Events, keyof Selection>`, so the allowlist alone resolves to
|
||||
// never — the owning package's client-safe, type-only subpath supplies the
|
||||
// cordis `Events` entry (and with it the branded `SettingsNamespace`).
|
||||
import type {} from '@deepseek-ai/dsh-settings/types'
|
||||
|
||||
type SettingsFace = Pick<IApiClient, 'settings'>
|
||||
|
||||
@@ -224,38 +194,60 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind one namespace scope to settings and connection invalidations on the
|
||||
* caller's plugin lifecycle. Listeners exist before the initial background
|
||||
* read starts, so activation never blocks on the settings transport.
|
||||
* @param ctx - owning browser plugin context.
|
||||
* @param spec - domain-owned namespace contract.
|
||||
* @returns the bound scope consumed by the domain's services and rows.
|
||||
*/
|
||||
export function bindSettingsScope<T>(
|
||||
ctx: Context,
|
||||
spec: SettingsScopeSpec<T>,
|
||||
): SettingsScope<T> {
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const controller = new SettingsScopeController<T>(
|
||||
connection.api,
|
||||
spec,
|
||||
connection.isLoopback ? 'host' : 'memory',
|
||||
)
|
||||
ctx.effect(() => {
|
||||
const refresh = (namespace?: string): void => {
|
||||
if (namespace !== undefined && namespace !== spec.namespace) return
|
||||
void controller.load()
|
||||
}
|
||||
const disposers = [
|
||||
ctx.on('settings/changed', refresh),
|
||||
ctx.on('connection/reset', () => { refresh() }),
|
||||
]
|
||||
void controller.load()
|
||||
return async () => {
|
||||
for (const dispose of disposers) dispose()
|
||||
await controller.dispose()
|
||||
}
|
||||
}, `runtime: ${spec.namespace} settings scope`)
|
||||
return controller
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
settingsScope: SettingsScopeService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings domain's base service. Features that own a preference reach the
|
||||
* settings transport through this service rather than a shared function: the
|
||||
* client bundle purity gate forbids cross-plugin value imports and directs
|
||||
* cross-plugin collaboration through cordis services
|
||||
* (`packages/client/tsdown.client.ts`).
|
||||
*/
|
||||
export class SettingsScopeService extends Service {
|
||||
/**
|
||||
* @param ctx - the providing plugin's context.
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'settingsScope')
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind one namespace scope to settings and connection invalidations on the
|
||||
* CALLER's plugin lifecycle — the service proxy binds `this.ctx` to the
|
||||
* caller at call time, so the scope's disposer belongs to the calling fiber.
|
||||
* Listeners exist before the initial background read starts, so activation
|
||||
* never blocks on the settings transport. The caller injects `connection`
|
||||
* for the transport and `remote` for the forwarded settings invalidation.
|
||||
* @param spec - domain-owned namespace contract.
|
||||
* @returns the bound scope consumed by the domain's services and rows.
|
||||
*/
|
||||
bind<T>(spec: SettingsScopeSpec<T>): SettingsScope<T> {
|
||||
const ctx = this.ctx
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const controller = new SettingsScopeController<T>(
|
||||
connection.api,
|
||||
spec,
|
||||
connection.isLoopback ? 'host' : 'memory',
|
||||
)
|
||||
ctx.effect(() => {
|
||||
const refresh = (namespace?: string): void => {
|
||||
if (namespace !== undefined && namespace !== spec.namespace) return
|
||||
void controller.load()
|
||||
}
|
||||
const disposers = [
|
||||
ctx.remote.$on('settings/document-updated', refresh),
|
||||
ctx.on('connection/reset', () => { refresh() }),
|
||||
]
|
||||
void controller.load()
|
||||
return async () => {
|
||||
for (const dispose of disposers) dispose()
|
||||
await controller.dispose()
|
||||
}
|
||||
}, `ui-settings: ${spec.namespace} settings scope`)
|
||||
return controller
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@ import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import {
|
||||
bindSettingsScope, SettingsScopeController, type SettingsScope,
|
||||
} from '../src/client/settings-scope.ts'
|
||||
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { SettingsScope } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SettingsScopeController, SettingsScopeService } from '../src/client/settings-scope.ts'
|
||||
|
||||
interface UiTestSettings {
|
||||
preference: 'light' | 'dark' | 'system'
|
||||
@@ -295,7 +295,7 @@ describe('SettingsScopeController', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('bindSettingsScope', () => {
|
||||
describe('SettingsScopeService.bind', () => {
|
||||
it('subscribes before the initial read and converges to the latest queued invalidation', async () => {
|
||||
const initial = deferred<ReturnType<typeof described>>()
|
||||
const describeCall = vi.fn()
|
||||
@@ -308,16 +308,18 @@ describe('bindSettingsScope', () => {
|
||||
isLoopback: true,
|
||||
} as never)
|
||||
let scope!: SettingsScope<UiTestSettings>
|
||||
new TestRemote(ctx)
|
||||
await ctx.plugin(SettingsScopeService).await()
|
||||
const fiber = ctx.plugin({
|
||||
inject: ['connection'],
|
||||
inject: ['connection', 'remote', 'settingsScope'],
|
||||
apply: (plugin: Context) => {
|
||||
scope = bindSettingsScope<UiTestSettings>(plugin, { namespace: 'ui-test' })
|
||||
scope = plugin.settingsScope.bind<UiTestSettings>({ namespace: 'ui-test' })
|
||||
},
|
||||
})
|
||||
await fiber.await()
|
||||
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledOnce() })
|
||||
ctx.emit('settings/changed', 'unrelated')
|
||||
ctx.emit('settings/changed', 'ui-test')
|
||||
ctx.remote.$dispatch('settings/document-updated', ['unrelated', 0])
|
||||
ctx.remote.$dispatch('settings/document-updated', ['ui-test', 0])
|
||||
ctx.emit('connection/reset')
|
||||
initial.resolve(described({ preference: 'dark' }, 1))
|
||||
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) })
|
||||
@@ -325,7 +327,7 @@ describe('bindSettingsScope', () => {
|
||||
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'system' }, revision: 3 })
|
||||
})
|
||||
await fiber.dispose()
|
||||
ctx.emit('settings/changed', 'ui-test')
|
||||
ctx.remote.$dispatch('settings/document-updated', ['ui-test', 0])
|
||||
await Promise.resolve()
|
||||
expect(describeCall).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
@@ -338,10 +340,12 @@ describe('bindSettingsScope', () => {
|
||||
isLoopback: false,
|
||||
} as never)
|
||||
let scope!: SettingsScope<UiTestSettings>
|
||||
new TestRemote(ctx)
|
||||
await ctx.plugin(SettingsScopeService).await()
|
||||
const fiber = ctx.plugin({
|
||||
inject: ['connection'],
|
||||
inject: ['connection', 'remote', 'settingsScope'],
|
||||
apply: (plugin: Context) => {
|
||||
scope = bindSettingsScope<UiTestSettings>(plugin, { namespace: 'ui-test' })
|
||||
scope = plugin.settingsScope.bind<UiTestSettings>({ namespace: 'ui-test' })
|
||||
},
|
||||
})
|
||||
await fiber.await()
|
||||
@@ -350,3 +354,4 @@ describe('bindSettingsScope', () => {
|
||||
expect(describeCall).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,20 +11,23 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-sidebar"
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../schema-form"
|
||||
},
|
||||
{
|
||||
"path": "../../api/gateway"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* source's own contract.
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
SlotsService, type ConversationSnapshot, type SessionId, type SessionListState,
|
||||
@@ -87,6 +88,9 @@ async function fullBench(sessions: SessionSummary[]) {
|
||||
ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } })
|
||||
ctx.provide('sessions', face)
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
// ui-theme's Appearance row binds a durable scope through these two.
|
||||
ctx.provide('remote', { $on: () => () => {} } as never)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
await provideSlotFaces(ctx)
|
||||
await ctx.plugin({ inject: localeInject, apply: applyLocale }).await()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
@@ -123,6 +127,9 @@ describe('apply', () => {
|
||||
await ctx.plugin(SlashService).await()
|
||||
ctx.provide('sessions', sessionsWith(FAMILY))
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
// ui-theme's Appearance row binds a durable scope through these two.
|
||||
ctx.provide('remote', { $on: () => () => {} } as never)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
await provideSlotFaces(ctx)
|
||||
await ctx.plugin({ inject: localeInject, apply: applyLocale }).await()
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { apply as applyNode } from '../src/index.ts'
|
||||
@@ -32,8 +33,11 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType<Context['plugi
|
||||
},
|
||||
} as never, () => null)
|
||||
ctx.provide('sessions', {})
|
||||
// The locale plugin binds a settings scope, which reads the connection handle.
|
||||
// The locale plugin binds a settings scope, which reads the connection handle
|
||||
// and the forwarded-event port.
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
ctx.provide('remote', { $on: () => () => {} } as never)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
await ctx.plugin({ inject: localeInject, apply: applyLocale }).await()
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
|
||||
@@ -35,7 +35,9 @@
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-settings",
|
||||
"@deepseek-ai/dsh-api-remotes"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
@@ -43,21 +45,26 @@
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ThemePreference } from '../theme-settings.ts'
|
||||
import type { ThemeKey } from './locales.ts'
|
||||
import type {} from './settings-contract.ts'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { createAppearanceRowStore } from './settings-store.ts'
|
||||
import css from './AppearanceRow.module.css'
|
||||
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
*/
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
bindSettingsScope, type ClientContext, type SettingsScope,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ClientContext, SettingsScope } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: the ctx.settingsScope Context merge. Cross-plugin collaboration
|
||||
// goes through the service, never a value import (client bundle purity gate).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { AppearanceRowInjected } from './AppearanceRow.tsx'
|
||||
@@ -212,8 +213,12 @@ export class ThemeService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Required services: settings transport plus slots/locale for the Appearance row. */
|
||||
export const inject = ['slots', 'locale', 'connection']
|
||||
/**
|
||||
* Required services: settings transport plus slots/locale for the Appearance
|
||||
* row. `remote` carries the forwarded settings invalidation that
|
||||
* `bindSettingsScope` subscribes to on this context.
|
||||
*/
|
||||
export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope']
|
||||
|
||||
/**
|
||||
* Client plugin body: provide the theme service and register the
|
||||
@@ -222,7 +227,7 @@ export const inject = ['slots', 'locale', 'connection']
|
||||
* @param ctx - client cordis context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const host = bindSettingsScope<ThemeSettings>(ctx, { namespace: THEME_SETTINGS_NAMESPACE })
|
||||
const host = ctx.settingsScope.bind<ThemeSettings>({ namespace: THEME_SETTINGS_NAMESPACE })
|
||||
const theme = new ThemeService(ctx, host)
|
||||
ctx.provide('theme', theme)
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
/**
|
||||
* Re-export outlet for the `settings.general.item` slot type consumed by this
|
||||
* package's Appearance row. The canonical home is the locale package (the
|
||||
* common dependency of every item registrant); this file exists so row
|
||||
* modules import the type from within their own package.
|
||||
*/
|
||||
export type { SettingsGeneralItemOwnerProps } from '@deepseek-ai/dsh-client-locale/client'
|
||||
// Side-effect type import: pulls the SlotMap merge into this program.
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
@@ -5,7 +5,8 @@ import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { SettingsScopeService } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import type { AppearanceRowInjected, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from '../src/theme-settings.ts'
|
||||
@@ -53,6 +54,9 @@ async function bench(isLoopback = true) {
|
||||
})
|
||||
})
|
||||
ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback } as never)
|
||||
// The settings transport and the forwarded-event port the plugin injects.
|
||||
new TestRemote(ctx)
|
||||
await ctx.plugin(SettingsScopeService).await()
|
||||
return {
|
||||
ctx, slots: ctx.get('slots') as SlotsService, locale, describe, mutate,
|
||||
setHostPreference: (next: string) => { preference = next },
|
||||
@@ -79,7 +83,7 @@ function faceOf(slots: SlotsService) {
|
||||
|
||||
describe('ui-theme apply', () => {
|
||||
it('declares the slot and locale services', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection'])
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope'])
|
||||
})
|
||||
|
||||
it('provides the service, registers localized copy, and registers the row (declaration before or after apply)', async () => {
|
||||
@@ -128,10 +132,10 @@ describe('ui-theme apply', () => {
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const theme = b.ctx.get('theme') as ThemeService
|
||||
await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') })
|
||||
b.ctx.emit('settings/changed', 'unrelated')
|
||||
b.ctx.remote.$dispatch('settings/document-updated', ['unrelated', 0])
|
||||
expect(b.describe).toHaveBeenCalledOnce()
|
||||
b.setHostPreference('light')
|
||||
b.ctx.emit('settings/changed', THEME_SETTINGS_NAMESPACE)
|
||||
b.ctx.remote.$dispatch('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0])
|
||||
await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('light') })
|
||||
b.setHostPreference('dark')
|
||||
b.ctx.emit('connection/reset')
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as ThemeInvariant from '@deepseek-ai/dsh-client-ui-theme/invariant'
|
||||
import { apply as localeApply, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
|
||||
describe('invariant companion', () => {
|
||||
it('registers under the package name with an empty installer', async () => {
|
||||
@@ -23,7 +24,7 @@ describe('invariant companion', () => {
|
||||
it('client apply provides ctx.theme over the slots/locale edges', async () => {
|
||||
// The feature registers its own Appearance settings row with localized
|
||||
// copy, hence the slots + locale edges.
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection'])
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope'])
|
||||
const ctx = new Context()
|
||||
new SlotsService(ctx)
|
||||
ctx.provide('connection', {
|
||||
@@ -33,6 +34,9 @@ describe('invariant companion', () => {
|
||||
}) } },
|
||||
isLoopback: true,
|
||||
} as never)
|
||||
// The settings row's transport and the forwarded-event port.
|
||||
ctx.provide('remote', { $on: () => () => {} } as never)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
await ctx.plugin({ inject: localeInject, apply: localeApply }).await()
|
||||
await ctx.plugin({ inject, apply: clientApply }).await()
|
||||
expect(ctx.get('theme')).toBeInstanceOf(ThemeService)
|
||||
|
||||
@@ -31,6 +31,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../ui-settings"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cleanup, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ISession, SessionId, TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { apply as applyTool, inject as injectTool } from '../src/client/apply.ts'
|
||||
import { toolChatSnapshot } from './tool-details-render.tsx'
|
||||
@@ -69,6 +69,9 @@ const LAYOUT_CHILDREN = {
|
||||
async function bench(nodes: ToolResultNode[]) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
// ui-theme's Appearance row binds a durable scope through these two.
|
||||
runtime.provide('remote', { $on: () => () => {} })
|
||||
runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
// (runningCalls) nest their so-far dispatches the same way.
|
||||
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import {
|
||||
@@ -158,6 +159,9 @@ async function bench(snapshot: ConversationSnapshot) {
|
||||
ctx.provide('workspaces', workspaces)
|
||||
ctx.provide('layout', layout)
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
// ui-theme's Appearance row binds a durable scope through these two.
|
||||
ctx.provide('remote', { $on: () => () => {} } as never)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
const locale = new LocaleService(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
slots.installLocale(locale)
|
||||
|
||||
@@ -13,7 +13,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent } from '@testing-library/react'
|
||||
import type { ISession, SessionId, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { SlotTestRuntime, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { apply as applyTool, inject as injectTool } from '@deepseek-ai/dsh-client-ui-tool/client'
|
||||
@@ -65,6 +65,9 @@ const LAYOUT_CHILDREN = {
|
||||
async function bench(nodes: ToolResultNode[]) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
// ui-theme's Appearance row binds a durable scope through these two.
|
||||
runtime.provide('remote', { $on: () => () => {} })
|
||||
runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
|
||||
runtime.provide('layout', layout)
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
@@ -198,6 +201,9 @@ describe('registrant declaration injection', () => {
|
||||
it('runs a registrant before ui-tool and waits on the actual toolview declaration', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
// ui-theme's Appearance row binds a durable scope through these two.
|
||||
runtime.provide('remote', { $on: () => () => {} })
|
||||
runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
|
||||
import { zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
|
||||
import { apply as localeApply, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { LocaleKeysOf } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { zh, type TrajectoryKey } from '../src/client/locales.ts'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
|
||||
@@ -202,8 +203,11 @@ async function bench(snapshot = historySnapshot(NODES)) {
|
||||
slots.register(
|
||||
{ name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never)
|
||||
// The locale plugin backs the locale-aware view tab label ('locale' in
|
||||
// inject); its settings scope needs a connection handle.
|
||||
// inject); its settings scope needs a connection handle and the
|
||||
// forwarded-event port.
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
ctx.provide('remote', { $on: () => () => {} } as never)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
ctx.plugin({ inject: [...localeInject], apply: localeApply })
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
|
||||
Reference in New Issue
Block a user