refactor(client): migrate consumers and remove Runtime

This commit is contained in:
imccyu 2026-08-22 21:19:39 +08:00
parent 049170c6d0
commit be531688f3
200 changed files with 1463 additions and 2148 deletions

View file

@ -22,6 +22,11 @@ interface AssembledPlugin extends WebBootEntry {
bundlePath: string
}
interface AssembledBootOptions {
/** Package ids omitted from this mounted composition. */
readonly exclude?: readonly string[]
}
interface ClientPackageManifest {
name?: string
exports?: Record<string, string | { default?: string }>
@ -187,24 +192,25 @@ export function installAssembledBootEnv(): void {
* Mount the assembled application on the fixture transport; the teardown
* registered by installAssembledBootEnv disposes it.
* @param search - fixture query string used to select deterministic host behavior.
* @param options - composition changes applied to this mount.
*/
export function mountAssembledApp(search = '?fixture'): void {
export function mountAssembledApp(search = '?fixture', options: AssembledBootOptions = {}): void {
const excluded = new Set(options.exclude)
const plugins = PLUGINS.filter(plugin => !excluded.has(plugin.id))
history.replaceState(null, '', `/${search}`)
const root = document.createElement('div')
root.id = 'root'
document.body.appendChild(root)
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ bundlePath: _bundlePath, ...plugin }) => plugin) }
win.__DSH_BOOT__ = { rev: 'fx', entries: plugins.map(({ bundlePath: _bundlePath, ...plugin }) => plugin) }
const [facadeRow] = bootInjections(win.__DSH_BOOT__)
if (facadeRow?.kind !== 'script') throw new Error('missing injected ModuleLoader facade row')
;(0, eval)(facadeRow.text)
// Mirror the blocking Host-injected scripts before the Vite entry calls create().
for (const id of ['@deepseek-ai/dsh-client-modules', '@deepseek-ai/dsh-client-runtime']) {
const plugin = PLUGINS.find(candidate => candidate.id === id)
if (plugin === undefined) throw new Error(`missing parser-preloaded fixture row ${id}`)
const code = bundles.get(plugin.url)
if (code === undefined) throw new Error(`missing built bundle ${plugin.url}`)
;(0, eval)(code)
}
// Mirror the blocking Host-injected modules script before the Vite entry calls create().
const modules = plugins.find(candidate => candidate.id === '@deepseek-ai/dsh-client-modules')
if (modules === undefined) throw new Error('missing parser-preloaded fixture row @deepseek-ai/dsh-client-modules')
const modulesCode = bundles.get(modules.url)
if (modulesCode === undefined) throw new Error(`missing built bundle ${modules.url}`)
;(0, eval)(modulesCode)
act(() => {
const entry = new AppWebEntry(root, {
loadBundle: async (url) => {

View file

@ -8,8 +8,9 @@
//
// Component behavior remains owned by per-package suites (SlotTestRuntime
// benches over src). This smoke additionally pins the resident interaction
// fixture's cross-plugin projection because only the built connection/runtime/
// workspace graph can prove that transport-to-row path end to end.
// fixture's cross-plugin projection because only the built connection,
// Controller, UI adapter, and Workspace graph can prove that transport-to-row
// path end to end.
import { resolve } from 'node:path'
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
import { expect, it } from 'vitest'
@ -129,3 +130,17 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
expect(styleOwners).toContain(plugin)
}
})
it('boots without ui-chat and does not select another conversation view implicitly', async () => {
mountAssembledApp('?fixture', { exclude: ['@deepseek-ai/dsh-client-ui-chat'] })
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
const boot = Reflect.get(window, '__DSH_BOOT__') as { entries: Array<{ id: string }> } | undefined
expect(boot?.entries.some(entry => entry.id === '@deepseek-ai/dsh-client-ui-chat')).toBe(false)
const sessionTitle = await within(tree).findByText('Fixture 历史会话')
fireEvent.click(sessionTitle)
await waitFor(() => {
expect(document.querySelector('[data-slot="conversation.session"]')).not.toBeNull()
}, { timeout: 10_000 })
expect(document.querySelector('[data-slot="conversation.view"]')).toBeNull()
})

View file

@ -505,6 +505,8 @@ describe('web e2e: settings modal and General preferences', () => {
const dialog = frPage.getByRole('dialog', { name: 'Settings' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 })
const preset = dialog.getByRole('button', { name: 'Standard mode' })
await expect.poll(() => preset.isEnabled(), { timeout: 10_000 }).toBe(true)
// The markup already ships `en`, so this alone cannot prove the sync ran
// — the zh scenario above is the discriminating half. Asserted here too
// so a future change that resolves en but writes the wrong tag is caught.

View file

@ -225,8 +225,8 @@ async function detailsTrack(page: Page): Promise<number> {
// plugin's client bundle exists and exports apply, the loader fail-louds and
// the frame never appears.
const UI_PLUGIN_DIRS = [
'connection', 'runtime', 'ui-theme', 'locale', 'ui-layout', 'ui-sidebar',
'ui-settings', 'ui-settings-general', 'ui-settings-models', 'ui-conversation',
'connection', 'ui-theme', 'locale', 'ui-layout', 'ui-renderer', 'ui-session', 'ui-sidebar',
'ui-settings', 'ui-settings-general', 'ui-settings-models', 'ui-conversation', 'ui-approval', 'ui-chat',
'ui-model-selection', 'ui-user-questions', 'ui-trajectory', '../session-query/session-log-export',
]
const ROUND_DONE_MARKER = 'WEB_ROUND_DONE'

View file

@ -125,7 +125,7 @@ export async function saveFailureShot(page: Page, name: string): Promise<void> {
* The conversation engine's Context key format, restated here rather than
* imported: these specs live in the Host compiler aggregate, which must not
* reach the Client plane. The engine's own copy is
* `conversationContextKey` in dsh-client-runtime; a drift between them makes
* `conversationContextKey` in ui-conversation; a drift between them makes
* the key miss its rendered node, so the assertion fails loudly.
* @param kind - Definition kind.
* @param id - Definition-local business identity.

View file

@ -119,7 +119,7 @@ export function apply(ctx: Context): void {
if (connection.hostDescription.getSnapshot() !== undefined) sessions.handleConnected()
ctx.typert.contexts.registerClient('agent', {
identity: candidate => sessions.scopeOf(candidate),
resolve: sessionId => sessions.scope(sessionId),
resolve: sessionId => sessions.resolveAgentScope(sessionId),
})
ctx.effect(() => async () => { await control.dispose() }, 'session-controller.client.control')
}

View file

@ -177,9 +177,6 @@
- id: api-remotes
name: '@deepseek-ai/dsh-api-remotes'
- id: client-runtime
name: '@deepseek-ai/dsh-client-runtime'
- id: cordis-client-runner
name: '@deepseek-ai/dsh-cordis-client-runner'
@ -195,6 +192,9 @@
- id: ui-renderer
name: '@deepseek-ai/dsh-client-ui-renderer'
- id: ui-session
name: '@deepseek-ai/dsh-client-ui-session'
- id: ui-sidebar
name: '@deepseek-ai/dsh-client-ui-sidebar'
@ -213,6 +213,12 @@
- id: ui-conversation
name: '@deepseek-ai/dsh-client-ui-conversation'
- id: ui-approval
name: '@deepseek-ai/dsh-client-ui-approval'
- id: ui-chat
name: '@deepseek-ai/dsh-client-ui-chat'
# Official occupants for the generic sidebar and conversation brand slots.
- id: ui-brand-official
name: '@deepseek-ai/dsh-client-ui-brand-official'

View file

@ -9,15 +9,16 @@
* ui-slots): in THIS unit the map holds only this package's own merges, but
* consumers merge more namespaces in and the intersection keeps them
* string-typed. The rule fires on the narrow-map view, not real redundancy. */
import type { Context } from '@deepseek-ai/cordis'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import {
type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS,
} from '@deepseek-ai/dsh-client-ui-slots'
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 type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
// Type-only: pulls the SlotRegistry service merge (ctx.slots).
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import {
LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, type LocaleSettings,
} from '../locale-settings.ts'
@ -146,7 +147,7 @@ export class LocaleRuntime {
private bound = new Map<string, Translate>()
private snapshot: LocaleSnapshot
private listeners = new Set<() => void>()
private readonly ctx: Context
private readonly ctx: ClientContext
private readonly host: SettingsScope<LocaleSettings> | undefined
/** Browser-derived locale standing wherever no explicit Host selection does. */
private readonly provisional: LocaleId
@ -157,7 +158,7 @@ export class LocaleRuntime {
* @param host - durable preference scope owned by the providing plugin;
* absent compositions (standalone dictionary registries) stay process-local.
*/
constructor(ctx: Context, host?: SettingsScope<LocaleSettings>) {
constructor(ctx: ClientContext, host?: SettingsScope<LocaleSettings>) {
this.ctx = ctx
this.host = host
this.provisional = resolveInitialLocale()

View file

@ -3,7 +3,7 @@
* plugin's apply-world change listener is the only writer; the row component
* reads via props.useStore.
*/
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
/** One selectable locale row (id + self-described label). */
export interface LanguageOptionRow {

View file

@ -3,7 +3,7 @@
* recovery after an HMR collapse of the declaring entry. */
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import {

View file

@ -10,7 +10,7 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, inject } from '@deepseek-ai/dsh-client-locale/client'

View file

@ -4,7 +4,7 @@ import { Context } from '@deepseek-ai/cordis'
import { apply as nodeApply } from '@deepseek-ai/dsh-client-locale'
import { apply as clientApply, COMMON_NS, LocaleRuntime, inject } from '@deepseek-ai/dsh-client-locale/client'
import * as LocaleInvariant from '@deepseek-ai/dsh-client-locale/invariant'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'

View file

@ -1,7 +1,9 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { createSnapshotStore, type SessionListState, type WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
import { LanguageRow } from '../src/client/LanguageRow.tsx'
import type { LanguageRowComponentProps } from '../src/client/LanguageRow.tsx'
@ -17,13 +19,16 @@ function emptySessions() {
return bindSnapshotSelector(store)
}
function emptyWorkspaces() {
const store = createSnapshotStore<WorkspaceListState>({
const store = createSnapshotStore<WorkspaceSnapshot>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
return bindSnapshotSelector(store)
}
type AttentionSnapshot = Parameters<Parameters<LanguageRowComponentProps['useSessionPendingInteraction']>[0]>[0]
const noAttention: AttentionSnapshot = new Map()
const useSessionPendingInteraction: LanguageRowComponentProps['useSessionPendingInteraction'] = selector => selector(noAttention)
function mount(active = 'en') {
// Real store instance — the sanctioned zero-machinery path for tests.
const store = createLanguageRowStore().create()
@ -31,6 +36,7 @@ function mount(active = 'en') {
const setLocale = vi.fn()
const props: LanguageRowComponentProps = {
useSessions: emptySessions(),
useSessionPendingInteraction,
useWorkspaces: emptyWorkspaces(),
useStore: bindSnapshotSelector(store),
actions: store.actions,

View file

@ -222,16 +222,13 @@ export function orderByModuleGraph(entries: readonly WebBootEntry[]): WebBootEnt
/** Bootstrap package whose ordinary client bundle supplies the module-system implementation. */
const CLIENT_MODULES_ID = '@deepseek-ai/dsh-client-modules'
/** Dynamic package whose ordinary client bundle must be registered before plugin boot starts. */
const CLIENT_RUNTIME_ID = '@deepseek-ai/dsh-client-runtime'
/** Ordinary dynamic bundles the HTML parser executes before the Vite shell. */
const PARSER_PRELOAD_IDS = [CLIENT_MODULES_ID, CLIENT_RUNTIME_ID] as const
const PARSER_PRELOAD_IDS = [CLIENT_MODULES_ID] as const
/**
* The boot protocol as index injection rows. The inline registration queue
* precedes blocking classic scripts for modules' and runtime's ordinary
* `lib/client.js` artifacts. Its `create()` method materializes the modules
* precedes the blocking classic script for modules' ordinary `lib/client.js`
* artifact. Its `create()` method materializes the modules
* bundle, delegates construction to that bundle, and leaves the same facade
* in live-registration mode. The graph global follows before the shell reads
* it.

View file

@ -14,7 +14,7 @@ import { ClientModuleRegistry, bootInjections, orderByModuleGraph } from '../src
import type { ClientModuleLoaderTarget, WebBootEntry, WebBootGraph } from '../src/client/index.ts'
const MODULES_ID = '@deepseek-ai/dsh-client-modules'
const RUNTIME_ID = '@deepseek-ai/dsh-client-runtime'
const UI_RENDERER_ID = '@deepseek-ai/dsh-client-ui-renderer'
let root: string | undefined
@ -99,7 +99,7 @@ const bootGraph = (): WebBootGraph => ({
rev: 'graph',
entries: [
{ id: MODULES_ID, url: '/plugins/modules.js?rev=m', rev: 'm' },
{ id: RUNTIME_ID, url: '/plugins/runtime.js?rev=r', rev: 'r' },
{ id: UI_RENDERER_ID, url: '/plugins/ui-renderer.js?rev=r', rev: 'r' },
],
})
@ -109,22 +109,22 @@ describe('HTML bootstrap facade', () => {
const { html, target } = injectedFacade(graph)
const facadeAt = html.indexOf('window.__ModuleLoader__=')
const modulesAt = html.indexOf('<script src="/plugins/modules.js?rev=m"></script>')
const runtimeAt = html.indexOf('<script src="/plugins/runtime.js?rev=r"></script>')
const graphAt = html.indexOf('globalThis["__DSH_BOOT__"] = ')
const entryAt = html.indexOf('<script type="module" src="/index.js"></script>')
expect([facadeAt, modulesAt, runtimeAt, graphAt, entryAt]).toEqual([...new Set([
facadeAt, modulesAt, runtimeAt, graphAt, entryAt,
expect(html).not.toContain('<script src="/plugins/ui-renderer.js?rev=r"></script>')
expect([facadeAt, modulesAt, graphAt, entryAt]).toEqual([...new Set([
facadeAt, modulesAt, graphAt, entryAt,
])].sort((a, b) => a - b))
target.load({ id: MODULES_ID, factory: () => modulesClient })
target.load({ id: RUNTIME_ID, factory: () => ({ marker: 'runtime' }) })
target.load({ id: UI_RENDERER_ID, factory: () => ({ marker: 'ui-renderer' }) })
const system = target.create({ boot: graph, staticModules: {} })
expect(target.mode).toBe('live')
expect(target.pendingQueue).toEqual([])
expect(system.manifest.rev).toBe('graph')
expect(await system.import(MODULES_ID)).toBe(modulesClient)
expect(await system.import(`${RUNTIME_ID}/client`)).toEqual({ marker: 'runtime' })
expect(await system.import(`${UI_RENDERER_ID}/client`)).toEqual({ marker: 'ui-renderer' })
expect(() => target.create({ boot: graph, staticModules: {} }))
.toThrow('create called after module-system boot')
})

View file

@ -1,6 +0,0 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: adeeca51ef2948da09c2906c803adcd49dcc4b74
README.zh.md: 8235593bf00a6634efa7c98a1b3c3bea9eafa564

View file

@ -1,97 +0,0 @@
# @deepseek-ai/dsh-client-runtime
English | [中文](README.zh.md)
Client cordis boot and React-free object services: SlotRegistry wraps SlotCore and supplies renderer data sources; SessionRuntime owns Session objects, list and scope state, and the shared event window and history paging used by registered conversation view targets. Each durable window uses one Session Controller journal stream; one Host-wide snapshot stream supplies queue, jobs, projections, approvals, and questions. WorkspaceRuntime depends on SessionRuntime and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`); Workspace Controller supplies its reconnecting snapshot stream. Domain packages subscribe to forwarded Host events through `ctx.remote.$on`.
Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state. A session's Agent scope, the client mirror of Host dsh-scope keyed by the shared Agent/Session id, is born when its row enters the list mirror and dies with the prune. Each `Session` holds a generic `ProjectionValueStore` seeded from Session list or page projection blocks and updated by control-stream projection replacements under higher-seq-wins. Domain keys, including `todos`, are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
For each prompt that can reach a local root or continuable child Agent, the runtime samples the browser's current `Intl.DateTimeFormat().resolvedOptions().timeZone` and attaches it to that one Session or subagent prompt RPC. It is neither cached nor included in Session creation or fork state, so travel and concurrent tabs keep message-local provenance. A browser that cannot provide a non-empty zone fails the prompt locally instead of silently substituting deployment state.
Settings owners share the React-free `SettingsScopeSpec`, `SettingsScope`, and snapshot types defined here. ui-settings owns `ctx.settingsScope.bind(spec)`, its Host transport, schema validation, and lifecycle; see [its package contract](../ui-settings/README.md).
## Slot declaration injection
`ctx.slots.inject(name, callback)` makes a full `SlotMap` key the dependency for a contribution whose plugin can activate independently from the declaring entry. It runs `callback` synchronously when the declaration exists, otherwise waits; declaration collapse disposes the callback effect, and redeclaration reruns it. The controller belongs to the caller's plugin fiber, so unloading the contributor cancels either the wait or its active registrations. A direct `slots.register()` into an undeclared slot still throws.
The callback returns one synchronous disposer or an iterable of disposers. A generator can therefore yield several `slots.register()` calls as one transaction: setup failure rolls earlier yields back and teardown runs them in reverse order. Declaration lifetimes use a dedicated monotonic epoch, so a collapse and redeclaration batched into one renderer notification still restarts the callback, while ordinary entry changes do not. Declaration-bound teardown runs synchronously with the ledger mutation, releasing runtime resources before subsequent same-tick registrations. See the [declaration-injection decision](../../../.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.md).
## Workspace and Session lists
Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal/order frames and unary mutation echoes arriving during a list request replay over its response. Every successful Workspace baseline re-establishes Host-durable Workspace order so reconnects adopt changes committed while this client was offline. `WorkspaceRuntime.insertBefore` installs an optimistic order immediately; only the latest unary echo may replace it, a newer Host order frame outranks an older echo, and a latest rejected request restores the last Host-confirmed order rather than an earlier uncommitted drag. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them. Workspace recency is derived only after both baselines are ready and never changes Workspace list order.
`SessionSummary.pendingInteraction` classifies the live user action blocking a Session as `approval`, `plan-review`, or `question`. `SessionManager` tracks control-stream requested/resolved frames by stable `interactionId` even before a Session object is instantiated; pre-instantiation state retains every live request, replaces duplicates, and removes resolved requests so the list status always has a matching answerable `PendingWait` when the Session is opened. The first pending question takes presentation priority over concurrent approvals to match composer routing, while only a request that satisfies the plan-review composer's binary rendering constraints keeps the distinct `plan-review` status. Every control generation begins with a complete baseline that replaces the pending set and therefore restores only requests that remain answerable.
`WorkspaceRuntime.delete(workspaceId)` removes the registration from the client projection after the successful unary response; the matching `host/workspace-removed` frame is idempotent and synchronizes other tabs. Session state and the current Session selection are independent, so accounted Sessions immediately project under Ungrouped after their Workspace disappears.
`WorkspaceListState.archivedSessionIds` mirrors the Host's registry-global archive set (a `readonly SessionId[]` in Host order, replaced only when membership changes; consumers needing O(1) lookups build a transient Set). It is full-snapshot state: the `workspace.list` baseline, the `archiveSession` unary echo, and the `host/archived-sessions-changed` frame each install the complete set. `WorkspaceRuntime.archiveSession(sessionId)` archives over the wire; the projection sweep clears the current selection into the New Session view state whenever it lands in the archive set — one rule covering the local echo, another tab's frame, and a reconnect baseline restoring a selection archived while this client was away. A set installed while a `workspace.list` request is in flight also supersedes that stale baseline's set. Grouping surfaces hide members everywhere while the session rows stay in the list store.
SlotRegistry gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; ui-renderer creates the hooks. Workspace business state does not enter `SessionListState` or an entry store.
`abbreviateHomePath` is the display-only POSIX home abbreviation used by Web Workspace hover cards and Tool summaries; a Windows drive or UNC path stays verbatim, and a missing, empty, or filesystem-root home leaves the path unchanged.
`indexSubagentDescendants()` derives per-parent total and running descendant counts from the retained list mirror. It follows only uninterrupted `origin: 'subagent'` ancestry, so an ordinary fork starts a separate ownership subtree; cycles stop without throwing, and a missing parent remains a harmless key until its summary arrives.
`SessionListState.jobsBySession` mirrors the Session Controller control stream, keyed by Session and needing no Session instance. Each control baseline replaces the complete map; later `jobs` frames are last-wins replacements for one Session. An empty set is stored as an absent key, so absence and `[]` are one representation and consumers never test a sentinel. The forwarded `api-session/removed` event also clears that Session's jobs.
`SessionRuntime.search(query, signal)` is a stateless one-shot action over `ctx.remote.session.search`. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. `searchResultLimit` re-exposes `SESSION_SEARCH_RESULT_LIMIT` — the bound the response schema itself enforces — as injected presentation data, so client plugins do not duplicate it. It is a protocol constant rather than per-connection state, so the connection handle does not carry it.
## New Session and the blank mirror
`WorkspaceRuntime.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path && sessionIds.includes(id)` — the Host's own membership rule, never cwd alone, so a cwd-matching unaccounted blank session is never hijacked) or calls `ctx.remote.session.create({workspaceId})`, returning the Session id for the caller to open. The shared `startSession` action targets an explicit Workspace first, then the current Session's Workspace, then the derived recent Workspace; with no Workspace it clears into the blank New Session page. `SessionSummary.blank` mirrors the Host's derived empty-log bit and only ever lowers on the client: it is seeded by `session.list` or `api-session/added`, flips false after the first accepted local `prompt()` and on any `api-session/status` event with `running: true`, and is re-aligned by every list pull. List surfaces hide blank rows; the store carries every row. `SessionRuntime.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` carrying `requestedSessionId` on failure.
`Session.composerPhase` treats any visible non-command Chat Node as conversation content, so a client plugin can project durable human input without opening a turn while a window containing only generic command rows retains the Host blank posture. List hiding and blank-session reuse still follow the Host blank bit. A history window that lacks the plugin-owned input Node returns to that blank posture until an older page restores it.
## Pending queue projection
`ConversationSnapshot.queue` is the Host's authoritative transient snapshot of both `agent.inbox.nextTurn` and `nextStep`. Rows are tagged `queued`, `steering`, or `context`; each carries its `MessageId`, complete editable text when every content block is text, and a flattened preview. Every control generation starts with complete queue snapshots, and later `queue` replacements follow `agent/inbox/spliced` changes; message-local inserted, claimed, and discarded notifications are not used to reconstruct the projection. `Session.updateQueue()` sends mutations without optimistic client state, so the next Host snapshot is the visible commit and a claim race can surface `queue-item-not-found`.
## Conversation assembly
Each `Session` gives its contiguous event window to a `ConversationNodeAssembler`. Plugins register business Definitions that map one event to a stable `{kind, id}`, create State at the unique start event, fold correlated updates, and build final nodes for registered view targets. The assembler owns the Context index, read-only predecessor lookup, and a reference-stable Turn/Step Location index. A live append evaluates each Definition once and updates only the matched Context; loading an older page preserves existing Context and node identities, matches only the newly prepended events, and replays Contexts whose predecessor or Location facts changed. Full replacement is reserved for open, resync, and gap repair.
Definition authors keep matching local to the current event, give every correlated event a stable business id, and make updates replayable by log `seq`; renderers consume final Node data and constrained Location values rather than scanning Session or Chat collections. The [Conversation Node cookbook](../../../docs/cookbook/adding-a-conversation-node.md) gives the complete registration and pagination path.
`ui-conversation` registers the built-in Chat Definitions and the keyed Chat snapshot builder. Append-origin user, assistant, and Tool results remain the human record; model-only replacement copies stay out, except that a compaction checkpoint becomes its own marker and resolves missing summary provenance when an older page supplies it. Durable inbox splice Contexts classify next-step user messages as steering without making inbox state a Session special case. Context messages retain producer provenance and form. StatsLine reads `ConversationSnapshot.chat.legacy.nodes`, while Session mirrors that legacy slice into the top-level `nodes`, `partial`, and `runningCalls` public compatibility fields without running a second business fold. `ui-trajectory` registers independent Definitions and a target builder over the same Session window; it preserves the existing stage-oriented view model without consuming the Chat compatibility fields or running another history fold.
The Chat builder keeps one mutable keyed store per Session. Content updates notify only the affected node key, structural changes rebuild order and Location membership, and a prepend adds rows without replacing existing keyed values. Assistant chunks update Definition State for every event but request at most one materialization per animation frame; final messages and Turn/Step closure publish immediately. See the [client Tool presentation decision](../../../.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md).
## Trajectory request data
Trajectory Definitions assemble one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric `turn` and `step`; compaction requests carry `step: 0` and a `turn` owner that may be `null`. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn. A cancellation-finalized `assistant/message` retains its durable result seq and provider provenance but does not complete the request; `step/end` classifies that request as an error. A `session/end-seed` boundary closes an unmatched compaction request as an error at the boundary time with `Compaction was interrupted before completion.`; a later start projects as an independent request instead of overwriting the orphan.
## Code Mode child-call tree
Every `ToolCallBlock` recursively owns its children through `subCalls`, in start order. Chat's Tool Definition correlates root calls and results by call id, folds Code Dispatch start/settlement records into that root Context, and projects one keyed recursive tree; child calls never become independent Chat roots. When a start falls outside the loaded window, its settlement remains renderable with `callTime: null`. A child update copies only its ancestor path, so unchanged siblings retain object identity. Edges that introduce a cycle or exceed the fixed 256-call depth limit are consumed without mutating the tree. Trajectory's Tool Definition independently assembles the same nested data contract for its target.
## Session title projection
`SessionManager` retains the generic `title` projection independently of list and Session-instance arrival. Session list summaries, page tails, and control frames all seed the same store; higher event seqs replace lower ones, and a replacement control baseline truncates rows beyond its watermark before seeding its values. Explicit Session removal clears the store. List `updatedAt` is Host-owned and derives from the latest human prompt, so a title change does not affect recency. The client-facing `SessionSummary.title` is only the durable title; `displayTitle` is always present and falls back through the cwd basename and Session id. `ISession.rename` settles the `title` projection cell directly from the Remote response's `{title, seq}` under the same higher-seq-wins rule, so list and `useProjection('title')` readers update before a later replay of the same seq.
## Model retry projection
The Host-owned LLM retry invariant validates provider-routed `llm/retry` and `llm/retry-started` records at the durable append boundary, including their identity, ordering, timer, integer, status, provider-delay, and non-empty diagnostic contracts. In the client, the Retry, Assistant, and Turn Error Definitions fold those records with Assistant and Turn/Step events: a failed attempt's streaming partial is removed and a durable retry notice appears at the retry event's sequence position. The notice is `scheduled` until the matching started record arrives; closing its owning Step or Turn first marks it `cancelled`, while the started record marks it `started`. Normal-mode notices carry their finite maximum; always-mode notices remain explicitly unbounded. A terminal `turn/end` error projects one `turn-error` node from its durable message and optional code — after exhausted retries it renders beside the settled retry notice; AUTH projections replace provider copy that may echo credential fragments with `API key is invalid`, while the raw diagnostic remains in the session log. An intermediate failure that scheduled another retry keeps only the retry notice for that attempt. Window rebuild and history replay use the same Definitions, so refresh neither resurrects discarded chunks nor loses terminal failure feedback. Visible unfinalized output is frozen as an interrupted Assistant node beside the terminal error.
A `turn/end` whose reason is `max-tokens` projects one `turn-max-tokens` node at the turn position: a warning-styled localized notice that the reply stopped at the per-request output cap, with the truncated output kept in the flow and guidance that sending "continue" resumes in a new turn. The notice carries no token counts because the event reports none. The same Definition rebuilds it on window rebuild and history replay, so the reason survives refresh and restore.
## Session forking
`ISessions.fork({sessionId, atSeq?, increaseTitle?})` resolves only after the child summary is locally addressable, carrying source lineage and cwd with `blank: false`; callers choose whether to open it. With `increaseTitle: true`, the client renames the child from the source session's persisted title: a trailing `(N)` or `(N)` is incremented without changing bracket style, while any other title gets ` (1)` appended; the rename is skipped when the source has no persisted title, and a rename failure rejects the promise but leaves the created child in place. This option is not sent in the Host fork request. A `workspace-attach-failed` response still identifies a child already published by the Host, so `SessionManager` reconciles that partial success before `SessionForkError` reaches the caller instead of making a retry create a duplicate child.
## Model selection ownership
Session Runtime carries no model-selection snapshot. `ui-model-selection` owns one scoped `ModelDirectory` per Session and calls `ctx.remote.session.models` and `selectModel` directly; Runtime supplies only Session scope and address information. That package resets its directory on `connection/reset`, shares one latest-generation-wins store between its two selectors, and disposes it with the Session scope.
## Model Experience
None, as this package adds no model-visible content; model selection belongs to `ui-model-selection` and the Host Session Controller.
#### KV Cache effect
None directly; this package neither selects a model nor alters the prompt prefix.
## Known Limitations and Deferred Work
- **`loader.unload` is a stub** — it throws not-implemented; the client has no unload chain from fiber disposal through registration and style removal.
- **Scope teardown is stage-driven and single-occupant** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`binding()`/`scope()`) is pure addressing, render-safe; the render layer reads the current bundle through the `currentProvideInfo` observable. The staged state can widen to a multi-pane list when concurrent panes land.
- **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches.

View file

@ -1,99 +0,0 @@
# @deepseek-ai/dsh-client-runtime
[English](README.md) | 中文
客户端 cordis 启动与不依赖 React 的对象服务:SlotRegistry 包装 SlotCore 并提供 renderer 数据源;SessionRuntime 拥有 Session 对象、列表与 scope 状态,以及供已注册 conversation view target 共用的事件窗口与历史分页。每个持久窗口使用一条 Session Controller journal stream;一条 Host 级 snapshot stream 提供 queue、jobs、projection、approval 与 question。WorkspaceRuntime 依赖 SessionRuntime,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`);Workspace Controller 提供它的可重连 snapshot stream。各领域包通过 `ctx.remote.$on` 订阅 Host 转发事件。
客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、Agent 和 cwd);客户端不持有任何实体化之前的会话状态。Agent scope 是 Host dsh-scope 的客户端镜像,以 Agent/Session 共用 id 为键,在会话行进入列表镜像时创建,并随 prune 销毁。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由 Session 列表或 page 的 projection block 播种,并经 control 流的 projection replacement 按 seq 高者胜更新。领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
对于每条可到达本地根 Agent 或可继续子 Agent 的提示词,运行时都会采样浏览器当前的 `Intl.DateTimeFormat().resolvedOptions().timeZone`,并只把该值附加到这一次 Session 或 subagent 提示词 RPC。该值既不缓存,也不包含在 Session 创建或 fork 状态中,因此旅行与并发标签页都能保留消息本地的来源信息。浏览器若无法提供非空时区,会在本地拒绝该提示词,而不会悄然使用部署状态代替。
设置所有者共用本包定义的不依赖 React 的 `SettingsScopeSpec`、`SettingsScope` 与快照类型。ui-settings 拥有 `ctx.settingsScope.bind(spec)`、对应的 Host 传输、schema 校验与生命周期;详见[该包的约定](../ui-settings/README.zh.md)。
<a id="slot-declaration-injection"></a>
## Slot 声明注入
`ctx.slots.inject(name, callback)` 将完整的 `SlotMap` key 作为贡献项的依赖,适用于贡献方插件可独立于声明条目激活的情形。声明存在时,它会同步运行 `callback`,否则等待;声明折叠会 dispose(资源释放)回调 effect,重新声明则会再次运行回调。控制器归调用方的插件 fiber 所有,因此卸载贡献方会取消等待或移除其活跃注册项。直接调用 `slots.register()` 向未声明 slot 注册仍会抛出异常。
回调返回一个同步 disposer 或由多个 disposer 构成的 iterable。因此,generator 可以 yield 多个 `slots.register()` 调用,并将它们组成一项事务:setup 失败会回滚先前 yield 的 effect,teardown 则按逆序运行它们。声明生命周期使用专用的单调 declaration epoch(声明代次),因此,即使折叠与重新声明合并在同一次 renderer 通知中,回调仍会重启,而普通条目变更不会重启它。声明绑定的 teardown 与账本变更同步运行,在同一 tick 内的后续注册之前释放运行时资源。详见 [slot 声明注入决策](../../../.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.zh.md)。
## Workspace 与 Session 列表
Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量插入或更新/移除/顺序帧与一元变更回显会在其响应之上回放。每次成功的 Workspace 基线都会重新建立 Host 持久 Workspace 顺序,因此重连会接纳该客户端离线期间提交的变更。`WorkspaceRuntime.insertBefore` 会立即安装乐观顺序;只有最新一元回声可以替换它,更新的 Host 顺序帧优先于旧回声,而最新请求被拒时会恢复最近一次由 Host 确认的顺序,不会恢复更早且尚未提交的拖拽。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
`SessionSummary.pendingInteraction` 将阻塞 Session 的实时用户操作分类为 `approval`、`plan-review` 或 `question`。`SessionManager` 依据稳定的 `interactionId` 跟踪 control 流的 requested/resolved 帧,即使 `Session` 对象尚未实例化也不例外;实例化前的状态会保留每个仍有效的请求、替换重复项并移除已解决的请求,因此打开 Session 时,列表状态始终有一个对应的可应答 `PendingWait`。审批与问题并发时,第一个 pending 问题具有更高的呈现优先级,以匹配 composer 路由;只有满足 plan-review composer 二元呈现约束的请求才会保留独立的 `plan-review` 状态。每一代 control 都以完整 baseline 开始并替换 pending 集合,因此只恢复仍可应答的请求。
`WorkspaceRuntime.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性,并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已纳入客户端投影的 Session 会立即投影到 Ungrouped 下。
`WorkspaceListState.archivedSessionIds` 镜像 Host 的注册表级全局归档集合(一个按 Host 顺序的 `readonly SessionId[]`,仅在成员变化时才替换;需要 O(1) 查询的消费方自建临时 Set)。它是全快照状态:`workspace.list` 基线、`archiveSession` 一元回声和 `host/archived-sessions-changed` 帧各自安装完整集合。`WorkspaceRuntime.archiveSession(sessionId)` 通过 wire 归档;投影层在当前 selection 落入归档集合时统一清空为 New Session 视图状态——一条规则同时覆盖本地回声、其他标签页的帧、以及重连基线恢复出一个离线期间被归档的 selection。在 `workspace.list` 请求进行中安装的集合还会取代该过期基线携带的集合。各分组视图在所有位置隐藏集合成员,而会话行本身仍留在列表 store 中。
SlotRegistry 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;ui-renderer 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或条目 store。
`abbreviateHomePath` 是 Web Workspace 悬停卡片与 Tool 摘要使用的仅展示 POSIX 家目录缩写;Windows 盘符或 UNC 路径保持原样,缺失、空或文件系统根的 home 不改写路径。
`indexSubagentDescendants()` 从保留的列表镜像中派生每个 parent 的后代总数与运行中后代数。它只沿不间断的 `origin: 'subagent'` 祖先链追踪,因此普通 fork 会开启独立的归属子树;遇到环时,追踪会停止但不会抛出异常,缺失的 parent 则会保留为无害的键,直至其摘要到达。
`SessionListState.jobsBySession` 镜像 Session Controller control 流,以 Session 为键,不需要 Session 实例。每份 control baseline 替换完整映射;后续 `jobs` 帧按 last-wins 替换单个 Session。被清空的集合存为缺失的键,因此「缺失」与 `[]` 是同一种表示,消费方永远不必检测哨兵值。转发的 `api-session/removed` 事件也会清除该 Session 的 jobs。
`SessionRuntime.search(query, signal)` 是基于 `ctx.remote.session.search` 的无状态单次操作。它返回经过排序的会话/snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit` 将 `SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。
## New Session 与 blank 镜像
`WorkspaceRuntime.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 Workspace 的既有空会话(`blank && cwd == workspace.path && sessionIds.includes(id)`——Host 自己的成员规则,绝不只按 cwd,避免劫持 cwd 匹配但未入账的空白会话),未命中则调用 `ctx.remote.session.create({workspaceId})`,返回 Session id 由调用方 open。共享的 `startSession` 操作优先使用明确指定的 Workspace,其次使用当前 Session 所属 Workspace,再其次使用派生的最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。`SessionSummary.blank` 镜像 Host 派生的空日志位,在客户端只降不升:由 `session.list` 或 `api-session/added` 播种,本地首次获 Host 接受的 `prompt()` 后与任何 `running: true` 的 `api-session/status` 事件都会将其翻为 false,每次列表拉取重新对齐。列表界面隐藏 blank 行;store 保留全部行。`SessionRuntime.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出携带 `requestedSessionId` 的 `SessionCreateError`。
`Session.composerPhase` 把任何可见的非命令 Chat Node 视为对话内容,因此客户端插件可以在不打开轮次的情况下投影持久用户输入,而仅包含通用命令行的窗口仍保持 Host blank 状态。列表隐藏和空白会话复用仍遵循 Host blank 位。缺少插件输入 Node 的历史窗口会恢复该空白状态,直到加载更早页面后该 Node 恢复。
## 待处理队列投影
`ConversationSnapshot.queue` 是 Host 提供的 `agent.inbox.nextTurn` 与 `nextStep` 权威瞬态快照。各行标记为 `queued`、`steering` 或 `context`,并携带 `MessageId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。每一代 control 都以完整 queue 快照开始,后续 `queue` replacement 跟随 `agent/inbox/spliced` 变更;面向单条消息的 inserted、claimed 与 discarded 通知不用于重建该投影。`Session.updateQueue()` 不做乐观客户端变更,下一份 Host 快照才是可见提交结果,claim 竞态则可能呈现 `queue-item-not-found`。
## Conversation 组装
每个 `Session` 都把连续事件窗口交给 `ConversationNodeAssembler`。插件注册业务 Definition,把单个事件映射为稳定的 `{kind, id}`,在唯一 start 事件处创建 State,折叠有关联的 update,再为已注册的视图目标构造最终节点。Assembler 负责 Context 索引、只读前序 Context 查询,以及引用稳定的 Turn/Step Location 索引。实时 append 只对每个 Definition 求值一次,并且只更新命中的 Context;加载更早分页时保留已有 Context 与节点身份,只匹配新 prepend 的事件,并重放前序依赖或 Location 事实发生变化的 Context。完整替换仅用于 open、resync 和 gap repair。
Definition 作者只根据当前事件完成匹配,为每条关联事件提供稳定业务 id,并保证 update 能按日志 `seq` 回放;renderer 只消费最终 Node data 与受限 Location value,不扫描 Session 或 Chat 集合。完整注册和分页路径见 [Conversation Node 实操手册](../../../docs/cookbook/adding-a-conversation-node.zh.md)。
`ui-conversation` 注册内建 Chat Definition 与 keyed Chat snapshot builder。append 来源的 user、assistant 和 Tool result 构成人类可见记录;仅供模型使用的 replacement 副本不进入 Chat,compaction 检查点除外,它会成为独立标记,并在更早分页补齐 summary 溯源后更新。持久 inbox splice Context 能把 next-step 用户消息判定为 steering,无须让 inbox 状态成为 Session 特例。上下文消息保留生产者 provenance 与 form。StatsLine 读取 `ConversationSnapshot.chat.legacy.nodes`;Session 则把该 legacy slice 镜像到顶层 `nodes`、`partial` 和 `runningCalls` 公共兼容字段,无须运行第二套业务 fold。`ui-trajectory` 在同一个 Session 窗口上注册独立 Definition 与 target builder;它保留现有的 stage-oriented view model,既不消费 Chat 兼容字段,也不运行另一套 history fold。
Chat builder 为每个 Session 保留一个 mutable keyed store。内容更新只通知受影响的 node key;结构变化才重建顺序和 Location 成员关系;prepend 只增加行,不替换既有 keyed value。每个 Assistant chunk 都会更新 Definition State,但最多每个 animation frame 请求一次物化;final message 与 Turn/Step 关闭会立即发布。参见 [Client Tool 展示所有权决策](../../../.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md)。
## Trajectory 请求数据
Trajectory Definition 组装出一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn` 与 `step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。由取消定稿的 `assistant/message` 会保留持久结果 seq 和提供方信息,但不会将请求标记为完成;`step/end` 会把该请求归类为错误。`session/end-seed` 边界会在边界时刻将未匹配的压缩请求以错误状态结束,错误固定为 `Compaction was interrupted before completion.`;后续 start 会投影为独立请求,而不会覆盖这项遗留的未匹配请求。
## Code Mode 子调用树
每个 `ToolCallBlock` 都通过 `subCalls` 按启动顺序递归拥有自己的子调用。Chat 的 Tool Definition 按 call id 关联 root call 与 result,把 Code Dispatch 的 start/settlement 记录折叠进该 root Context,并投影为一棵 keyed 递归树;child call 不会成为独立 Chat root。start 落在已加载窗口之外时,其 settlement 仍以 `callTime: null` 渲染。一次 child 更新只复制其祖先链,因此未变化的 sibling 保持对象身份。会引入环或超过固定 256 层深度上限的边会被消费,但不会修改树。Trajectory 的 Tool Definition 为自己的 target 独立组装同一种嵌套数据契约。
## Session 标题投影
`SessionManager` 独立于列表和 Session 实例到达情况,保留通用的 `title` projection。Session 列表摘要、page 尾部与 control 帧都会播种同一个 store;seq 更高的事件替换较低值,而替换 control baseline 会先截断超过其 watermark 的行,再播种自己的值。显式移除 Session 也会清除该 store。列表 `updatedAt` 由 Host 所有并根据最近一次真人 prompt 派生,因此标题变化不影响新近程度。面向客户端的 `SessionSummary.title` 只包含实际的持久化标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。`ISession.rename` 用 Remote 响应中的 `{title, seq}` 直接结算 `title` 投影格,遵循同一 seq 高者胜规则,因此列表行和所有 `useProjection('title')` 读者会在后续同 seq 回放前更新。
## 模型重试投影
Host 所属的 LLM(大语言模型)retry invariant 会在持久追加边界验证按提供方路由的 `llm/retry` 与 `llm/retry-started` 记录,包括标识、顺序、计时器、整数、状态、提供方延迟和非空诊断字段约定。客户端的 Retry、Assistant 与 Turn Error Definition 把这些记录和 Assistant、Turn/Step 事件一起折叠:失败尝试的流式输出片段会被移除,并在 retry 事件的序列位置插入一条持久重试提示。该提示在匹配的 started 记录到达前为 `scheduled`;如果所属 Step 或 Turn 先关闭,则标记为 `cancelled`,started 记录到达后则标记为 `started`。normal mode 提示携带其有限上限;always mode 提示保持显式无界。终态 `turn/end` 错误会从持久消息与可选错误码投影出一个 `turn-error` 节点——重试耗尽后它与定格的重试提示并列渲染;AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。安排了下一次重试的中间失败只保留该次尝试的重试提示。窗口重建与历史回放使用同一组 Definition,因此刷新既不会让已丢弃的分片重新出现,也不会丢失终态失败反馈。可见但尚未定稿的输出会在终态错误旁冻结为中断的 Assistant 节点。
reason 为 `max-tokens` 的 `turn/end` 会在该轮位置投影出一个 `turn-max-tokens` 节点:一条 warning 样式的本地化提示,说明回答在单次请求的输出 token 上限处停止,已截断的输出保留在对话流中,并提示发送“继续”可在新一轮接着输出。事件本身不携带 token 数量,提示因此不显示任何数字。窗口重建与历史回放使用同一 Definition 重建该节点,刷新和恢复后结束原因保持一致。
## 会话 fork
`ISessions.fork({sessionId, atSeq?, increaseTitle?})` 只在子会话摘要已能在本地寻址后才完成;该摘要携带源会话的谱系和 cwd,且 `blank: false`,由调用方决定是否打开。`increaseTitle: true` 会在 client 端根据源会话的持久化标题重命名子会话:尾部 `(N)` 或 `(N)` 递增并保留括号样式,其余标题追加 ` (1)`;源会话没有持久化标题时跳过改名,改名失败时拒绝 promise 但保留已创建的子会话。该选项不会进入 Host fork 请求。即使响应为 `workspace-attach-failed`,其中仍会标识 Host 已发布的子会话,因此 `SessionManager` 会先将这一部分成功对账,再让 `SessionForkError` 到达调用方,避免重试创建重复的子会话。
## 模型选择所有权
Session Runtime 不携带模型选择快照。`ui-model-selection` 为每个 Session 拥有一个 scope 绑定的 `ModelDirectory`,并直接调用 `ctx.remote.session.models` 与 `selectModel`;Runtime 只提供 Session scope 和地址信息。该包在 `connection/reset` 时重置目录,让两个 selector 共用一份 latest-generation-wins store,并随 Session scope 销毁它。
## 模型体验
无,因为本包不添加模型可见内容;模型选择由 `ui-model-selection` 与 Host Session Controller 所有。
#### KV Cache 影响
无直接影响;本包既不选择模型,也不改变提示词前缀。
## 已知限制与暂缓事项
- **`loader.unload` 是 stub**:它会抛出 not-implemented;客户端没有从 fiber dispose 到注册与样式移除的卸载链。
- **scope 拆卸由阶段驱动且只有一个占用者**:已 staged 的会话精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ 会话位于 stage);在 staged 状态下被移除的会话,其 scope 会冻结保留,直到 stage 转向其他会话,而非直到真实观察者数量降为零。解析(`binding()`/`scope()`)只是纯寻址,可安全用于渲染;渲染层经 `currentProvideInfo` observable 读取当前 bundle。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。
- **插件 bundle 从该包导入值时必须使用 `/client` 子路径**:裸包名不在 loader externals 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配。

View file

@ -1,111 +0,0 @@
{
"name": "@deepseek-ai/dsh-client-runtime",
"description": "Client core services: SlotRegistry, SessionRuntime (scope tree + object layer)",
"version": "0.1.1-rc.2",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/client/runtime"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dsh": {
"client": {
"external": [
"@deepseek-ai/dsh-api-gateway/client",
"@deepseek-ai/dsh-api-session-controller/client",
"@deepseek-ai/dsh-api-workspace-controller/client"
],
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-typert-registry",
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-api-session-controller",
"@deepseek-ai/dsh-api-workspace-controller"
],
"platform": "web",
"immediately": true
}
},
"license": "MIT",
"dependencies": {
"immer": "^10.1.1",
"zustand": "~4.4.7"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-gateway": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
"@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-util-crypto": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-gateway": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
"@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-util-crypto": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"@types/react": "~18.3.1",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"react": "^18.2.0",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts"
]
}

View file

@ -1,250 +0,0 @@
/** 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-api-remotes/client'
import {
createSessionControlStream,
SESSION_SEARCH_RESULT_LIMIT,
} from '@deepseek-ai/dsh-api-session-controller/client'
import {
createWorkspaceStateStream, ClientWorkspaceModel,
} from '@deepseek-ai/dsh-api-workspace-controller/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-remotes/client'
import type { TypertContext } from '@deepseek-ai/dsh-typert-protocol'
import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotRegistry } from './slots.ts'
import { SessionRuntime } from './sessions/service.ts'
import type { SessionListState } from './sessions/service.ts'
import { WorkspaceRuntime } from './workspaces/service.ts'
import type { ConversationSnapshot } from './sessions/conversation.ts'
import type { UseProjection } from './sessions/projection-store.ts'
import { ConversationEventRegistry } from './conversation/event-registry.ts'
import { ConversationViewRegistry } from './conversation/view-registry.ts'
export { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
export { SESSION_SEARCH_RESULT_LIMIT }
export { SlotRegistry } from './slots.ts'
export { ConversationEventRegistry } from './conversation/event-registry.ts'
export { ConversationViewRegistry } from './conversation/view-registry.ts'
export { ConversationNodeAssembler } from './sessions/conversation-assembler.ts'
export { ConversationLocationIndex } from './sessions/conversation-location-index.ts'
export { conversationContextKey } from './contract/conversation.ts'
export type {
ChatConversationViewNode, ConversationContextReader, ConversationEventInput,
ConversationLocationData, ConversationLocationDataScope, ConversationLocationDataStore,
ConversationStepDataMap,
ConversationLocation, ConversationMatch, ConversationMatchResult,
ConversationNodeContext, ConversationNodeDefinition, ConversationPreviousContext,
ConversationPublication, ConversationTimelineSnapshot, ConversationTurnDataMap, ConversationViewBuilder,
ConversationViewDefinition, ConversationViewNode, ConversationViewSnapshotMap,
ConversationViewSnapshotStore, StepLocation, TurnLocation,
} from './contract/conversation.ts'
export type { ConversationRuntime } from './sessions/conversation-assembler.ts'
export type { RootOwnerProps } from './slots.ts'
export { SessionCreateError, SessionRuntime, scopeOf, workspaceTitleOf } from './sessions/service.ts'
export { indexSubagentDescendants } from './sessions/subagent-lineage.ts'
export type { SubagentDescendantSummary } from './sessions/subagent-lineage.ts'
export { SessionProvideChannel } from './sessions/provide.ts'
export type { SessionProvideChannelHost } from './sessions/provide.ts'
export { createScope } from './agents/scope.ts'
export type { AgentScopeHandle } from './agents/scope.ts'
export { DirectoryBrowseError, WorkspaceCreateError, WorkspaceRuntime } from './workspaces/service.ts'
export { abbreviateHomePath, 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'
export type { IWorkspaces } from './contract/workspaces.ts'
export type {
SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
} from './sessions/service.ts'
export type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './sessions/manager.ts'
export type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client'
export type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types'
export type { WorkspaceListPhase } from '@deepseek-ai/dsh-api-workspace-controller/client'
export type { WorkspaceListState } from './workspaces/service.ts'
export type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-client-connection/client'
export type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-remotes/client'
// Runtime owns the snapshot store; ui-renderer only binds it to React.
export { createSnapshotStore, defineStore, shallowEqual } from './contract/store.ts'
export type {
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
} from './contract/store.ts'
export type {
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
AssistantTiming, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot,
CommandNode, CompactionSummaryNode, ComposerPhase,
ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage,
LegacyConversationSlice, PartialAssistant, RunningToolCall,
SteeringMessageNode, TodoItem, ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode,
UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
export {
EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, toAssistantBlock, toAssistantBlocks,
} from './sessions/conversation.ts'
export { emptyAssistantBlock } from './sessions/partial.ts'
export { isTokenDelta } from './sessions/assistant-timing.ts'
export { contextForm, contextProvenance, sessionRecallLabels } from './sessions/context-provenance.ts'
export { displayFailureMessage } from './sessions/failure-display.ts'
export type {
ConversationContext, ConversationContextOriginKind,
} from './sessions/conversation-context.ts'
export type {
ContextProvenanceView, ContextRole, KnownContextForm,
} from './sessions/context-provenance.ts'
export type {
ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView,
} from './sessions/request-inspection.ts'
export { PendingWait } from './sessions/pending.ts'
export type {
PendingInteraction, PendingInteractionStatus, PendingKind, PendingPayloads,
PendingQuestionAnswer, PendingQuestionItem, PendingQuestionOption, PendingRespondReceipt,
} from './sessions/pending.ts'
// Projection value store (push model; see the session-projection subsystem
// page, docs/subsystems/session-projection.md): host-computed
// whole values per key; domains ship projection support with zero client code.
export type {
ProjectionsBaseline, ProjectionValueStore, SessionProjectionMap, UseProjection,
} from './sessions/projection-store.ts'
export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
/** Client-side Cordis context after declaration merging. */
export type ClientContext = Context
declare module '@deepseek-ai/dsh-typert-protocol' {
interface TypertContextMap {
/** Client Agent scope identity; the agent and session share one wire id. */
agent: TypertContext<SessionId>
}
}
/** The conversation-snapshot selector hook supplied to session-scoped UI entries. */
export type UseConversationSession = SnapshotSelectorHook<ConversationSnapshot>
declare module '@deepseek-ai/dsh-client-ui-slots' {
/**
* Session standard kit, real members (ui-slots declares the empty seat;
* the runtime — where the subjects live — merges the concrete types):
* every session-scope slot component receives these from the framework.
*/
interface SessionStandardProps {
useSession: SnapshotSelectorHook<ConversationSnapshot>
/** The framework-resolved session id (owners never pass it). */
sessionId: SessionId
/** The fifth framework hook seat: key-addressed projection reader (undefined = capability absent). */
useProjection: UseProjection
}
/** Standard kit for slots that remain mounted while current session changes. */
interface SessionMaybeStandardProps {
useSession: MaybeSnapshotSelectorHook<ConversationSnapshot>
/** Current session id; absent in the no-session state. */
sessionId: SessionId | undefined
/** Key-addressed projection reader; every key reads absent while no session is current. */
useProjection: UseProjection
}
/** Props injected into every global slot component. */
interface GlobalStandardProps {
useSessions: SnapshotSelectorHook<SessionListState>
/** Selector hook over real Workspaces and their independent baseline lifecycle. */
useWorkspaces: SnapshotSelectorHook<import('./workspaces/service.ts').WorkspaceListState>
}
}
declare module '@deepseek-ai/cordis' {
interface Events {
/**
* A slot's definition or registration set changed.
* @mode emit
* @param key - the mutated SlotMap key.
*/
'slots/changed'(key: string): void
/**
* A connection generation was (re-)established. Wire-derived caches must
* treat their state as stale and repull. Session follow and control
* streams own their independent resume and baseline lifecycles.
* @mode emit
*/
'connection/reset'(): void
}
interface Context {
slots: import('./slots.ts').SlotRegistry
/** Event-to-business-Context Definition registry. */
conversationEvents: import('./conversation/event-registry.ts').ConversationEventRegistry
/** Per-target Conversation snapshot builder registry. */
conversationViews: import('./conversation/view-registry.ts').ConversationViewRegistry
/** The outward face only; the concrete service stays inside the runtime. */
sessions: import('./contract/sessions.ts').ISessions
/** The outward face only; the concrete service stays inside the runtime. */
workspaces: import('./contract/workspaces.ts').IWorkspaces
}
}
/** Required services: the wire handle and Client Typert registry. */
export const inject = [
'connection',
'typert',
'remote',
'remote.commands',
'remote.session',
'remote.workspace',
]
/** Mounts the browser runtime services and connection stream.
* @param ctx - Client Cordis context.
*/
export function apply(ctx: Context): void {
ctx.plugin(SlotRegistry)
const conversation = {
events: new ConversationEventRegistry(ctx),
views: new ConversationViewRegistry(ctx),
}
const connection = ctx.get('connection') as ConnectionHandle
const sessions = new SessionRuntime(ctx, connection.api, ctx.remote, conversation)
ctx.remote.$on('api-session/added', (summary) => { sessions.handleSessionAdded(summary) })
ctx.remote.$on('api-session/removed', (sessionId) => { sessions.handleSessionRemoved(sessionId) })
ctx.remote.$on('api-session/status', (sessionId, running) => {
sessions.handleSessionStatus(sessionId, running)
})
ctx.remote.$on('api-session/activity', (sessionId, updatedAt) => {
sessions.handleSessionActivity(sessionId, updatedAt)
})
ctx.remote.$on('api-session/error', (sessionId, message) => {
sessions.handleSessionError(sessionId, message)
})
const sessionControl = createSessionControlStream(ctx.remote, {
accept: (frame) => { sessions.handleControlFrame(frame) },
failed: (error) => { console.error('[web-runtime] session control stream failed:', error) },
})
sessionControl.start()
ctx.typert.contexts.registerClient('agent', {
identity: candidate => sessions.scopeOf(candidate),
resolve: sessionId => sessions.resolveAgentScope(sessionId),
})
const workspaceModel = new ClientWorkspaceModel(ctx.remote.workspace)
const workspaces = new WorkspaceRuntime(ctx, connection.api, workspaceModel, sessions)
const workspaceControl = createWorkspaceStateStream(ctx.remote, {
accept: workspaceModel,
carrierFailed: () => { workspaceModel.handleCarrierFailure() },
failed: (error) => { workspaceModel.handleStreamFailure(error) },
})
workspaceControl.start()
ctx.effect(
() => workspaces.startInitialSelection(),
'runtime: initial Workspace selection',
)
ctx.on('connection/reset', () => { sessions.handleConnected() })
if (connection.hostDescription.getSnapshot() !== undefined) sessions.handleConnected()
ctx.effect(() => async () => {
await Promise.all([
workspaceControl.dispose(),
sessionControl.dispose(),
])
}, 'runtime: connection streams')
}

View file

@ -1,4 +0,0 @@
/** Host loader entry for the browser runtime exported from `./client` and `./loader`. */
/** Host plugin body — no host-side behavior for the runtime plugin. */
export function apply(_ctx: unknown): void {}

View file

@ -1,52 +0,0 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-runtime`.
* @module @deepseek-ai/dsh-client-runtime/invariant
*/
/* jscpd:ignore-start */
/* oxlint-disable typescript/no-redundant-type-constituents --
* `keyof SlotMap & string` is the declare-merge key pattern: SlotMap is empty
* in this compilation unit (intersection reads `never`) but consumers merge
* keys in; the rule fires on the empty-map view, not on real redundancy. */
import type { Context } from '@deepseek-ai/cordis'
import type { SlotMap } from '@deepseek-ai/dsh-client-ui-slots'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-runtime'
/** Cordis companion plugin name. */
export const name = 'client-runtime-invariant'
/** Service required before the companion can register. */
export const inject = ['invariants']
/**
* Owned relation: every 'slots/changed'(key) emission must observe the
* mutation already applied — SlotCore bumps the key's version synchronously
* before the service re-emits, so a zero version at dispatch time means the
* event fired without (or ahead of) its mutation.
*/
const install: InvariantInstaller = (ctx, fail) => {
ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName !== 'slots/changed') return
const key: unknown = args[0]
if (typeof key !== 'string' || key === '') {
fail("'slots/changed' dispatched without a slot key argument")
return
}
const slots = ctx.get('slots')
// Event payloads carry keys as plain strings; getVersion is statically
// keyed, so restore the SlotMap-key type after the runtime string check.
if (slots !== undefined && slots.getVersion(key as keyof SlotMap & string) === 0) {
fail(`'slots/changed' fired for "${key}" before any mutation bumped its version — emission must follow the applied mutation`)
}
}, { global: true })
}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View file

@ -1,10 +0,0 @@
/** Node half: the empty host apply (Loader governance + dsh.client discovery placeholder). */
import { describe, expect, it } from 'vitest'
import { apply } from '../src/index.ts'
describe('node half', () => {
it('apply is a no-op host placeholder', () => {
apply(undefined)
expect(true).toBe(true) // reaching here without throw is the contract
})
})

View file

@ -1,75 +0,0 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../attachment/attachment"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../ui-slots"
},
{
"path": "../../host/apiproxy"
},
{
"path": "../../interaction/commands"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/tools"
},
{
"path": "../../compaction/compaction"
},
{
"path": "../../session/session-projection"
},
{
"path": "../../session/session-title"
},
{
"path": "../../todo/tool-todo"
},
{
"path": "../../llm/llm"
},
{
"path": "../../llm/llm-retry"
},
{
"path": "../../runtime-diagnostics/invariants"
},
{
"path": "../../typert/protocol"
},
{
"path": "../../typert/registry"
},
{
"path": "../../api/remotes/tsconfig.client.json"
},
{
"path": "../../api/session-controller/tsconfig.client.json"
},
{
"path": "../../api/workspace-controller/tsconfig.client.json"
},
{
"path": "../../util/crypto"
}
],
"exclude": [
"**/*.legacy.*"
]
}

View file

@ -1,3 +0,0 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-runtime', ['lib/types/index.js', 'lib/types/invariant.js'])

View file

@ -9,7 +9,7 @@
*/
import { useEffect } from 'react'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { IconAgentPresetOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
// Type-only: pulls the ui-conversation SlotMap merge (the header actions).

View file

@ -5,7 +5,7 @@
*/
import { useEffect, useState } from 'react'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { AgentPresetSettingsState } from './settings-store.ts'
import { presetDisplayText, type AgentPresetSettingsKey } from './locales.ts'

View file

@ -13,7 +13,7 @@
*/
import { useEffect, useState } from 'react'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { IconAgentPresetOutline16, IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
// Type-only: pulls the ui-conversation SlotMap merge (the hero seat).

View file

@ -15,7 +15,7 @@ import type { ReactNode } from 'react'
import {
Button, IconBrowseOutline16, IconCopyOutline16, IconFolderOpenOutline16, IconPlusOutline16, IconTrashOutline16, Modal, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { draftBlocker, type AgentPresetSectionState } from './section-store.ts'
import { presetDisplayText, type AgentPresetSettingsKey } from './locales.ts'

View file

@ -22,8 +22,8 @@ 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 {} from '@deepseek-ai/dsh-client-ui-renderer/client'
// Type-only: pulls the Session UI navigation service merge (ctx.uiSession).
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
// Type-only: pulls the Workspace UI navigation service merge (ctx.uiWorkspace).
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import { AgentPresetLabel } from './AgentPresetLabel.tsx'
import type { AgentPresetLabelInjected } from './AgentPresetLabel.tsx'
@ -104,7 +104,7 @@ export function apply(ctx: ClientContext): void {
// The new-session chip and the header label: one controller, because the
// staged choice belongs to the flow rather than to any one session.
ctx.inject(['slots', 'conversation', 'sessions', 'uiSession'], (scope: ClientContext) => {
ctx.inject(['slots', 'conversation', 'sessions', 'uiWorkspace'], (scope: ClientContext) => {
const api = (scope.get('connection') as ConnectionHandle).api
const seat = new AgentPresetSeatController(api, (): SeatSessionSummary | undefined => {
const state = scope.sessions.list.getSnapshot()
@ -165,7 +165,7 @@ export function apply(ctx: ClientContext): void {
// The introduce cue makes the chip announce the pick the user never
// made on this screen — the stage happened back in settings.
seat.stage('cordis', true)
scope.uiSession.startSession()
scope.uiWorkspace.startSession()
}
const chip = scope.slots.register({
name: 'conversation.hero.agentPreset',

View file

@ -11,9 +11,8 @@
*/
import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
import {
createSnapshotStore, type SessionId, type SnapshotStore,
} from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { messageOf, presetOptions } from './settings-store.ts'
import type { AgentPresetOption } from './settings-store.ts'

View file

@ -15,7 +15,7 @@
*/
import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import { beginRosterRead, messageOf, writeDefaultPreset } from './settings-store.ts'
/** Ids a preset directory may be named, mirroring the host's own rule. */

View file

@ -8,7 +8,7 @@
*/
import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client'
/** The agent-preset settings namespace on the host wire. */

View file

@ -143,8 +143,8 @@ function declareConversation(slots: SlotRegistry): () => void {
} as never, () => null)
}
/** A Session UI double recording new-session starts. */
function uiSessionDouble() {
/** A Workspace UI double recording new-session starts. */
function uiWorkspaceDouble() {
const starts: unknown[] = []
return {
starts,
@ -306,8 +306,8 @@ describe('ui-agent-preset apply', () => {
const conversation = declareConversation(slots)
ctx.provide('conversation', {} as never)
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
ctx.provide('uiSession', uiSessionDouble() as never)
const fiber = ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply })
ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
const fiber = ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply })
await fiber.await()
const chip = slots.entries('conversation.hero.agentPreset')[0]!
@ -328,8 +328,8 @@ describe('ui-agent-preset apply', () => {
const conversation = declareConversation(slots)
ctx.provide('conversation', {} as never)
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
ctx.provide('uiSession', uiSessionDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await()
ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
const chip = slots.entries('conversation.hero.agentPreset')[0]!
const seat = (chip.inject as unknown as () => AgentPresetSeatInjected)()
@ -364,8 +364,8 @@ describe('ui-agent-preset apply', () => {
byId: { s1: { id: 's1', blank: true, agentPreset: 'standard' } },
}
ctx.provide('sessions', sessionsDouble(state) as never)
ctx.provide('uiSession', uiSessionDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await()
ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
remote.emit('agent-preset/selected', ['s1', 'minimal'])
@ -378,8 +378,8 @@ describe('ui-agent-preset apply', () => {
const conversation = declareConversation(slots)
ctx.provide('conversation', {} as never)
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
ctx.provide('uiSession', uiSessionDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await()
ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
const chip = slots.entries('conversation.hero.agentPreset')[0]!
const seat = (chip.inject as unknown as () => AgentPresetSeatInjected)()
@ -413,8 +413,8 @@ describe('ui-agent-preset apply', () => {
} = { byId: {} }
const sessions = sessionsDouble(state)
ctx.provide('sessions', sessions as never)
ctx.provide('uiSession', uiSessionDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await()
ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
const chip = (slots.entries('conversation.hero.agentPreset')[0]!
.inject as unknown as () => AgentPresetSeatInjected)()
@ -441,8 +441,8 @@ describe('ui-agent-preset apply', () => {
byId: { s1: { id: 's1', blank: true } },
})
ctx.provide('sessions', sessions as never)
ctx.provide('uiSession', uiSessionDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await()
ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
const chip = (slots.entries('conversation.hero.agentPreset')[0]!
.inject as unknown as () => AgentPresetSeatInjected)()
@ -465,8 +465,8 @@ describe('ui-agent-preset apply', () => {
}
const sessions = sessionsDouble(state)
ctx.provide('sessions', sessions as never)
ctx.provide('uiSession', uiSessionDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await()
ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
const chip = (slots.entries('conversation.hero.agentPreset')[0]!
.inject as unknown as () => AgentPresetSeatInjected)()
@ -488,8 +488,8 @@ describe('ui-agent-preset apply', () => {
declareConversation(slots)
ctx.provide('conversation', {} as never)
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
ctx.provide('uiSession', uiSessionDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await()
ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
const label = (slots.entries('conversation.session.header.actions')[0]!
.inject as unknown as () => AgentPresetLabelInjected)()
const row = (slots.entries('settings.general.item')[0]!
@ -509,9 +509,9 @@ describe('ui-agent-preset apply', () => {
const conversation = declareConversation(slots)
ctx.provide('conversation', {} as never)
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
const uiSession = uiSessionDouble()
ctx.provide('uiSession', uiSession as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await()
const uiWorkspace = uiWorkspaceDouble()
ctx.provide('uiWorkspace', uiWorkspace as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
const seat = (slots.entries('conversation.hero.agentPreset')[0]!
.inject as unknown as () => AgentPresetSeatInjected)()
@ -523,7 +523,7 @@ describe('ui-agent-preset apply', () => {
// new-session flow began.
expect(section.startCreatorDraft).toBeDefined()
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis')
expect(uiSession.starts).toHaveLength(1)
expect(uiWorkspace.starts).toHaveLength(1)
// A cross-screen stage carries the introduce cue; the chip acknowledges
// it once, and a repeat acknowledgement leaves the snapshot untouched.
@ -547,8 +547,8 @@ describe('ui-agent-preset apply', () => {
} = { byId: {} }
const sessions = sessionsDouble(state)
ctx.provide('sessions', sessions as never)
ctx.provide('uiSession', uiSessionDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await()
ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
const seat = (slots.entries('conversation.hero.agentPreset')[0]!
.inject as unknown as () => AgentPresetSeatInjected)()

View file

@ -10,7 +10,7 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx'
import type { AgentPresetLabelProps } from '../src/client/AgentPresetLabel.tsx'
import { AgentPresetRow } from '../src/client/AgentPresetRow.tsx'

View file

@ -9,7 +9,7 @@
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { AgentPresetSection } from '../src/client/AgentPresetSection.tsx'
import type { AgentPresetSectionProps } from '../src/client/AgentPresetSection.tsx'
import type { AgentPresetSectionState, CopyDraft } from '../src/client/section-store.ts'

View file

@ -1,6 +1,7 @@
/** Official DeepSeek Harness occupants for the generic browser-brand slots. */
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
import { OfficialBrandMark, OfficialBrandName } from './Brand.tsx'

View file

@ -2,7 +2,7 @@
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import { apply, inject } from '../src/client/index.ts'
import { OfficialBrandMark, OfficialBrandName } from '../src/client/Brand.tsx'

View file

@ -3,7 +3,7 @@
* CommandUiRuntime (`ctx.commandUi`) implements this face; business packages
* consume `register` alone.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
/** Copy for an option that must be acknowledged before onSelect can run. */

View file

@ -6,7 +6,7 @@
* is the only extra dimension.
*/
import type { CommandDescriptor } from '@deepseek-ai/dsh-commands/types'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
export type { CommandDescriptor } from '@deepseek-ai/dsh-commands/types'

View file

@ -5,13 +5,16 @@
* popupSelect shell self-registers into conversation.input.overlay with
* per-session resolution.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
// Type-only: pulls the 'conversation.input.overlay' SlotMap declaration (the
// key's owner) into this program so the overlay registration below typechecks
// against the real declaration — no runtime edge to ui-conversation.
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
import { CommandUiRuntime } from './service.ts'
import type { PopupSelectInjected } from './PopupSelectView.tsx'
import { PopupSelectView } from './PopupSelectView.tsx'
@ -57,7 +60,7 @@ export function apply(ctx: ClientContext): void {
ctx.plugin(CommandUiRuntime)
ctx.inject(['slots', 'commandUi', 'sessions'], (scope: ClientContext) => {
const command = scope.commandUi
const sessions = scope.sessions
const sessions = scope.get('sessions') as ISessions
scope.slots.inject('conversation.input.overlay', () => scope.slots.register({
name: 'conversation.input.overlay',
id: 'command-popup',

View file

@ -9,8 +9,8 @@
* Input side owns the span/bare-token CAS guard) and focuses the composer;
* the controller never touches the input machine.
*/
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { TokenSpan } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
import type { SelectOption } from './contract.ts'

View file

@ -13,7 +13,9 @@ import type { Context } from '@deepseek-ai/cordis'
// (`commands/change` rides the allowlist) into this program.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type { CommandResult } from '@deepseek-ai/dsh-commands/types'
import type { ClientContext, ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
import type {
CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, InputTriggerCandidate, InputTriggerPick,

View file

@ -8,8 +8,9 @@
*/
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import { createScope, scopeOf, SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
import type { CommandUiContract } from '../src/client/contract.ts'
import type { PopupSelectInjected } from '../src/client/PopupSelectView.tsx'

View file

@ -10,8 +10,8 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import type { CommandResult } from '@deepseek-ai/dsh-commands/types'
import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource, SubmitImageAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts'

View file

@ -42,7 +42,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Services required by the Conversation plugin. */
export const inject = [
'slots', 'sessions', 'uiSession', 'locale', 'settingsScope',
'slots', 'sessions', 'uiSession', 'uiWorkspace', 'locale', 'settingsScope',
]
// Stable no-session sources keep the renderer's observable-hook cache and
@ -65,6 +65,12 @@ const ABSENT_MENU_LAUNCHER = {
subscribe: () => () => {},
}
interface WorkspaceNavigation {
connectWorkspace(
workspaceId: Parameters<ConversationInjected['selectWorkspace']>[0],
): Promise<SessionId>
}
/** Resolve the session-scoped Conversation action face, failing loud. */
function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
const scoped = sessions.scope(id)
@ -90,6 +96,7 @@ function concreteConversation(ctx: Context): ConversationController {
export function apply(ctx: Context): void {
const sessions = ctx.sessions
const slots = ctx.slots
const workspaceNavigation = ctx.get('uiWorkspace') as WorkspaceNavigation
const uiConversation = new UiConversation(ctx, sessions)
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-conversation: dictionaries')
@ -184,7 +191,7 @@ export function apply(ctx: Context): void {
composerBlock: sessionId === undefined ? ABSENT_BLOCK : composerBlocks.storeFor(sessionId),
},
selectWorkspace: async (workspaceId) => {
const nextId = await ctx.uiSession.connectWorkspace(workspaceId)
const nextId = await workspaceNavigation.connectWorkspace(workspaceId)
if (sessionId !== undefined && nextId !== sessionId) {
const from = inputHub.shell(sessionId)
const draft = from.snapshot.draft

View file

@ -1,119 +0,0 @@
/** Presentation-only pending interactions received through Remote Events. */
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import {
createSnapshotStore,
type ObservableSnapshot,
type PendingInteraction,
type PendingInteractionStatus,
} from '@deepseek-ai/dsh-client-runtime/client'
const EMPTY_INTERACTIONS: readonly PendingInteraction[] = []
const ABSENT_INTERACTIONS: ObservableSnapshot<readonly PendingInteraction[]> = {
getSnapshot: () => EMPTY_INTERACTIONS,
subscribe: () => () => {},
}
interface PendingEntry {
readonly interaction: PendingInteraction
readonly status: PendingInteractionStatus
readonly precedence: number
}
interface PendingPresentationSnapshot {
readonly interactions: ReadonlyMap<SessionId, readonly PendingInteraction[]>
readonly statuses: ReadonlyMap<SessionId, PendingInteractionStatus>
}
/** Presentation sources shared by the composer and Session navigation. */
export interface PendingInteractionPresentation {
/** Effective pending-interaction status by Session. */
readonly statuses: ObservableSnapshot<ReadonlyMap<SessionId, PendingInteractionStatus>>
/**
* Resolve the effective composer interaction for one Session.
* @param sessionId - current Session identity, or absence outside a Session scope.
* @returns an identity-stable observable source.
*/
forSession(sessionId: SessionId | undefined): ObservableSnapshot<readonly PendingInteraction[]>
/**
* Publish one domain-owned interaction until its disposer runs.
* @param interaction - answerable presentation object.
* @param status - sidebar presentation kind.
* @param precedence - deterministic cross-domain priority; larger values win.
* @returns idempotent removal function.
*/
present(
interaction: PendingInteraction,
status: PendingInteractionStatus,
precedence: number,
): () => void
}
/** Aggregate domain-owned Remote Event waits without putting them on Session state. */
export class PendingInteractionPresenter implements PendingInteractionPresentation {
private readonly entries = new Map<string, PendingEntry>()
private readonly sources = new Map<SessionId, ObservableSnapshot<readonly PendingInteraction[]>>()
private readonly state = createSnapshotStore<PendingPresentationSnapshot>({
interactions: new Map(),
statuses: new Map(),
})
/** Effective pending-interaction status by Session. */
readonly statuses: ObservableSnapshot<ReadonlyMap<SessionId, PendingInteractionStatus>> = {
getSnapshot: () => this.state.getSnapshot().statuses,
subscribe: listener => this.state.subscribe(listener),
}
/** @inheritdoc */
forSession(sessionId: SessionId | undefined): ObservableSnapshot<readonly PendingInteraction[]> {
if (sessionId === undefined) return ABSENT_INTERACTIONS
let source = this.sources.get(sessionId)
if (source === undefined) {
source = {
getSnapshot: () => this.state.getSnapshot().interactions.get(sessionId) ?? EMPTY_INTERACTIONS,
subscribe: listener => this.state.subscribe(listener),
}
this.sources.set(sessionId, source)
}
return source
}
/** @inheritdoc */
present(
interaction: PendingInteraction,
status: PendingInteractionStatus,
precedence: number,
): () => void {
if (this.entries.has(interaction.key)) {
throw new Error(`ui-conversation: duplicate pending interaction key '${interaction.key}'`)
}
const entry = { interaction, status, precedence }
this.entries.set(interaction.key, entry)
this.publish()
let active = true
return () => {
if (!active) return
active = false
interaction.markSettled()
this.entries.delete(interaction.key)
this.publish()
}
}
private publish(): void {
const selected = new Map<SessionId, PendingEntry>()
for (const entry of this.entries.values()) {
const previous = selected.get(entry.interaction.sessionId)
if (previous === undefined || entry.precedence >= previous.precedence) {
selected.set(entry.interaction.sessionId, entry)
}
}
this.state.set({
interactions: new Map(
[...selected].map(([sessionId, entry]) => [sessionId, [entry.interaction]] as const),
),
statuses: new Map(
[...selected].map(([sessionId, entry]) => [sessionId, entry.status] as const),
),
})
}
}

View file

@ -33,7 +33,8 @@ function sessionFakeFor() {
async function bench() {
const runtime = await SlotTestRuntime.create()
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
const connectWorkspace = vi.spyOn(runtime.ctx.uiSession, 'connectWorkspace').mockResolvedValue(ROOT)
const connectWorkspace = vi.fn(async () => ROOT)
runtime.ctx.provide('uiWorkspace', { connectWorkspace } as never)
const sessionFake = sessionFakeFor()
await runtime.sessions.add({
id: ROOT,

View file

@ -14,6 +14,7 @@ const SID = 'session-1' as SessionId
async function bench() {
const runtime = await SlotTestRuntime.create()
runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never)
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
const locale = new LocaleRuntime(runtime.ctx)
runtime.ctx.provide('locale', locale)

View file

@ -50,6 +50,7 @@ function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) {
async function bench(opts?: { blank?: boolean }) {
const runtime = await SlotTestRuntime.create()
runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never)
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
const locale = new LocaleRuntime(runtime.ctx)
runtime.ctx.provide('locale', locale)
@ -71,6 +72,7 @@ async function bench(opts?: { blank?: boolean }) {
describe('resident composer', () => {
it('renders the locked view state while no session exists at all', async () => {
const runtime = await SlotTestRuntime.create()
runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never)
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
const locale = new LocaleRuntime(runtime.ctx)
runtime.ctx.provide('locale', locale)
@ -97,6 +99,7 @@ describe('resident composer', () => {
it('keeps the complete Hero tree mounted when the first Workspace session appears', async () => {
const runtime = await SlotTestRuntime.create()
runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never)
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
const locale = new LocaleRuntime(runtime.ctx)
runtime.ctx.provide('locale', locale)
@ -161,6 +164,7 @@ describe('resident composer', () => {
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.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never)
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
const locale = new LocaleRuntime(runtime.ctx)
runtime.ctx.provide('locale', locale)

View file

@ -1,100 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import { PendingInteractionPresenter } from '../src/client/pending-interactions.ts'
const sid = (value: string) => value as SessionId
function approval(id: string, sessionId = sid('session')): PendingWait<'approval'> {
return new PendingWait(
'approval', id, sessionId, { approvalId: id, toolName: 'bash' },
() => Promise.resolve({ ok: true, value: { accepted: true } }),
)
}
function question(id: string, sessionId = sid('session')): PendingWait<'question'> {
return new PendingWait(
'question', id, sessionId, { questions: [{ id: 'choice', question: 'Choose?' }] },
() => Promise.resolve({ ok: true, value: { accepted: true } }),
)
}
describe('PendingInteractionPresenter', () => {
it('publishes one effective interaction and status per Session by precedence', () => {
const presenter = new PendingInteractionPresenter()
const source = presenter.forSession(sid('session'))
const notifyInteraction = vi.fn()
const notifyStatuses = vi.fn()
source.subscribe(notifyInteraction)
presenter.statuses.subscribe(notifyStatuses)
const approvalWait = approval('approval')
const questionWait = question('question')
const removeApproval = presenter.present(approvalWait, 'approval', 0)
expect(source.getSnapshot()).toEqual([approvalWait])
expect(presenter.statuses.getSnapshot().get(sid('session'))).toBe('approval')
const removeQuestion = presenter.present(questionWait, 'question', 1)
expect(source.getSnapshot()).toEqual([questionWait])
expect(presenter.statuses.getSnapshot().get(sid('session'))).toBe('question')
removeQuestion()
expect(source.getSnapshot()).toEqual([approvalWait])
removeApproval()
expect(source.getSnapshot()).toEqual([])
expect(presenter.statuses.getSnapshot()).toEqual(new Map())
expect(notifyInteraction).toHaveBeenCalledTimes(4)
expect(notifyStatuses).toHaveBeenCalledTimes(4)
})
it('uses publication order to replace an equal-precedence interaction', () => {
const presenter = new PendingInteractionPresenter()
const source = presenter.forSession(sid('session'))
const first = question('first')
const second = question('second')
const removeFirst = presenter.present(first, 'question', 1)
const removeSecond = presenter.present(second, 'plan-review', 1)
expect(source.getSnapshot()).toEqual([second])
expect(presenter.statuses.getSnapshot().get(sid('session'))).toBe('plan-review')
removeSecond()
expect(source.getSnapshot()).toEqual([first])
removeFirst()
})
it('isolates Sessions, rejects duplicate keys, and removes idempotently', () => {
const presenter = new PendingInteractionPresenter()
const first = approval('same', sid('first'))
const secondSession = approval('second', sid('second'))
const removeFirst = presenter.present(first, 'approval', 0)
const removeSecond = presenter.present(secondSession, 'approval', 0)
expect(presenter.forSession(sid('first')).getSnapshot()).toEqual([first])
expect(presenter.forSession(sid('second')).getSnapshot()).toEqual([secondSession])
expect(() => presenter.present(approval('same', sid('first')), 'approval', 0))
.toThrow("duplicate pending interaction key 'a:same'")
removeFirst()
removeFirst()
expect(() => first.respond({
ok: true,
value: { sessionId: sid('first'), approvalId: 'same', outcome: 'rejected' },
})).toThrow('already settled')
expect(presenter.forSession(sid('first')).getSnapshot()).toEqual([])
expect(presenter.forSession(sid('second')).getSnapshot()).toEqual([secondSession])
removeSecond()
})
it('returns stable empty sources for absent and known Sessions', () => {
const presenter = new PendingInteractionPresenter()
const absent = presenter.forSession(undefined)
const first = presenter.forSession(sid('first'))
expect(presenter.forSession(undefined)).toBe(absent)
expect(absent.getSnapshot()).toEqual([])
const dispose = absent.subscribe(() => {})
dispose()
expect(presenter.forSession(sid('first'))).toBe(first)
expect(first.getSnapshot()).toEqual([])
})
})

View file

@ -9,7 +9,6 @@ import { makeTranslate, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-run
import type { QueuedMessage } from '@deepseek-ai/dsh-api-session-controller/client'
import { ComposerBlockRegistry } from '../src/client/input/blocks.ts'
import { InputHub } from '../src/client/input/hub.ts'
import { PendingInteractionPresenter } from '../src/client/pending-interactions.ts'
import { ConversationController, UnsupportedImageMediaTypeError } from '../src/client/service.ts'
import { zh } from '../src/client/locales.ts'
@ -29,7 +28,6 @@ async function bench() {
const fiber = runtime.ctx.plugin(ConversationController, {
input: hub,
blocks: new ComposerBlockRegistry(),
pendingInteractions: new PendingInteractionPresenter(),
})
await fiber.await()
const root = runtime.ctx.get('conversation') as ConversationController
@ -127,7 +125,6 @@ describe('ConversationController', () => {
await bare.plugin(ConversationController, {
input: new InputHub(bare, makeTranslate(zh, {})),
blocks: new ComposerBlockRegistry(),
pendingInteractions: new PendingInteractionPresenter(),
}).await()
const orphan = bare.get('conversation') as ConversationController
await expect(orphan.send('x')).rejects.toThrow(/sessions service unavailable/)

View file

@ -1,7 +1,7 @@
import { useLayoutEffect, useRef, useState } from 'react'
import type { HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client'
import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
import { basename } from './turn-deliverables.ts'
import type { NS } from './locales.ts'
import css from './ProducedFiles.module.css'

View file

@ -8,9 +8,11 @@
* the owning view renders an empty chain and inert prose at zero cost.
*/
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-chat/client'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import { ProducedFiles } from './ProducedFiles.tsx'
import { en, NS, zh, type DeliverablesKey } from './locales.ts'
import {
@ -28,7 +30,7 @@ export { ProducedFiles, type ProducedFilesProps } from './ProducedFiles.tsx'
export { producedForClosing } from './turn-deliverables.ts'
/** Required services for the tail-slot registration and its dictionaries. */
export const inject = ['slots', 'locale', 'conversationEvents', 'connection']
export const inject = ['slots', 'locale', 'uiConversation', 'connection']
/**
* Client plugin body: register the dictionaries and the turn-tail entry.
@ -36,7 +38,7 @@ export const inject = ['slots', 'locale', 'conversationEvents', 'connection']
*/
export function apply(ctx: ClientContext): void {
const connection = ctx.get('connection') as ConnectionHandle
ctx.conversationEvents.register(deliverablesDefinition)
ctx.uiConversation.events.register(deliverablesDefinition)
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries')
ctx.slots.inject(
'conversation.chat.turnTail',

View file

@ -3,12 +3,10 @@
* model-free: the vocabulary is the mutation tools' own follow-along
* `locations`, never the closing prose.
*/
import type {
ConversationNodeDefinition, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client'
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
import type { ToolResultNode, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
interface ProducedPath {
readonly seq: number
@ -20,7 +18,7 @@ export interface DeliverablesTurnData {
readonly produced: readonly ProducedPath[]
}
declare module '@deepseek-ai/dsh-client-runtime/client' {
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface ConversationTurnDataMap {
/** Successful mutation paths accumulated in this Turn. */
deliverables: DeliverablesTurnData

View file

@ -9,15 +9,16 @@ import { Context } from '@deepseek-ai/cordis'
import { act, cleanup, fireEvent, render, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
ConversationEventRegistry, ConversationNodeAssembler, SlotRegistry,
} from '@deepseek-ai/dsh-client-runtime/client'
ConversationNodeAssembler, UiConversation,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
ConversationEventInput, ConversationLocationDataStore, ConversationMatch, ConversationNodeDefinition,
ConversationTimelineSnapshot, ConversationTurnDataMap, ConversationViewDefinition,
ConversationViewNode, TurnLocation,
} from '@deepseek-ai/dsh-client-runtime/client'
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import {
fitProducedFiles, ProducedFiles, type ProducedFilesProps,
@ -115,7 +116,7 @@ function at(
seq, time: seq * 1_000, type, data,
...(type === 'tool/result' ? { surfaceOp: 'append' } : {}),
} as ConversationEventInput['event'],
view,
...(view === undefined ? {} : { view }),
}
}
@ -456,7 +457,7 @@ describe('plugin registration', () => {
it('registers the tail entry and fiber disposal removes it', async () => {
const ctx = new Context()
await ctx.plugin(SlotRegistry).await()
await ctx.plugin(ConversationEventRegistry).await()
new UiConversation(ctx, { binding: () => undefined } as never)
// The owning view's child declaration, stood up by a bench root entry.
ctx.slots.register({
name: 'root',

View file

@ -40,8 +40,8 @@ import {
Button, IconCheckOutline16, IconChevronRightOutline14, IconEditOutline16, IconFolderClose16, IconFolderOpen16,
IconPlusOutline16, Modal,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client'
import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-runtime/client'
import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-client-connection/client'
import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-ui-workspace/client'
import type { Translate } from '@deepseek-ai/dsh-client-locale/client'
import css from './DirectoryBrowser.module.css'

View file

@ -5,7 +5,7 @@
*/
import { createElement } from 'react'
import type { ReactElement } from 'react'
import type { DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client'
import type { DirectoryListing } from '@deepseek-ai/dsh-client-connection/client'
import type { Translate } from '@deepseek-ai/dsh-client-locale/client'
// Type-only: the owner contract of the directory-flow holes.
import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'

View file

@ -7,17 +7,19 @@
* cordis.yml row; no client code branches on a capability kind. The dialog's
* copy is locale-registered here — the flow package owns its own strings.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
// Type-only: pulls the SlotMap merge declaring the directory-flow holes.
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
// Type-only: pulls the SlotRegistry service merge (ctx.slots).
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type { BrowseFlowInjected } from './flow.ts'
import { BrowseDirectoryFlow } from './flow.ts'
/** Locale namespace owning the browser dialog's copy. */
const LOCALE_NS = 'directory-browser'
/** Required services (cordis fiber inject): the slot registry, the wire-facing workspace service, and locale. */
export const inject = ['slots', 'workspaces', 'locale']
/** Required services (cordis fiber inject): the slot registry, workspace UI service, and locale. */
export const inject = ['slots', 'uiWorkspace', 'locale']
/**
* Client plugin body: register the dialog's dictionaries and the browse flow
@ -73,8 +75,8 @@ export function apply(ctx: ClientContext): void {
}, 'directory-picker-browse: dialog dictionaries')
const injected = (): BrowseFlowInjected => ({
listDirectory: (path, signal) => ctx.workspaces.listDirectory(path, signal),
createDirectory: (path, name) => ctx.workspaces.createDirectory(path, name),
listDirectory: (path, signal) => ctx.uiWorkspace.listDirectory(path, signal),
createDirectory: (path, name) => ctx.uiWorkspace.createDirectory(path, name),
t: ctx.locale.bind(LOCALE_NS),
})
// Both declaration lifetimes must be live before the pair installs; the

View file

@ -2,8 +2,8 @@
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import type { DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client'
import type { DirectoryListing } from '@deepseek-ai/dsh-client-connection/client'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'
@ -34,7 +34,7 @@ async function bench() {
ctx.provide('locale', new LocaleRuntime(ctx))
const listDirectory = vi.fn(async (): Promise<DirectoryListing> => homeListing)
const createDirectory = vi.fn(async (path: string, name: string) => `${path}/${name}`)
ctx.provide('workspaces', { listDirectory, createDirectory } as never)
ctx.provide('uiWorkspace', { listDirectory, createDirectory } as never)
const slots = ctx.get('slots') as SlotRegistry
const declare = () => slots.register({
name: 'root',
@ -53,7 +53,7 @@ function owner(overrides: Partial<DirectoryFlowOwnerProps> = {}): DirectoryFlowO
describe('directory-picker-browse client half', () => {
it('declares the services it drives', () => {
expect(inject).toEqual(['slots', 'workspaces', 'locale'])
expect(inject).toEqual(['slots', 'uiWorkspace', 'locale'])
})
it('fills both directory-flow holes for declarations before or after apply, and leaves with its fiber', async () => {

View file

@ -1,8 +1,8 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import type { DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client'
import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-runtime/client'
import type { DirectoryListing } from '@deepseek-ai/dsh-client-connection/client'
import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-ui-workspace/client'
import { DirectoryBrowser } from '../src/client/DirectoryBrowser.tsx'
afterEach(cleanup)

View file

@ -7,15 +7,17 @@
* both sides of the native interaction with one cordis.yml row; no client
* code branches on a capability kind.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
// Type-only: pulls the SlotMap merge declaring the directory-flow holes.
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
// Type-only: pulls the SlotRegistry service merge (ctx.slots).
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type { NativeFlowInjected } from './flow.ts'
import { NativeDirectoryFlow } from './flow.ts'
/** Required services (cordis fiber inject): the slot registry and the wire-facing workspace service. */
export const inject = ['slots', 'workspaces']
/** Required services (cordis fiber inject): the slot registry and workspace UI service. */
export const inject = ['slots', 'uiWorkspace']
/**
* Client plugin body: register the renderless native flow into both
@ -24,7 +26,7 @@ export const inject = ['slots', 'workspaces']
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
const injected = (): NativeFlowInjected => ({ pick: () => ctx.workspaces.pickDirectory() })
const injected = (): NativeFlowInjected => ({ pick: () => ctx.uiWorkspace.pickDirectory() })
// Both declaration lifetimes must be live before the pair installs; the
// generator makes the two registrations one transactional effect. The
// outer/inner nesting order is arbitrary; neither hole has precedence.

View file

@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
import { afterEach } from 'vitest'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'
import { apply, inject } from '../src/client/index.ts'
import { NativeDirectoryFlow } from '../src/client/flow.ts'
@ -17,7 +17,7 @@ async function bench() {
const ctx = new Context()
await ctx.plugin(SlotRegistry).await()
const pickDirectory = vi.fn(async (): Promise<string | null> => '/tmp/picked')
ctx.provide('workspaces', { pickDirectory } as never)
ctx.provide('uiWorkspace', { pickDirectory } as never)
const slots = ctx.get('slots') as SlotRegistry
const declare = () => slots.register({
name: 'root',
@ -36,7 +36,7 @@ function owner(overrides: Partial<DirectoryFlowOwnerProps> = {}): DirectoryFlowO
describe('directory-picker-native client half', () => {
it('declares the services it drives', () => {
expect(inject).toEqual(['slots', 'workspaces'])
expect(inject).toEqual(['slots', 'uiWorkspace'])
})
it('fills both directory-flow holes for declarations before or after apply, and leaves with its fiber', async () => {

View file

@ -3,7 +3,7 @@ import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type {} from '@deepseek-ai/dsh-commands/types'
import type {
ConversationNodeDefinition,
} from '@deepseek-ai/dsh-client-runtime/client'
} from '@deepseek-ai/dsh-client-ui-conversation/client'
/** Goal-owned human command input projected independently of model messages. */
export interface GoalCommandInputData {
@ -12,7 +12,7 @@ export interface GoalCommandInputData {
readonly time: number
}
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
declare module '@deepseek-ai/dsh-client-ui-chat/client' {
interface ChatNodeDataMap {
/** Human-entered `/goal` command input. */
'command-input': GoalCommandInputData

View file

@ -8,13 +8,22 @@
* their CAS ref reads the session's current projected value at call time.
* Goal creation stays on the /goal host command.
*/
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
// Type-only: pulls the generated Remote API and ctx.remote merge through the Client assembly boundary.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
// Type-only: pulls the ui-conversation SlotMap merge (the input.dock entry).
// Type-only: pulls the Session Controller service used for projected goal state.
import type {} from '@deepseek-ai/dsh-api-session-controller/client'
// Type-only: pulls the Chat node slot and its keyed data map.
import type {} from '@deepseek-ai/dsh-client-ui-chat/client'
// Type-only: pulls the Conversation service and input-dock slot.
import type {} from '@deepseek-ai/dsh-client-ui-conversation/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 renderer-owned slots service.
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
// Type-only: pulls the Session standard useProjection seat.
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
// Type-only: the `goal` SessionProjectionMap key merge (single source, the domain's pure outlet).
import type { GoalProjection, GoalRef } from '@deepseek-ai/dsh-goal/client'
import type { GoalActionResult, GoalBarActions } from './slots.ts'
@ -38,14 +47,14 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
const NS = 'goal'
/** Required services for the Goal dock, command-input projection, Remote mutations, and copy. */
export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale', 'conversationEvents']
export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale', 'uiConversation']
/**
* Client plugin body: the GoalBar dock entry with its mutation verbs.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.conversationEvents.register(goalCommandInputDefinition)
ctx.uiConversation.events.register(goalCommandInputDefinition)
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-goal: dictionaries')
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({

View file

@ -14,8 +14,9 @@ import { Context, Service } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { afterEach } from 'vitest'
import { SlotRegistry, type SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { ConversationEventRegistry } from '@deepseek-ai/dsh-client-runtime/src/client/conversation/event-registry.ts'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import { UiConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { GoalProjection } from '@deepseek-ai/dsh-goal/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
@ -52,7 +53,18 @@ async function bench(options: {
} = {}) {
const ctx = new Context()
const calls: { method: string; args: unknown[] }[] = []
const conversationEvents = new ConversationEventRegistry(ctx)
const sessions = {
binding: (id: SessionId) => ({
sessionId: id,
session: { projections: { faceOf: (key: string) => ({
getSnapshot: () => (key === 'goal' ? options.projection : undefined),
subscribe: () => () => {},
}) } },
ctx,
}),
}
ctx.provide('sessions', sessions)
const conversationEvents = new UiConversation(ctx, sessions as never).events
function answer<T>(method: string, value: T) {
return (...args: unknown[]) => {
calls.push({ method, args })
@ -88,16 +100,6 @@ async function bench(options: {
},
} as never, (() => null) as never)
ctx.provide('locale', new LocaleRuntime(ctx))
ctx.provide('sessions', {
binding: (id: SessionId) => ({
sessionId: id,
session: { projections: { faceOf: (key: string) => ({
getSnapshot: () => (key === 'goal' ? options.projection : undefined),
subscribe: () => () => {},
}) } },
ctx,
}),
})
const fiber = ctx.plugin({ inject: [...inject], apply })
return {
ctx,

View file

@ -2,15 +2,17 @@
import { cleanup, render, within } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import type {
ChatConversationViewNode, ChatSnapshot, ConversationEventInput,
ConversationNodeDefinition, ConversationViewDefinition,
} from '@deepseek-ai/dsh-client-runtime/client'
import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-runtime/client'
ConversationEventInput, ConversationNodeDefinition, ConversationViewDefinition,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
ChatConversationViewNode, ChatSnapshot,
} from '@deepseek-ai/dsh-client-ui-chat/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { commandDefinition } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation-nodes/command.ts'
import { chatViewDefinition } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts'
import { commandDefinition } from '@deepseek-ai/dsh-client-ui-chat/src/client/conversation-nodes/command.ts'
import { chatViewDefinition } from '@deepseek-ai/dsh-client-ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts'
import { GoalCommandInputView } from '../src/client/GoalCommandInputView.tsx'
import {
goalCommandInputDefinition, goalCommandText,
@ -38,7 +40,6 @@ class TestViewDefinitions {
function entry(seq: number, type: string, data: unknown): ConversationEventInput {
return {
event: { seq, time: 1_700_000_000_000 + seq, type, data } as ConversationEventInput['event'],
view: undefined,
}
}

View file

@ -4,7 +4,7 @@
* see registerSource alone, the conversation wiring layer resolves its
* per-session controller through sessionOf.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type { InputTriggerSource } from '../types.ts'
import type { InputTriggerController } from './controller.ts'

View file

@ -7,8 +7,9 @@
* only the source roster. One controller per session scope; the service
* disposes it with the scope fiber.
*/
import type { ClientContext, SessionId, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { detectTrigger } from '../core/detect.ts'
import { MENU_CLOSED, menuReduce, seedGroups } from '../core/menu.ts'
import type { MenuEvent, MenuState, TriggerHit } from '../core/contract.ts'

View file

@ -6,7 +6,10 @@
*/
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import { resolveClientSessions } from '@deepseek-ai/dsh-api-session-controller/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
import { InputTriggerService } from './service.ts'
import type { MenuViewInjected } from './slots.ts'
import { MenuView } from './MenuView.tsx'
@ -57,7 +60,7 @@ export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(MENU_NS, { zh, en }), 'ui-input-trigger: menu dictionaries')
ctx.inject(['slots', 'inputTriggers', 'sessions'], (scope: ClientContext) => {
const inputTriggers = scope.inputTriggers
const sessions = scope.sessions
const sessions = resolveClientSessions(scope)
scope.slots.inject('conversation.input.overlay', () => scope.slots.register({
name: 'conversation.input.overlay',
id: 'slash-menu',

View file

@ -7,7 +7,9 @@
*/
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { ClientContext, ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { InputTriggerSource } from '../types.ts'
import { InputTriggerController } from './controller.ts'
import type { InputTriggerServiceContract } from './contract.ts'

View file

@ -1,30 +1,8 @@
/**
* Overlay-slot contract surface of the slash plugin. The
* 'conversation.input.overlay' slot is OWNED by the ui-conversation composer
* entry (declaring is claiming: anchor, children declaration, lifecycle),
* but the SlotMap type merge lives here: the owner package depends on this
* one, so the dependency direction admits no reverse type import, and a
* type-erased registration is ruled out. The owner's
* program picks this merge up transitively through its ui-input-trigger imports.
*/
// Type-only edge: the SlotMap augmentation below merges into this package's interface.
import type {} from '@deepseek-ai/dsh-client-ui-slots'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
/** Slash-menu props for the Conversation-owned input overlay. */
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { MenuState } from '../core/contract.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/**
* The InputBar floating overlay anchor: MenuView (this package) and the
* popupSelect shell (ui-commands) contribute list entries; each reads its
* own store and renders null while closed. Declared (children table) by
* ui-conversation's composer entry; the anchor hides with the input
* under a takeover.
*/
'conversation.input.overlay': { kind: 'list'; scope: 'session' }
}
}
/** Injected business face of the MenuView overlay entry (copy rides the standard locale seat, not this face). */
export interface MenuViewInjected {
/** The service's menu state store (read-only here; MenuView subscribes). */

View file

@ -1,14 +1,22 @@
/**
* Frozen cross-package contract for the input trigger pipeline. Types only —
* no runtime code. Sources (ui-commands / ui-skill / ui-reference) and the
* conversation input layer import from here; changes require main-thread
* arbitration.
* Input-trigger provider contract. Types only — no runtime code. The
* conversation input layer owns and exports the shared machine currency;
* this module re-exports it for trigger providers.
*
* Providers receive a {@link ClientSessionContext} projection per call —
* never a Cordis context or the mutable Session. RPC and service access go
* through the provider plugin's own root context captured at registration.
*/
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type {
PickOutcome, TokenSpan,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
export type {
ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CommandClaim, ConsumeTokenRequest,
InsertReferenceRequest, InsertTextRequest, PickOutcome, ReferenceInsert, SubmitImageAttachment,
SubmitOutcome, TokenSpan,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
/**
* The provider-facing projection of one client session. It carries stable
@ -41,85 +49,6 @@ export interface InputTriggerCandidate {
readonly value?: string
}
/** Pick-moment snapshot of the trigger token span. CAS: stale draftRev ⇒ the whole action no-ops. */
export interface TokenSpan {
readonly start: number
readonly end: number
readonly draftRev: number
}
/** Base64-encoded composer image accompanying one claimed submit transaction. */
export interface SubmitImageAttachment {
/** Declared media type; the host verifies it against the decoded bytes. */
readonly mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif'
/** Canonical base64 encoding of the image bytes. */
readonly data: string
/** Optional display name; never interpreted as a path. */
readonly name?: string
}
/**
* Command-mode entry credential. Pure data + a closure method — no class, no
* cross-package runtime value (client bundle purity).
*/
export interface CommandClaim {
/** Integrity-watched draft prefix, e.g. `'/goal '` — breaking startsWith releases the claim. */
readonly token: string
/** Ghost-text hint rendered while the claim's args are blank. */
readonly hint?: string
/**
* Whether composer image attachments may accompany this command's submit.
* Absent = the composer refuses to submit while images are attached, keeping
* the draft and the images in place behind a visible notice.
*/
readonly images?: boolean
/**
* Enter transaction, supplied by the source as a closure.
* @param images - serialized composer images accompanying the submission;
* the composer passes them only when {@link CommandClaim.images} is true.
*/
submit(args: string, actx: ClientContext, images: readonly SubmitImageAttachment[]): Promise<SubmitOutcome>
}
/**
* Inline reference insertion. The draft holds the complete display text while
* the occurrence retains its range; the owner supplies both user-facing projections at insert time
* (the model representation is serialized on submit via the source codec).
*/
export interface ReferenceInsert {
readonly source: string
readonly ref: string
/** Inline display label (fallback-cached on the occurrence). */
readonly label: string
/** Optional domain glyph shown beside the label. */
readonly appearance?: 'session' | 'file' | 'folder'
/** Clipboard / persistence projection, e.g. `/name` (never the model form). */
readonly clipboardText: string
}
/** Settled result of a command submit transaction. */
export interface SubmitOutcome {
readonly kind: 'success' | 'error'
readonly text?: string
}
/**
* Unified pick return. `undefined` = miss → default sink; `'handled'` = the
* source dealt with it internally (e.g. opened its popup shell). The `text`
* arm is the plain-text reference path (decision recorded in
* .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md):
* the token span is
* replaced with literal text — no occurrence identity, no placeholder; any
* chip visual is derived downstream by scanning the draft against the
* source lexicons.
*/
export type PickOutcome =
| { readonly claim: CommandClaim }
| { readonly insert: ReferenceInsert }
| { readonly text: string; readonly continue?: boolean }
| 'handled'
| undefined
/**
* Non-text composer submission state visible to enter adjudication. The
* composer owns the actual attachment payloads; adjudication only needs their
@ -235,73 +164,3 @@ export interface TriggerGuard {
/** plain: '/' and '@' live; claimed: '/' suppressed, '@' live; frozen: none. */
readonly tier: 'plain' | 'claimed' | 'frozen'
}
/** Keys the menu intercepts while open (all behind the IME composition guard). */
export type ArbitrateKey = 'up' | 'down' | 'enter' | 'escape'
/** consumed = key handled; pick-highlighted = enter picked the highlight; pass = let the input see it. */
export type ArbitrateOutcome = 'consumed' | 'pick-highlighted' | 'pass'
/** Request payload of the scoped begin-command input event. */
export interface BeginCommandRequest {
readonly claim: CommandClaim
readonly span: TokenSpan
}
/** Request payload of the scoped insert-reference input event. */
export interface InsertReferenceRequest {
readonly reference: ReferenceInsert
readonly span: TokenSpan
}
/** Request payload of the scoped consume-token input event. */
export interface ConsumeTokenRequest {
readonly guard:
| { readonly kind: 'span'; readonly span: TokenSpan }
| { readonly kind: 'bare-token'; readonly token: string }
}
/** Request payload of the scoped insert-text input event (the plain-text reference path). */
export interface InsertTextRequest {
/** Literal replacement for the trigger token span (e.g. `/name `). */
readonly text: string
readonly span: TokenSpan
/** Keep completion open after the splice (directory descent): the input re-tracks at the caret. */
readonly continue?: boolean
}
declare module '@deepseek-ai/cordis' {
interface Events {
/**
* Applies one command claim to the scoped Input. Dispatched with the
* session's scope carrier; the owning session's input listener returns
* `true` only after the phase and span CAS checks pass and the machine
* actually mutated — producers treat anything else as "not applied".
* @param request - Claim and menu-time span CAS.
* @mode bail
*/
'slash/input-begin-command'(request: BeginCommandRequest): true | undefined
/**
* Inserts one reference into the scoped Input (same carrier routing and
* applied-truth contract as begin-command).
* @param request - Reference and menu-time span CAS.
* @mode bail
*/
'slash/input-insert-reference'(request: InsertReferenceRequest): true | undefined
/**
* Consumes one command token after business success (popup settle /
* menu-pick execute). Same carrier routing and applied-truth contract.
* @param request - Exact span or bare-token guard.
* @mode bail
*/
'slash/input-consume-token'(request: ConsumeTokenRequest): true | undefined
/**
* Replaces the trigger token span with literal text — the plain-text
* reference path. Same carrier routing and applied-truth
* contract; the draft gains ordinary characters, no occurrence entry.
* @param request - Replacement text and menu-time span CAS.
* @mode bail
*/
'slash/input-insert-text'(request: InsertTextRequest): true | undefined
}
}

View file

@ -7,8 +7,11 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { createScope, scopeOf, SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import {
createScope, resolveClientSessions, scopeOf,
} from '@deepseek-ai/dsh-api-session-controller/client'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { apply, inject, InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
import type { MenuViewInjected } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
@ -77,7 +80,7 @@ describe('apply', () => {
const injectEntry = entries[0]!.inject as unknown as (sessionId: SessionId) => MenuViewInjected
const injected = injectEntry(sid('a'))
const controller = inputTriggers.sessionOf(
(ctx.get('sessions') as { scope(id: SessionId): Context }).scope(sid('a')),
resolveClientSessions(ctx).scope(sid('a'))!,
)
expect(injected.menu).toBe(controller.menu)
// The pick face routes into the controller pipeline (closed menu → no-op).

View file

@ -9,7 +9,7 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { zh } from '../src/client/locales.ts'

View file

@ -9,8 +9,8 @@
*/
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { InputTriggerController, InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
import type {
BeginCommandRequest, ClientSessionContext, CommandClaim, InsertReferenceRequest, PickOutcome,

View file

@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
import type { JobView } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types'
import { IconChevronDownOutline14, StateDot, useDismissOnOutsidePointer, type StateDotState } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import { NS } from './locales.ts'

View file

@ -4,9 +4,11 @@
* through the `jobsBySession` list mirror, so the plugin issues no RPC and
* holds no state of its own beyond popover visibility.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import { JobListAction } from './JobListAction.tsx'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
import { en, NS, zh, type JobKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {

View file

@ -7,7 +7,7 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/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'
@ -93,7 +93,9 @@ describe('ui-job invariant companion', () => {
expect(JobInvariant.name).toBe('client-ui-jobs-invariant')
expect(JobInvariant.inject).toEqual(['invariants'])
// Emitting an unrelated event proves the companion installed no audit.
expect(() => { (ctx.emit as (event: string) => void)('slots/changed') }).not.toThrow()
expect(() => {
Reflect.apply(ctx.emit.bind(ctx), undefined, ['unrelated/event'])
}).not.toThrow()
await fiber.dispose()
})
})

View file

@ -2,7 +2,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionId, SessionListState, JobView } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { JobListAction, type JobListActionProps } from '../src/client/JobListAction.tsx'
import { zh } from '../src/client/locales.ts'

View file

@ -14,6 +14,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react
import type { ReactNode } from 'react'
import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import { computeColumns, SIDEBAR_AUTO_COLLAPSE, SIDEBAR_DEFAULT } from './columns.ts'
import { DocumentTitle } from './DocumentTitle.tsx'
import type { createLayoutStore } from './stores.ts'
import css from './AppFrame.module.css'
@ -89,12 +90,17 @@ export function AppFrame({
useSessions,
actions,
renderSlot,
SessionProvider,
}: AppFrameProps) {
const panels = useStore(s => s)
const detailsSession = useSessions((s) => {
const current = s.current
return current !== undefined && s.byId[current]?.blank === false ? current : undefined
})
const documentTitle = useSessions((s) => {
const current = s.current
return current === undefined ? undefined : s.byId[current]?.title
})
const frameRef = useRef<HTMLDivElement | null>(null)
const [viewport, setViewport] = useState(() => window.innerWidth)
@ -170,6 +176,7 @@ export function AppFrame({
data-details-collapsed={cols.details === 0 || undefined}
data-dragging={dragging || undefined}
>
<DocumentTitle {...documentTitle === undefined ? {} : { title: documentTitle }} />
<div className={css.sidebarCol}>
{/* Render-site slot call with live concession output: a closed
sidebar keeps the mounted slot at the compact-rail width, and the
@ -185,10 +192,12 @@ export function AppFrame({
{/* Both column occupants stay at fixed tree positions from first
paint — no loading gate: a bare status line reads worse than
the shell's own pending rendering. The conversation
is session-maybe; the strict details entry naturally renders
empty while no session is current. */}
is session-maybe; SessionProvider withholds the strict details
entry while no session is current. */}
<CenterColumn>{renderSlot('conversation', {})}</CenterColumn>
<DetailsColumn>{renderSlot('details', {})}</DetailsColumn>
<DetailsColumn>
<SessionProvider>{renderSlot('details', {})}</SessionProvider>
</DetailsColumn>
</>
<div className={css.overlayLayer} data-shell-overlay>
{renderSlot('shell.overlay', {})}

View file

@ -7,7 +7,9 @@
* with the runtime sessions service. A second effect seats the theme
* presenter, which projects ctx.theme snapshots onto document.body.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
import type {} from '@deepseek-ai/dsh-client-ui-theme/client'
import type { PanelActions } from './service.ts'
import { AppFrame } from './AppFrame.tsx'

View file

@ -7,7 +7,7 @@
* derives its PropsStore share from the return type, and the service face
* receives the bound actions through the registration's inject hook.
*/
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
import {
clampWidth, DETAILS_DEFAULT, DETAILS_MAX, DETAILS_MIN,
SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN,

View file

@ -2,7 +2,7 @@
/**
* AppFrame interaction spec under the four-share props form: real layout
* store instance (createLayoutStore().create() — the test-sanctioned engine
* path), a recording renderSlot stub, and a render-prop SessionProvider stub
* path), a recording renderSlot stub, and a SessionProvider component stub
* (the real one is framework-wired to the renderer host; its own behavior is
* ui-renderer's spec territory). Drag sequences (pointer capture + rAF flush),
* concession response to viewport change, and details staying mounted at
@ -17,22 +17,24 @@ import { AppFrame } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame.
import type { AppFrameProps } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame.tsx'
import { SIDEBAR_COLLAPSED } from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts'
import { createLayoutStore } from '@deepseek-ai/dsh-client-ui-layout/src/client/stores.ts'
import type {
SessionId, SessionListState, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
// Session selection controls for the SessionProvider and useSessions stubs.
const selectedSession = { current: 's-test' as SessionId | undefined }
const selectedSessionBlank = { current: false }
const baselinesReady = { current: true }
const selectedSessionTitle = { current: undefined as string | undefined }
const workspacesReady = { current: true }
type AttentionSnapshot = Parameters<Parameters<AppFrameProps['useSessionPendingInteraction']>[0]>[0]
const noAttention: AttentionSnapshot = new Map()
const useSessionPendingInteraction: AppFrameProps['useSessionPendingInteraction'] = selector => selector(noAttention)
// Render-prop contract stub fed through the standard seat prop (the renderer
// injects the real one in production): session mode runs children(id), empty
// mode runs the empty branch — the frame must work against exactly this
// shape. Typed as the seat's own component type so the branded sessionId
// parameter stays contract-checked.
// Provider contract stub fed through the standard seat prop (the renderer
// injects the real one in production): session mode renders children and
// empty mode runs the empty branch.
const SessionProviderStub: AppFrameProps['SessionProvider'] = ({ children, empty }) =>
selectedSession.current === undefined ? <>{empty?.() ?? null}</> : <>{children(selectedSession.current)}</>
selectedSession.current === undefined ? <>{empty?.() ?? null}</> : <>{children}</>
/** Observer stub: captures the callback so tests can fire resizes manually. */
@ -70,15 +72,24 @@ function mountFrame() {
ids: current === undefined ? [] : [current],
byId: current === undefined
? {}
: { [current]: { id: current, displayTitle: 'Test', running: false, blank: selectedSessionBlank.current, updatedAt: 1 } },
: {
[current]: {
id: current,
displayTitle: 'Test',
running: false,
blank: selectedSessionBlank.current,
updatedAt: 1,
...(selectedSessionTitle.current === undefined ? {} : { title: selectedSessionTitle.current }),
},
},
current,
phase: 'ready',
} as SessionListState
return sel(sessionState)
}) as never
const workspaceState: WorkspaceListState = {
const workspaceState: WorkspaceSnapshot = {
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: baselinesReady.current, recentWorkspaceId: undefined,
...(workspacesReady.current ? {} : { state: 'loading' as const, phase: 'pending' as const }),
}
const element = () => (
<AppFrame
@ -86,7 +97,8 @@ function mountFrame() {
actions={instance.actions}
renderSlot={renderSlot}
useSessions={useSessions}
useWorkspaces={((sel: (s: WorkspaceListState) => unknown) => sel(workspaceState)) as never}
useSessionPendingInteraction={useSessionPendingInteraction}
useWorkspaces={((sel: (s: WorkspaceSnapshot) => unknown) => sel(workspaceState)) as never}
SessionProvider={SessionProviderStub}
/>
)
@ -114,7 +126,8 @@ beforeEach(() => {
frameWidth = 1920
selectedSession.current = 's-test' as SessionId
selectedSessionBlank.current = false
baselinesReady.current = true
selectedSessionTitle.current = undefined
workspacesReady.current = true
vi.useFakeTimers()
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => setTimeout(() => { cb(0) }, 16) as unknown as number)
@ -132,11 +145,28 @@ beforeEach(() => {
afterEach(() => {
cleanup()
document.title = ''
vi.useRealTimers()
vi.unstubAllGlobals()
vi.unstubAllEnvs()
})
describe('AppFrame', () => {
it('projects the selected durable Session title', () => {
vi.stubEnv('DSH_CLIENT_TITLE', 'Product')
selectedSessionTitle.current = 'First'
const { rerenderFrame } = mountFrame()
expect(document.title).toBe('First — Product')
selectedSessionTitle.current = 'Revised'
act(() => { rerenderFrame() })
expect(document.title).toBe('Revised — Product')
selectedSession.current = undefined
act(() => { rerenderFrame() })
expect(document.title).toBe('Product')
})
it('renders three tracks from store state', () => {
const { frame } = mountFrame()
expect(tracks(frame)).toEqual([280, 0])
@ -158,15 +188,17 @@ describe('AppFrame', () => {
// No current session: the session-maybe conversation shell owns the New
// Session view itself — the center column renders it unconditionally.
selectedSession.current = undefined
const { slotCalls, getByTestId } = mountFrame()
const { slotCalls, getByTestId, queryByTestId } = mountFrame()
expect(getByTestId('center-content')).toBeTruthy()
expect(slotCalls.map(c => c.key)).toContain('conversation')
expect(queryByTestId('details-content')).toBeNull()
expect(slotCalls.map(c => c.key)).toContain('details')
})
it('renders both column occupants before baselines settle (no loading gate)', () => {
// No loading gate: a bare loading status reads worse than the shell's own
// pending rendering — both occupants mount from first paint.
baselinesReady.current = false
workspacesReady.current = false
const { slotCalls } = mountFrame()
expect(slotCalls.map(c => c.key)).toContain('conversation')
expect(slotCalls.map(c => c.key)).toContain('details')

View file

@ -3,7 +3,7 @@
import { Context } from '@deepseek-ai/cordis'
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { apply as themeApply, inject as themeInject, ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client'
import { apply, inject, LayoutController } from '@deepseek-ai/dsh-client-ui-layout/client'
@ -94,7 +94,7 @@ describe('ui-layout client apply', () => {
expect(ctx.get('layout')).toBeUndefined()
expect(slots.entries('root')).toHaveLength(0)
expect(slots.spec('sidebar')).toBeUndefined()
// The built-in root declaration survives entry teardown (runtime-owned).
// The built-in root declaration survives entry teardown (renderer-owned).
expect(slots.spec('root')).toEqual({ kind: 'single', scope: 'root' })
})
})

View file

@ -9,7 +9,8 @@
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
import type { MessageId, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { MessageId } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type {
MessageFeedbackDeleteResult,
MessageFeedbackItem,

View file

@ -7,13 +7,18 @@
* @module @deepseek-ai/dsh-client-ui-message-feedback/client
*/
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
// Type-only: pulls the generated Remote API and ctx.remote merge through the Client assembly boundary.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
// Type-only: pulls the ui-conversation SlotMap merge (the assistant-actions entry).
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
// Type-only: pulls the SlotRegistry service merge (ctx.slots).
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-chat/client'
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
import { MessageFeedbackController } from './controller.ts'
import { MessageFeedbackActions } from './MessageFeedbackActions.tsx'
import type { MessageFeedbackInjected } from './slots.ts'

View file

@ -11,7 +11,8 @@
import { Context, Service } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup } from '@testing-library/react'
import { SlotRegistry, type SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import type { MessageId } from '@deepseek-ai/dsh-client-connection/client'
import type { MessageFeedbackItem, MessageFeedbackVersion } from '@deepseek-ai/dsh-message-feedback/types'

View file

@ -10,8 +10,8 @@ import type {
} from '@deepseek-ai/dsh-api-session-controller/types'
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import type { TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
/** Directory snapshot both entries render from. */
export interface ModelDirectoryState {

View file

@ -13,12 +13,15 @@
*/
// Type-only: the carrier types, the forwarded Host-event face and the ctx.remote merge.
import type { ModelSelection, SessionModels } from '@deepseek-ai/dsh-api-session-controller/types'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { resolveClientSessions } from '@deepseek-ai/dsh-api-session-controller/client'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type { CommandUiContract, SelectOption } from '@deepseek-ai/dsh-client-ui-commands/client'
// Type-only: pulls the ui-conversation SlotMap merge (the input.model seat).
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import type { ModelDirectoryState } from './directory.ts'
import { ModelDirectoryResolver } from './service.ts'
@ -122,7 +125,7 @@ export function apply(ctx: ClientContext): void {
ctx.inject(['commandUi', 'modelDirectories'], (scope: ClientContext) => {
const command = scope.get('commandUi') as CommandUiContract
const models = scope.modelDirectories
const sessions = scope.sessions
const sessions = resolveClientSessions(scope)
scope.effect(() => command.register({
name: 'model',
description: t('command.description'),
@ -153,7 +156,7 @@ export function apply(ctx: ClientContext): void {
// Entry 2: the composer's named model seat over the SAME directory.
ctx.inject(['slots', 'modelDirectories'], (scope: ClientContext) => {
const models = scope.modelDirectories
const sessions = scope.sessions
const sessions = resolveClientSessions(scope)
scope.slots.inject('conversation.input.model', () => scope.slots.register({
name: 'conversation.input.model',
locale: NS,

View file

@ -14,8 +14,8 @@
*/
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionRuntime } from '@deepseek-ai/dsh-client-runtime/client'
import { resolveClientSessions } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { ModelDirectory } from './directory.ts'
declare module '@deepseek-ai/cordis' {
@ -70,7 +70,7 @@ export class ModelDirectoryResolver extends Service {
const { live } = this
const existing = live.directories.get(sessionId)
if (existing !== undefined) return existing
const sessions = this.ctx.get('sessions') as SessionRuntime
const sessions = resolveClientSessions(this.ctx)
const actx = sessions.scope(sessionId)
if (actx === undefined) throw new Error(`ui-model-selection: session "${String(sessionId)}" resolved no scope`)
const directory = new ModelDirectory(

View file

@ -5,7 +5,7 @@
* merge lives here.
*/
import type { ModelSelection } from '@deepseek-ai/dsh-api-remotes/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { ModelDirectoryState } from './directory.ts'
/** Injected business face of the composer model seat. */

View file

@ -10,8 +10,8 @@
*/
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import { createScope } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { createScope } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import type { ModelSelection } from '@deepseek-ai/dsh-api-session-controller/types'

View file

@ -2,7 +2,7 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ModelSelection } from '@deepseek-ai/dsh-api-remotes/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { ComponentProps } from 'react'
import type { ModelDirectoryState } from '../src/client/directory.ts'
import { ModelSelect } from '../src/client/ModelSelect.tsx'

View file

@ -5,7 +5,7 @@
*/
import { useEffect, useState } from 'react'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import {
IconChevronDownOutline14, Menu, RiskConfirmation,

View file

@ -13,15 +13,18 @@
* The General-settings row separately writes the default preset for sessions
* created later through the host Settings API.
*/
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client'
import { resolveClientSessions, type SessionFace } from '@deepseek-ai/dsh-api-session-controller/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'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-session/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 { CommandUiContract, SelectOption } from '@deepseek-ai/dsh-client-ui-commands/client'
import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
import type { PermissionSelect } from '@deepseek-ai/dsh-permission-presets/client'
@ -80,7 +83,7 @@ function optionsOf(value: PermissionSelect, t: (key: string) => string): SelectO
*/
export function apply(ctx: ClientContext): void {
const command = ctx.get('commandUi') as CommandUiContract
const sessions = ctx.sessions
const sessions = resolveClientSessions(ctx)
// This optional bundle and ui-conversation can load independently, so each
// owns the same safety copy under its own locale namespace.
/* jscpd:ignore-start */

View file

@ -11,7 +11,7 @@ import type {
} from '@deepseek-ai/dsh-api-remotes/client'
import {
createSnapshotStore, type SnapshotStore,
} from '@deepseek-ai/dsh-client-runtime/client'
} from '@deepseek-ai/dsh-client-store'
import type {
SchemaNode, SettingsDescribeFace, SettingsSchemaService,
} from '@deepseek-ai/dsh-client-ui-settings/client'

View file

@ -10,7 +10,8 @@
*/
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import { SlotRegistry, type SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'

Some files were not shown because too many files have changed in this diff Show more