Merge branch 'master' into feat/produced-files-folder
This commit is contained in:
@@ -13,7 +13,7 @@ The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-
|
||||
3. **Component props are the four shares, all derived**: `PropsRuntime<K>` (SlotMap: owner params + `useSession`/`sessionId` on session scope + global `useSessions`/`useWorkspaces`) & `PropsRenderSlots<S>` (children keys) & `PropsStore<H>` (store factory) & the inject face. Never hand-write a member a share already derives; never re-type a share locally.
|
||||
4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five standing seats, plus the `use<Name>` hooks the renderer binds from provide contributions and inject `hooks` compartments. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.)
|
||||
5. **Live data has exactly three channels**: parent knows it → owner props at the renderSlot site; only the component knows it → local state; shared across entries or survives remounts → a store declared at register. Derived data is a pure function over framework-hook data (`useMemo`), never its own subscription.
|
||||
6. **Stores: read `props.useStore`, write `props.actions.*`** — the declared actions are the complete mutation surface. Write the store as an exported `createXXXStore()` factory (module-level handles are forbidden — de-facto singletons); share by passing one handle to several registers inside `apply`. Production code never calls the factory or `.create()` outside `apply`; tests do (that is the sanctioned zero-machinery path).
|
||||
6. **Stores: read `props.useStore`, write `props.actions.*`** — the declared actions are the complete mutation API. Write the store as an exported `createXXXStore()` factory (module-level handles are forbidden — de-facto singletons); share by passing one handle to several registers inside `apply`. Production code never calls the factory or `.create()` outside `apply`; tests do (that is the sanctioned zero-machinery path).
|
||||
7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hand-made hooks, no ReactNode producers, no whole-service objects. A registrant-private reactive fact uses the reserved `hooks` compartment (bare observables the renderer binds to `use<Name>`; components never see the sources). The plugin may use only the dependencies named by its `inject` declaration; there is no wider ctx to reach for.
|
||||
|
||||
## Reactive read and contract-currency discipline
|
||||
@@ -32,7 +32,7 @@ How live data reaches render code, and what UI domains may share:
|
||||
The `/client` entrypoint of a UI plugin package is its public browser API, not a convenience barrel. Three rules apply package-wide (do not restate them as per-file comments):
|
||||
|
||||
1. **A UI plugin exports no values beyond what cordis loading needs** — `apply` / `inject` (and `Config` where present), plus store factories consumed type-only by components (`ReturnType<typeof createXXXStore>`). Shared types (owner data, injected values, composed prop aliases) may also be exported. Implementation components, pure helpers, constants, and store handles stay internal. Adding any new value export requires user sign-off, not a matching consumer.
|
||||
2. **Same-package tests import internals directly** — relative `../src/client/xxx.ts` from package tests, or the `./src/*` subpath where a spec lives outside the package. Never widen the public surface to make a test compile.
|
||||
2. **Same-package tests import internals directly** — relative `../src/client/xxx.ts` from package tests, or the `./src/*` subpath where a spec lives outside the package. Never widen the public API to make a test compile.
|
||||
3. **Cross-package imports of another plugin's symbols are in principle forbidden.** The sanctioned routes are the slot system (register/renderSlot) and ctx services. If neither fits, stop and escalate — do not add an export to unblock yourself.
|
||||
|
||||
## ctx discipline (components never see ctx)
|
||||
|
||||
@@ -71,7 +71,7 @@ const MARKDOWN_FIXTURE = [
|
||||
'- first item',
|
||||
' - nested item',
|
||||
'',
|
||||
'| Surface | State |',
|
||||
'| Area | State |',
|
||||
'| --- | --- |',
|
||||
'| history | rendered |',
|
||||
'| streaming | stable |',
|
||||
@@ -2836,6 +2836,12 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
})
|
||||
return Promise.resolve({ accepted: true })
|
||||
},
|
||||
// Satisfies the ApiProxy contract type only: the browser export button
|
||||
// fetches GET /api/session.export directly (window.fetch), so this stub is
|
||||
// never reached through the fixture's dispatch.
|
||||
downloads: {
|
||||
sessionLog: () => Promise.resolve(new Response('fixture mode does not serve session export', { status: 404 })),
|
||||
},
|
||||
}
|
||||
|
||||
const rpc: ClientConnectionRpc = {
|
||||
|
||||
@@ -53,7 +53,7 @@ export interface HostDescriptionSource {
|
||||
export const inject: string[] = []
|
||||
|
||||
/**
|
||||
* The ctx.connection service surface: the api client plus a one-shot
|
||||
* The ctx.connection service API: the API client plus a one-shot
|
||||
* controller starter (the runtime plugin supplies sinks when its object layer
|
||||
* is ready — connection stays consumer-agnostic).
|
||||
*/
|
||||
|
||||
@@ -16,7 +16,7 @@ export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: ns-by-locale dictionary registry with a stable
|
||||
* bind(ns) surface — it emits no cordis events and owns no cross-plugin
|
||||
* bind(ns) API — it emits no cordis events and owns no cross-plugin
|
||||
* mutable relation; fallback-chain resolution and locale-store behavior are
|
||||
* asserted directly by this package's behavior specs.
|
||||
*/
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/modules/README.md
|
||||
README.md: a1d578850c2518a85dc32f048768b78caf5ffec4
|
||||
README.md: efaff699839b977cc45f89f3c164402241b90dc2
|
||||
README.zh.md: 772a4870f7ef6730d9d3d4db434ed771d97984f0
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Client module system: the browser peer of Node's internal ESM loader, built as a lazy CJS table. The web shell mounts the vendored cordis Loader for entry governance (fiber lifecycle, inject waiting, update/refresh) and injects this package's `ClientModuleLoader` through its `internal` contract — the vendored side's only consumption point is `EntryTree.import`, so replacing `internal` replaces exactly "how plugin code arrives" and nothing else.
|
||||
|
||||
Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`window.__ModuleLoader__.load({id, factory})`); every module body side effect — CSS injection included — lives in the factory closure and runs at materialization (`factory(require)` → export surface, memoized in `loadCache`), not at script execution. A factory that requires another registered-but-unmaterialized module materializes it recursively, so load order needs no external sequencing; require cycles throw (factory-form CJS cannot deliver partial exports). `<id>/client` and the bare id name the same surface (a plugin bundle IS its package's client half).
|
||||
Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`window.__ModuleLoader__.load({id, factory})`); every module body side effect — CSS injection included — lives in the factory closure and runs at materialization (`factory(require)` → exports, memoized in `loadCache`), not at script execution. A factory that requires another registered-but-unmaterialized module materializes it recursively, so load order needs no external sequencing; require cycles throw (factory-form CJS cannot deliver partial exports). `<id>/client` and the bare id resolve to the same exports (a plugin bundle IS its package's client half).
|
||||
|
||||
Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → load its external classic script + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the asynchronous load branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (script load and factory registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and materialized record so the next prefetch/import reloads the script (the HMR hook).
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
* factory (`window.__ModuleLoader__.load({id, factory})`); every module body
|
||||
* side effect — including CSS injection — lives inside the factory closure
|
||||
* and runs at materialization, not at script execution. Materialization
|
||||
* (factory(require) → export surface) happens on first import/require and is
|
||||
* (factory(require) → exports) happens on first import/require and is
|
||||
* memoized in {@link ClientModuleLoader.loadCache}; a factory that requires
|
||||
* another registered-but-unmaterialized module materializes it recursively,
|
||||
* so load order needs no external sequencing.
|
||||
*
|
||||
* Resolution branch order (import): seed word → shell instance; memoized
|
||||
* record → surface; static registry (shell-own modules, e.g. app-shell) →
|
||||
* record → exports; static registry (shell-own modules, e.g. app-shell) →
|
||||
* module; registered factory → materialize; graph row → load + materialize;
|
||||
* anything else → throw (loud — the runtime mirror of the
|
||||
* build-time bundle purity gate). The synchronous `require` handed to
|
||||
@@ -149,13 +149,13 @@ export interface ClientPluginHandoff {
|
||||
id: string
|
||||
/**
|
||||
* Closure factory holding the whole bundle body: receives the synchronous
|
||||
* require bound to the module table and returns the bundle's export
|
||||
* surface. Runs once, at materialization.
|
||||
* require bound to the module table and returns the bundle's exports. Runs
|
||||
* once, at materialization.
|
||||
*/
|
||||
factory: (require: (spec: string) => unknown) => Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Window surface of the web boot protocol: the host-injected graph, the registration sink, and the kernel handoff slot. */
|
||||
/** Window API of the web boot protocol: the host-injected graph, registration sink, and kernel handoff slot. */
|
||||
export interface DshWindow {
|
||||
/** Host-composed entry graph, injected before the shell bundle runs; wire-boundary raw until {@link parseBootManifest}. */
|
||||
__DSH_BOOT__?: unknown
|
||||
@@ -174,8 +174,8 @@ export interface DshWindow {
|
||||
export interface ClientModuleRecord {
|
||||
/** Module id (entry name / package name). */
|
||||
id: string
|
||||
/** The materialized export surface (factory `module.exports`, or the shell module for static registrations). */
|
||||
surface: unknown
|
||||
/** Materialized exports (`module.exports` from a factory, or a statically registered shell module). */
|
||||
exports: unknown
|
||||
/** Owned `<style data-plugin>` tag ids (`data-plugin-css` values) injected during materialization. */
|
||||
styles: string[]
|
||||
/** Observed `require()` edges (module-graph boundary; only table words can appear today). */
|
||||
@@ -190,7 +190,7 @@ export interface ClientModuleRecord {
|
||||
export interface ClientModuleLoader {
|
||||
/** Discriminant against Node's internal loader shapes ('v1'/'v2'). */
|
||||
version: 'client'
|
||||
/** Materialized-module registry: id → record. The governance-side read face for entry export surfaces. */
|
||||
/** Materialized-module registry: id → record. The governance-side read API for entry exports. */
|
||||
loadCache: Map<string, ClientModuleRecord>
|
||||
/**
|
||||
* Internal contract consumed by the vendored Loader's `tree.import`. Resolves
|
||||
@@ -199,7 +199,7 @@ export interface ClientModuleLoader {
|
||||
* @param specifier - module specifier (entry name or table word).
|
||||
* @param parentURL - importer URL (unused — the client module graph is flat).
|
||||
* @param attrs - Import attributes (unused; interface parity with Node's loader contract).
|
||||
* @returns the module's export surface.
|
||||
* @returns the module's exports.
|
||||
*/
|
||||
import(specifier: string, parentURL: string, attrs: Record<string, unknown>): Promise<unknown>
|
||||
/**
|
||||
|
||||
@@ -28,7 +28,7 @@ const defaultLoadBundle = (url: string): Promise<void> => new Promise((resolve,
|
||||
/**
|
||||
* A plugin bundle IS its package's client half: `<id>/client` (the exports
|
||||
* subpath external bundles emit) and the bare graph id name the same
|
||||
* surface, so table lookups normalize the suffix away.
|
||||
* exports, so table lookups normalize the suffix away.
|
||||
*/
|
||||
const stripClientSuffix = (spec: string): string =>
|
||||
spec.endsWith('/client') ? spec.slice(0, -'/client'.length) : spec
|
||||
@@ -123,8 +123,8 @@ export class ClientModuleSystem implements ClientModuleLoader {
|
||||
this.materializing.add(id)
|
||||
try {
|
||||
const edges = new Set<string>()
|
||||
const surface = registered(this.makeRequire(edges))
|
||||
const record: ClientModuleRecord = { id, surface, styles: claimStyles(id), edges }
|
||||
const exports = registered(this.makeRequire(edges))
|
||||
const record: ClientModuleRecord = { id, exports, styles: claimStyles(id), edges }
|
||||
this.loadCache.set(id, record)
|
||||
return record
|
||||
} finally {
|
||||
@@ -146,8 +146,8 @@ export class ClientModuleSystem implements ClientModuleLoader {
|
||||
if (this.statics.has(spec)) return this.statics.get(spec)
|
||||
const id = stripClientSuffix(spec)
|
||||
const record = this.loadCache.get(id)
|
||||
if (record !== undefined) return record.surface
|
||||
if (this.factories.has(id)) return this.materialize(id).surface
|
||||
if (record !== undefined) return record.exports
|
||||
if (this.factories.has(id)) return this.materialize(id).exports
|
||||
throw new Error(
|
||||
`client-modules: require("${spec}") missed the module table — not a platform seed word, not a shell-own module, `
|
||||
+ 'and no registered factory (a build-time externals drift, or a forbidden cross-plugin value import)',
|
||||
@@ -158,11 +158,11 @@ export class ClientModuleSystem implements ClientModuleLoader {
|
||||
async import(specifier: string): Promise<unknown> {
|
||||
if (this.seed.has(specifier)) return this.seed.get(specifier)
|
||||
const existing = this.loadCache.get(specifier)
|
||||
if (existing !== undefined) return existing.surface
|
||||
if (existing !== undefined) return existing.exports
|
||||
if (this.statics.has(specifier)) {
|
||||
const surface = this.statics.get(specifier)
|
||||
this.loadCache.set(specifier, { id: specifier, surface, styles: [], edges: new Set() })
|
||||
return surface
|
||||
const exports = this.statics.get(specifier)
|
||||
this.loadCache.set(specifier, { id: specifier, exports, styles: [], edges: new Set() })
|
||||
return exports
|
||||
}
|
||||
if (!this.factories.has(specifier)) {
|
||||
const row = this.graphRows.get(specifier)
|
||||
@@ -174,7 +174,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
|
||||
}
|
||||
await this.arrive(row)
|
||||
}
|
||||
return this.materialize(specifier).surface
|
||||
return this.materialize(specifier).exports
|
||||
}
|
||||
|
||||
registerStatic(id: string, module: unknown): void {
|
||||
|
||||
@@ -70,7 +70,7 @@ describe('lazy CJS arrival', () => {
|
||||
expect(b.loader.loadCache.size).toBe(0)
|
||||
})
|
||||
|
||||
it('import materializes once and memoizes the export surface', async () => {
|
||||
it('import materializes once and memoizes the exports', async () => {
|
||||
const ran: string[] = []
|
||||
const b = bench([row('a')], { a: () => { ran.push('a'); return { marker: 'a' } } })
|
||||
const first = await b.loader.import('a', '', {})
|
||||
@@ -83,8 +83,8 @@ describe('lazy CJS arrival', () => {
|
||||
|
||||
it('import without prefetch loads, registers, and materializes in one call', async () => {
|
||||
const b = bench([row('a')], { a: () => ({ marker: 'direct' }) })
|
||||
const surface = await b.loader.import('a', '', {})
|
||||
expect((surface as { marker: string }).marker).toBe('direct')
|
||||
const exports = await b.loader.import('a', '', {})
|
||||
expect((exports as { marker: string }).marker).toBe('direct')
|
||||
expect(b.fetched).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -123,8 +123,8 @@ describe('require resolution', () => {
|
||||
})
|
||||
await b.loader.prefetch('a')
|
||||
await b.loader.prefetch('b')
|
||||
const surface = await b.loader.import('a', '', {})
|
||||
expect((surface as { got: string }).got).toBe('from-b')
|
||||
const exports = await b.loader.import('a', '', {})
|
||||
expect((exports as { got: string }).got).toBe('from-b')
|
||||
expect(order).toEqual(['a', 'b'])
|
||||
expect(b.loader.loadCache.get('a')?.edges.has('b/client')).toBe(true)
|
||||
expect(b.loader.loadCache.has('b')).toBe(true)
|
||||
@@ -135,8 +135,8 @@ describe('require resolution', () => {
|
||||
const b = bench([row('a')], {
|
||||
a: req => ({ dep: req('react') }),
|
||||
}, { seed: { react } })
|
||||
const surface = await b.loader.import('a', '', {})
|
||||
expect((surface as { dep: unknown }).dep).toBe(react)
|
||||
const exports = await b.loader.import('a', '', {})
|
||||
expect((exports as { dep: unknown }).dep).toBe(react)
|
||||
expect(await b.loader.import('react', '', {})).toBe(react)
|
||||
expect(b.loader.loadCache.has('react')).toBe(false)
|
||||
})
|
||||
@@ -284,8 +284,8 @@ describe('default transport seam', () => {
|
||||
})
|
||||
})
|
||||
const loader: ClientModuleLoader = new ClientModuleSystem({ modules: [row('dee')], staticModules: {} })
|
||||
const surface = await loader.import('dee', '', {})
|
||||
expect((surface as { marker: string }).marker).toBe('via-script')
|
||||
const exports = await loader.import('dee', '', {})
|
||||
expect((exports as { marker: string }).marker).toBe('via-script')
|
||||
expect(append).toHaveBeenCalledOnce()
|
||||
expect([...document.querySelectorAll('script')]).toEqual([])
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@ import type {
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
// Store contract types are ui-slots authority; re-exported beside the engine
|
||||
// so store consumers get one import surface.
|
||||
// so store consumers get one import path.
|
||||
export type {
|
||||
ActionsDecl, BakedActions, BoundActions, StoreFactory, StoreHandle, StoreInstance, StoreSpec,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -165,7 +165,7 @@ function deepFreeze(value: unknown): void {
|
||||
|
||||
/** A live engine instance: the contract instance plus the raw engine store. */
|
||||
export interface EngineStoreInstance<T, A extends ActionsDecl<T>> extends StoreInstance<T, A> {
|
||||
/** The underlying engine store (framework/test surface; components never see it). */
|
||||
/** The underlying engine store (framework/test API; components never see it). */
|
||||
readonly store: SnapshotStore<T>
|
||||
}
|
||||
|
||||
|
||||
@@ -243,7 +243,7 @@ export function apply(ctx: Context): void {
|
||||
workspaces.handleHostEnvelope(envelope)
|
||||
// Typed-event bridge: the session layer ignores registry frames (no
|
||||
// session routing); consumers (command directory caches, the settings
|
||||
// and model surfaces) subscribe on ctx.
|
||||
// and model services) subscribe on ctx.
|
||||
const frame = envelope.payload
|
||||
if (frame.type === 'host/commands-changed') ctx.emit('commands/changed')
|
||||
else if (frame.type === 'host/session-preset-changed') {
|
||||
|
||||
@@ -430,7 +430,7 @@ export class SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- List surface ----
|
||||
// ---- List API ----
|
||||
|
||||
/** Full refresh via session.list (single-flight: an in-flight call is reused). */
|
||||
refreshList(): Promise<void> {
|
||||
@@ -629,7 +629,7 @@ export class SessionManager {
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
// ---- Subscription surface (for useSessionList) ----
|
||||
// ---- Subscription API (for useSessionList) ----
|
||||
|
||||
/**
|
||||
* uSES subscription entry for useSessionList.
|
||||
|
||||
@@ -60,7 +60,7 @@ export interface SessionOptions {
|
||||
* remaining public members are manager/runtime entry points.
|
||||
*/
|
||||
export class Session implements SessionFace {
|
||||
// ---- Window and derived state (all private; the snapshot is the only read surface) ----
|
||||
// ---- Window and derived state (all private; the snapshot is the only read API) ----
|
||||
private events: SessionEvent[] = []
|
||||
/** Wire views aligned with `events` by index (envelope-level annotations; undefined = no view).
|
||||
* Kept parallel rather than merged so `events` stays the raw log slice (model-visible ⟺ logged). */
|
||||
@@ -428,7 +428,7 @@ export class Session implements SessionFace {
|
||||
await this.open()
|
||||
}
|
||||
|
||||
// ---- Subscription surface (useSyncExternalStore direct wiring) ----
|
||||
// ---- Subscription API (useSyncExternalStore direct wiring) ----
|
||||
|
||||
/**
|
||||
* uSES subscription entry.
|
||||
|
||||
@@ -25,7 +25,7 @@ const CSS_VIRTUAL_PREFIX = '\0dsh-css:'
|
||||
const CSS_VIRTUAL_SUFFIX = '.mjs'
|
||||
|
||||
/**
|
||||
* Wire/type layers a client bundle may inline: browser-safe contract surfaces
|
||||
* Wire/type layers a client bundle may inline: browser-safe contracts
|
||||
* with no runtime identity to share (no Symbol/instanceof/singleton state).
|
||||
* Everything else under @deepseek-ai/* is either a module-table entry
|
||||
* (external) or a leak the purity gate rejects.
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-agent-preset/README.md
|
||||
README.md: 008066114e9c49e5c74299979e24c27a4c9621c9
|
||||
README.zh.md: e07d5994ae196cd03be7818fe4ade1aafda9aa55
|
||||
README.md: 3b0db5a3eedca256a00b65a3bd2738f22c0eb62e
|
||||
README.zh.md: 6f3c350f973119c201572f2c03145338b5cc5b00
|
||||
|
||||
@@ -36,6 +36,8 @@ A fourth surface, its own settings page (`settings.section` id `agent-presets`,
|
||||
|
||||
The browser edits no composition text. Editing YAML in a web textarea was a weak surface (no completion, no highlighting, no diff), so a new preset is a host-side copy of an existing one — the dialog collects an id (it becomes the directory name, which is why it must be named up front and cannot change later) and an optional display name, and `{ from, id, name? }` is all that crosses the wire. Everything else — description, composition, skills — is edited in the preset's own files, and the page's other job is getting the user TO those files: the copy completes by opening the new directory, and every custom row keeps a location action. Where the host has no desktop opener (`hasDocument: false` on the roster; remote and container deployments), the same actions answer the directory as text on the row instead of offering a button that would spawn into nothing.
|
||||
|
||||
A preset publishes its own description, of any length, and the grid sizes every card row alike — so an unbounded description would set the height of the whole roster. Cards clamp it to four lines and offer the rest in a tooltip, attached only while the text is actually cut off. The clamp is CSS, so the whole description stays in the accessibility tree whatever the card shows.
|
||||
|
||||
A shipped preset opens in the read-only viewer. It is the known-good composition a copy starts from, so reading it is the point; it offers no location and no delete — its install is overwritten by upgrades and is not the user's to manage. The intro carries the guidance a create button used to imply: duplicate an existing preset and make it yours, or let the agent draft one in Creator mode.
|
||||
|
||||
Beside copying sits the conversational entry: when the roster carries the self-referential `cordis` preset, a dashed add-card (the Models page's affordance) stages it and starts a new session — the section closes the settings panel through the shell's owner-prop `close` and the new-session chip's own applier composes the blank session the workspace flow produces. The seat keeps a late roster load from regressing the display: staged pick first, then the composition the current session already carries, then the deployment default.
|
||||
@@ -44,7 +46,7 @@ The dialog mirrors the host's own containment rule (`[a-z0-9][a-z0-9-]*`) and re
|
||||
|
||||
Deleting removes the preset directory. Sessions already composed from it keep running — a composition is mounted once at session creation and nothing re-reads the file.
|
||||
|
||||
A roster row carrying `broken` (the host's shape check found the composition missing or unloadable) renders as a marked card: red border, a Broken badge, the reason verbatim, the body disabled — it cannot become the default — and duplication disabled, since a copy of a broken preset is another broken preset. A broken custom row keeps its location and delete actions, because the files are where it gets fixed and deleting is how a ghost directory (composition deleted by hand, directory still blocking the id) is cleared; a broken shipped row withholds the viewer too — there is no readable composition to show. The two pickers (the General row and the new-session chip) drop broken presets entirely: they choose the NEXT session's composition, and offering one that cannot compose would only defer the failure to the session start.
|
||||
A roster row carrying `broken` (the host's shape check found the composition missing or unloadable) renders as a marked card: red border, a "Failed to load" badge (what discovery observed, not a claim that the files are damaged — the usual cause is a composition the user just edited or deleted), the reason verbatim, the body disabled — it cannot become the default — and duplication disabled, since a copy of a broken preset is another broken preset. A broken custom row keeps its location and delete actions, because the files are where it gets fixed and deleting is how a ghost directory (composition deleted by hand, directory still blocking the id) is cleared; a broken shipped row withholds the viewer too — there is no readable composition to show. The two pickers (the General row and the new-session chip) drop broken presets entirely: they choose the NEXT session's composition, and offering one that cannot compose would only defer the failure to the session start.
|
||||
|
||||
Setting the default writes the `agent-presets` settings namespace, which the host exposes to configuration clients ([`dsh-apiproxy`](../../host/apiproxy/README.md) keeps an explicit allowlist — a namespace outside it makes a picker move and then silently forget).
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ preset 文件提供一套未国际化的 `name` 与 `description`,Web 将其
|
||||
|
||||
浏览器不再编辑任何组装文本。在网页文本域里编 YAML 是弱功能(无补全、无高亮、无 diff),因此新 preset 是宿主端对既有 preset 的一次复制——对话框只收集一个 id(它将成为目录名,所以必须当场取好、事后无法更改)与一个可选显示名,跨越传输层的只有 `{ from, id, name? }`。其余一切——描述、组装、skills——都在 preset 自己的文件里编辑,而本页的另一职责正是把用户送到那些文件面前:复制以打开新目录作为收尾,每张自定义卡片也保有一个位置操作。宿主没有桌面打开器时(名单上的 `hasDocument: false`;远程与容器部署),同样的操作改为把目录以文本显示在卡片上,而不是提供一个点了没反应的按钮。
|
||||
|
||||
preset 自行发布描述,长度不限,而网格让每一行卡片等高——因此不加约束的描述会决定整份名单的高度。卡片把描述截断为四行,其余内容由 tooltip 承载,且仅在文本确实被裁切时才挂载。截断由 CSS 完成,因此无论卡片显示多少,完整描述始终留在无障碍树中。
|
||||
|
||||
随附 preset 在只读查看器中打开。它是副本据以出发的已知良好组装,因此能读到它正是意义所在;它不提供位置也不提供删除——它的安装目录会被升级覆盖,不归用户管理。开篇引导语承担了从前创建按钮所暗示的信息:复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。
|
||||
|
||||
复制旁边是对话式入口:名单携带自指的 `cordis` preset 时,一张虚线添加卡(模型页的同款样式)会暂存它并开启新会话——分区经外壳的 owner-prop `close` 关闭设置面板,新会话 chip 自己的应用器负责组装工作区流程产出的空白会话。seat 会防止晚到的名单加载回退显示:暂存选择优先,其次是当前会话已携带的组装,最后才是部署默认值。
|
||||
@@ -44,7 +46,7 @@ preset 文件提供一套未国际化的 `name` 与 `description`,Web 将其
|
||||
|
||||
删除会移除整个 preset 目录。已据其组装的会话继续运行——组装在会话创建时挂载一次,此后没有任何东西会重新读取该文件。
|
||||
|
||||
名单行携带 `broken`(宿主的形状检查发现组装缺失或不可加载)时渲染为标记卡片:红色边框、「已损坏」徽记、原样展示的原因、卡片主体禁用——它不能成为默认——复制也禁用,因为损坏 preset 的副本只是又一个损坏的 preset。损坏的自定义行保留位置与删除动作:文件正是修复它的地方,而删除正是清掉幽灵目录(组装文件被手动删除、目录仍占着 id)的方式;损坏的内置行连查看器也不提供——没有可读的组装可展示。两个选择器(通用设置行与新会话 chip)则完全不列出损坏的 preset:它们选的是下一个会话的组装,列出无法组装的选项只会把失败推迟到会话启动。
|
||||
名单行携带 `broken`(宿主的形状检查发现组装缺失或不可加载)时渲染为标记卡片:红色边框、「加载失败」徽记(discovery 观察到的事实,而非断言文件已损坏——常见起因是用户刚编辑或删除了组装文件)、原样展示的原因、卡片主体禁用——它不能成为默认——复制也禁用,因为损坏 preset 的副本只是又一个损坏的 preset。损坏的自定义行保留位置与删除动作:文件正是修复它的地方,而删除正是清掉幽灵目录(组装文件被手动删除、目录仍占着 id)的方式;损坏的内置行连查看器也不提供——没有可读的组装可展示。两个选择器(通用设置行与新会话 chip)则完全不列出损坏的 preset:它们选的是下一个会话的组装,列出无法组装的选项只会把失败推迟到会话启动。
|
||||
|
||||
设置默认值写入的是 `agent-presets` settings 命名空间,宿主需将其暴露给配置客户端([`dsh-apiproxy`](../../host/apiproxy/README.md) 维护一份显式白名单——不在其中的命名空间会让选择器动一下然后悄悄忘记)。
|
||||
|
||||
|
||||
@@ -161,15 +161,28 @@
|
||||
color: var(--dsw-alias-bg-layer-3);
|
||||
}
|
||||
|
||||
/* Bounded to four lines. A preset publishes its own description, so one long
|
||||
one would otherwise stretch every card in its grid row (`.cards` sizes rows
|
||||
1fr). Clamping is CSS alone: the whole text stays in the DOM for assistive
|
||||
tech, and the card offers it on hover when it is actually cut off. The
|
||||
description does not grow to fill the card — `-webkit-line-clamp` on a
|
||||
flex-stretched box leaves the clamp height and the box height disagreeing,
|
||||
so `.cardId` takes the free space with an auto margin instead. */
|
||||
.cardDesc {
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
flex: 1;
|
||||
min-height: 42px;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 4;
|
||||
overflow: hidden;
|
||||
/* A user-authored description may carry an unbreakable path or URL. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cardId {
|
||||
margin-top: auto;
|
||||
font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
font-size: 11px;
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
* mounted once at session creation and nothing re-reads the file.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Button, IconBrowseOutline16, IconCopyOutline16, IconFolderOpenOutline16, IconPlusOutline16, IconTrashOutline16, Modal,
|
||||
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 { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -137,6 +137,39 @@ function CopyDialog({ state, t, actions }: CopyDialogProps): ReactNode {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one card's description, clamped by CSS and offered in full on hover.
|
||||
* The tooltip is attached only while the text is actually cut off, so a short
|
||||
* description does not answer a hover with a bubble repeating the card.
|
||||
* @param props.text - the description as rendered, already localized.
|
||||
* @returns the description element, tooltip-anchored while it overflows.
|
||||
*/
|
||||
function CardDescription({ text }: { text: string }): ReactNode {
|
||||
const ref = useRef<HTMLSpanElement | null>(null)
|
||||
const [truncated, setTruncated] = useState(false)
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current
|
||||
/* v8 ignore next -- the ref is attached before layout effects run. */
|
||||
if (el === null) return
|
||||
const measure = () => { setTruncated(el.scrollHeight > el.clientHeight) }
|
||||
measure()
|
||||
// Card width follows the settings pane, which resizes with the window.
|
||||
if (typeof ResizeObserver === 'undefined') return
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(el)
|
||||
return () => { observer.disconnect() }
|
||||
}, [text])
|
||||
return (
|
||||
// Capped near the card's own width: the default half-viewport bubble would
|
||||
// spill a description out of the settings dialog and across the app behind it.
|
||||
<Tooltip label={text} side="bottom" delayMs={400} disabled={!truncated} maxWidth={360}>
|
||||
{/* The empty title stops the card body's native tooltip from climbing to
|
||||
this span: a cut-off description answers with one bubble, not two. */}
|
||||
<span ref={ref} className={css.cardDesc} title="">{text}</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Agent presets section content column.
|
||||
* @param props - composed slot props.
|
||||
@@ -247,7 +280,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
</span>
|
||||
{row.isDefault ? <span className={css.inUse}>{t('inUse')}</span> : null}
|
||||
</span>
|
||||
<span className={css.cardDesc}>{text.description ?? t('noDescription')}</span>
|
||||
<CardDescription text={text.description ?? t('noDescription')} />
|
||||
{row.broken === undefined
|
||||
? null
|
||||
: <span className={css.cardBrokenReason} role="alert">{row.broken}</span>}
|
||||
|
||||
@@ -57,8 +57,8 @@ export const en: Record<AgentPresetSettingsKey, string> = {
|
||||
builtInGroup: 'Built-in',
|
||||
customGroup: 'Custom',
|
||||
noDescription: 'No description.',
|
||||
brokenBadge: 'Broken',
|
||||
brokenNoCopy: 'Broken presets cannot be duplicated',
|
||||
brokenBadge: 'Failed to load',
|
||||
brokenNoCopy: 'A preset that failed to load cannot be duplicated',
|
||||
copyOf: 'Copied from',
|
||||
composition: 'Composition (agent.cordis.yml)',
|
||||
cancel: 'Cancel',
|
||||
@@ -117,8 +117,8 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
|
||||
builtInGroup: '内置',
|
||||
customGroup: '自定义',
|
||||
noDescription: '暂无描述。',
|
||||
brokenBadge: '已损坏',
|
||||
brokenNoCopy: '预设已损坏,无法复制',
|
||||
brokenBadge: '加载失败',
|
||||
brokenNoCopy: '预设加载失败,不能复制',
|
||||
copyOf: '复制自',
|
||||
composition: '组装(agent.cordis.yml)',
|
||||
cancel: '取消',
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* action follows the host's desktop capability.
|
||||
*/
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
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-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { AgentPresetSection } from '../src/client/AgentPresetSection.tsx'
|
||||
@@ -452,3 +452,68 @@ describe('deleting a preset', () => {
|
||||
expect(actions.remove).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('a long card description', () => {
|
||||
/** jsdom has no ResizeObserver; the description watches its own box through one. */
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
|
||||
const LONG = '始终用简体中文交流的友好通用助手,提供持久 bash 与文件编辑能力。'.repeat(8)
|
||||
|
||||
/** Force the clamp to report an overflow: jsdom lays nothing out, so both heights are 0. */
|
||||
function clamp(overflowing: boolean): void {
|
||||
vi.spyOn(Element.prototype, 'scrollHeight', 'get').mockReturnValue(overflowing ? 400 : 80)
|
||||
vi.spyOn(Element.prototype, 'clientHeight', 'get').mockReturnValue(80)
|
||||
}
|
||||
|
||||
beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) })
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('offers the whole description on hover once the card cuts it off', () => {
|
||||
clamp(true)
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
renderSection({ rows: [{ id: 'zh', trust: 'user', isDefault: false, name: '中文助手', description: LONG }] })
|
||||
|
||||
fireEvent.mouseEnter(within(rowFor('zh')).getByText(LONG))
|
||||
act(() => { vi.advanceTimersByTime(400) })
|
||||
|
||||
expect(screen.getByRole('tooltip').textContent).toBe(LONG)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('stays quiet when the description already fits', () => {
|
||||
clamp(false)
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
renderSection({ rows: [{ id: 'zh', trust: 'user', isDefault: false, name: '中文助手', description: '短描述。' }] })
|
||||
|
||||
fireEvent.mouseEnter(within(rowFor('zh')).getByText('短描述。'))
|
||||
act(() => { vi.advanceTimersByTime(400) })
|
||||
|
||||
// A bubble repeating what is already fully on the card is noise.
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('renders where the runtime has no ResizeObserver', () => {
|
||||
vi.unstubAllGlobals()
|
||||
clamp(true)
|
||||
|
||||
expect(() => {
|
||||
renderSection({ rows: [{ id: 'zh', trust: 'user', isDefault: false, description: LONG }] })
|
||||
}).not.toThrow()
|
||||
// The first measurement does not depend on the observer.
|
||||
expect(within(rowFor('zh')).getByText(LONG).getAttribute('title')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||
README.md: 605bba15d704c0c6e9f28abb3cddeb68bdd7e0d8
|
||||
README.md: ed8f888d35693ecaa2667ea432b462f4bb3369cf
|
||||
README.zh.md: e6a2dd0b545b66ab01b213b5ebc937e22af8ac1a
|
||||
|
||||
@@ -40,7 +40,7 @@ The composer bar declares session-scoped single seats for `'conversation.input.p
|
||||
|
||||
The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. Visible nodes supply only the turn and step counts plus the LLM and tool wall times, which are window-scoped facts about what is on screen rather than accounting; durable token and context groups remain visible when compaction leaves no assistant node in the loaded window. The same window fold averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English); a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them. The turn-count, step-count, duration, cache, and token labels use the same namespace. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy renders as the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection. The ring and header read `projectedTokens` — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header ([rationale](../../llm/token-meter/README.md)). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation.
|
||||
|
||||
`src/client/` is organized by domain. `contract/` is the shared face for slot declarations, composed props, and cross-domain types; `skeleton/`, `chat/`, `input/`, `queue/`, and `settings/` keep their implementations internal, while `apply.ts` is their assembly point. The `/client` export surface contains only loader entries, service classes, and contract types; components and store factories reach the page through slot registrations.
|
||||
`src/client/` is organized by domain. `contract/` is the shared face for slot declarations, composed props, and cross-domain types; `skeleton/`, `chat/`, `input/`, `queue/`, and `settings/` keep their implementations internal, while `apply.ts` is their assembly point. The `/client` exports contain only loader entries, service classes, and contract types; components and store factories reach the page through slot registrations.
|
||||
|
||||
A finished turn materializes one ordered `turn-tail` Conversation Node. Its engine-owned `TurnLocation` supplies the closing Assistant and Turn data; the renderer places the `conversation.chat.turnTail` chain before that node's IconActions and dispatches `TurnTailOwnerProps` containing the Turn, closing seq, and `openFile`. This package owns only the hole; `@deepseek-ai/dsh-client-ui-deliverables` accumulates mutation-tool `locations` into Turn data and owns the produced-files row, chip cap, and copy, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost. The closing prose participates through the same off switch: the chat view asks the optional `chatFileMentions` service (ctx.get; provided by the same plugin) for a closing message's inline-code vocabulary and threads the result into MarkdownText's `fileMentions` seam — an absent service leaves the prose inert.
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ export class ConversationService extends Service implements IConversation {
|
||||
|
||||
/**
|
||||
* Send a prompt into the scoped session. Business failures also land in the
|
||||
* session snapshot's promptError (object-layer surface); the rejection here
|
||||
* session snapshot's promptError (object-layer state); the rejection here
|
||||
* exists for caller choreography (the composer restores the draft on it).
|
||||
* @param text - prompt text, sent verbatim as one text block.
|
||||
*/
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
// @vitest-environment jsdom
|
||||
// apply inject factories exercised end to end against the terminal thin
|
||||
// shape: the strict session surface (views triple, draft mirror), the
|
||||
// API: the strict session API (views triple, draft mirror), the
|
||||
// provide-channel input face (machine-sink submit choreography incl.
|
||||
// optimistic clear + failure restore), the resident surface (selectWorkspace
|
||||
// optimistic clear + failure restore), the resident API (selectWorkspace
|
||||
// draft carrying), the composer-bar stop face, openDetails = select action +
|
||||
// layout orchestration, and the closeDetails details surface. Complements
|
||||
// layout orchestration, and the closeDetails details API. Complements
|
||||
// chat-apply.spec.tsx (registration) and selection-survival.spec.tsx (store
|
||||
// axis). History opening is NOT an inject concern — the runtime sessions
|
||||
// service opens on watch (sessions-service.spec.ts owns that behavior).
|
||||
//
|
||||
// The inject surfaces are read off the ledger entries deliberately (typed at
|
||||
// The inject APIs are read off the ledger entries deliberately (typed at
|
||||
// this spec's own contract): these cases pin factory choreography the UI
|
||||
// guards would mask. Rendering-path acceptance lives in
|
||||
// chat-toolview-slot.spec.tsx.
|
||||
@@ -75,30 +75,30 @@ async function bench() {
|
||||
const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.composer.bar' | 'conversation.view' | 'details') =>
|
||||
runtime.slots.entries(key)[0]!
|
||||
/** Resolve store instance + call the inject the way the outlet would. */
|
||||
const conversationSurface = (id: SessionId) => {
|
||||
const conversationApi = (id: SessionId) => {
|
||||
const entry = entryOf('conversation.session')
|
||||
const instance = runtime.storeOf('conversation.session', id) as ChatInstance
|
||||
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationSessionInjected)(
|
||||
id, instance.actions)
|
||||
return { instance, injected }
|
||||
}
|
||||
const conversationHeaderSurface = (id: SessionId) => {
|
||||
const conversationHeaderApi = (id: SessionId) => {
|
||||
const entry = entryOf('conversation.session.header')
|
||||
const instance = runtime.storeOf('conversation.session.header', id) as ChatInstance
|
||||
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationSessionHeaderInjected)(
|
||||
id, instance.actions)
|
||||
return { instance, injected }
|
||||
}
|
||||
const residentSurface = (id: SessionId | undefined) => {
|
||||
const residentApi = (id: SessionId | undefined) => {
|
||||
const entry = entryOf('conversation')
|
||||
return (entry.inject as unknown as (sessionId: SessionId | undefined) => ConversationInjected)(id)
|
||||
}
|
||||
const composerSurface = (id: SessionId | undefined) => {
|
||||
const composerApi = (id: SessionId | undefined) => {
|
||||
const entry = entryOf('conversation.composer.bar')
|
||||
return (entry.inject as unknown as (sessionId: SessionId | undefined) => ComposerBarInjected)(id)
|
||||
}
|
||||
/** Same resolution for the chat entry riding the view ring. */
|
||||
const chatViewSurface = (id: SessionId) => {
|
||||
const chatViewApi = (id: SessionId) => {
|
||||
const entry = entryOf('conversation.view')
|
||||
const instance = runtime.storeOf('conversation.view', id) as ChatInstance
|
||||
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ChatViewInjected)(
|
||||
@@ -106,7 +106,7 @@ async function bench() {
|
||||
return { instance, injected }
|
||||
}
|
||||
/** Materialize the input provide contribution the way the runtime does. */
|
||||
const inputSurface = (id: SessionId) => {
|
||||
const inputApi = (id: SessionId) => {
|
||||
const info = runtime.sessions.provideInfo(id)!
|
||||
const state = info.hooks['input'] as {
|
||||
getSnapshot: () => { draft: string }
|
||||
@@ -120,21 +120,21 @@ async function bench() {
|
||||
}
|
||||
return {
|
||||
runtime, feature, slots: runtime.slots, entryOf,
|
||||
conversationSurface, conversationHeaderSurface, residentSurface, composerSurface, chatViewSurface, inputSurface,
|
||||
conversationApi, conversationHeaderApi, residentApi, composerApi, chatViewApi, inputApi,
|
||||
sessionFake, layoutFake,
|
||||
}
|
||||
}
|
||||
|
||||
describe('conversation slot inject surface', () => {
|
||||
it('assembles the thin surface side-effect-free', async () => {
|
||||
describe('conversation slot inject API', () => {
|
||||
it('assembles the thin API side-effect-free', async () => {
|
||||
const b = await bench()
|
||||
const { injected } = b.conversationSurface(ROOT)
|
||||
const { injected } = b.conversationApi(ROOT)
|
||||
// Assembly has no session side effects: opening the event window belongs
|
||||
// to the runtime watch path, not the inject factory.
|
||||
expect(b.sessionFake.open).not.toHaveBeenCalled()
|
||||
expect(injected.views.list().map(v => v.id)).toEqual(['chat'])
|
||||
|
||||
const chatView = b.chatViewSurface(ROOT)
|
||||
const chatView = b.chatViewApi(ROOT)
|
||||
chatView.injected.loadOlder()
|
||||
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
|
||||
chatView.injected.forkAt(17)
|
||||
@@ -149,8 +149,8 @@ describe('conversation slot inject surface', () => {
|
||||
|
||||
it('the provide-channel input face submits through the machine sink: trim, optimistic clear, failure restore without clobber', async () => {
|
||||
const b = await bench()
|
||||
const { injected } = b.conversationSurface(ROOT)
|
||||
const { state, actions } = b.inputSurface(ROOT)
|
||||
const { injected } = b.conversationApi(ROOT)
|
||||
const { state, actions } = b.inputApi(ROOT)
|
||||
// Whitespace-only: the machine treats it as empty — no prompt, draft kept.
|
||||
actions.setDraft(' ')
|
||||
actions.submit()
|
||||
@@ -176,16 +176,16 @@ describe('conversation slot inject surface', () => {
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
expect(state.getSnapshot().draft).toBe('typed during flight')
|
||||
// The provide contribution is idempotent per session: one shell identity.
|
||||
expect(b.inputSurface(ROOT).state).toBe(state)
|
||||
expect(b.inputApi(ROOT).state).toBe(state)
|
||||
// The draft mirror rides the conversation inject face.
|
||||
const mirrored: string[] = []
|
||||
const unbind = injected.bindDraftMirror(text => mirrored.push(text))
|
||||
actions.setDraft('mirrored text')
|
||||
expect(mirrored).toEqual(['mirrored text'])
|
||||
unbind()
|
||||
// Stop failure is swallowed (promptError owns the surface).
|
||||
// Stop failure is swallowed (promptError owns the display).
|
||||
b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x', details: {} } })
|
||||
b.composerSurface(ROOT).stop!()
|
||||
b.composerApi(ROOT).stop!()
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
expect(b.sessionFake.cancel).toHaveBeenCalledTimes(1)
|
||||
await b.runtime.dispose()
|
||||
@@ -216,20 +216,20 @@ describe('conversation slot inject surface', () => {
|
||||
|
||||
it('openDetails (chat view face) writes the selection through the store actions and opens the panel', async () => {
|
||||
const b = await bench()
|
||||
const { instance, injected } = b.chatViewSurface(ROOT)
|
||||
const { instance, injected } = b.chatViewApi(ROOT)
|
||||
injected.openDetails({ turnSeq: 2, callId: 'c1' })
|
||||
expect(instance.store.getSnapshot().selection).toEqual({ turnSeq: 2, callId: 'c1' })
|
||||
expect(b.layoutFake.openDetails).toHaveBeenCalledTimes(1)
|
||||
// The chat view shares the conversation entry's store instance: selection
|
||||
// writes land where the skeleton and details read.
|
||||
const conv = b.conversationSurface(ROOT)
|
||||
const conv = b.conversationApi(ROOT)
|
||||
expect(conv.instance).toBe(instance)
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('openFile (chat view face) resolves against session cwd and calls workspaces.openPath', async () => {
|
||||
const b = await bench()
|
||||
const { injected } = b.chatViewSurface(ROOT)
|
||||
const { injected } = b.chatViewApi(ROOT)
|
||||
injected.openFile('src/a.ts')
|
||||
await vi.waitFor(() => {
|
||||
expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['/proj/src/a.ts'] })
|
||||
@@ -239,11 +239,11 @@ describe('conversation slot inject surface', () => {
|
||||
|
||||
it('routes workspace switching through the runtime owner, carrying the draft', async () => {
|
||||
const b = await bench()
|
||||
const resident = b.residentSurface(ROOT)
|
||||
const resident = b.residentApi(ROOT)
|
||||
// Same-session connect (the picked workspace resolves to this session):
|
||||
// no draft movement, plain re-open.
|
||||
b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(ROOT))
|
||||
const { state, actions } = b.inputSurface(ROOT)
|
||||
const { state, actions } = b.inputApi(ROOT)
|
||||
actions.setDraft('carry me')
|
||||
void resident.selectWorkspace('workspace-1' as never)
|
||||
await vi.waitFor(() => {
|
||||
@@ -261,7 +261,7 @@ describe('conversation slot inject surface', () => {
|
||||
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [OTHER] })
|
||||
})
|
||||
expect(state.getSnapshot().draft).toBe('')
|
||||
expect(b.inputSurface(OTHER).state.getSnapshot().draft).toBe('carry me')
|
||||
expect(b.inputApi(OTHER).state.getSnapshot().draft).toBe('carry me')
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
@@ -269,7 +269,7 @@ describe('conversation slot inject surface', () => {
|
||||
const b = await bench()
|
||||
// No-session resident (hero before any session): connect resolves and
|
||||
// navigation proceeds without any draft choreography.
|
||||
const noSession = b.residentSurface(undefined)
|
||||
const noSession = b.residentApi(undefined)
|
||||
b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(ROOT))
|
||||
void noSession.selectWorkspace('workspace-0' as never)
|
||||
await vi.waitFor(() => {
|
||||
@@ -279,15 +279,15 @@ describe('conversation slot inject surface', () => {
|
||||
// Cross-session connect with an EMPTY draft: no move, no clearing.
|
||||
const OTHER = 'b9-other' as SessionId
|
||||
await b.runtime.sessions.add({ id: OTHER }, { current: false })
|
||||
const resident = b.residentSurface(ROOT)
|
||||
const { state } = b.inputSurface(ROOT)
|
||||
const resident = b.residentApi(ROOT)
|
||||
const { state } = b.inputApi(ROOT)
|
||||
expect(state.getSnapshot().draft).toBe('')
|
||||
b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(OTHER))
|
||||
void resident.selectWorkspace('workspace-3' as never)
|
||||
await vi.waitFor(() => {
|
||||
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [OTHER] })
|
||||
})
|
||||
expect(b.inputSurface(OTHER).state.getSnapshot().draft).toBe('')
|
||||
expect(b.inputApi(OTHER).state.getSnapshot().draft).toBe('')
|
||||
|
||||
// Connect failure: the rejection propagates to the caller (the view owns
|
||||
// the rollback) and no further navigation happens.
|
||||
@@ -310,7 +310,7 @@ describe('conversation slot inject surface', () => {
|
||||
|
||||
it('views read face projects the ring ledger (subscribe/version through ctx.slots)', async () => {
|
||||
const b = await bench()
|
||||
const { injected } = b.conversationSurface(ROOT)
|
||||
const { injected } = b.conversationApi(ROOT)
|
||||
const before = injected.views.version()
|
||||
const listener = vi.fn()
|
||||
const unsub = injected.views.subscribe(listener)
|
||||
@@ -332,7 +332,7 @@ describe('conversation slot inject surface', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('details inject surface', () => {
|
||||
describe('details inject API', () => {
|
||||
it('details injects the one layout callback; selection rides the shared store instead', async () => {
|
||||
const b = await bench()
|
||||
const entry = b.entryOf('details')
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-goal/README.md
|
||||
README.md: a53fb3a89eaee364cb025ca728ca42ce934887b0
|
||||
README.md: f0446aa0637bc181f7fdc22e5d0d3192e0ac20cf
|
||||
README.zh.md: 1ad9f50aee5b103f6455e4d4b7d29fa9eb29a108
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Goal surface plugin, browser half: the `GoalBar` strip is the second standalone card in the `conversation.input.dock` composer-context stack (order 10, after Todo and before Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear through `ctx.remote.goals` — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the rejected Remote error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types.
|
||||
The `/client` exports are the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-layout/README.md
|
||||
README.md: fa60520a20ac8a7f25d494879c68efb06a28998f
|
||||
README.md: c58b9a26ac794131aaa197722d14bd04de9f0cae
|
||||
README.zh.md: 2ce3d1972d39013d84216596dc57d48ab2d245d9
|
||||
|
||||
@@ -6,7 +6,7 @@ Shell plugin: three-column AppFrame (drag handles and concession chain) plus the
|
||||
|
||||
AppFrame always mounts the conversation and details columns; a connected Session renders through `SessionProvider`. The transient layout store starts the sidebar at its default width and details closed, and it never reads or writes `localStorage`. Hero and other unselected states also derive a zero rendered details width without changing that stored preference. AppFrame retains the last non-blank Session id across those states: the first Session remains closed, an explicit details action opens the contract default width, returning to the same Session restores its unchanged width, and selecting a different Session closes details before paint. The conversation owner share is empty, while the sidebar owner share contains only `collapsed` and `width`; registrants obtain business data from standard hooks and actions from their own inject faces.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`), `LayoutService`, and the four owner-share interfaces. AppFrame, the panel store, and the concession solver remain package-internal.
|
||||
The `/client` exports are the plugin body (`apply`/`inject`), `LayoutService`, and the four owner-share interfaces. AppFrame, the panel store, and the concession solver remain package-internal.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { createLayoutStore } from './stores.ts'
|
||||
import { LayoutService } from './service.ts'
|
||||
import { ThemePresenter } from './theme-presenter.ts'
|
||||
|
||||
// Contract surface only (export-convergence rule: cross-package consumers
|
||||
// Contract exports only (export-convergence rule: cross-package consumers
|
||||
// keep a symbol exported; test-only/package-internal symbols live off /src).
|
||||
// ILayout: the ctx.layout face consumers and test fakes type against.
|
||||
// OwnerShare contracts below are the render-side halves registrants compose
|
||||
@@ -64,7 +64,7 @@ export interface ConvOwnerProps {}
|
||||
/** Details owner share: empty — sessionId arrives as a framework-standard prop. */
|
||||
export interface DetailsOwnerProps {}
|
||||
|
||||
/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */
|
||||
/** Required services (cordis fiber inject — the loader passes all module exports as an object plugin). */
|
||||
export const inject = ['slots', 'theme']
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// ONE register() call declares the three child slots + seats the store factory
|
||||
// + wires the panel actions through the inject hook; teardown cascades
|
||||
// (service unprovided + declarations gone + registration cleared). Node half
|
||||
// and the invariant companion ride along — one-line surfaces the aggregate
|
||||
// and the invariant companion ride along — one line exposes the aggregate
|
||||
// coverage gate still requires exercised.
|
||||
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
@@ -111,7 +111,7 @@ describe('node half + invariant companion', () => {
|
||||
const register = vi.fn().mockReturnValue(() => {})
|
||||
const ctx = { invariants: { register } } as never
|
||||
// The /invariant subpath types live in lib/types (build product); assert
|
||||
// the surface so the call stays typed where lint runs without a build.
|
||||
// the API so the call stays typed where lint runs without a build.
|
||||
const dispose = await (invariant as { apply: (ctx: never) => Promise<() => void> }).apply(ctx)
|
||||
expect(register).toHaveBeenCalledWith('@deepseek-ai/dsh-client-ui-layout', expect.any(Function))
|
||||
// The installer is the declared no-op — calling it must not throw.
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-model/README.md
|
||||
README.md: 519429834f214fcb82eeb692378fb79770fa30be
|
||||
README.md: fdc3258eb37b45f4773648d2d796c275f8657d47
|
||||
README.zh.md: 116e151d1afeaaa22618c408eed2c7542d1357e7
|
||||
|
||||
@@ -10,7 +10,7 @@ When the Host reports that no adapter serves the session's route (`session.model
|
||||
|
||||
Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope. Addressed subagent sessions expose neither entry, and their directory rejects loads, selections, and reconnect refreshes, because ordinary Agent-bound model RPCs would activate persisted child history outside the direct-parent continuation path.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`), `ModelService`, `ModelDirectory` with its state shape, and the seat's injected face type.
|
||||
The `/client` exports are the plugin body (`apply`/`inject`), `ModelService`, `ModelDirectory` with its state fields, and the seat's injected face type.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-models/README.md
|
||||
README.md: e0c5728d47e053df1934ef9eb69df3f8d985a4ec
|
||||
README.zh.md: fe11e6cdd190e19d5b5dac6dc95950ba59a3172b
|
||||
README.md: 89a253fca9f16ea5655cdd7536a441e98dcc4d3c
|
||||
README.zh.md: 350be495f1491738e6e861ba127b3b0070a5f073
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status.
|
||||
|
||||
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint) and each adapter's model catalog. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which would hide even the models that support the level. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped.
|
||||
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Those two are what a hand-declared route names for itself: the create card asks for both because nothing can default them, so the editor reaches both rather than leaving them to `settings.yaml`. Clearing the name unsets it and the route falls back to its id, which is what the placeholder shows; the protocol has no such fallback. A catalog route gets neither — it defaults its name from its catalog entry, and its models each carry their own protocol, so a route-level one could only override every one of them. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which would hide even the models that support the level. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`/`maxTokens`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped.
|
||||
|
||||
The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface.
|
||||
|
||||
@@ -28,7 +28,7 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Only the API key and curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)). DeepSeek exposes `baseURL`, `reasoningEffort`, and model `id`/`name`/`contextWindow`/`maxTokens`; pi-ai exposes `baseURL` and `reasoning`. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name.
|
||||
- **Only the API key and curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)). Both families expose `baseURL` and model `id`/`name`/`contextWindow`/`maxTokens`; a hand-declared pi-ai route also exposes `displayName` and `api`. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name.
|
||||
- **Credential cleanup is intentionally narrow** — deleting a row removes the configured, writable credential only when its reference is the exact `<ROUTE>_API_KEY` target this page derives. Custom references, environment credentials, and unidentifiable targets are retained because the row cannot prove ownership of them.
|
||||
- **Only pi-ai routes can be hand-declared** — the custom-provider card writes into `llm-pi-ai`, the one namespace whose profiles describe a whole provider. A `llm-deepseek` route is a composition fact, not something this page can create.
|
||||
- **Interrogation covers OpenAI-compatible endpoints** — the adapter reads only that model-list response format, so a gateway speaking another protocol reports that it cannot be asked and its models are entered by hand.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。
|
||||
|
||||
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可查询提供方所提供的模型。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),以及各适配器自己的模型目录。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会连支持该档位的模型也一并隐藏。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。
|
||||
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可查询提供方所提供的模型。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的那类 pi-ai 路由的**显示名称**与 **API 协议**。这两个字段是手工声明路由为自己命名的东西:创建卡片之所以索要它们,正因为没有东西能为它们兜底,因此编辑器也够得着这两个,而不是把它们留给 `settings.yaml`。清空名称即取消设置,路由退回自己的 id——占位符显示的就是它;协议没有这样的兜底。内置目录路由两个都不给:它的名称由目录条目兜底,它的每个模型各自带着自己的协议,路由级协议只可能把它们全部覆盖掉。Provider ID 保持固定:它是 settings 的键、是其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会连支持该档位的模型也一并隐藏。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`/`maxTokens`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。
|
||||
|
||||
前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。凭据引用已配置时,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。
|
||||
|
||||
@@ -28,7 +28,7 @@ pi-ai profile 的 `models` 列表就在卡片上编辑:一行一个模型,
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md))。DeepSeek 公开 `baseURL`、`reasoningEffort` 与模型的 `id`/`name`/`contextWindow`/`maxTokens`;pi-ai 公开 `baseURL` 与 `reasoning`。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `settings.yaml` 中;编辑器未展示的现有模型字段会予以保留。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。
|
||||
- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md))。两个家族都公开 `baseURL` 与模型的 `id`/`name`/`contextWindow`/`maxTokens`;手工声明的 pi-ai 路由还公开 `displayName` 与 `api`。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `settings.yaml` 中;编辑器未展示的现有模型字段会予以保留。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。
|
||||
- **凭据清理范围刻意保持狭窄**:删除一行时,仅当其引用与页面派生的 `<ROUTE>_API_KEY` 目标完全一致,才会清除已配置且可写的凭据。自定义引用、环境凭据和无法识别的目标会保留,因为该行无法证明自己拥有它们。
|
||||
- **只有 pi-ai 路由可以手工声明**:自定义提供方卡片写入 `llm-pi-ai`——唯一一个其 profile 描述整个提供方的 namespace。`llm-deepseek` 路由是组合面的事实,不是本页能创建的东西。
|
||||
- **询问只覆盖 OpenAI 兼容端点**:适配器只读这种模型列表响应格式,因此讲其他协议的网关会报告自己无法被询问,其模型需手工填写。
|
||||
|
||||
@@ -236,7 +236,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
<div className={styles['field']}>
|
||||
<span className={styles['fieldLabel']}>{t('customApi')}</span>
|
||||
<select
|
||||
className={styles['input']}
|
||||
className={`${styles['input']} ${styles['selectInput']}`}
|
||||
value={protocol}
|
||||
aria-label={t('customApi')}
|
||||
disabled={profileDisabled}
|
||||
|
||||
@@ -54,6 +54,8 @@ interface EditorTarget extends ProviderIdentity {
|
||||
settingsPath: readonly string[]
|
||||
/** Writable credential identified under this page's conventional reference. */
|
||||
credentialRef?: string
|
||||
/** The adapter reports this route as one it does not ship (see {@link ProviderEditorProps.declared}). */
|
||||
declared?: boolean
|
||||
}
|
||||
|
||||
/** Values that vary around the shared provider-editor rendering. */
|
||||
@@ -71,6 +73,7 @@ function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps):
|
||||
provider={target.provider}
|
||||
displayName={target.displayName}
|
||||
settingsPath={target.settingsPath}
|
||||
{...target.declared === true ? { declared: true } : {}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -135,6 +138,10 @@ function targetOf(row: ProviderRow): EditorTarget {
|
||||
settingsNs: row.entry.settingsNs,
|
||||
settingsPath: row.entry.settingsPath,
|
||||
...credentialRef === undefined ? {} : { credentialRef },
|
||||
// Absent is not "shipped": an adapter that answers nothing leaves the
|
||||
// route-level fields only a declared route owns off the card, exactly as
|
||||
// it leaves the custom tag off the row.
|
||||
...row.entry.declared === true ? { declared: true } : {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,8 +184,10 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
setAdding(false)
|
||||
setDeclaring(false)
|
||||
if (changed) {
|
||||
setSavedTarget(target)
|
||||
void controller.load()
|
||||
// Announced only once the refreshed directory is in the snapshot the
|
||||
// notice reads its name from: an apply can rename the route, and the
|
||||
// target captured when the card opened still carries the old name.
|
||||
void controller.load().then(() => { setSavedTarget(target) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +227,17 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
)
|
||||
}
|
||||
|
||||
// The saved provider as the directory currently names it. The route id is
|
||||
// what the apply cannot change, so it is what the notice is keyed by; a row
|
||||
// the same apply removed keeps the captured identity, since nothing newer
|
||||
// exists to name it with.
|
||||
const savedRow = savedTarget === undefined
|
||||
? undefined
|
||||
: state.rows.find(row => row.entry.provider === savedTarget.provider)
|
||||
const savedIdentity = savedRow === undefined
|
||||
? savedTarget
|
||||
: { provider: savedRow.entry.provider, displayName: savedRow.entry.displayName }
|
||||
|
||||
const configured = state.rows.filter(row => row.configured)
|
||||
const addable = state.rows.filter(row => !row.configured && row.entry.settingsNs !== '')
|
||||
const addTarget = adding ? editing : undefined
|
||||
@@ -232,11 +252,11 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
<h2 className={styles['title']}>{t('title')}</h2>
|
||||
<p className={styles['intro']}>{t('intro')}</p>
|
||||
{!state.writable && state.status === 'ready' ? <p className={styles['notice']}>{t('readOnly')}</p> : null}
|
||||
{savedTarget === undefined
|
||||
{savedIdentity === undefined
|
||||
? null
|
||||
: (
|
||||
<p className={styles['savedNotice']} role="status" aria-live="polite">
|
||||
{providerCopy(t('savedProvider'), savedTarget)}
|
||||
{providerCopy(t('savedProvider'), savedIdentity)}
|
||||
</p>
|
||||
)}
|
||||
<ul className={styles['rows']}>
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
* a key is entered; a blank key materializes a reference-free profile for
|
||||
* provider-native authentication);
|
||||
* the collapsed 自定义设置 area carries the per-family extras (`baseURL` for
|
||||
* both families and DeepSeek's id/name/context-window model catalog).
|
||||
* both families, DeepSeek's id/name/context-window model catalog, and the
|
||||
* display name and wire protocol of a pi-ai route the adapter does not ship —
|
||||
* the two fields the create card asked that route for, editable here for the
|
||||
* same reason).
|
||||
* Reasoning effort is deliberately absent: it is a per-MODEL capability, and
|
||||
* the models under one provider disagree about it, so a provider-scoped
|
||||
* control can only be set to a value some of them reject. The composer's
|
||||
@@ -30,7 +33,7 @@ import {
|
||||
import { apiKeyFailure } from './apiKey.ts'
|
||||
import { EditorFooter } from './EditorFooter.tsx'
|
||||
import { ModelListEditor } from './ModelListEditor.tsx'
|
||||
import { deriveKeyRef, messageOf } from './store.ts'
|
||||
import { deriveKeyRef, messageOf, protocolChoices } from './store.ts'
|
||||
import type { en } from './locales.ts'
|
||||
import styles from './ModelsSection.module.css'
|
||||
|
||||
@@ -48,6 +51,14 @@ export interface ProviderEditorProps {
|
||||
displayName: string
|
||||
/** Hide the title row (the add card renders its own provider select). */
|
||||
hideTitle?: boolean
|
||||
/**
|
||||
* Whether the adapter reports this route as hand-declared — absent from its
|
||||
* installed catalog. Such a route carries its own wire protocol, chosen when
|
||||
* it was created and editable here for the same reason; a catalog route's
|
||||
* models each carry theirs, so a route-level protocol there could only
|
||||
* override every one of them and the card does not offer it.
|
||||
*/
|
||||
declared?: boolean
|
||||
/** The owning namespace view (schema, layers, secrets). */
|
||||
namespace: SettingsNamespaceView
|
||||
/** Path from the section root to this provider's profile. */
|
||||
@@ -139,6 +150,14 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
const disabled = props.readOnly || busy
|
||||
const layout = layoutOf(namespace.ns)
|
||||
const keyRef = refFor(namespace, settingsPath, props.provider)
|
||||
// The same schema read the create card makes, so the choices offered here
|
||||
// and there cannot drift apart: both come from the adapter's own `Config`.
|
||||
// Only the pi-ai layout has a per-route protocol for the read to find, and
|
||||
// it rehydrates the whole section schema, so the other layouts skip it.
|
||||
const protocols = useMemo(
|
||||
() => layout === 'pi-ai' ? protocolChoices(namespace) : [],
|
||||
[layout, namespace],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let stale = false
|
||||
@@ -289,11 +308,15 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
}
|
||||
|
||||
/**
|
||||
* The curated fields of one known adapter family. Taking the narrowed
|
||||
* family as a parameter is what makes `EFFORT_FIELD` total here: an
|
||||
* unknown namespace never reaches this body.
|
||||
* The curated fields of one known adapter family. The family arrives
|
||||
* narrowed so the per-family branches below are total: an unknown namespace
|
||||
* renders the hint instead and never reaches this body.
|
||||
*/
|
||||
const curatedFields = (family: 'deepseek' | 'pi-ai'): ReactNode => {
|
||||
// What a hand-declared route names for itself and nothing else can supply.
|
||||
// A whole-section `llm-deepseek` profile is a composition fact with no
|
||||
// per-route identity for its schema to carry, hence the family test.
|
||||
const ownsIdentity = family === 'pi-ai' && props.declared === true
|
||||
const customModels = getPath(draft, ['models'])
|
||||
const modelsOverridden = hasPath(draft, ['models'])
|
||||
const models = modelDrafts(modelsOverridden ? customModels : inheritedModels())
|
||||
@@ -334,6 +357,33 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
<details className={styles['customized']}>
|
||||
<summary className={styles['customizedSummary']}>{t('customized')}</summary>
|
||||
<div className={styles['customizedBody']}>
|
||||
{/* The name and the protocol are the create card's two remaining
|
||||
profile fields; a route the adapter ships defaults both from
|
||||
its catalog entry and neither belongs on its card. */}
|
||||
{ownsIdentity
|
||||
? (
|
||||
<div className={styles['field']}>
|
||||
<span className={styles['fieldLabel']}>{t('customDisplayName')}</span>
|
||||
<input
|
||||
className={styles['input']}
|
||||
type="text"
|
||||
value={stringAt(draft, 'displayName') ?? ''}
|
||||
// What this route is called the moment the field is
|
||||
// cleared, which is the layer beneath the one this field
|
||||
// edits: a `cordis.yml` may pin a name for a route the
|
||||
// catalog does not ship, and only when nothing does is
|
||||
// the answer the route id. Reading the effective value
|
||||
// instead would echo the stored override back as the
|
||||
// thing clearing restores.
|
||||
placeholder={stringAt(getPath(namespace.base, settingsPath), 'displayName')
|
||||
?? props.provider}
|
||||
aria-label={t('customDisplayName')}
|
||||
disabled={disabled}
|
||||
onChange={(event) => { setField('displayName', event.target.value) }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
<div className={styles['field']}>
|
||||
<span className={styles['fieldLabel']}>{t('baseUrl')}</span>
|
||||
<input
|
||||
@@ -350,6 +400,31 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* The protocol sits beside the endpoint it describes, as it does
|
||||
on the create card. */}
|
||||
{ownsIdentity
|
||||
? (
|
||||
<div className={styles['field']}>
|
||||
<span className={styles['fieldLabel']}>{t('customApi')}</span>
|
||||
<select
|
||||
className={`${styles['input']} ${styles['selectInput']}`}
|
||||
value={probeApi ?? ''}
|
||||
aria-label={t('customApi')}
|
||||
disabled={disabled}
|
||||
onChange={(event) => { setField('api', event.target.value) }}
|
||||
>
|
||||
{/* A profile naming no protocol — hand-written into
|
||||
settings.yaml with no model to need one — selects
|
||||
nothing rather than reading as if it had picked the
|
||||
first choice. The option is named because a screen
|
||||
reader announces it either way, and an empty one is
|
||||
announced as a choice with no identity. */}
|
||||
{probeApi === undefined ? <option value="">{t('customApiUnset')}</option> : null}
|
||||
{protocols.map(choice => <option key={choice} value={choice}>{choice}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
{/* Both families edit the same rows through the same contract; only
|
||||
the extras differ — DeepSeek's inherited capacities, pi-ai's
|
||||
endpoint interrogation. */}
|
||||
|
||||
@@ -80,6 +80,7 @@ export const en = {
|
||||
customRouteTaken: 'A provider already uses this ID.',
|
||||
customDisplayName: 'Display name',
|
||||
customApi: 'API protocol',
|
||||
customApiUnset: 'Not selected',
|
||||
customNeedsBaseUrl: 'A custom provider needs a base URL.',
|
||||
customNeedsModels: 'A custom provider needs at least one model.',
|
||||
create: 'Create provider',
|
||||
@@ -173,6 +174,7 @@ export const zh: typeof en = {
|
||||
customRouteTaken: '已有提供方使用了这个 ID。',
|
||||
customDisplayName: '显示名称',
|
||||
customApi: 'API 协议',
|
||||
customApiUnset: '未选择',
|
||||
customNeedsBaseUrl: '自定义提供方需要填写 API 地址。',
|
||||
customNeedsModels: '自定义提供方至少需要一个模型。',
|
||||
create: '创建提供方',
|
||||
|
||||
@@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import Schema from '@deepseek-ai/schemastery'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { ModelsSection } from '../src/client/ModelsSection.tsx'
|
||||
import { ModelsSection, providerCopy } from '../src/client/ModelsSection.tsx'
|
||||
import type { ModelsSectionInjected } from '../src/client/ModelsSection.tsx'
|
||||
import { CustomProviderCard } from '../src/client/CustomProviderCard.tsx'
|
||||
import { formatCapacity, parseCapacity } from '../src/client/DeepSeekModelsEditor.tsx'
|
||||
@@ -47,6 +47,7 @@ function fail<T>(message: string, code: string): RpcResponse<T> {
|
||||
function piAiNamespace(
|
||||
providers: Record<string, unknown>,
|
||||
userProviders: Record<string, unknown> = providers,
|
||||
baseProviders: Record<string, unknown> = {},
|
||||
): SettingsNamespaceView {
|
||||
return {
|
||||
ns: 'llm-pi-ai',
|
||||
@@ -54,7 +55,7 @@ function piAiNamespace(
|
||||
// `value` is the effective section; `user` is only the layer this page
|
||||
// writes. They differ whenever a composition `base` supplies something.
|
||||
value: { providers },
|
||||
base: {},
|
||||
base: { providers: baseProviders },
|
||||
user: { providers: userProviders },
|
||||
applies: 'live',
|
||||
secrets: [],
|
||||
@@ -66,6 +67,8 @@ function scriptedFace(options: {
|
||||
providers?: Record<string, unknown>
|
||||
/** User layer, when it differs from the effective section. */
|
||||
userProviders?: Record<string, unknown>
|
||||
/** Composition layer, for a route a `cordis.yml` pins rather than the page. */
|
||||
baseProviders?: Record<string, unknown>
|
||||
/** Routes the adapter reports as hand-declared; the rest come back as shipped. */
|
||||
declaredRoutes?: readonly string[]
|
||||
discover?: ReturnType<typeof vi.fn>
|
||||
@@ -75,7 +78,7 @@ function scriptedFace(options: {
|
||||
const providers = options.providers ?? {
|
||||
openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy.example/v1' },
|
||||
}
|
||||
const namespace = piAiNamespace(providers, options.userProviders ?? providers)
|
||||
const namespace = piAiNamespace(providers, options.userProviders ?? providers, options.baseProviders ?? {})
|
||||
const discover = options.discover ?? vi.fn(() => Promise.resolve(ok({ models: [] })))
|
||||
const mutate = options.mutate ?? vi.fn(() => Promise.resolve(ok(namespace)))
|
||||
const set = options.set ?? vi.fn(() => Promise.resolve(ok({})))
|
||||
@@ -719,10 +722,152 @@ describe('hand-declared providers', () => {
|
||||
expect(fields()).toEqual([en.customRoute, en.customDisplayName, en.baseUrl, en.customApi, en.keyInput])
|
||||
cleanup()
|
||||
|
||||
// A shipped route's models each carry their own protocol, so its editor
|
||||
// offers no route-level protocol to override them with.
|
||||
await mountSection({ providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } })
|
||||
openEditor('openai')
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
expect(fields()).toEqual([en.keyInput, en.baseUrl])
|
||||
cleanup()
|
||||
|
||||
// A hand-declared route named its own protocol at creation, so editing it
|
||||
// reaches the same field the create card asked for.
|
||||
await mountSection({
|
||||
providers: { 'acme-gateway': { api: 'openai-completions', baseURL: 'https://gateway.acme.example/v1' } },
|
||||
declaredRoutes: ['acme-gateway'],
|
||||
})
|
||||
openEditor('acme-gateway')
|
||||
expect(fields()).toEqual([en.keyInput, en.customDisplayName, en.baseUrl, en.customApi])
|
||||
})
|
||||
|
||||
it('renames a declared route and falls back to its id when the name is cleared', async () => {
|
||||
const { mutate } = await mountSection({
|
||||
providers: {
|
||||
'acme-gateway': { displayName: 'Acme Gateway', api: 'openai-completions', baseURL: 'https://acme.test/v1' },
|
||||
},
|
||||
declaredRoutes: ['acme-gateway'],
|
||||
})
|
||||
openEditor('acme-gateway')
|
||||
|
||||
const name = screen.getByLabelText<HTMLInputElement>(en.customDisplayName)
|
||||
expect(name.value).toBe('Acme Gateway')
|
||||
// The route id, not the stored name: it is what the route will be called
|
||||
// the moment the field is cleared.
|
||||
expect(name.placeholder).toBe('acme-gateway')
|
||||
fireEvent.change(name, { target: { value: 'Acme 网关' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
expect(firstMutate(mutate).ops)
|
||||
.toEqual([{ op: 'set', path: ['providers', 'acme-gateway', 'displayName'], value: 'Acme 网关' }])
|
||||
})
|
||||
|
||||
it('offers the composition name as what a cleared field falls back to', async () => {
|
||||
// A `cordis.yml` can pin a route the catalog does not ship, so a declared
|
||||
// route's profile is not always the page's own. The field edits the user
|
||||
// layer alone, and clearing it restores the layer beneath — the
|
||||
// composition name here, not the route id — so that is what it offers.
|
||||
await mountSection({
|
||||
providers: { 'acme-gateway': { displayName: 'Acme (pinned)', api: 'openai-completions' } },
|
||||
baseProviders: { 'acme-gateway': { displayName: 'Acme (pinned)', api: 'openai-completions' } },
|
||||
userProviders: {},
|
||||
declaredRoutes: ['acme-gateway'],
|
||||
})
|
||||
openEditor('acme-gateway')
|
||||
|
||||
const name = screen.getByLabelText<HTMLInputElement>(en.customDisplayName)
|
||||
expect(name.value).toBe('')
|
||||
expect(name.placeholder).toBe('Acme (pinned)')
|
||||
})
|
||||
|
||||
it('names the provider as the refreshed directory reports it after a rename', async () => {
|
||||
// The status line used to echo the target captured when the card opened,
|
||||
// which never lied while the name could not change. It can now.
|
||||
const { face } = await mountSection({
|
||||
providers: { 'acme-gateway': { displayName: 'Acme Gateway', api: 'openai-completions' } },
|
||||
declaredRoutes: ['acme-gateway'],
|
||||
})
|
||||
// The reload after the write answers with the renamed route, exactly as
|
||||
// the adapter re-registers it.
|
||||
face.llm.providers = vi.fn(() => Promise.resolve(ok({
|
||||
providers: [{
|
||||
provider: 'acme-gateway',
|
||||
displayName: 'Acme 网关',
|
||||
settingsNs: 'llm-pi-ai',
|
||||
settingsPath: ['providers', 'acme-gateway'],
|
||||
active: true,
|
||||
declared: true,
|
||||
}],
|
||||
})))
|
||||
openEditor('acme-gateway')
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.customDisplayName), { target: { value: 'Acme 网关' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
|
||||
const notice = await screen.findByRole('status')
|
||||
expect(notice.textContent).toBe(providerCopy(en.savedProvider, {
|
||||
provider: 'acme-gateway',
|
||||
displayName: 'Acme 网关',
|
||||
}))
|
||||
})
|
||||
|
||||
it('drops the stored name rather than storing an empty one the adapter refuses', async () => {
|
||||
// `llm-pi-ai` rejects an empty displayName outright, so clearing the field
|
||||
// must unset it — which is also what the user means: use the route id.
|
||||
const { mutate } = await mountSection({
|
||||
providers: { 'acme-gateway': { displayName: 'Acme Gateway', api: 'openai-completions' } },
|
||||
declaredRoutes: ['acme-gateway'],
|
||||
})
|
||||
openEditor('acme-gateway')
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.customDisplayName), { target: { value: ' ' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
expect(firstMutate(mutate).ops)
|
||||
.toEqual([{ op: 'unset', path: ['providers', 'acme-gateway', 'displayName'] }])
|
||||
})
|
||||
|
||||
it('edits the protocol a declared route was created with', async () => {
|
||||
const { mutate } = await mountSection({
|
||||
providers: {
|
||||
'acme-gateway': {
|
||||
apiKeyEnv: 'ACME_GATEWAY_API_KEY',
|
||||
api: 'openai-completions',
|
||||
baseURL: 'https://gateway.acme.example/v1',
|
||||
models: [{ id: 'acme-large' }],
|
||||
},
|
||||
},
|
||||
declaredRoutes: ['acme-gateway'],
|
||||
})
|
||||
openEditor('acme-gateway')
|
||||
|
||||
const protocol = screen.getByLabelText<HTMLSelectElement>(en.customApi)
|
||||
expect(protocol.value).toBe('openai-completions')
|
||||
fireEvent.change(protocol, { target: { value: 'anthropic-messages' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
// Only the protocol travels: every other stored field is unchanged, so no
|
||||
// op restates it.
|
||||
expect(firstMutate(mutate)).toEqual({
|
||||
ns: 'llm-pi-ai',
|
||||
ops: [{ op: 'set', path: ['providers', 'acme-gateway', 'api'], value: 'anthropic-messages' }],
|
||||
expectedRevision: 3,
|
||||
})
|
||||
})
|
||||
|
||||
it('selects nothing for a declared route whose profile names no protocol', async () => {
|
||||
// A route hand-written into settings.yaml with no model needs no protocol
|
||||
// to resolve, so the card can be opened over one. The select must not read
|
||||
// as if that route had picked its first choice.
|
||||
await mountSection({
|
||||
providers: { 'acme-gateway': { baseURL: 'https://gateway.acme.example/v1' } },
|
||||
declaredRoutes: ['acme-gateway'],
|
||||
})
|
||||
openEditor('acme-gateway')
|
||||
|
||||
expect(screen.getByLabelText<HTMLSelectElement>(en.customApi).value).toBe('')
|
||||
})
|
||||
|
||||
it('retries only the key after the profile landed, and reports the provider on cancel', async () => {
|
||||
|
||||
@@ -61,6 +61,29 @@ describe('ModelsSection theme styles', () => {
|
||||
expect(block('.rowCard')).not.toMatch(/\bbackground\s*:/)
|
||||
})
|
||||
|
||||
it('gives every dropdown the shared chevron instead of the OS arrow', () => {
|
||||
// `select.input` caps the control at 240px, and the OS arrow is painted
|
||||
// flush inside that shrunk right edge — visibly tighter than every other
|
||||
// control on the page. `.selectInput` is what removes it, reserves the
|
||||
// right pad, and paints the shared chevron; a `<select>` that takes
|
||||
// `.input` alone silently keeps the OS one.
|
||||
const sources = readdirSync(fileURLToPath(new URL('../src/client/', import.meta.url)))
|
||||
.filter(name => name.endsWith('.tsx'))
|
||||
.map(name => ({
|
||||
name,
|
||||
text: readFileSync(fileURLToPath(new URL(`../src/client/${name}`, import.meta.url)), 'utf8'),
|
||||
}))
|
||||
const bare = sources.flatMap(({ name, text }) => text
|
||||
.split('<select')
|
||||
.slice(1)
|
||||
// The element's own attributes end at the first `>`; a child `<option>`
|
||||
// carries no className of its own and must not answer for the select.
|
||||
.map(rest => rest.slice(0, rest.indexOf('>')))
|
||||
.filter(attributes => !attributes.includes('selectInput'))
|
||||
.map(() => name))
|
||||
expect(bare).toEqual([])
|
||||
})
|
||||
|
||||
it('never falls back to a literal colour', () => {
|
||||
// A token that resolves is never the problem; an undeclared one takes this
|
||||
// branch, and a literal here is a single colour for both themes.
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-permission/README.md
|
||||
README.md: 742e82d767152073ab963dc74c0565d6e8f8e5c4
|
||||
README.md: cf3981d66745bd5a65c83e09daeb8463023f447b
|
||||
README.zh.md: 70bbbb2d14358cbe52a6fc27deb7ce01d5f3679b
|
||||
|
||||
@@ -6,7 +6,7 @@ Permission browser surfaces for two different lifetimes. The General-settings ro
|
||||
|
||||
The current-session surface remains a popupSelect DECORATION hung on the host `/permission` command (`ctx.command.decorate`). A decoration is not a second command — the host command keeps its slash-menu row, the argued path (`/permission <preset>` switches directly), and the durable lifecycle logging; the decoration replaces only the bare invocation with the picker: one flat preset list with the current value marked active and kebab-case preset names rendered as title-case labels (`workspace-write` → `Workspace Write`, the composer chip's display transform twin), where a pick submits the `/permission <preset>` command line. Options and the active mark read the session's `permissions` projection (the same host-computed select the composer chip renders), so both current-session surfaces share one read source and one write path, and the pushed projection frame is the single confirmation both follow. The decoration is available exactly while the projection key is present; a permission-less composition shows neither picker nor Settings row.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`).
|
||||
The `/client` exports are the plugin body (`apply`/`inject`).
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Hover/focus label bubble (figma tooltip pill: dark plate, white text).
|
||||
// TODO: interaction is a placeholder (horizontal overflow clamps, but there
|
||||
// is no vertical flip on viewport collision and no arrow) — visuals and
|
||||
// behavior get a proper pass later.
|
||||
// TODO: interaction is a placeholder (horizontal overflow clamps and a
|
||||
// vertical collision flips the bubble to the other side, but there is no
|
||||
// arrow) — visuals and behavior get a proper pass later.
|
||||
// The anchor is the child element itself (cloneElement, no wrapper node), so
|
||||
// attaching a tooltip never changes the anchor's layout context. The bubble is
|
||||
// position:fixed and coordinates come from the anchor's rect at show time, so
|
||||
@@ -33,10 +33,12 @@ type TooltipLabel = string | (() => string)
|
||||
* @param props.delayMs - hover delay in milliseconds; keyboard focus remains immediate.
|
||||
* @param props.disabled - suppress the bubble while true; the anchor renders identically so
|
||||
* toggling never remounts it (which would cut its CSS transitions).
|
||||
* @param props.maxWidth - bubble width cap in pixels, for labels long enough that the default
|
||||
* half-viewport cap would render a slab wider than the surface the anchor sits on.
|
||||
* @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's.
|
||||
* @returns the cloned anchor plus a fixed-position bubble while hovered/focused.
|
||||
*/
|
||||
export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, children }: { label: TooltipLabel; side?: TooltipSide; delayMs?: number; disabled?: boolean; children: ReactElement<AnchorProps> }) {
|
||||
export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, maxWidth, children }: { label: TooltipLabel; side?: TooltipSide; delayMs?: number; disabled?: boolean; maxWidth?: number; children: ReactElement<AnchorProps> }) {
|
||||
const anchor = useRef<HTMLElement | null>(null)
|
||||
// React 18 keeps the element's ref outside props; forward it so wrapping an
|
||||
// anchor in Tooltip never silently severs the owner's ref.
|
||||
@@ -46,33 +48,53 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
|
||||
if (typeof childRef === 'function') childRef(el)
|
||||
else if (childRef != null) (childRef as MutableRefObject<HTMLElement | null>).current = el
|
||||
}, [childRef])
|
||||
const [pos, setPos] = useState<{ x: number; y: number } | null>(null)
|
||||
// The anchor's edges rather than final coordinates: a vertical flip has to
|
||||
// re-derive the bubble's own top from the opposite edge.
|
||||
const [pos, setPos] = useState<{ x: number; top: number; bottom: number } | null>(null)
|
||||
// Where the bubble actually sits, which is the requested side until the
|
||||
// viewport refuses it.
|
||||
const [placement, setPlacement] = useState<TooltipSide>(side)
|
||||
const bubble = useRef<HTMLSpanElement | null>(null)
|
||||
const resolvedLabel = pos === null
|
||||
? null
|
||||
: typeof label === 'function' ? label() : label
|
||||
// Horizontal viewport clamp: fixed positioning knows nothing about edges, so
|
||||
// a centered bubble near the right edge would clip. Each measurement resets
|
||||
// the base position before applying a direct style offset, allowing a shorter
|
||||
// label or wider viewport to release a previous clamp without another render.
|
||||
const y = pos === null
|
||||
? 0
|
||||
: placement === 'right'
|
||||
? pos.top + (pos.bottom - pos.top) / 2
|
||||
: placement === 'top' ? pos.top - 8 : pos.bottom + 8
|
||||
const EDGE_MARGIN = 12
|
||||
// Viewport fit: fixed positioning knows nothing about edges, so a centered
|
||||
// bubble near the right edge would clip and a long label under an anchor low
|
||||
// on the page would run off the bottom. Horizontally the bubble slides back
|
||||
// inside; vertically it flips to the opposite side, which is the only move
|
||||
// that does not cover the anchor being read. Each measurement resets the base
|
||||
// position first, so a shorter label or a larger viewport releases a previous
|
||||
// adjustment without another render.
|
||||
useLayoutEffect(() => {
|
||||
if (pos === null) return
|
||||
const clamp = () => {
|
||||
const fit = () => {
|
||||
const el = bubble.current
|
||||
/* v8 ignore next -- pos is set only while the bubble is mounted. */
|
||||
if (el === null) return
|
||||
const EDGE_MARGIN = 12
|
||||
el.style.left = `${pos.x}px`
|
||||
const r = el.getBoundingClientRect()
|
||||
let dx = 0
|
||||
if (r.right > window.innerWidth - EDGE_MARGIN) dx = window.innerWidth - EDGE_MARGIN - r.right
|
||||
if (r.left + dx < EDGE_MARGIN) dx = EDGE_MARGIN - r.left
|
||||
el.style.left = `${pos.x + dx}px`
|
||||
if (side === 'right') return
|
||||
// Flip only into a side that genuinely fits, so an anchor with room on
|
||||
// neither side keeps the requested placement instead of oscillating.
|
||||
const fitsBelow = pos.bottom + 8 + r.height <= window.innerHeight - EDGE_MARGIN
|
||||
const fitsAbove = pos.top - 8 - r.height >= EDGE_MARGIN
|
||||
if (placement === 'bottom' && !fitsBelow && fitsAbove) setPlacement('top')
|
||||
if (placement === 'top' && !fitsAbove && fitsBelow) setPlacement('bottom')
|
||||
}
|
||||
clamp()
|
||||
window.addEventListener('resize', clamp)
|
||||
return () => { window.removeEventListener('resize', clamp) }
|
||||
}, [pos, resolvedLabel])
|
||||
fit()
|
||||
window.addEventListener('resize', fit)
|
||||
return () => { window.removeEventListener('resize', fit) }
|
||||
}, [placement, pos, resolvedLabel, side])
|
||||
const showTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
// Hover and focus are independent triggers: the bubble hides only after
|
||||
// BOTH clear (hovering away from a focused anchor must not drop it).
|
||||
@@ -100,11 +122,10 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
|
||||
/* v8 ignore next -- the ref is attached by event time: events fire on the cloned anchor. */
|
||||
if (el === null) return
|
||||
const r = el.getBoundingClientRect()
|
||||
setPos(side === 'right'
|
||||
? { x: r.right + 10, y: r.top + r.height / 2 }
|
||||
: side === 'top'
|
||||
? { x: r.left + r.width / 2, y: r.top - 8 }
|
||||
: { x: r.left + r.width / 2, y: r.bottom + 8 })
|
||||
// Every show starts from the requested side; the fit pass flips it only
|
||||
// where this anchor's position demands it.
|
||||
setPlacement(side)
|
||||
setPos({ x: side === 'right' ? r.right + 10 : r.left + r.width / 2, top: r.top, bottom: r.bottom })
|
||||
}
|
||||
const showAfterHoverDelay = () => {
|
||||
cancelShow()
|
||||
@@ -132,7 +153,13 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
|
||||
onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() },
|
||||
})}
|
||||
{pos !== null && (
|
||||
<span ref={bubble} className={css.bubble} data-side={side} style={{ left: pos.x, top: pos.y }} role="tooltip">
|
||||
<span
|
||||
ref={bubble}
|
||||
className={css.bubble}
|
||||
data-side={placement}
|
||||
style={{ left: pos.x, top: y, ...maxWidth === undefined ? {} : { maxWidth } }}
|
||||
role="tooltip"
|
||||
>
|
||||
{resolvedLabel}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -15,7 +15,7 @@ export const name = 'client-ui-primitives-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: pure props-in React atoms with zero cordis surface —
|
||||
* No runtime invariant: pure props-in React atoms with no Cordis API —
|
||||
* no events, no services, no mutable cross-plugin state; rendering contracts
|
||||
* are asserted directly by this package's component specs.
|
||||
*/
|
||||
|
||||
@@ -95,6 +95,18 @@ describe('Tooltip', () => {
|
||||
const rect = (left: number, right: number): DOMRect =>
|
||||
({ left, right, top: 0, bottom: 20, width: right - left, height: 20, x: left, y: 0, toJSON: () => ({}) })
|
||||
|
||||
it('caps the bubble width where the label would otherwise slab across the surface', () => {
|
||||
render(
|
||||
<Tooltip label="A description long enough to need a cap" side="bottom" maxWidth={360}>
|
||||
<button type="button">anchor</button>
|
||||
</Tooltip>,
|
||||
)
|
||||
fireEvent.mouseEnter(screen.getByText('anchor'))
|
||||
|
||||
// The stylesheet's half-viewport cap stays the default; this one overrides it.
|
||||
expect(screen.getByRole('tooltip').style.maxWidth).toBe('360px')
|
||||
})
|
||||
|
||||
it('clamps a bubble overflowing the right viewport edge back inside', () => {
|
||||
const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(900, 1100))
|
||||
try {
|
||||
@@ -161,19 +173,88 @@ describe('Tooltip', () => {
|
||||
}
|
||||
})
|
||||
|
||||
/** Anchor and bubble rects, so a placement test measures real room rather than jsdom's all-zero boxes. */
|
||||
const placed = (anchorTop: number, anchorBottom: number, bubbleHeight: number) =>
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
|
||||
const [top, bottom] = this.getAttribute('role') === 'tooltip'
|
||||
? [0, bubbleHeight]
|
||||
: [anchorTop, anchorBottom]
|
||||
return {
|
||||
left: 100, right: 200, top, bottom, width: 100, height: bottom - top, x: 100, y: top, toJSON: () => ({}),
|
||||
}
|
||||
})
|
||||
|
||||
it('supports top placement for anchors at the viewport bottom', () => {
|
||||
render(
|
||||
<Tooltip label="Above" side="top">
|
||||
<button type="button">anchor</button>
|
||||
</Tooltip>,
|
||||
)
|
||||
fireEvent.mouseEnter(screen.getByText('anchor'))
|
||||
const bubble = screen.getByRole('tooltip')
|
||||
expect(bubble.getAttribute('data-side')).toBe('top')
|
||||
// jsdom rects are all-zero: top placement lands at the -8 gutter and the
|
||||
// zero-width measured rect clamps left to the 12px edge margin.
|
||||
expect(bubble.style.left).toBe('12px')
|
||||
expect(bubble.style.top).toBe('-8px')
|
||||
const spy = placed(700, 720, 20)
|
||||
try {
|
||||
render(
|
||||
<Tooltip label="Above" side="top">
|
||||
<button type="button">anchor</button>
|
||||
</Tooltip>,
|
||||
)
|
||||
fireEvent.mouseEnter(screen.getByText('anchor'))
|
||||
const bubble = screen.getByRole('tooltip')
|
||||
// There is room above, so the requested side stands: the bubble's own
|
||||
// top sits at the anchor's top less the 8px gutter.
|
||||
expect(bubble.getAttribute('data-side')).toBe('top')
|
||||
expect(bubble.style.top).toBe('692px')
|
||||
expect(bubble.style.left).toBe('150px')
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('flips a bottom bubble above an anchor with no room below', () => {
|
||||
// jsdom's viewport is 768 tall: a 300px bubble under an anchor ending at
|
||||
// 700 would run off, and there is room for it above.
|
||||
const spy = placed(600, 700, 300)
|
||||
try {
|
||||
render(
|
||||
<Tooltip label="Tall" side="bottom">
|
||||
<button type="button">anchor</button>
|
||||
</Tooltip>,
|
||||
)
|
||||
fireEvent.mouseEnter(screen.getByText('anchor'))
|
||||
const bubble = screen.getByRole('tooltip')
|
||||
expect(bubble.getAttribute('data-side')).toBe('top')
|
||||
expect(bubble.style.top).toBe('592px')
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('flips a top bubble below an anchor with no room above', () => {
|
||||
const spy = placed(10, 40, 100)
|
||||
try {
|
||||
render(
|
||||
<Tooltip label="Tall" side="top">
|
||||
<button type="button">anchor</button>
|
||||
</Tooltip>,
|
||||
)
|
||||
fireEvent.mouseEnter(screen.getByText('anchor'))
|
||||
const bubble = screen.getByRole('tooltip')
|
||||
expect(bubble.getAttribute('data-side')).toBe('bottom')
|
||||
expect(bubble.style.top).toBe('48px')
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the requested side when neither side fits', () => {
|
||||
// A bubble taller than the viewport has no home; oscillating between the
|
||||
// two would be worse than honouring the request.
|
||||
const spy = placed(300, 400, 900)
|
||||
try {
|
||||
render(
|
||||
<Tooltip label="Huge" side="bottom">
|
||||
<button type="button">anchor</button>
|
||||
</Tooltip>,
|
||||
)
|
||||
fireEvent.mouseEnter(screen.getByText('anchor'))
|
||||
expect(screen.getByRole('tooltip').getAttribute('data-side')).toBe('bottom')
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('chains the anchor\'s own handlers ahead of the tooltip\'s', () => {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-sidebar/README.md
|
||||
README.md: 45ae267d98b17bbc612cf932f5b95b42ba6ff4bf
|
||||
README.md: 4eb9eeb73f1f8398eb9d16434996840182ba79a9
|
||||
README.zh.md: a9fb927305d0bab5fb4d27adbfdbec90dfa1dd6d
|
||||
|
||||
@@ -12,7 +12,7 @@ Scrollbars in the column are a pointer affordance: the shell rebinds ui-theme's
|
||||
|
||||
The foot is the `sidebar.settings` seat: the sidebar renders only the bottom-pinned layout slot and shares its column state (`wide`); ui-settings registers the trigger row and settings panel there.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`) plus the contract types only; SidebarRoot, the row components, and the tree derivation remain package-internal behind the slot registration.
|
||||
The `/client` exports are the plugin body (`apply`/`inject`) plus the contract types only; SidebarRoot, the row components, and the tree derivation remain package-internal behind the slot registration.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-skill/README.md
|
||||
README.md: 36b4cf4181d74ca1ea05fd8ed2db5e42fa36c7f2
|
||||
README.md: 0456db4de9453e5060e39b5f061422486e44dfc9
|
||||
README.zh.md: 336f43117e7bc4de41a31e636ee0966e5d1a2cd6
|
||||
|
||||
@@ -8,7 +8,7 @@ A pick lands the literal `/name ` text and the prompt ships the same literal ([s
|
||||
|
||||
A failed `skill.list` throws from `candidates`, which the slash shell logs and folds into a silent menu-group drop — the menu shows only pending/ready states.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`) only; the source object is internal to the registration effect.
|
||||
The `/client` exports are the plugin body (`apply`/`inject`) only; the source object is internal to the registration effect.
|
||||
|
||||
## Skill tool row
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-slash/README.md
|
||||
README.md: 3f97351bea04dbc268e6acc105be10e1e4e0a4b5
|
||||
README.md: e6c9c3240d03a0cde57eda0bce2a995f91786f4e
|
||||
README.zh.md: cf9e42d419cb472509a74ba227990b1502b38a75
|
||||
|
||||
@@ -8,7 +8,7 @@ Layering: `src/core/` is the pure core — `detectTrigger`, `menuReduce`/`seedGr
|
||||
|
||||
MenuView renders the menu store into the `conversation.input.overlay` slot (list kind, session scope) and renders null while closed. Typed triggers seed every source registered for that trigger; a programmatic launcher seeds only its requested source and publishes the source name through the controller's `launcher` snapshot store until the menu closes or typed tracking resumes. Groups sort by the optional `SlashSource.order` (lower first, default 0, ties keep registration order) under title rows localized through the `slash.menu` locale namespace (an unknown source shows its raw name); the list height clamps to the space above the composer, and a pointer down outside both the menu and the surrounding composer card dismisses it. The slot is owned by ui-conversation's composer entry (anchor, children declaration, lifecycle); its SlotMap type merge lives in this package's `src/client/slots.ts` because the dependency direction (ui-conversation → ui-slash) admits no reverse type import. Combobox pattern: focus stays in the textarea, rows pick on mousedown, the highlight rides `aria-activedescendant`.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`), `SlashService`, `MenuViewInjected`, and the contract types. MenuView itself is internal — the slot registration closes over it.
|
||||
The `/client` exports are the plugin body (`apply`/`inject`), `SlashService`, `MenuViewInjected`, and the contract types. MenuView itself is internal — the slot registration closes over it.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export interface LocaleFace extends HostObservable<{ revision: number }> {
|
||||
bind(ns: string): Translate
|
||||
}
|
||||
|
||||
/** Minimal observable surface for host-provided standard-kit data sources. */
|
||||
/** Minimal observable API for host-provided standard-kit data sources. */
|
||||
export interface HostObservable<T> {
|
||||
getSnapshot(): T
|
||||
subscribe(fn: () => void): () => void
|
||||
@@ -97,7 +97,7 @@ export interface RenderOpts {
|
||||
hookContext?: unknown
|
||||
}
|
||||
|
||||
/** Host surface the runtime SlotsService presents to the installed renderer. */
|
||||
/** Host API the runtime SlotsService presents to the installed renderer. */
|
||||
export interface SlotRendererHost {
|
||||
/**
|
||||
* Subscribe to a key's registration changes (microtask-batched).
|
||||
@@ -167,8 +167,8 @@ export interface SlotRendererHost {
|
||||
/** The installation contract: runtime owns install()/renderSlot(); web-react implements rendering. */
|
||||
export interface SlotRenderer {
|
||||
/**
|
||||
* Render the root slot tree over the host surface (the only ctx-level entry).
|
||||
* @param host - the installing service's host surface.
|
||||
* Render the root slot tree over the host API (the only ctx-level entry).
|
||||
* @param host - the installing service's host API.
|
||||
* @param ownerProps - owner props from the shell's renderSlot('root', ...) call.
|
||||
* @returns the rendered tree.
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SlotCore terminal-design behavior: the single register composition API —
|
||||
// a-priori 'root', children declaration/authorization, load-time validation,
|
||||
// one-axis lifecycle cascade, store scope pinning, subscription surface.
|
||||
// one-axis lifecycle cascade, store scope pinning, subscription API.
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SlotComponent, StoreHandle } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -230,7 +230,7 @@ describe('store scope pinning', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('subscription surface', () => {
|
||||
describe('subscription API', () => {
|
||||
it('tracks declaration epochs separately from ordinary entry mutations', () => {
|
||||
const core = new SlotCore()
|
||||
expect(core.declarationEpoch('root')).toBe(1)
|
||||
|
||||
@@ -5,8 +5,8 @@ import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
'surface.a': { kind: 'single'; scope: 'root' }
|
||||
'surface.b': { kind: 'single'; scope: 'root' }
|
||||
'dynamic.a': { kind: 'single'; scope: 'root' }
|
||||
'dynamic.b': { kind: 'single'; scope: 'root' }
|
||||
'surface.injected': { kind: 'single'; scope: 'root'; inject: { token: string } }
|
||||
}
|
||||
}
|
||||
@@ -16,17 +16,17 @@ const Comp: SlotComponent<object> = () => null
|
||||
describe('dynamic-key escape hatch', () => {
|
||||
it('specDynamic reads wide-typed specs for string keys; undefined while undeclared', () => {
|
||||
const core = new SlotCore()
|
||||
expect(core.specDynamic('surface.a')).toBeUndefined()
|
||||
core.register({ name: 'root', children: { 'surface.a': { kind: 'single', scope: 'root' } } }, Comp as never)
|
||||
expect(core.specDynamic('surface.a')).toEqual({ kind: 'single', scope: 'root' })
|
||||
expect(core.specDynamic('dynamic.a')).toBeUndefined()
|
||||
core.register({ name: 'root', children: { 'dynamic.a': { kind: 'single', scope: 'root' } } }, Comp as never)
|
||||
expect(core.specDynamic('dynamic.a')).toEqual({ kind: 'single', scope: 'root' })
|
||||
expect(core.specDynamic('never.declared')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('spec() narrows by SlotMap key', () => {
|
||||
const core = new SlotCore()
|
||||
core.register({ name: 'root', children: { 'surface.a': { kind: 'single', scope: 'root' } } }, Comp as never)
|
||||
expect(core.spec('surface.a')).toEqual({ kind: 'single', scope: 'root' })
|
||||
expect(core.spec('surface.b')).toBeUndefined()
|
||||
core.register({ name: 'root', children: { 'dynamic.a': { kind: 'single', scope: 'root' } } }, Comp as never)
|
||||
expect(core.spec('dynamic.a')).toEqual({ kind: 'single', scope: 'root' })
|
||||
expect(core.spec('dynamic.b')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('records the parent-declared Slot inject on the runtime spec', () => {
|
||||
@@ -41,9 +41,9 @@ describe('dynamic-key escape hatch', () => {
|
||||
|
||||
it('entries/getVersion on an untouched key return the frozen empty array and 0', () => {
|
||||
const core = new SlotCore()
|
||||
expect(core.entries('surface.b')).toHaveLength(0)
|
||||
expect(core.entries('surface.b')).toBe(core.entries('surface.b'))
|
||||
expect(core.getVersion('surface.b')).toBe(0)
|
||||
expect(core.entries('dynamic.b')).toHaveLength(0)
|
||||
expect(core.entries('dynamic.b')).toBe(core.entries('dynamic.b'))
|
||||
expect(core.getVersion('dynamic.b')).toBe(0)
|
||||
})
|
||||
|
||||
it('isLive is false for entries the core never held', () => {
|
||||
@@ -103,7 +103,7 @@ declare function NarrowTakeover(props: PropsRuntime<'chain.takeover'> & { matche
|
||||
|
||||
describe('terminal-design type chain', () => {
|
||||
it('holds the positive chain and the compile-time negatives', () => {
|
||||
// Everything below is compile-surface only.
|
||||
// Everything below is compile-time only.
|
||||
const samples = (core: SlotCore, chat: ChatHandle, fp: FrameProps, cp: ConvProps, acts: BoundActions<ChatHandle>) => {
|
||||
// ── positive chain ─────────────────────────────────────────────
|
||||
// Frame: children + factory store + inject; actions arrive baked.
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-theme/README.md
|
||||
README.md: 7b81728b02c0dadcbd134a4bca20c49721a3dd52
|
||||
README.md: cab9961a6d703d600a856e71339cda7062d20d62
|
||||
README.zh.md: 81b64c356749b6ffe12694b218e92eaa483ff739
|
||||
|
||||
@@ -20,5 +20,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Third-party themes are a surface, not a product** — registering one means overriding same-named alias variables; no validation exists that an override set is complete.
|
||||
- **Third-party themes are an extension point, not a product** — registering one means overriding same-named alias variables; no validation exists that an override set is complete.
|
||||
- **The token sheets are the sole color authority** — values absent from cssdesign (for example the design's #4176E6 tab blue) are deliberately not appended; the nearest semantic token wins. Design-owner-approved additions are the exception and enter as a static step plus a semantic alias in the same change (`--dsw-static-blue-900` / `--dsw-alias-label-primary-bluish`).
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md
|
||||
README.md: d3786b6460c5df7eaa6d24e68c80025e7fb29ae4
|
||||
README.zh.md: 5eb1451b9a3a9896d5486fcf5c8d9cf30d6159a0
|
||||
README.md: e82b2cc9d4a65c3095aeee7002fb6c43a43b695d
|
||||
README.zh.md: a1ba62393c2aae3f6baa7c481dd80f04dbbb477d
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8.
|
||||
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. The toolbar's Export button downloads the session log — the root plus every subagent descendant — as a ZIP streamed by the host (`GET /api/session.export`): every file is the session's stored artifact text verbatim (`session.jsonl` at the root, `subagents/<id>/session.jsonl` for descendants; no manifest, byte-identical to the backend's durable artifact), and every image any included log references sits under `media/<attachmentId>.<ext>`. Fixture mode (no host) answers 404 for the export. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。
|
||||
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。工具栏的 “Export” 按钮会将会话日志——根会话及其全部子代理——下载为宿主流式返回的 ZIP(`GET /api/session.export`):每个文件都是会话存储工件的逐字原文(根为 `session.jsonl`,子代理为 `subagents/<id>/session.jsonl`;无清单,与后端持久化工件逐字节一致),每个被包含日志引用的图片则放在 `media/<attachmentId>.<ext>` 下。fixture 模式(无宿主)对导出应答 404。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
@@ -49,6 +50,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
@@ -60,6 +62,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
|
||||
@@ -156,8 +156,8 @@ type DetailTab =
|
||||
| 'tools'
|
||||
| 'overview'
|
||||
| 'rendered'
|
||||
| 'raw'
|
||||
| 'source'
|
||||
| 'origin'
|
||||
| 'input'
|
||||
| 'output'
|
||||
| 'schema'
|
||||
@@ -800,7 +800,7 @@ function RequestOptions({
|
||||
)
|
||||
}
|
||||
|
||||
function messageOriginLabel(source: unknown): string {
|
||||
function messageSourceLabel(source: unknown): string {
|
||||
if (typeof source !== 'object' || source === null || Array.isArray(source)) {
|
||||
return 'Unknown'
|
||||
}
|
||||
@@ -823,16 +823,16 @@ function messageOriginLabel(source: unknown): string {
|
||||
return `${kind[0]?.toUpperCase() ?? ''}${kind.slice(1)}`
|
||||
}
|
||||
|
||||
function MessageOrigin({ record }: { record: TableRecord }) {
|
||||
function MessageSource({ record }: { record: TableRecord }) {
|
||||
const source = record.cell.messageSource
|
||||
if (source === undefined) return <p className={css.noPayload}>Origin not recorded</p>
|
||||
if (source === undefined) return <p className={css.noPayload}>Source not recorded</p>
|
||||
const data = typeof source === 'object' && source !== null
|
||||
? source
|
||||
: { value: source }
|
||||
return (
|
||||
<JsonTree
|
||||
data={data}
|
||||
label="Message origin JSON"
|
||||
label="Message source JSON"
|
||||
className={css.jsonPayload}
|
||||
/>
|
||||
)
|
||||
@@ -897,17 +897,17 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] {
|
||||
if (record.cell.kind === 'compacted') {
|
||||
return [
|
||||
{ id: 'overview', label: 'Summary' },
|
||||
{ id: 'source', label: 'Raw Output' },
|
||||
{ id: 'raw', label: 'Raw Output' },
|
||||
]
|
||||
}
|
||||
if (isMarkdownRecord(record)) {
|
||||
return [
|
||||
{ id: 'overview', label: 'Summary' },
|
||||
{ id: 'rendered', label: 'Preview' },
|
||||
{ id: 'source', label: 'Source' },
|
||||
{ id: 'raw', label: 'Raw' },
|
||||
...(record.cell.messageSource === undefined
|
||||
? []
|
||||
: [{ id: 'origin', label: 'Origin' } as const]),
|
||||
: [{ id: 'source', label: 'Source' } as const]),
|
||||
]
|
||||
}
|
||||
return [
|
||||
@@ -2855,14 +2855,14 @@ export function TrajectoryTable({
|
||||
>
|
||||
{selected.cell.messageSource !== undefined && (
|
||||
<div>
|
||||
<dt>Origin</dt>
|
||||
<dt>Source</dt>
|
||||
<dd className={css.overviewParentLinks}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.overviewHierarchyNavLink}
|
||||
onClick={() => { activateTab('origin') }}
|
||||
onClick={() => { activateTab('source') }}
|
||||
>
|
||||
<span>{messageOriginLabel(selected.cell.messageSource)}</span>
|
||||
<span>{messageSourceLabel(selected.cell.messageSource)}</span>
|
||||
<IconChevronRightOutline14
|
||||
className={css.overviewHierarchyJumpIconTight}
|
||||
size={11}
|
||||
@@ -2875,7 +2875,7 @@ export function TrajectoryTable({
|
||||
<div>
|
||||
<dt>
|
||||
{selectedAssistantRequestTarget !== undefined
|
||||
? 'Origin'
|
||||
? 'Source'
|
||||
: 'Hierarchy'}
|
||||
</dt>
|
||||
<dd className={css.overviewParentLinks}>
|
||||
@@ -2999,7 +2999,7 @@ export function TrajectoryTable({
|
||||
onOpenCall={openCallSummary}
|
||||
/>
|
||||
)}
|
||||
{!promptSelected && selected !== undefined && activeTab === 'source' && (
|
||||
{!promptSelected && selected !== undefined && activeTab === 'raw' && (
|
||||
<MarkdownRecordContent
|
||||
record={selected}
|
||||
rendered={false}
|
||||
@@ -3008,8 +3008,8 @@ export function TrajectoryTable({
|
||||
onOpenCall={openCallSummary}
|
||||
/>
|
||||
)}
|
||||
{!promptSelected && selected !== undefined && activeTab === 'origin' && (
|
||||
<MessageOrigin record={selected} />
|
||||
{!promptSelected && selected !== undefined && activeTab === 'source' && (
|
||||
<MessageSource record={selected} />
|
||||
)}
|
||||
{!promptSelected && selected !== undefined && activeTab === 'input' && (
|
||||
<RecordPayload record={selected} direction="input" />
|
||||
|
||||
@@ -164,6 +164,46 @@
|
||||
font: 14px/14px var(--ds-font-family-code);
|
||||
}
|
||||
|
||||
.export {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
padding: 0 7px;
|
||||
gap: 4px;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
}
|
||||
|
||||
.export:hover:not(:disabled) {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.export:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.export:disabled {
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.exportIcon {
|
||||
flex: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.25;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.search {
|
||||
display: flex;
|
||||
flex: 0 1 164px;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/** Trajectory toolbar: timeline and ledger fold controls. */
|
||||
|
||||
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { NS } from './locales.ts'
|
||||
import css from './TrajectoryToolbar.module.css'
|
||||
|
||||
export interface TrajectoryToolbarProps {
|
||||
@@ -24,6 +26,14 @@ export interface TrajectoryToolbarProps {
|
||||
searchQuery: string
|
||||
/** Update the live ledger search query. */
|
||||
onSearchQueryChange: (query: string) => void
|
||||
/** Whether the session-log export is in flight. */
|
||||
exporting: boolean
|
||||
/** Trigger the session-log export download. */
|
||||
onExport: () => void
|
||||
/** Export failure message, shown while set; null while idle or successful. */
|
||||
exportError: string | null
|
||||
/** Translate a toolbar dictionary key. */
|
||||
t: TranslateNS<typeof NS>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,17 +52,21 @@ export function TrajectoryToolbar({
|
||||
onToggleAllAssistants,
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
exporting,
|
||||
onExport,
|
||||
exportError,
|
||||
t,
|
||||
}: TrajectoryToolbarProps) {
|
||||
return (
|
||||
<div className={css.root} role="toolbar" aria-label="Trajectory toolbar">
|
||||
<div className={css.root} role="toolbar" aria-label={t('toolbar.aria')}>
|
||||
<div className={css.inner}>
|
||||
<div className={css.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.toggle}
|
||||
aria-label="Use actual duration"
|
||||
aria-label={t('toolbar.useActualDuration')}
|
||||
aria-pressed={actualDuration}
|
||||
title={actualDuration ? 'Use equal-width operations' : 'Use actual duration'}
|
||||
title={actualDuration ? t('toolbar.useEqualWidth') : t('toolbar.useActualDuration')}
|
||||
onClick={() => { onActualDurationChange(!actualDuration) }}
|
||||
>
|
||||
<svg
|
||||
@@ -64,7 +78,7 @@ export function TrajectoryToolbar({
|
||||
<circle cx="8" cy="8" r="5.25" />
|
||||
<path d="M8 4.75V8l2.25 1.5" />
|
||||
</svg>
|
||||
Duration
|
||||
{t('toolbar.duration')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -74,7 +88,7 @@ export function TrajectoryToolbar({
|
||||
hidden
|
||||
onClick={() => { onActualTimeChange(!actualTime) }}
|
||||
>
|
||||
<span>Actual time</span>
|
||||
<span>{t('toolbar.actualTime')}</span>
|
||||
<span className={css.controlTrack} data-on={actualTime || undefined} aria-hidden="true">
|
||||
<span className={css.controlThumb} />
|
||||
</span>
|
||||
@@ -82,28 +96,42 @@ export function TrajectoryToolbar({
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
aria-label={allTurnsCollapsed ? 'Expand turns' : 'Collapse turns'}
|
||||
aria-label={allTurnsCollapsed ? t('toolbar.expandTurns') : t('toolbar.collapseTurns')}
|
||||
aria-pressed={allTurnsCollapsed}
|
||||
title={allTurnsCollapsed ? 'Expand turns' : 'Collapse turns'}
|
||||
title={allTurnsCollapsed ? t('toolbar.expandTurns') : t('toolbar.collapseTurns')}
|
||||
onClick={onToggleAllTurns}
|
||||
>
|
||||
<span className={css.actionIcon} aria-hidden="true">
|
||||
{allTurnsCollapsed ? '⊞' : '⊟'}
|
||||
</span>
|
||||
Turns
|
||||
{t('toolbar.turns')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
aria-label={allAssistantsCollapsed ? 'Expand calls' : 'Collapse calls'}
|
||||
aria-label={allAssistantsCollapsed ? t('toolbar.expandCalls') : t('toolbar.collapseCalls')}
|
||||
aria-pressed={allAssistantsCollapsed}
|
||||
title={allAssistantsCollapsed ? 'Expand calls' : 'Collapse calls'}
|
||||
title={allAssistantsCollapsed ? t('toolbar.expandCalls') : t('toolbar.collapseCalls')}
|
||||
onClick={onToggleAllAssistants}
|
||||
>
|
||||
<span className={css.actionIcon} aria-hidden="true">
|
||||
{allAssistantsCollapsed ? '⊞' : '⊟'}
|
||||
</span>
|
||||
Calls
|
||||
{t('toolbar.calls')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.export}
|
||||
aria-label={t('toolbar.exportAria')}
|
||||
title={exportError ?? (exporting ? t('toolbar.exporting') : t('toolbar.exportTitle'))}
|
||||
disabled={exporting}
|
||||
onClick={onExport}
|
||||
>
|
||||
<svg className={css.exportIcon} viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M8 3v7m0 0 3-3m-3 3L5 7" />
|
||||
<path d="M3 11.5V13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-1.5" />
|
||||
</svg>
|
||||
{t('toolbar.export')}
|
||||
</button>
|
||||
</div>
|
||||
<div className={css.search}>
|
||||
@@ -111,8 +139,8 @@ export function TrajectoryToolbar({
|
||||
<input
|
||||
type="search"
|
||||
className={css.searchInput}
|
||||
aria-label="Search trajectory"
|
||||
placeholder="Search"
|
||||
aria-label={t('toolbar.search')}
|
||||
placeholder={t('toolbar.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(event) => { onSearchQueryChange(event.currentTarget.value) }}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {
|
||||
AssistantBlock, AssistantMessageNode, ConversationSnapshot,
|
||||
SnapshotStore,
|
||||
@@ -71,6 +71,8 @@ export interface TrajectoryViewInjected {
|
||||
}
|
||||
loadOlder: () => Promise<boolean>
|
||||
setActualDuration: (actualDuration: boolean) => void
|
||||
/** Download the session log (including subagent logs) as a ZIP archive; rejects on failure. */
|
||||
exportLog: () => Promise<void>
|
||||
}
|
||||
|
||||
interface UsageLike {
|
||||
@@ -118,9 +120,9 @@ function addUsage(
|
||||
}
|
||||
|
||||
export function TrajectoryView({
|
||||
useSession, useDuration, loadOlder, setActualDuration,
|
||||
inspect, onInspectDone,
|
||||
}: ConvViewProps & InjectFace<TrajectoryViewInjected>) {
|
||||
useSession, useDuration, loadOlder, setActualDuration, exportLog,
|
||||
inspect, onInspectDone, t,
|
||||
}: ConvViewProps & InjectFace<TrajectoryViewInjected> & PropsLocale<'trajectory'>) {
|
||||
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_TURN_IDS)
|
||||
const [collapsedAssistants, setCollapsedAssistants] =
|
||||
useState<ReadonlySet<string>>(EMPTY_RECORD_IDS)
|
||||
@@ -128,6 +130,8 @@ export function TrajectoryView({
|
||||
const actualDuration = useDuration(value => value)
|
||||
const [actualTime, setActualTime] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [exportError, setExportError] = useState<string | null>(null)
|
||||
const [searchIndex] = useState(() => new TrajectorySearchIndex())
|
||||
const [searchIndexRevision, setSearchIndexRevision] = useState(0)
|
||||
const searchIndexTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
@@ -443,6 +447,19 @@ export function TrajectoryView({
|
||||
return loadOlder()
|
||||
}, [loadOlder])
|
||||
|
||||
const onExport = useCallback(() => {
|
||||
if (exporting) return
|
||||
setExporting(true)
|
||||
setExportError(null)
|
||||
void exportLog().then(
|
||||
() => { setExporting(false) },
|
||||
(error: unknown) => {
|
||||
setExportError(error instanceof Error ? error.message : String(error))
|
||||
setExporting(false)
|
||||
},
|
||||
)
|
||||
}, [exportLog, exporting])
|
||||
|
||||
return (
|
||||
<div className={css.root} data-conversation-composer-overlay="">
|
||||
<TrajectoryToolbar
|
||||
@@ -462,7 +479,16 @@ export function TrajectoryView({
|
||||
onToggleAllAssistants={toggleAllAssistants}
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
exporting={exporting}
|
||||
onExport={onExport}
|
||||
exportError={exportError}
|
||||
t={t}
|
||||
/>
|
||||
{exportError !== null && (
|
||||
<div className={css.exportError} role="alert">
|
||||
{exportError}
|
||||
</div>
|
||||
)}
|
||||
<TrajectoryTimeline
|
||||
turns={timelineTurns}
|
||||
mode={timelineMode}
|
||||
|
||||
42
packages/client/ui-trajectory/src/client/export-log.ts
Normal file
42
packages/client/ui-trajectory/src/client/export-log.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Session log export: browser download of the host-streamed ZIP. The archive
|
||||
* itself is produced and streamed by the host (GET /api/session.export); this
|
||||
* module only derives the download filename and triggers the browser save.
|
||||
* @module
|
||||
*/
|
||||
|
||||
/**
|
||||
* Collapse an untrusted session id into one safe path/filename segment.
|
||||
* Distinct ids may collapse onto one segment (impossible for the host-minted
|
||||
* UUIDs, so no uniqueness suffix is kept).
|
||||
* @param id - the raw session id.
|
||||
* @returns a filesystem-safe single segment.
|
||||
*/
|
||||
function safeSessionIdSegment(id: string): string {
|
||||
return id.replace(/[^A-Za-z0-9_-]/g, '_')
|
||||
}
|
||||
|
||||
/**
|
||||
* The export archive filename for one session (same convention the host's
|
||||
* Content-Disposition uses).
|
||||
* @param sessionId - the root session id.
|
||||
* @returns the download filename.
|
||||
*/
|
||||
export function sessionLogZipFilename(sessionId: string): string {
|
||||
return `dsh-session-${safeSessionIdSegment(sessionId)}.zip`
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a browser download of a blob response.
|
||||
* @param blob - the response body to save (passed straight through, no copy).
|
||||
* @param filename - the download filename.
|
||||
*/
|
||||
export function downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
anchor.click()
|
||||
// Revoke one tick later: some browsers read the blob URL after click().
|
||||
setTimeout(() => { URL.revokeObjectURL(url) }, 0)
|
||||
}
|
||||
@@ -4,20 +4,24 @@
|
||||
*/
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
// Type-only: the 'conversation.view' SlotMap row (declared by the slot's
|
||||
// owning package) must be in the program for the register calls to type.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { createTrajectoryDurationStore } from './duration-store.ts'
|
||||
import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx'
|
||||
import { downloadBlob, sessionLogZipFilename } from './export-log.ts'
|
||||
import { en, NS, zh } from './locales.ts'
|
||||
import { registerTrajectoryAssistantDefinition } from './trajectory-assistant-definition.ts'
|
||||
import { registerTrajectoryCompactionDefinitions } from './trajectory-compaction-definition.ts'
|
||||
import { registerTrajectoryMessageDefinitions } from './trajectory-message-definitions.ts'
|
||||
import { registerTrajectoryRequestHeaderDefinition } from './trajectory-request-header-definition.ts'
|
||||
import { registerTrajectoryConversationView } from './trajectory-snapshot-builder.ts'
|
||||
import { registerTrajectoryToolDefinition } from './trajectory-tool-definition.ts'
|
||||
import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx'
|
||||
|
||||
/** Required services: the conversation slot, registries, and ordinary Session paging. */
|
||||
export const inject = ['slots', 'conversationEvents', 'conversationViews', 'sessions']
|
||||
/** Required services: the conversation slot, registries, ordinary Session paging, and the locale service. */
|
||||
export const inject = ['slots', 'conversationEvents', 'conversationViews', 'sessions', 'locale']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the trajectory view tab. The registration
|
||||
@@ -25,6 +29,11 @@ export const inject = ['slots', 'conversationEvents', 'conversationViews', 'sess
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-trajectory: dictionaries')
|
||||
// Registration-time text (the view tab label) reads through the bound
|
||||
// translate as a thunk, so it follows the active locale without
|
||||
// re-registration.
|
||||
const t = ctx.locale.bind(NS)
|
||||
const duration = createTrajectoryDurationStore()
|
||||
registerTrajectoryMessageDefinitions(ctx)
|
||||
registerTrajectoryRequestHeaderDefinition(ctx)
|
||||
@@ -36,7 +45,8 @@ export function apply(ctx: Context): void {
|
||||
name: 'conversation.view',
|
||||
id: 'trajectory',
|
||||
order: 10,
|
||||
label: 'Trajectory',
|
||||
locale: NS,
|
||||
label: () => t('view.trajectory'),
|
||||
inject: (sessionId: SessionId): TrajectoryViewInjected => {
|
||||
const session = ctx.sessions.binding(sessionId)?.session
|
||||
if (session === undefined) {
|
||||
@@ -50,6 +60,23 @@ export function apply(ctx: Context): void {
|
||||
return session.getSnapshot().views.get('trajectory') !== before
|
||||
},
|
||||
setActualDuration: (value) => { duration.set(value) },
|
||||
exportLog: async () => {
|
||||
// The host streams the ZIP (root + descendant artifacts verbatim)
|
||||
// from GET /api/session.export; the browser downloads the response.
|
||||
// A null origin (no-location Node contexts) falls back like the
|
||||
// carrier's resolveBase so the URL stays valid.
|
||||
const loc = (globalThis as { location?: { origin?: string } }).location
|
||||
const origin = loc?.origin !== undefined && loc.origin !== 'null' ? loc.origin : 'http://dsh.internal'
|
||||
const url = new URL('/api/session.export', origin)
|
||||
url.searchParams.set('sessionId', sessionId)
|
||||
url.searchParams.set('includeDescendants', 'true')
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '')
|
||||
throw new Error(`Export failed: HTTP ${response.status}${detail === '' ? '' : ` ${detail}`}`)
|
||||
}
|
||||
downloadBlob(await response.blob(), sessionLogZipFilename(sessionId))
|
||||
},
|
||||
}
|
||||
},
|
||||
}, TrajectoryView))
|
||||
|
||||
76
packages/client/ui-trajectory/src/client/locales.ts
Normal file
76
packages/client/ui-trajectory/src/client/locales.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/** `trajectory` namespace dictionaries (view tab label + toolbar strings). */
|
||||
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
export const NS = 'trajectory'
|
||||
|
||||
/** The trajectory dictionary key set (the source of truth for both locales). */
|
||||
export type TrajectoryKey =
|
||||
| 'view.trajectory'
|
||||
| 'toolbar.aria'
|
||||
| 'toolbar.duration'
|
||||
| 'toolbar.useActualDuration'
|
||||
| 'toolbar.useEqualWidth'
|
||||
| 'toolbar.actualTime'
|
||||
| 'toolbar.turns'
|
||||
| 'toolbar.expandTurns'
|
||||
| 'toolbar.collapseTurns'
|
||||
| 'toolbar.calls'
|
||||
| 'toolbar.expandCalls'
|
||||
| 'toolbar.collapseCalls'
|
||||
| 'toolbar.export'
|
||||
| 'toolbar.exportAria'
|
||||
| 'toolbar.exporting'
|
||||
| 'toolbar.exportTitle'
|
||||
| 'toolbar.search'
|
||||
| 'toolbar.searchPlaceholder'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** The trajectory view tab label and toolbar strings. */
|
||||
'trajectory': TrajectoryKey
|
||||
}
|
||||
}
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh: Record<TrajectoryKey, string> = {
|
||||
'view.trajectory': '轨迹',
|
||||
'toolbar.aria': '轨迹工具栏',
|
||||
'toolbar.duration': 'Duration',
|
||||
'toolbar.useActualDuration': 'Use actual duration',
|
||||
'toolbar.useEqualWidth': 'Use equal-width operations',
|
||||
'toolbar.actualTime': '实际时间',
|
||||
'toolbar.turns': 'Turns',
|
||||
'toolbar.expandTurns': 'Expand turns',
|
||||
'toolbar.collapseTurns': 'Collapse turns',
|
||||
'toolbar.calls': 'Calls',
|
||||
'toolbar.expandCalls': 'Expand calls',
|
||||
'toolbar.collapseCalls': 'Collapse calls',
|
||||
'toolbar.export': 'Export',
|
||||
'toolbar.exportAria': 'Export session log',
|
||||
'toolbar.exporting': 'Exporting…',
|
||||
'toolbar.exportTitle': 'Export session log (ZIP, includes subagents)',
|
||||
'toolbar.search': '搜索轨迹',
|
||||
'toolbar.searchPlaceholder': '搜索',
|
||||
}
|
||||
|
||||
/** English dictionary. */
|
||||
export const en: Record<TrajectoryKey, string> = {
|
||||
'view.trajectory': 'Trajectory',
|
||||
'toolbar.aria': 'Trajectory toolbar',
|
||||
'toolbar.duration': 'Duration',
|
||||
'toolbar.useActualDuration': 'Use actual duration',
|
||||
'toolbar.useEqualWidth': 'Use equal-width operations',
|
||||
'toolbar.actualTime': 'Actual time',
|
||||
'toolbar.turns': 'Turns',
|
||||
'toolbar.expandTurns': 'Expand turns',
|
||||
'toolbar.collapseTurns': 'Collapse turns',
|
||||
'toolbar.calls': 'Calls',
|
||||
'toolbar.expandCalls': 'Expand calls',
|
||||
'toolbar.collapseCalls': 'Collapse calls',
|
||||
'toolbar.export': 'Export',
|
||||
'toolbar.exportAria': 'Export session log',
|
||||
'toolbar.exporting': 'Exporting…',
|
||||
'toolbar.exportTitle': 'Export session log (ZIP, includes subagents)',
|
||||
'toolbar.search': 'Search trajectory',
|
||||
'toolbar.searchPlaceholder': 'Search',
|
||||
}
|
||||
@@ -13,6 +13,18 @@
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
}
|
||||
|
||||
.exportError {
|
||||
box-sizing: border-box;
|
||||
flex: none;
|
||||
width: 100%;
|
||||
padding: 4px 10px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
color: var(--dsw-alias-label-danger, var(--dsw-alias-label-primary));
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
font: var(--dsw-font-xxs-12);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ledger {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* Real tsdown artifact shape: lib/client.js hands off through
|
||||
* window.__ModuleLoader__.load, resolves externals through the injected
|
||||
* require, returns the export surface (apply + inject), and a mounted apply
|
||||
* require, returns the exports (apply + inject), and a mounted apply
|
||||
* registers the view tab into a real SlotsService ring. Skips when dist/ is
|
||||
* not built (`pnpm --filter @deepseek-ai/dsh-client-ui-trajectory bundle`).
|
||||
*/
|
||||
@@ -52,24 +52,24 @@ describe('tsdown client artifact', () => {
|
||||
['@deepseek-ai/dsh-client-runtime/client', await import('@deepseek-ai/dsh-client-runtime/client')],
|
||||
['@deepseek-ai/dsh-client-ui-primitives', await import('@deepseek-ai/dsh-client-ui-primitives')],
|
||||
])
|
||||
const surface = handoff!.factory((spec) => {
|
||||
const exports = handoff!.factory((spec) => {
|
||||
if (!modules.has(spec)) throw new Error(`unexpected require: ${spec}`)
|
||||
return modules.get(spec)
|
||||
})
|
||||
return { handoff: handoff!, surface }
|
||||
return { handoff: handoff!, exports }
|
||||
}
|
||||
|
||||
it.skipIf(code === undefined)('hands off with the manifest id and a DI-require factory', async () => {
|
||||
const { handoff, surface } = await loadArtifact()
|
||||
const { handoff, exports } = await loadArtifact()
|
||||
expect(handoff.id).toBe(PLUGIN_ID)
|
||||
expect(surface.apply).toBeTypeOf('function')
|
||||
expect(surface.inject).toEqual([
|
||||
'slots', 'conversationEvents', 'conversationViews', 'sessions',
|
||||
expect(exports.apply).toBeTypeOf('function')
|
||||
expect(exports.inject).toEqual([
|
||||
'slots', 'conversationEvents', 'conversationViews', 'sessions', 'locale',
|
||||
])
|
||||
})
|
||||
|
||||
it.skipIf(code === undefined)('mounted as an object plugin, apply registers the view tab on the real ring', async () => {
|
||||
const { surface } = await loadArtifact()
|
||||
const { exports } = await loadArtifact()
|
||||
const ctx = new Context()
|
||||
const slots = new SlotsService(ctx)
|
||||
await ctx.plugin(ConversationEventRegistry).await()
|
||||
@@ -80,9 +80,14 @@ describe('tsdown client artifact', () => {
|
||||
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
|
||||
}, (_p: { renderSlot?: unknown }) => null)
|
||||
// Paging is session-owned; this registration-only probe never renders the
|
||||
// entry, so the binding stays deliberately empty.
|
||||
// entry, so the binding stays deliberately empty. The locale plugin backs
|
||||
// the locale-aware view tab label (its settings scope needs a connection
|
||||
// handle).
|
||||
ctx.provide('sessions', { binding: () => undefined })
|
||||
const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void })
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
const locale = await import('@deepseek-ai/dsh-client-locale/client')
|
||||
ctx.plugin({ inject: [...locale.inject], apply: locale.apply })
|
||||
const fiber = ctx.plugin(exports as { apply: (ctx: Context) => void })
|
||||
await fiber.await()
|
||||
const events = ctx.get('conversationEvents') as ConversationEventRegistry
|
||||
const views = ctx.get('conversationViews') as ConversationViewRegistry
|
||||
|
||||
24
packages/client/ui-trajectory/tests/export-log.spec.ts
Normal file
24
packages/client/ui-trajectory/tests/export-log.spec.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// @vitest-environment node
|
||||
/**
|
||||
* Session-log export filename derivation. The archive itself is produced and
|
||||
* streamed by the host (GET /api/session.export); this package only derives
|
||||
* the download filename and triggers the browser save.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sessionLogZipFilename } from '../src/client/export-log.ts'
|
||||
|
||||
describe('sessionLogZipFilename', () => {
|
||||
it('keeps safe session ids verbatim', () => {
|
||||
expect(sessionLogZipFilename('session-abc_1-2')).toBe('dsh-session-session-abc_1-2.zip')
|
||||
})
|
||||
|
||||
it('neutralizes unsafe id characters that could shape the filename', () => {
|
||||
expect(sessionLogZipFilename('../evil')).toBe('dsh-session-___evil.zip')
|
||||
expect(sessionLogZipFilename('a/b')).toBe('dsh-session-a_b.zip')
|
||||
})
|
||||
|
||||
it('strips dots so a dot-only id cannot shape a dot segment', () => {
|
||||
expect(sessionLogZipFilename('..')).toBe('dsh-session-__.zip')
|
||||
})
|
||||
})
|
||||
61
packages/client/ui-trajectory/tests/toolbar.spec.tsx
Normal file
61
packages/client/ui-trajectory/tests/toolbar.spec.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Trajectory toolbar export button: click dispatch, in-flight disable, and error surfacing. */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { LocaleKeysOf } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { TrajectoryToolbar, type TrajectoryToolbarProps } from '../src/client/TrajectoryToolbar.tsx'
|
||||
import { zh, type TrajectoryKey } from '../src/client/locales.ts'
|
||||
|
||||
/** Test translator pinned to the Simplified Chinese dictionary. */
|
||||
const zhT = (key: LocaleKeysOf<'trajectory'>): string => zh[key as TrajectoryKey] ?? key
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function baseProps(overrides: Partial<TrajectoryToolbarProps> = {}): TrajectoryToolbarProps {
|
||||
return {
|
||||
actualDuration: false,
|
||||
onActualDurationChange: vi.fn(),
|
||||
actualTime: false,
|
||||
onActualTimeChange: vi.fn(),
|
||||
allTurnsCollapsed: false,
|
||||
onToggleAllTurns: vi.fn(),
|
||||
allAssistantsCollapsed: false,
|
||||
onToggleAllAssistants: vi.fn(),
|
||||
searchQuery: '',
|
||||
onSearchQueryChange: vi.fn(),
|
||||
exporting: false,
|
||||
onExport: vi.fn(),
|
||||
exportError: null,
|
||||
t: zhT,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('TrajectoryToolbar export', () => {
|
||||
it('renders the export button and dispatches the export callback on click', () => {
|
||||
const onExport = vi.fn()
|
||||
render(<TrajectoryToolbar {...baseProps({ onExport })} />)
|
||||
const button = screen.getByRole('button', { name: 'Export session log' })
|
||||
fireEvent.click(button)
|
||||
expect(onExport).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('disables the button while an export is in flight and blocks dispatch', () => {
|
||||
const onExport = vi.fn()
|
||||
render(<TrajectoryToolbar {...baseProps({ exporting: true, onExport })} />)
|
||||
const button = screen.getByRole('button', { name: 'Export session log' }) as HTMLButtonElement
|
||||
expect(button.disabled).toBe(true)
|
||||
fireEvent.click(button)
|
||||
expect(onExport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces an export failure as the button title', () => {
|
||||
render(<TrajectoryToolbar {...baseProps({ exportError: 'Export failed: internal boom' })} />)
|
||||
const button = screen.getByRole('button', { name: 'Export session log' })
|
||||
expect(button.title).toBe('Export failed: internal boom')
|
||||
})
|
||||
})
|
||||
@@ -29,6 +29,9 @@ import {
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx'
|
||||
import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
|
||||
import { zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
|
||||
import { apply as localeApply, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { LocaleKeysOf } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { zh, type TrajectoryKey } from '../src/client/locales.ts'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
|
||||
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
|
||||
import type { TrajectoryTurnModel } from '../src/client/layout.ts'
|
||||
@@ -132,6 +135,12 @@ function standaloneDuration(): Pick<
|
||||
}
|
||||
}
|
||||
|
||||
function standaloneExport(
|
||||
onExport: () => Promise<void> = vi.fn(() => Promise.resolve()),
|
||||
): Pick<ComponentProps<typeof TrajectoryView>, 'exportLog'> {
|
||||
return { exportLog: onExport }
|
||||
}
|
||||
|
||||
function fakeSession(nodes: ConversationSnapshot['nodes']) {
|
||||
const store = createSnapshotStore(historySnapshot(nodes))
|
||||
return { store, useSession: bindSnapshotSelector(store) }
|
||||
@@ -153,14 +162,18 @@ function emptyWorkspaces() {
|
||||
}
|
||||
|
||||
/** Standalone view props: the session-scope standard kit the outlet would bake. */
|
||||
function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
|
||||
function standaloneProps(
|
||||
nodes: ConversationSnapshot['nodes'],
|
||||
): ConvViewProps & { t: (key: LocaleKeysOf<'trajectory'>) => string } {
|
||||
return {
|
||||
sessionId: SID,
|
||||
useSession: fakeSession(nodes).useSession,
|
||||
useSessions: emptySessions(),
|
||||
useWorkspaces: emptyWorkspaces(),
|
||||
useProjection: (() => undefined) as never,
|
||||
} as unknown as ConvViewProps
|
||||
// The locale seat the outlet would inject for the declared namespace.
|
||||
t: (key: LocaleKeysOf<'trajectory'>) => zh[key as TrajectoryKey] ?? key,
|
||||
} as unknown as ConvViewProps & { t: (key: LocaleKeysOf<'trajectory'>) => string }
|
||||
}
|
||||
|
||||
/** Real-stack bench: root Context + real SlotsService ring + the plugin fiber. */
|
||||
@@ -188,6 +201,10 @@ async function bench(snapshot = historySnapshot(NODES)) {
|
||||
const chatBody = vi.fn(() => <div data-testid="chat-body" />)
|
||||
slots.register(
|
||||
{ name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never)
|
||||
// The locale plugin backs the locale-aware view tab label ('locale' in
|
||||
// inject); its settings scope needs a connection handle.
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
ctx.plugin({ inject: [...localeInject], apply: localeApply })
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
return { ctx, slots, fiber, loadOlder, sessionStore }
|
||||
@@ -232,7 +249,9 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
|
||||
return {
|
||||
loadOlder: trajectory.loadOlder,
|
||||
setActualDuration: trajectory.setActualDuration,
|
||||
exportLog: trajectory.exportLog,
|
||||
useDuration: bindSnapshotSelector(trajectory.hooks.duration),
|
||||
t: (key: TrajectoryKey) => zh[key],
|
||||
}
|
||||
})()
|
||||
: injected
|
||||
@@ -352,7 +371,7 @@ describe('tab switching in ConversationRoot', () => {
|
||||
expect(screen.queryByText(/turns ·/)).toBeNull()
|
||||
expect(view.container.querySelectorAll('tr[data-turn-start="true"]')).toHaveLength(2)
|
||||
expect(screen.queryByRole('columnheader')).toBeNull()
|
||||
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
|
||||
expect(screen.getByRole('toolbar', { name: '轨迹工具栏' })).toBeTruthy()
|
||||
expect(screen.getByRole('region', { name: 'Trajectory timeline' })).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-conversation-composer-overlay]')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collapse turns' }))
|
||||
@@ -365,6 +384,17 @@ describe('tab switching in ConversationRoot', () => {
|
||||
expect(b.loadOlder).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('labels the trajectory tab in the active locale', async () => {
|
||||
const b = await bench()
|
||||
const labelOf = () => tabsOf(b.slots).find(tab => tab.id === 'trajectory')?.label
|
||||
expect(labelOf()).toBe('Trajectory')
|
||||
const locale = b.ctx.get('locale') as { setLocale(id: string): void }
|
||||
locale.setLocale('zh')
|
||||
expect(labelOf()).toBe('轨迹')
|
||||
locale.setLocale('en')
|
||||
expect(labelOf()).toBe('Trajectory')
|
||||
})
|
||||
|
||||
it('opens a local record inspector and switches payload tabs without opening chat details', async () => {
|
||||
const b = await bench()
|
||||
mount(b.slots)
|
||||
@@ -553,7 +583,7 @@ describe('tab switching in ConversationRoot', () => {
|
||||
const b = await bench(historySnapshot([]))
|
||||
mount(b.slots)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
|
||||
expect(screen.getByRole('toolbar', { name: '轨迹工具栏' })).toBeTruthy()
|
||||
expect(screen.getByText('No timing data')).toBeTruthy()
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', {
|
||||
name: 'Collapse turns',
|
||||
@@ -1100,13 +1130,62 @@ describe('timeline projection', () => {
|
||||
...standaloneProps([]),
|
||||
...standaloneHistory(historySnapshot([])),
|
||||
...standaloneDuration(),
|
||||
...standaloneExport(),
|
||||
},
|
||||
))
|
||||
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
|
||||
expect(screen.getByRole('toolbar', { name: '轨迹工具栏' })).toBeTruthy()
|
||||
expect(screen.queryByRole('row')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('session log export', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
Reflect.deleteProperty(URL, 'createObjectURL')
|
||||
Reflect.deleteProperty(HTMLAnchorElement.prototype, 'click')
|
||||
})
|
||||
|
||||
it('downloads the host-streamed ZIP with descendants on click', async () => {
|
||||
// exportLog always fetches a URL instance, so the mock's shape stays narrow.
|
||||
const fetchMock = vi.fn(async (input: URL) => {
|
||||
expect(input.pathname).toBe('/api/session.export')
|
||||
expect(input.searchParams.get('sessionId')).toBe(SID)
|
||||
expect(input.searchParams.get('includeDescendants')).toBe('true')
|
||||
return new Response('zip-bytes')
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const createObjectURL = vi.fn(() => 'blob:export')
|
||||
URL.createObjectURL = createObjectURL
|
||||
const clickAnchor = vi.fn()
|
||||
HTMLAnchorElement.prototype.click = clickAnchor
|
||||
const b = await bench(historySnapshot(NODES))
|
||||
mount(b.slots)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Export session log' }))
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
// The blob download lands a few microtasks after the fetch settles.
|
||||
await vi.waitFor(() => {
|
||||
expect(createObjectURL).toHaveBeenCalled()
|
||||
})
|
||||
expect(clickAnchor).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces the download failure in the visible alert bar', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('boom', { status: 404 })))
|
||||
const b = await bench(historySnapshot(NODES))
|
||||
mount(b.slots)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Export session log' }))
|
||||
await vi.waitFor(() => {
|
||||
const alert = screen.queryByRole('alert')
|
||||
expect(alert).not.toBeNull()
|
||||
expect(alert!.textContent).toContain('HTTP 404')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('TrajectoryView state', () => {
|
||||
it('persists the duration preference through the runtime snapshot-store seam', () => {
|
||||
const firstDuration = createTrajectoryDurationStore()
|
||||
@@ -1117,6 +1196,7 @@ describe('TrajectoryView state', () => {
|
||||
const first = render(
|
||||
<TrajectoryView
|
||||
{...commonProps}
|
||||
{...standaloneExport()}
|
||||
useDuration={bindSnapshotSelector(firstDuration)}
|
||||
setActualDuration={(value) => { firstDuration.set(value) }}
|
||||
/>,
|
||||
@@ -1132,6 +1212,7 @@ describe('TrajectoryView state', () => {
|
||||
render(
|
||||
<TrajectoryView
|
||||
{...commonProps}
|
||||
{...standaloneExport()}
|
||||
useDuration={bindSnapshotSelector(restoredDuration)}
|
||||
setActualDuration={(value) => { restoredDuration.set(value) }}
|
||||
/>,
|
||||
@@ -1140,6 +1221,8 @@ describe('TrajectoryView state', () => {
|
||||
.toBe('true')
|
||||
})
|
||||
|
||||
|
||||
|
||||
it('keeps ledger and timeline selection on the same event after prepend', () => {
|
||||
const older = {
|
||||
kind: 'user', seq: 1, time: 1_000,
|
||||
@@ -1154,6 +1237,7 @@ describe('TrajectoryView state', () => {
|
||||
<TrajectoryView
|
||||
{...standaloneProps([])}
|
||||
{...standaloneDuration()}
|
||||
{...standaloneExport()}
|
||||
useSession={bindSnapshotSelector(store)}
|
||||
loadOlder={vi.fn(() => Promise.resolve(false))}
|
||||
/>,
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../ui-conversation"
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ export { bindSnapshotSelector } from './bind.ts'
|
||||
/**
|
||||
* Selector hook over a session's conversation snapshot. Wide (`object`) by
|
||||
* default inside this dependency-inverted package; runtime narrows it once at
|
||||
* its export surface (`UseSession<ConversationSnapshot>`) — the snapshot type
|
||||
* its exports (`UseSession<ConversationSnapshot>`) — the snapshot type
|
||||
* never flows back into web-react.
|
||||
*/
|
||||
export type UseSession<Snap extends object = object> = SnapshotSelectorHook<Snap>
|
||||
|
||||
@@ -20,7 +20,7 @@ export const HostContext = createContext<SlotRendererHost | null>(null)
|
||||
/**
|
||||
* Read the installed renderer host; throws outside the rendered root tree
|
||||
* (framework components must not render detached from the renderer).
|
||||
* @returns the host surface.
|
||||
* @returns the host API.
|
||||
*/
|
||||
export function useHost(): SlotRendererHost {
|
||||
const host = useContext(HostContext)
|
||||
@@ -132,7 +132,7 @@ export function SessionMaybeProvider({ children }: { children: ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** SessionProvider surface: render-prop body plus the no-session branch. */
|
||||
/** SessionProvider API: render-prop body plus the no-session branch. */
|
||||
export interface SessionProviderProps {
|
||||
/** No-session body (also covers a current id whose session cannot be resolved). */
|
||||
empty?: (() => ReactNode) | undefined
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Integration against the real ui-slots SlotCore through a passthrough host:
|
||||
* registrations go through the real register() (options form, children
|
||||
* declaration), and the outlets ride the real subscribe/getVersion/entries/
|
||||
* isLive surfaces — microtask-batched notifications, mutation-stable entry
|
||||
* isLive APIs — microtask-batched notifications, mutation-stable entry
|
||||
* references (the cache axis), and ledger-fed stale bindings are the
|
||||
* real-core semantics the fake-host suite cannot vouch for.
|
||||
*/
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/web/README.md
|
||||
README.md: 48355a046910bd5b78249af7b7781dab6a7c3a60
|
||||
README.md: 4b44e0e2c2e192363ce32ac2decc218f5ea17bd2
|
||||
README.zh.md: f0b567569849c698e8a1a29323e7088b49996522
|
||||
|
||||
@@ -6,7 +6,7 @@ Web shell kernel: `new AppWebEntry(el, seams?).run()` mounts the whole client th
|
||||
|
||||
Shell self-sufficiency (web2 hard rule): the kernel value-imports no plugin package — the boot status store and signals are hand-rolled here (`loader-status.ts`), so the loading page works while (and especially when) plugins fail. The app-shell assembly (`@deepseek-ai/dsh-client-app-shell`, a shell-owned pseudo entry with no npm package behind it) is the only module registered through `registerStatic`; it inject-waits on slots/sessions/layout like any plugin.
|
||||
|
||||
`PLATFORM_MODULES` (src/platform.ts) is the single source of truth for the shared module surface: seed-table keys, tsdown client externals, and the vite alias set are its projections.
|
||||
`PLATFORM_MODULES` (src/platform.ts) is the single source of truth for shared modules: seed-table keys, tsdown client externals, and the Vite alias set are its projections.
|
||||
|
||||
The optional override parameter `seams` forwards the module system's `loadBundle` transport override (`BootSeams`) for environments where external `<script>` execution cannot reach the page context; ordinary browser callers omit it.
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import type { PlatformModule } from './platform.ts'
|
||||
|
||||
/**
|
||||
* Build the static table handed to the module loader at boot.
|
||||
* @returns module specifier → export-surface entity (one entry per platform word).
|
||||
* @returns module specifier → exported entity (one entry per platform word).
|
||||
*/
|
||||
export function getStaticModules(): Record<string, unknown> {
|
||||
// The satisfies pin is the projection contract: a word added to
|
||||
|
||||
Reference in New Issue
Block a user