feat(remote): deliver allowlisted Host events through ctx.remote.$on
api/remotes owns the allowlist and its type projection; type-meta owns the shape predicate, the selection seat, and the internal remote/host-event carrier signal; api/gateway's Client half turns that signal into $on callbacks through a private dispatch. apiproxy forwards each allowlisted emission verbatim in one host/remote-event frame, registered ahead of the derived invalidation frames so frame order is unchanged, and drops the three per-event variants it replaces. Owner packages move their Events declarations into client-safe ./types exports, so a consumer's listener signature is the Host's own declaration.
This commit is contained in:
@@ -152,14 +152,14 @@ describe('connection client apply', () => {
|
||||
sockets[1]!.receive(JSON.stringify({
|
||||
type: 'server-request',
|
||||
rpcId: 'host-browser',
|
||||
method: 'host/commands-changed',
|
||||
payload: { type: 'host/commands-changed' },
|
||||
method: 'host/remote-event',
|
||||
payload: { type: 'host/remote-event', event: 'commands/change', args: [] },
|
||||
}))
|
||||
expect(await muxFrame).toMatchObject({
|
||||
value: { rpcId: 'mux-browser', payload: { type: 'session/subscribed', lastSeq: 8 } },
|
||||
})
|
||||
expect(await hostFrame).toMatchObject({
|
||||
value: { rpcId: 'host-browser', payload: { type: 'host/commands-changed' } },
|
||||
value: { rpcId: 'host-browser', payload: { type: 'host/remote-event', event: 'commands/change' } },
|
||||
})
|
||||
expect(errors).toHaveBeenCalledTimes(2)
|
||||
await vi.waitFor(() => { expect(envelopes.flat()).toHaveLength(2) })
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('WebSocket downlinks', () => {
|
||||
},
|
||||
async function * (signal) {
|
||||
try {
|
||||
yield { rpcId: RpcId('host-1'), payload: { type: 'host/commands-changed' } }
|
||||
yield { rpcId: RpcId('host-1'), payload: { type: 'host/remote-event', event: 'commands/change', args: [] } }
|
||||
await untilAbort(signal)
|
||||
} finally {
|
||||
hostAborted = true
|
||||
@@ -116,8 +116,8 @@ describe('WebSocket downlinks', () => {
|
||||
expect(await hostFrame).toEqual({
|
||||
type: 'server-request',
|
||||
rpcId: 'host-1',
|
||||
method: 'host/commands-changed',
|
||||
payload: { type: 'host/commands-changed' },
|
||||
method: 'host/remote-event',
|
||||
payload: { type: 'host/remote-event', event: 'commands/change', args: [] },
|
||||
})
|
||||
|
||||
const muxClosed = once(mux, 'close')
|
||||
|
||||
@@ -150,29 +150,6 @@ declare module '@deepseek-ai/cordis' {
|
||||
* @param key - the mutated SlotMap key.
|
||||
*/
|
||||
'slots/changed'(key: string): void
|
||||
/**
|
||||
* The host command registry changed (host/commands-changed passthrough).
|
||||
* Pure invalidation signal: subscribers refetch `command.list` in the
|
||||
* background rather than diffing.
|
||||
* @mode emit
|
||||
*/
|
||||
'commands/changed'(): void
|
||||
/**
|
||||
* One settings namespace's resolved value changed on the host
|
||||
* (host/settings-changed passthrough). Subscribers refetch
|
||||
* `settings.describe`; the frame carries no values.
|
||||
* @mode emit
|
||||
* @param ns - the namespace whose resolved value changed.
|
||||
*/
|
||||
'settings/changed'(ns: string): void
|
||||
/**
|
||||
* One credential reference's state changed on the host
|
||||
* (host/credentials-changed passthrough). The ref is an
|
||||
* environment-variable NAME — never a value.
|
||||
* @mode emit
|
||||
* @param ref - the reference whose configured state changed.
|
||||
*/
|
||||
'credentials/changed'(ref: string): void
|
||||
/**
|
||||
* The host provider topology changed (host/models-changed passthrough).
|
||||
* Subscribers refetch `llm.providers`/`llm.models`/`session.models`.
|
||||
@@ -241,16 +218,15 @@ export function apply(ctx: Context): void {
|
||||
onHostEnvelope: (envelope) => {
|
||||
sessions.handleHostEnvelope(envelope)
|
||||
workspaces.handleHostEnvelope(envelope)
|
||||
// Typed-event bridge: the session layer ignores registry frames (no
|
||||
// session routing); consumers (command directory caches, the settings
|
||||
// and model services) subscribe on ctx.
|
||||
// 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.
|
||||
const frame = envelope.payload
|
||||
if (frame.type === 'host/commands-changed') ctx.emit('commands/changed')
|
||||
if (frame.type === 'host/remote-event') ctx.emit('remote/host-event', frame.event, frame.args)
|
||||
else if (frame.type === 'host/session-preset-changed') {
|
||||
ctx.emit('session/preset-changed', frame.sessionId, frame.agentPreset)
|
||||
}
|
||||
else if (frame.type === 'host/settings-changed') ctx.emit('settings/changed', frame.ns)
|
||||
else if (frame.type === 'host/credentials-changed') ctx.emit('credentials/changed', frame.ref)
|
||||
else if (frame.type === 'host/models-changed') ctx.emit('models/changed')
|
||||
},
|
||||
onConnected: () => {
|
||||
|
||||
@@ -1,16 +1,45 @@
|
||||
/**
|
||||
* Wire-to-typed-event bridge: host/commands-changed
|
||||
* → ctx 'commands/changed'; host/session-preset-changed →
|
||||
* ctx 'session/preset-changed'; each established connection generation →
|
||||
* ctx 'connection/reset' (the forced cache-invalidation broadcast).
|
||||
* 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);
|
||||
* 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
|
||||
* cache-invalidation broadcast).
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
// Type-only: the api-remotes facade carries both the allowlist's selection seat
|
||||
// and the owner packages' `./types` declarations, which together give `$on` its
|
||||
// key face and per-event listener signatures.
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import * as RuntimeClient from '../src/client/index.ts'
|
||||
import { FakeApiClient } from './fake-api.ts'
|
||||
|
||||
/**
|
||||
* Compile-time face of `ctx.remote.$on`, asserted by type-checking this file
|
||||
* rather than by running it: the allowlist narrows the key set, and each
|
||||
* listener's parameters come from the owner package's own cordis `Events`
|
||||
* declaration (so a brand cannot be flattened on the way to a consumer).
|
||||
* @param ctx - any client Context carrying the Remote service.
|
||||
*/
|
||||
function forwardedEventContracts(ctx: Context): void {
|
||||
ctx.remote.$on('settings/document-updated', (namespace, source) => {
|
||||
// @ts-expect-error -- the brand survives the wire: a bare string is not a SettingsNamespace
|
||||
const bare: typeof namespace = 'plain-string'
|
||||
void bare; void namespace; void source
|
||||
})
|
||||
ctx.remote.$on('credentials/updated', () => {})
|
||||
ctx.remote.$on('commands/change', () => {})
|
||||
// @ts-expect-error -- client-local event outside the allowlist
|
||||
ctx.remote.$on('slots/changed', () => {})
|
||||
// @ts-expect-error -- declared host event the allowlist does not select
|
||||
ctx.remote.$on('skills/change', () => {})
|
||||
}
|
||||
void forwardedEventContracts
|
||||
|
||||
interface Bench {
|
||||
ctx: Context
|
||||
sinks: ConnectionSinks | undefined
|
||||
@@ -33,41 +62,64 @@ async function mount(): Promise<Bench> {
|
||||
},
|
||||
}
|
||||
ctx.reflect.provide('connection', handle)
|
||||
ctx.reflect.provide('remote', {})
|
||||
await ctx.plugin(RuntimeClient).await()
|
||||
return bench
|
||||
}
|
||||
|
||||
describe('wire event bridge', () => {
|
||||
it('broadcasts commands/changed on a host/commands-changed frame, not on other host frames', async () => {
|
||||
it('republishes a forwarded host event verbatim, and routes no other host frame there', async () => {
|
||||
const bench = await mount()
|
||||
let changed = 0
|
||||
bench.ctx.on('commands/changed', () => { changed++ })
|
||||
bench.sinks?.onHostEnvelope?.({ rpcId: 'r1' as never, payload: { type: 'host/commands-changed' } })
|
||||
expect(changed).toBe(1)
|
||||
const seen: unknown[][] = []
|
||||
bench.ctx.on('remote/host-event', (event, args) => { seen.push([event, ...args]) })
|
||||
bench.sinks?.onHostEnvelope?.({
|
||||
rpcId: 'r1' as never,
|
||||
payload: { type: 'host/remote-event', event: 'commands/change', args: [] },
|
||||
})
|
||||
expect(seen).toEqual([['commands/change']])
|
||||
|
||||
bench.sinks?.onHostEnvelope?.({
|
||||
rpcId: 'r2' as never,
|
||||
payload: { type: 'host/session-status', sessionId: 's1' as never, running: true },
|
||||
})
|
||||
expect(changed).toBe(1)
|
||||
expect(seen).toEqual([['commands/change']])
|
||||
})
|
||||
|
||||
it('broadcasts the settings/credentials/models invalidations with their frame payloads', async () => {
|
||||
it('carries each forwarded event name with its own argument list, unfiltered', async () => {
|
||||
const bench = await mount()
|
||||
const seen: unknown[][] = []
|
||||
bench.ctx.on('settings/changed', ns => seen.push(['settings', ns]))
|
||||
bench.ctx.on('credentials/changed', ref => seen.push(['credentials', ref]))
|
||||
bench.ctx.on('models/changed', () => seen.push(['models']))
|
||||
bench.sinks?.onHostEnvelope?.({ rpcId: 'r3' as never, payload: { type: 'host/settings-changed', ns: 'llm-pi-ai' } })
|
||||
bench.sinks?.onHostEnvelope?.({ rpcId: 'r4' as never, payload: { type: 'host/credentials-changed', ref: 'OPENAI_API_KEY' } })
|
||||
bench.sinks?.onHostEnvelope?.({ rpcId: 'r5' as never, payload: { type: 'host/models-changed' } })
|
||||
bench.ctx.on('remote/host-event', (event, args) => { seen.push([event, ...args]) })
|
||||
|
||||
bench.sinks?.onHostEnvelope?.({
|
||||
rpcId: 'r3' as never,
|
||||
payload: { type: 'host/remote-event', event: 'settings/document-updated', args: ['llm-pi-ai', 7] },
|
||||
})
|
||||
bench.sinks?.onHostEnvelope?.({
|
||||
rpcId: 'r4' as never,
|
||||
payload: { type: 'host/remote-event', event: 'credentials/updated', args: ['OPENAI_API_KEY'] },
|
||||
})
|
||||
// The carrier does not second-guess the name: selecting what a consumer can
|
||||
// receive is the allowlist's job, and dropping an unsubscribed name is the
|
||||
// Remote service's. This plugin republishes whatever the frame carried.
|
||||
bench.sinks?.onHostEnvelope?.({
|
||||
rpcId: 'r5' as never,
|
||||
payload: { type: 'host/remote-event', event: 'nobody/listening', args: ['ignored'] },
|
||||
})
|
||||
|
||||
expect(seen).toEqual([
|
||||
['settings', 'llm-pi-ai'],
|
||||
['credentials', 'OPENAI_API_KEY'],
|
||||
['models'],
|
||||
['settings/document-updated', 'llm-pi-ai', 7],
|
||||
['credentials/updated', 'OPENAI_API_KEY'],
|
||||
['nobody/listening', 'ignored'],
|
||||
])
|
||||
})
|
||||
|
||||
it('still broadcasts the typed models/changed invalidation (its host frame is unchanged)', async () => {
|
||||
const bench = await mount()
|
||||
let models = 0
|
||||
bench.ctx.on('models/changed', () => { models++ })
|
||||
bench.sinks?.onHostEnvelope?.({ rpcId: 'r6' as never, payload: { type: 'host/models-changed' } })
|
||||
expect(models).toBe(1)
|
||||
})
|
||||
|
||||
it('broadcasts session/preset-changed with the recomposed session and its new preset', async () => {
|
||||
const bench = await mount()
|
||||
const seen: Array<[string, string]> = []
|
||||
|
||||
@@ -39,6 +39,7 @@ export { FixtureSession, TestSessions } from './sessions.ts'
|
||||
export { stubSettingsScope } from './settings-scope.ts'
|
||||
export type { StubSettingsScope } from './settings-scope.ts'
|
||||
export { TestWorkspaces } from './workspaces.ts'
|
||||
export { TestRemote } from './remote.ts'
|
||||
export { conversationSnapshot, workspaceListState } from './fixtures.ts'
|
||||
export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts'
|
||||
export { makeTranslate } from './translate.ts'
|
||||
|
||||
55
packages/client/test-runtime/src/remote.ts
Normal file
55
packages/client/test-runtime/src/remote.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/** Test-owned Remote face: `$on` subscriptions driven by the internal forwarded-event plumbing. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
|
||||
/**
|
||||
* Remote service test double for the forwarded-event path. Feature specs need
|
||||
* `ctx.remote.$on` to exist (their plugins inject `remote`) and need forwarded
|
||||
* 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.
|
||||
*
|
||||
* `$mount` rejects: a spec that reaches a generated namespace through this
|
||||
* double has outgrown it and needs the real Client Remote 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.
|
||||
* @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[])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to one forwarded host event.
|
||||
* @param event - forwarded host event name.
|
||||
* @param listener - receives the Host argument list verbatim.
|
||||
* @returns disposer removing this subscription.
|
||||
*/
|
||||
$on(event: string, listener: (...args: never[]) => void): () => void {
|
||||
const listeners = this.subscriptions.get(event) ?? new Set()
|
||||
this.subscriptions.set(event, listeners)
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Generated-namespace mount, unsupported by this double.
|
||||
* @returns never; always rejects.
|
||||
*/
|
||||
$mount(): Promise<() => Promise<void>> {
|
||||
return Promise.reject(new Error('TestRemote: $mount needs the real Client Remote service'))
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,8 @@
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-settings"
|
||||
"@deepseek-ai/dsh-client-ui-settings",
|
||||
"@deepseek-ai/dsh-api-remotes"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
@@ -47,6 +48,8 @@
|
||||
},
|
||||
"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:^",
|
||||
@@ -56,10 +59,10 @@
|
||||
"@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": {
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
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: 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'
|
||||
// Type-only: pulls the settings shell's SlotMap merge (the 'settings.section' entry).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -43,7 +46,7 @@ export type { AgentPresetOption, AgentPresetSettingsState } from './settings-sto
|
||||
export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.ts'
|
||||
|
||||
/** Required services (cordis fiber inject). */
|
||||
export const inject = ['slots', 'locale', 'connection']
|
||||
export const inject = ['slots', 'locale', 'connection', 'remote']
|
||||
|
||||
/**
|
||||
* Mount the General-settings row.
|
||||
@@ -71,15 +74,17 @@ export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => {
|
||||
// The roster is a live directory and the default is a settings field, so
|
||||
// both an external settings edit and a reconnect can move this row.
|
||||
const refresh = (ns?: string): void => {
|
||||
if (ns !== undefined && ns !== AGENT_PRESET_SETTINGS_NS) return
|
||||
const refresh = (): void => {
|
||||
void controller.load()
|
||||
// The section reads the same roster and marks the same default, so a
|
||||
// change made from either surface converges both.
|
||||
if (section.store.getSnapshot().status !== 'idle') void section.load()
|
||||
}
|
||||
const disposers = [
|
||||
ctx.on('settings/changed', refresh),
|
||||
ctx.remote.$on('settings/document-updated', (ns) => {
|
||||
if (ns !== AGENT_PRESET_SETTINGS_NS) return
|
||||
refresh()
|
||||
}),
|
||||
ctx.on('connection/reset', () => { refresh() }),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
@@ -132,8 +137,8 @@ export function apply(ctx: ClientContext): void {
|
||||
// the next session keeps offering the previous default until a reload,
|
||||
// which is exactly the session the setting claims to govern. A staged
|
||||
// pick survives: `load()` prefers it over the refreshed fallback.
|
||||
const settingsMoved = scope.on('settings/changed', (ns?: string) => {
|
||||
if (ns !== undefined && ns !== AGENT_PRESET_SETTINGS_NS) return
|
||||
const settingsMoved = scope.remote.$on('settings/document-updated', (ns) => {
|
||||
if (ns !== AGENT_PRESET_SETTINGS_NS) return
|
||||
void seat.load()
|
||||
})
|
||||
// Authoring writes a FILE, not a setting, so nothing on the wire
|
||||
|
||||
@@ -10,7 +10,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-agent-preset/client'
|
||||
import { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx'
|
||||
import type { AgentPresetLabelInjected } from '../src/client/AgentPresetLabel.tsx'
|
||||
@@ -78,6 +78,9 @@ async function bench() {
|
||||
await ctx.plugin(SlotsService).await()
|
||||
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.
|
||||
new TestRemote(ctx)
|
||||
const calls: string[] = []
|
||||
ctx.provide('connection', {
|
||||
api: {
|
||||
@@ -169,7 +172,7 @@ function sessionsDouble(state: {
|
||||
|
||||
describe('ui-agent-preset apply', () => {
|
||||
it('declares the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection'])
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote'])
|
||||
})
|
||||
|
||||
it('registers the General row and the settings section', async () => {
|
||||
@@ -250,11 +253,11 @@ describe('ui-agent-preset apply', () => {
|
||||
await section.load()
|
||||
const before = calls.length
|
||||
|
||||
ctx.emit('settings/changed', 'agent-presets')
|
||||
ctx.emit('remote/host-event', 'settings/document-updated', ['agent-presets', 1])
|
||||
await vi.waitFor(() => { expect(calls.length).toBe(before + 2) })
|
||||
const afterRelevant = calls.length
|
||||
|
||||
ctx.emit('settings/changed', 'llm-deepseek')
|
||||
ctx.emit('remote/host-event', 'settings/document-updated', ['llm-deepseek', 1])
|
||||
await Promise.resolve()
|
||||
|
||||
// Both surfaces re-read on their own namespace; an unrelated one moves
|
||||
@@ -282,7 +285,7 @@ describe('ui-agent-preset apply', () => {
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const before = calls.length
|
||||
|
||||
ctx.emit('settings/changed', 'agent-presets')
|
||||
ctx.emit('remote/host-event', '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
|
||||
@@ -333,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('settings/changed', 'llm-deepseek')
|
||||
ctx.emit('remote/host-event', 'settings/document-updated', ['llm-deepseek', 1])
|
||||
await Promise.resolve()
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('standard')
|
||||
|
||||
ctx.emit('settings/changed', 'agent-presets')
|
||||
ctx.emit('remote/host-event', 'settings/document-updated', ['agent-presets', 1])
|
||||
await vi.waitFor(() => {
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('minimal')
|
||||
})
|
||||
|
||||
@@ -40,6 +40,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-slash",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-api-remotes"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
@@ -49,6 +50,8 @@
|
||||
"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:^",
|
||||
@@ -57,10 +60,10 @@
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
|
||||
@@ -45,7 +45,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
const NS = 'command'
|
||||
|
||||
/** Required services: the '/' source registry plus the scope + wire faces the service reads, and the copy's locale registry. */
|
||||
export const inject = ['slash', 'sessions', 'connection', 'locale']
|
||||
export const inject = ['slash', 'sessions', 'connection', 'locale', 'remote']
|
||||
|
||||
/**
|
||||
* Client plugin body: mount the service, then register the popupSelect shell
|
||||
|
||||
@@ -11,6 +11,9 @@ import { Service } from '@deepseek-ai/cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: pulls the ctx.remote merge and the forwarded-event key face
|
||||
// (`commands/change` rides the allowlist) into this program.
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type {
|
||||
CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick,
|
||||
SubmitOutcome,
|
||||
@@ -93,7 +96,7 @@ function fuzzyCandidates(candidates: readonly SlashCandidate[], rawQuery: string
|
||||
|
||||
/** Command surface: session-keyed directory + '/' source + contribution registry + per-session popups. */
|
||||
export class CommandService extends Service implements CommandServiceContract {
|
||||
static inject = ['slash', 'sessions', 'connection']
|
||||
static inject = ['slash', 'sessions', 'connection', 'remote']
|
||||
|
||||
private readonly directory: CommandDirectory
|
||||
private readonly live: LiveState = { contributions: new Map(), decorations: new Map(), popups: new Map() }
|
||||
@@ -123,7 +126,7 @@ export class CommandService extends Service implements CommandServiceContract {
|
||||
matchEnter: (session, line, signal) => this.matchEnter(session, line, signal),
|
||||
warm: (session) => { this.directory.warm(session.sessionId) },
|
||||
}), 'command: slash source')
|
||||
ctx.on('commands/changed', () => { this.directory.invalidateAll() })
|
||||
ctx.remote.$on('commands/change', () => { this.directory.invalidateAll() })
|
||||
// A preset switch changes which commands one session's agent resolves and
|
||||
// registers nothing globally, so the registry-wide signal above never
|
||||
// fires for it: repull that key alone, soft, so the old snapshot serves
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { CommandServiceContract } from '../src/client/contract.ts'
|
||||
import type { PopupSelectInjected } from '../src/client/PopupSelectView.tsx'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply, CommandService, inject } from '../src/client/index.ts'
|
||||
|
||||
const sid = (k: string): SessionId => k as SessionId
|
||||
@@ -38,6 +39,8 @@ async function bench() {
|
||||
name: 'root', children: { 'conversation.input.overlay': { kind: 'list', scope: 'session' } },
|
||||
} as never, (() => null) as never)
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
// CommandService injects `remote` for the forwarded directory invalidation.
|
||||
new TestRemote(ctx)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
const mint = (key: string) => {
|
||||
@@ -50,7 +53,7 @@ async function bench() {
|
||||
|
||||
describe('apply', () => {
|
||||
it('declares the services it binds', () => {
|
||||
expect(inject).toEqual(['slash', 'sessions', 'connection', 'locale'])
|
||||
expect(inject).toEqual(['slash', 'sessions', 'connection', 'locale', 'remote'])
|
||||
})
|
||||
|
||||
it('mounts ctx.command, registers the source and the overlay entry, and folds up on disposal', async () => {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ClientSessionContext, ConsumeTokenRequest, SlashPick, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts'
|
||||
@@ -78,6 +79,9 @@ async function bench(opts: BenchOptions = {}) {
|
||||
: undefined,
|
||||
})
|
||||
ctx.provide('connection', { api })
|
||||
// CommandService injects `remote`; the directory invalidation arrives on the
|
||||
// same `remote/host-event` signal the connection sink republishes.
|
||||
new TestRemote(ctx)
|
||||
/** Notices the fake conversation face collected (runDetached routing). */
|
||||
const notices: Array<{ scope: SessionId | undefined; level: 'info' | 'error'; text: string }> = []
|
||||
ctx.provide('conversation', {
|
||||
@@ -611,7 +615,7 @@ describe('directory invalidation events', () => {
|
||||
},
|
||||
})
|
||||
await warm(proj('s1'))
|
||||
ctx.emit('commands/changed')
|
||||
ctx.emit('remote/host-event', 'commands/change', [])
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined()
|
||||
expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-settings",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-api-remotes"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
@@ -45,6 +46,8 @@
|
||||
},
|
||||
"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-schema-form": "workspace:^",
|
||||
@@ -52,10 +55,10 @@
|
||||
"@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": {
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
|
||||
@@ -12,6 +12,9 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
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'
|
||||
// Type-only: pulls the ctx.remote merge and the forwarded-event key face
|
||||
// (settings/credentials invalidations ride the allowlist) into this program.
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { ModelsSection } from './ModelsSection.tsx'
|
||||
import type { ModelsSectionInjected } from './ModelsSection.tsx'
|
||||
import { DeepSeekOnboardingDialog } from './DeepSeekOnboardingDialog.tsx'
|
||||
@@ -48,7 +51,7 @@ export function refreshIfLoaded(controller: ModelsSettingsStore): void {
|
||||
* ui-settings' apply, whose activation order relative to this one is NOT
|
||||
* constrained; registration depends on each slot through `slots.inject()`.
|
||||
*/
|
||||
export const inject = ['slots', 'locale', 'connection']
|
||||
export const inject = ['slots', 'locale', 'connection', 'remote']
|
||||
|
||||
/**
|
||||
* Register the Models section once the `settings.section` declaration is on
|
||||
@@ -82,8 +85,8 @@ export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => {
|
||||
const refresh = (): void => { refreshIfLoaded(controller) }
|
||||
const disposers = [
|
||||
ctx.on('settings/changed', refresh),
|
||||
ctx.on('credentials/changed', refresh),
|
||||
ctx.remote.$on('settings/document-updated', refresh),
|
||||
ctx.remote.$on('credentials/updated', refresh),
|
||||
ctx.on('models/changed', refresh),
|
||||
ctx.on('connection/reset', refresh),
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply, inject, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-models/client'
|
||||
import { ModelsSection } from '../src/client/ModelsSection.tsx'
|
||||
import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx'
|
||||
@@ -18,6 +18,9 @@ async function bench() {
|
||||
await ctx.plugin(SlotsService).await()
|
||||
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.
|
||||
new TestRemote(ctx)
|
||||
// The apply path only captures the wire face; no call leaves this fake
|
||||
// until a section actually loads.
|
||||
ctx.provide('connection', { api: {} } as never)
|
||||
@@ -39,7 +42,7 @@ function declare(slots: SlotsService): () => void {
|
||||
|
||||
describe('ui-models apply', () => {
|
||||
it('declares the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection'])
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote'])
|
||||
})
|
||||
|
||||
it('registers the models nav entry for declarations before or after apply', async () => {
|
||||
@@ -135,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('settings/changed', 'llm-pi-ai')
|
||||
b.ctx.emit('credentials/changed', 'OPENAI_API_KEY')
|
||||
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.emit('models/changed')
|
||||
b.ctx.emit('connection/reset')
|
||||
})
|
||||
@@ -167,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('credentials/changed', 'DEEPSEEK_API_KEY')
|
||||
b.ctx.emit('remote/host-event', 'credentials/updated', ['DEEPSEEK_API_KEY'])
|
||||
expect(load).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -37,6 +37,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-command"
|
||||
"@deepseek-ai/dsh-client-ui-command",
|
||||
"@deepseek-ai/dsh-api-remotes"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
@@ -46,6 +47,8 @@
|
||||
},
|
||||
"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:^",
|
||||
@@ -56,14 +59,15 @@
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-permission": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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-schema-form": "workspace:^",
|
||||
"@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-slash": "workspace:^",
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
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: 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 { ClientContext, SessionFace } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
|
||||
import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
@@ -38,7 +41,7 @@ export type {
|
||||
} from './settings-store.ts'
|
||||
|
||||
/** Required services (cordis fiber inject). */
|
||||
export const inject = ['command', 'sessions', 'slots', 'locale', 'connection']
|
||||
export const inject = ['command', 'sessions', 'slots', 'locale', 'connection', 'remote']
|
||||
|
||||
const ACCESS_NS = 'permission.access'
|
||||
|
||||
@@ -118,12 +121,12 @@ export function apply(ctx: ClientContext): void {
|
||||
})
|
||||
|
||||
ctx.effect(() => {
|
||||
const refresh = (ns?: string): void => {
|
||||
if (ns !== undefined && ns !== PERMISSION_SETTINGS_NS) return
|
||||
refreshPermissionIfLoaded(controller)
|
||||
}
|
||||
const refresh = (): void => { refreshPermissionIfLoaded(controller) }
|
||||
const disposers = [
|
||||
ctx.on('settings/changed', refresh),
|
||||
ctx.remote.$on('settings/document-updated', (ns) => {
|
||||
if (ns !== PERMISSION_SETTINGS_NS) return
|
||||
refresh()
|
||||
}),
|
||||
ctx.on('connection/reset', () => { refresh() }),
|
||||
]
|
||||
return () => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SlotsService, type SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { CommandDecoration } from '@deepseek-ai/dsh-client-ui-command/client'
|
||||
import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client'
|
||||
import {
|
||||
@@ -37,6 +38,9 @@ async function bench() {
|
||||
const locale = new LocaleService(ctx)
|
||||
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.
|
||||
new TestRemote(ctx)
|
||||
ctx.slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
@@ -158,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('settings/changed', 'another')
|
||||
b.ctx.emit('settings/changed', 'permission')
|
||||
b.ctx.emit('remote/host-event', 'settings/document-updated', ['another', 1])
|
||||
b.ctx.emit('remote/host-event', 'settings/document-updated', ['permission', 1])
|
||||
b.ctx.emit('connection/reset')
|
||||
await b.fiber.dispose()
|
||||
expect(b.decoration()).toBeUndefined()
|
||||
|
||||
@@ -43,6 +43,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-settings",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-connection"
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-api-remotes"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
@@ -50,6 +51,8 @@
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"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:^",
|
||||
@@ -58,10 +61,10 @@
|
||||
"@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": {
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
|
||||
@@ -12,6 +12,9 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
// Type-only: pulls ctx.locale and the 'settings.general.item' SlotMap merge.
|
||||
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 { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx'
|
||||
import { GeneralSection } from './GeneralSection.tsx'
|
||||
import { SettingsDocumentAction } from './SettingsDocumentAction.tsx'
|
||||
@@ -51,7 +54,7 @@ const NS = 'settings'
|
||||
* ui-settings' apply, whose activation order relative to this one is NOT
|
||||
* constrained; registrations depend on their slots through `slots.inject()`.
|
||||
*/
|
||||
export const inject = ['slots', 'locale', 'connection']
|
||||
export const inject = ['slots', 'locale', 'connection', 'remote']
|
||||
|
||||
/**
|
||||
* Register the `settings` dictionaries, the chrome content, and the General
|
||||
@@ -83,12 +86,12 @@ export function apply(ctx: ClientContext): void {
|
||||
})
|
||||
|
||||
ctx.effect(() => {
|
||||
const refresh = (ns?: string): void => {
|
||||
if (ns !== undefined && ns !== WELCOME_NOTICE_SETTINGS_NAMESPACE) return
|
||||
refreshWelcomeIfLoaded(welcomeController)
|
||||
}
|
||||
const refresh = (): void => { refreshWelcomeIfLoaded(welcomeController) }
|
||||
const disposers = [
|
||||
ctx.on('settings/changed', refresh),
|
||||
ctx.remote.$on('settings/document-updated', (ns) => {
|
||||
if (ns !== WELCOME_NOTICE_SETTINGS_NAMESPACE) return
|
||||
refresh()
|
||||
}),
|
||||
ctx.on('connection/reset', () => {
|
||||
refresh()
|
||||
refreshDocumentIfLoaded(documentController)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client'
|
||||
import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
|
||||
import { GeneralSection } from '../src/client/GeneralSection.tsx'
|
||||
@@ -33,6 +33,9 @@ async function bench(isLoopback = true) {
|
||||
await ctx.plugin(SlotsService).await()
|
||||
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.
|
||||
new TestRemote(ctx)
|
||||
const settingsDescribe = vi.fn(() => Promise.resolve({
|
||||
rpcId: 'settings-general' as never,
|
||||
result: {
|
||||
@@ -86,7 +89,7 @@ function generalEntry(slots: SlotsService) {
|
||||
|
||||
describe('ui-settings-general apply', () => {
|
||||
it('declares the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection'])
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote'])
|
||||
})
|
||||
|
||||
it('fills all six seats for declarations before or after apply', async () => {
|
||||
@@ -167,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('settings/changed', 'unrelated')
|
||||
b.ctx.emit('remote/host-event', 'settings/document-updated', ['unrelated', 1])
|
||||
expect(b.settingsDescribe).toHaveBeenCalledOnce()
|
||||
b.ctx.emit('settings/changed', WELCOME_NOTICE_SETTINGS_NAMESPACE)
|
||||
b.ctx.emit('remote/host-event', '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) })
|
||||
|
||||
@@ -37,6 +37,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user