summaryrefslogtreecommitdiffhomepage
path: root/packages/ui-contract/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-06 18:55:53 +0900
committerAdam Malczewski <[email protected]>2026-06-06 18:55:53 +0900
commit22936857685c318b71752d625808100b1a96e63e (patch)
tree5e10a73d616c206e3820a8d8568e5f3d4c8a302e /packages/ui-contract/src
parent969afc45f895230fe3da1c737f18e64452efc8f2 (diff)
downloaddispatch-22936857685c318b71752d625808100b1a96e63e.tar.gz
dispatch-22936857685c318b71752d625808100b1a96e63e.zip
feat(frontend,wire): surface system (FE slice 1) + @dispatch/wire types-only split (B2)
FE slice 1 — backend-declared, frontend-agnostic surface system (verified live): new types-only @dispatch/ui-contract (SurfaceSpec / field kinds / region / ActionRef / catalog), surface-registry (typed service handle), transport-ws (Bun WS :24205, path-agnostic upgrade), surface-loaded-extensions (first real surface); kernel HostAPI.getExtensions; host-bin wiring; bin/up. Harness: retire AGENTS 'backend only', ORCHESTRATOR §3/§7/§8, frontend-design.md locked. B2 — wire-types split (chat-slice prerequisite): new types-only @dispatch/wire single-sources the wire ABI (AgentEvent + 11 variants; conversation model Chunk/ChatMessage/Role/TurnId/StepId + 6 chunk variants; Usage) with zero @dispatch/* deps. @dispatch/kernel re-exports via shims so its public surface is byte-identical (zero consumer blast radius). transport-contract re-exports AgentEvent from @dispatch/wire and drops its @dispatch/kernel dependency, so HTTP clients (the web frontend) consume the wire without the kernel runtime. tsc -b + biome clean; 460 vitest + 77 bun pass.
Diffstat (limited to 'packages/ui-contract/src')
-rw-r--r--packages/ui-contract/src/index.ts198
1 files changed, 198 insertions, 0 deletions
diff --git a/packages/ui-contract/src/index.ts b/packages/ui-contract/src/index.ts
new file mode 100644
index 0000000..ea0fc26
--- /dev/null
+++ b/packages/ui-contract/src/index.ts
@@ -0,0 +1,198 @@
+/**
+ * UI contract — the frontend-agnostic vocabulary for backend-declared "surfaces".
+ *
+ * A SURFACE is a "data transportation surface": a typed description of what data an
+ * extension exposes, its semantics, and the actions that can act on it — NOT UI. It
+ * carries STRUCTURE + SEMANTICS + ACTIONS, never styling and never a rendering-
+ * framework token. Any client (web/Svelte, CLI, future TUI/mobile) renders a surface
+ * in its own idiom, so swapping or adding a client is a zero-backend-change event.
+ * See `notes/frontend-design.md` §4.
+ *
+ * This package is types-only (zero runtime) and has ZERO `@dispatch/*` dependencies,
+ * so a separate client repo can depend on JUST this contract.
+ */
+
+/**
+ * Where a surface mounts — a coarse, semantic placement hint, NOT a layout/CSS
+ * instruction. A client maps a region to its own idiom; an unknown region falls back
+ * to the client's default placement. Deliberately left open (a `string`): region
+ * names are not finalized (the old-Dispatch "view" sidebar UX will be revisited).
+ */
+export type Region = string;
+
+/**
+ * A typed reference to a backend action a field can invoke. The client posts it back
+ * (with a payload) to `POST /surfaces/:surfaceId/actions/:actionId`; the surface id
+ * comes from context. (Backend-side this maps to a `command` today — a future review
+ * may unify `command` → `action`; see `notes/restructure-plan.md` §8.)
+ */
+export interface ActionRef {
+ readonly actionId: string;
+}
+
+/** One selectable option in a `selector` field. */
+export interface SurfaceOption {
+ readonly value: string;
+ readonly label: string;
+}
+
+/**
+ * A field within a surface — a SEMANTIC value, not a widget. `kind` is the
+ * discriminant a client switches on to pick a renderer. Names are training-baked
+ * hints; the contract is the data shape.
+ */
+export type SurfaceField =
+ | ToggleField
+ | ProgressField
+ | SelectorField
+ | StatField
+ | ButtonField
+ | CustomField;
+
+/** A boolean setting plus the action that flips it. */
+export interface ToggleField {
+ readonly kind: "toggle";
+ readonly label: string;
+ readonly value: boolean;
+ readonly action: ActionRef;
+}
+
+/** A bounded ratio in [0, 1] with a label (e.g. a cache-hit rate). Read-only. */
+export interface ProgressField {
+ readonly kind: "progress";
+ readonly label: string;
+ readonly value: number;
+}
+
+/** An enum choice: the current value, the options, and the action that sets it. */
+export interface SelectorField {
+ readonly kind: "selector";
+ readonly label: string;
+ readonly value: string;
+ readonly options: readonly SurfaceOption[];
+ readonly action: ActionRef;
+}
+
+/** A read-only labelled scalar readout. */
+export interface StatField {
+ readonly kind: "stat";
+ readonly label: string;
+ readonly value: string;
+}
+
+/** A labelled action trigger. */
+export interface ButtonField {
+ readonly kind: "button";
+ readonly label: string;
+ readonly action: ActionRef;
+}
+
+/**
+ * The escape hatch (isolation guardrail 2): data that fits no semantic field kind.
+ * Carries an opaque `payload` + a `rendererId`; clients WITH a renderer for that id
+ * show it, others GRACEFULLY SKIP. Keep rare — and the owning extension should export
+ * a typed payload type so its bespoke renderer narrows `payload` via a typed symbol
+ * (not a blind `unknown`).
+ */
+export interface CustomField {
+ readonly kind: "custom";
+ readonly rendererId: string;
+ readonly payload: unknown;
+}
+
+/**
+ * A surface: an ordered set of fields mounted in a region, with a title. The atomic
+ * unit a backend extension contributes and a client renders.
+ */
+export interface SurfaceSpec {
+ readonly id: string;
+ readonly region: Region;
+ readonly title: string;
+ readonly fields: readonly SurfaceField[];
+}
+
+/**
+ * A surface-catalog entry — discovery metadata only (no field data). Returned by
+ * `GET /surfaces`; parallels the model catalog. The full spec + live values come from
+ * `GET /surfaces/:id`.
+ */
+export interface SurfaceCatalogEntry {
+ readonly id: string;
+ readonly region: Region;
+ readonly title: string;
+}
+
+/** The surface catalog: the list of available surfaces a client can choose to show. */
+export type SurfaceCatalog = readonly SurfaceCatalogEntry[];
+
+/**
+ * A live update for a subscribed surface (pushed over the WS channel — §5). v1
+ * carries the full new spec (the simplest "patch"); granular field-level patches are
+ * deferred until a real surface needs them (P4).
+ */
+export interface SurfaceUpdate {
+ readonly surfaceId: string;
+ readonly spec: SurfaceSpec;
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Surface WebSocket protocol — the typed message envelopes the surface channel
+// carries. The carrier (a WebSocket) is INJECTED; these are the payloads both the
+// server (transport-ws) and any client serialize/deserialize. Slice 1 is
+// surfaces-only; chat deltas join this channel in a later slice (a separate union).
+// ─────────────────────────────────────────────────────────────────────────────
+
+/** A client → server message on the surface channel. */
+export type SurfaceClientMessage = SubscribeMessage | UnsubscribeMessage | InvokeMessage;
+
+/** Begin receiving live updates for a surface (server replies with `surface`, then `update`s). */
+export interface SubscribeMessage {
+ readonly type: "subscribe";
+ readonly surfaceId: string;
+}
+
+/** Stop receiving updates for a surface. */
+export interface UnsubscribeMessage {
+ readonly type: "unsubscribe";
+ readonly surfaceId: string;
+}
+
+/** Invoke a field's action; `payload` is the new value (e.g. a toggle's boolean). */
+export interface InvokeMessage {
+ readonly type: "invoke";
+ readonly surfaceId: string;
+ readonly actionId: string;
+ readonly payload?: unknown;
+}
+
+/** A server → client message on the surface channel. */
+export type SurfaceServerMessage =
+ | CatalogMessage
+ | SurfaceMessage
+ | SurfaceUpdateMessage
+ | SurfaceErrorMessage;
+
+/** The current surface catalog (sent on connect and whenever it changes). */
+export interface CatalogMessage {
+ readonly type: "catalog";
+ readonly catalog: SurfaceCatalog;
+}
+
+/** The full current spec for a surface the client just subscribed to. */
+export interface SurfaceMessage {
+ readonly type: "surface";
+ readonly spec: SurfaceSpec;
+}
+
+/** A live update for a subscribed surface. */
+export interface SurfaceUpdateMessage {
+ readonly type: "update";
+ readonly update: SurfaceUpdate;
+}
+
+/** A surface-scoped error (e.g. unknown surface id, invoke failed). */
+export interface SurfaceErrorMessage {
+ readonly type: "error";
+ readonly surfaceId?: string;
+ readonly message: string;
+}