@ai-matrx/rich-content 0.2.161 → 0.2.162

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,9 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.162
4
+ ### Unreleased
5
+
6
+ - **THE RENDER RATCHET** (`display/chat-markdown/block-registry/render-ratchet.ts`; Arman, 2026-10-14: "once you go forward, you do not go back"). `BlockRenderer` keeps, per mounted block, the furthest render stage it drew (text → loader → generic → component) and a backward frame mid-stream (a bridge declining a half-arrived item, a mid-string parse, an emptied/retracted frame, a late row) keeps the last good decision on screen instead of swapping the component for its loader and back (each swap was an unmount + remount). It lets go when the block settles (the final value is the truth), when a different block takes the slot, or mid-stream only after `RATCHET_RELEASE_MIN_FRAMES` (120) distinct frames AND `RATCHET_RELEASE_MIN_MS` (8 s). Consumer action: none.
7
+ - **One measured loader answer** (SHAPES-BUGS 2.5): `resolveLoadingSlugForKind` answers `origin: "pending"` while the kind's schema is still being read (host port `schemaState`, optional), never a premature "generic". New `useLoadingSlugForKind(kind)` (`kinds/react/loading/use-loading-slug`) demands the schema and re-reads on arrival — every surface reads it. Consumer action: matrx-frontend's Stream-tab facts and Preview loading path use the hook.
8
+ - **A wrapping XML tag is consumed** (SHAPES-BUGS 2.2): `<output>\n{…kind…}\n</output>` no longer leaves `<output>` / `</output>` as two stray code cards around the component, live or reloaded. Consumer action: none.
9
+
3
10
  ## 0.2.161
11
+ ### Unreleased
4
12
 
5
- UNVERIFIED: this release has no Unreleased entry and the source comparison could not be made (the caller supplied no source comparison). Whether source changed is unknown; consumer action is unknown.
6
- Check the package's gitHead on npm against this version's gitHead: `git diff <previous gitHead> <this gitHead> -- apps/shared/rich-content/src`.
13
+ - **`KindRenderHarness` / `useInsideKindRenderHarness`** (`kinds/react/render-harness`): a harness (kind-registry Preview, Shape Studio) wraps its renders in it, and `GenericStructuredBlock` files no `generic_floor_render` sighting from inside one. Scoped to the subtree — a generic block elsewhere on the same page still files. Consumer action: matrx-frontend wraps `KindRenderPaths` output in `<KindRenderHarness>`.
7
14
 
8
15
  ## 0.2.160
9
16
 
@@ -10,6 +10,7 @@ import { collectKindProblems } from "../../../kinds/react/kind-problems.js";
10
10
  import { readEnvelope } from "../../../kinds/redux/render-block-envelope.js";
11
11
  import { reportKindComponentIncident } from "../../../host/kind-incident.js";
12
12
  import { KindFixItBar } from "../../../host/app-bindings.js";
13
+ import { useInsideKindRenderHarness } from "../../../kinds/react/render-harness.js";
13
14
  import { StructuredValueTabs } from "./StructuredValueTabs.js";
14
15
  const StillArriving = /* @__PURE__ */ jsxs("div", { className: "mb-2 flex items-center gap-1.5 text-xs text-muted-foreground", children: [
15
16
  /* @__PURE__ */ jsx(Braces, { className: "h-3.5 w-3.5 shrink-0 animate-pulse" }),
@@ -18,14 +19,15 @@ const StillArriving = /* @__PURE__ */ jsxs("div", { className: "mb-2 flex items-
18
19
  function useGenericFloorAlarm(metadata) {
19
20
  const kind = readEnvelope(metadata)?.root.kind ?? null;
20
21
  const reason = readIrRouteMarker(metadata)?.reason ?? null;
22
+ const insideHarness = useInsideKindRenderHarness();
21
23
  useEffect(() => {
22
- if (!kind) return;
24
+ if (!kind || insideHarness) return;
23
25
  reportKindComponentIncident({
24
26
  kind,
25
27
  errorType: "generic_floor_render",
26
28
  message: `"${kind}" rendered through the GENERIC structured viewer` + (reason === "inactive" ? " \u2014 a component row exists but is held is_active=false." : " \u2014 no component is bound, so the reader sees a key/value dump.")
27
29
  });
28
- }, [kind, reason]);
30
+ }, [kind, reason, insideHarness]);
29
31
  }
30
32
  const GenericStructuredBlock = (props) => {
31
33
  useGenericFloorAlarm(props.metadata);
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
3
  import { durableRecordId } from "@ai-matrx/kit/ids";
4
- import React, { useCallback } from "react";
4
+ import React, { useCallback, useRef } from "react";
5
5
  import { RichContentHostGate } from "../../../host/RichContentHostGate.js";
6
6
  import { BlockComponents, LoadingComponents } from "./BlockComponentRegistry.js";
7
7
  import { resolveArtifactDef } from "../../../host/app-bindings.js";
@@ -53,6 +53,7 @@ import {
53
53
  KindRecordChrome,
54
54
  kindHasRecordChrome
55
55
  } from "../../../host/app-bindings.js";
56
+ import { advanceRatchet, EMPTY_RATCHET } from "./render-ratchet.js";
56
57
  import {
57
58
  isBlockLoading,
58
59
  resolveBlockDispatch,
@@ -292,11 +293,19 @@ const BlockRendererGated = ({
292
293
  const envelopeKind = readEnvelope(terminalBlock.metadata)?.root.kind ?? announcedKind ?? null;
293
294
  const kindRouteVersion = useContentIrKindVersion(envelopeKind);
294
295
  useEnsureKindRenderable(envelopeKind);
295
- const decision = decideBlockRender(inputBlock, {
296
+ const frameDecision = decideBlockRender(inputBlock, {
296
297
  isStreamActive,
297
298
  suppressLoadingGate,
298
299
  route: (b) => routeBlockAtRegistryVersion(b, kindRouteVersion)
299
300
  });
301
+ const ratchetRef = useRef(EMPTY_RATCHET);
302
+ const settledBlock = !isStreamActive || inputBlock.isStreamingBlock === false;
303
+ const ratcheted = suppressLoadingGate ? { state: ratchetRef.current, decision: frameDecision } : advanceRatchet(ratchetRef.current, frameDecision, {
304
+ settled: settledBlock,
305
+ now: Date.now()
306
+ });
307
+ ratchetRef.current = ratcheted.state;
308
+ const decision = ratcheted.decision;
300
309
  const block = decision.block;
301
310
  const interruptedEnvelope = readEnvelope(block.metadata);
302
311
  const hasInterruptedKind = Boolean(
@@ -0,0 +1,76 @@
1
+ /**
2
+ * THE RENDER RATCHET — a streaming block's render only moves FORWARD.
3
+ *
4
+ * Arman, 2026-10-14: "Going forwards is ok, but once you go forward, you do not go back
5
+ * unless we're ABSOLUTELY CONFIDENT something has gone wrong and we have multiple pieces of
6
+ * evidence." The stages, in order:
7
+ *
8
+ * 0 TEXT — prose, or a raw code/JSON card
9
+ * 1 LOADER — a JSON region that could be (or is) a kind, still arriving
10
+ * 2 GENERIC — the structured floor drew it
11
+ * 3 COMPONENT — the kind's own component (routed, provisional, artifact, dispatch)
12
+ *
13
+ * `decideBlockRender` is a pure function of ONE frame, and a single frame of a token stream
14
+ * is often not valid: a bridge declines a half-arrived item, a nested value is mid-string, a
15
+ * component row is a beat late, a parser has not seen the closing brace. Each of those used
16
+ * to drop the block a stage (component → loader → component), and because each stage is a
17
+ * different element, React unmounted the component and mounted it again — the "re-render"
18
+ * the reader sees. The ratchet sits on top of that decision at the ONE place every block is
19
+ * drawn (`BlockRenderer`): a frame that would move the block BACKWARD keeps the last good
20
+ * decision on screen (its block, its props) and waits for the next frame.
21
+ *
22
+ * It lets go only on strong evidence:
23
+ * - the block SETTLED (its own `isStreamingBlock === false`, or the message stream ended):
24
+ * the final decision is the truth, whatever stage it is;
25
+ * - the content stopped CONTINUING the held block (the slot now holds a different block):
26
+ * the ratchet starts over for the new one;
27
+ * - mid-stream, the frames have disagreed for at least RATCHET_RELEASE_MIN_FRAMES distinct
28
+ * content updates AND RATCHET_RELEASE_MIN_MS of wall time, both — a region that really
29
+ * turned into something else keeps saying so, a transient hiccup never lasts that long.
30
+ *
31
+ * Pure: `advanceRatchet` takes the previous state and returns the next; the hook that keeps
32
+ * the state per mounted block lives in BlockRenderer.
33
+ */
34
+ import type { BlockRenderDecision } from "./BlockRenderer.js";
35
+ /**
36
+ * Distinct content updates a backward verdict must survive before it is believed mid-stream.
37
+ * A token stream sends roughly one update per animation frame; transient invalid states
38
+ * (mid-string, mid-key, a declined half item) last a handful. 120 is ~2 s of steady tokens.
39
+ */
40
+ export declare const RATCHET_RELEASE_MIN_FRAMES = 120;
41
+ /**
42
+ * Wall time a backward verdict must persist mid-stream, AND-ed with the frame count, so a
43
+ * burst of fast chunks cannot meet the bar in a blink and a stalled stream cannot meet it on
44
+ * time alone. Generous on purpose: the stream's end is the normal release.
45
+ */
46
+ export declare const RATCHET_RELEASE_MIN_MS = 8000;
47
+ export type RenderStage = 0 | 1 | 2 | 3;
48
+ export declare const RENDER_STAGE_NAMES: readonly ["text", "loader", "generic", "component"];
49
+ /** Where a single frame's decision sits on the forward path. */
50
+ export declare function renderStageOf(decision: BlockRenderDecision): RenderStage;
51
+ export interface RatchetState {
52
+ /** The decision on screen. */
53
+ held: BlockRenderDecision | null;
54
+ heldStage: RenderStage;
55
+ /** The held block's content — the continuation check reads it. */
56
+ heldContent: string;
57
+ /** The last content seen (frames are counted on content CHANGE, so a re-render is not a frame). */
58
+ lastContent: string;
59
+ /** Consecutive distinct frames that wanted to go backward, and since when. */
60
+ disagreeFrames: number;
61
+ disagreeSince: number | null;
62
+ }
63
+ export declare const EMPTY_RATCHET: RatchetState;
64
+ export interface RatchetInput {
65
+ /** The block settled: its own stream closed, or the message stream ended. */
66
+ settled: boolean;
67
+ /** Wall clock (ms). */
68
+ now: number;
69
+ }
70
+ /**
71
+ * One frame through the ratchet: the state to keep and the decision to draw.
72
+ */
73
+ export declare function advanceRatchet(state: RatchetState, next: BlockRenderDecision, { settled, now }: RatchetInput): {
74
+ state: RatchetState;
75
+ decision: BlockRenderDecision;
76
+ };
@@ -0,0 +1,73 @@
1
+ import { GENERIC_STRUCTURED_COMPONENT_KEY } from "../../../kinds/react/kind-route.js";
2
+ const RATCHET_RELEASE_MIN_FRAMES = 120;
3
+ const RATCHET_RELEASE_MIN_MS = 8e3;
4
+ const CONTINUATION_PREFIX = 48;
5
+ const RENDER_STAGE_NAMES = ["text", "loader", "generic", "component"];
6
+ const TEXT_TYPES = /* @__PURE__ */ new Set(["text", "code"]);
7
+ function renderStageOf(decision) {
8
+ if (decision.gate?.kind === "provisional") return 3;
9
+ if (decision.gate?.kind === "loader") return 1;
10
+ const type = String(decision.block.type);
11
+ if (TEXT_TYPES.has(type)) return 0;
12
+ if (type === GENERIC_STRUCTURED_COMPONENT_KEY) return 2;
13
+ return 3;
14
+ }
15
+ const EMPTY_RATCHET = {
16
+ held: null,
17
+ heldStage: 0,
18
+ heldContent: "",
19
+ lastContent: "",
20
+ disagreeFrames: 0,
21
+ disagreeSince: null
22
+ };
23
+ function contentOf(decision) {
24
+ const content = decision.rawBlock.content;
25
+ return typeof content === "string" ? content : "";
26
+ }
27
+ function continues(heldContent, next) {
28
+ const prefix = heldContent.slice(0, CONTINUATION_PREFIX).trimEnd();
29
+ if (!prefix) return true;
30
+ return next.startsWith(prefix) || prefix.startsWith(next.slice(0, CONTINUATION_PREFIX).trimEnd());
31
+ }
32
+ function accept(next, stage, content) {
33
+ return {
34
+ held: next,
35
+ heldStage: stage,
36
+ heldContent: content,
37
+ lastContent: content,
38
+ disagreeFrames: 0,
39
+ disagreeSince: null
40
+ };
41
+ }
42
+ function advanceRatchet(state, next, { settled, now }) {
43
+ const stage = renderStageOf(next);
44
+ const content = contentOf(next);
45
+ const take = () => {
46
+ const accepted = accept(next, stage, content);
47
+ return { state: accepted, decision: next };
48
+ };
49
+ if (!state.held || settled) return take();
50
+ if (!content.trim() && state.heldContent.trim()) {
51
+ return { state: { ...state, lastContent: content }, decision: state.held };
52
+ }
53
+ if (!continues(state.heldContent, content)) return take();
54
+ if (stage >= state.heldStage) return take();
55
+ const isNewFrame = content !== state.lastContent;
56
+ const disagreeFrames = state.disagreeFrames + (isNewFrame ? 1 : 0);
57
+ const disagreeSince = state.disagreeSince ?? now;
58
+ if (disagreeFrames >= RATCHET_RELEASE_MIN_FRAMES && now - disagreeSince >= RATCHET_RELEASE_MIN_MS) {
59
+ return take();
60
+ }
61
+ return {
62
+ state: { ...state, lastContent: content, disagreeFrames, disagreeSince },
63
+ decision: state.held
64
+ };
65
+ }
66
+ export {
67
+ EMPTY_RATCHET,
68
+ RATCHET_RELEASE_MIN_FRAMES,
69
+ RATCHET_RELEASE_MIN_MS,
70
+ RENDER_STAGE_NAMES,
71
+ advanceRatchet,
72
+ renderStageOf
73
+ };
@@ -96,6 +96,10 @@ function withFenceSurface(metadata, language, content, envelopes) {
96
96
  const envelope = envelopes.fenceRegion(language, content);
97
97
  return envelope ? { ...metadata ?? {}, [IR_ENVELOPE_KEY]: envelope } : metadata;
98
98
  }
99
+ const XML_TAG = /<\/?[A-Za-z][\w:.-]*(?:\s[^<>]*)?\/?>/g;
100
+ function isBareXmlTagPiece(content) {
101
+ return /</.test(content) && !content.replace(XML_TAG, "").trim();
102
+ }
99
103
  const wrapperSources = /* @__PURE__ */ new WeakMap();
100
104
  function wrapperSourceOf(block) {
101
105
  return wrapperSources.get(block);
@@ -166,6 +170,7 @@ function recoverEmbeddedKindJsonBlocksWith(input, envelopes) {
166
170
  );
167
171
  followsKind = false;
168
172
  if (!content) continue;
173
+ if (genericXml && isBareXmlTagPiece(content)) continue;
169
174
  recovered.push({
170
175
  ...block,
171
176
  content,
@@ -11,6 +11,12 @@ export interface RichContentKindRegistry {
11
11
  getSchema(kind: string): KindSchema | undefined;
12
12
  getEmittedJsonSchema(kind: string): unknown;
13
13
  getDeclaredLoadingComponent(kind: string): string | null;
14
+ /**
15
+ * Has this kind's schema been read yet? "pending" until the read settles. Optional: a host
16
+ * without it is treated as always settled. The loading-slug answer reads it so a cold
17
+ * registry says "checking" instead of a wrong "generic" (one measured answer, 2026-10-14).
18
+ */
19
+ schemaState?(kind: string): "loaded" | "pending" | "absent" | "failed";
14
20
  isKnownKind(kind: string): boolean;
15
21
  isRenderableKind(kind: string): boolean;
16
22
  listDefinitions(): KindDefinition[];
@@ -1,6 +1,6 @@
1
1
  import { type KindLoadingSlug } from "./kind-loading-slugs.js";
2
2
  /** Where the answer came from — surfaced so the Stream tab / doctor can say. */
3
- export type LoadingSlugOrigin = "declared" | "derived" | "generic";
3
+ export type LoadingSlugOrigin = "declared" | "derived" | "generic" | "pending";
4
4
  export interface LoadingSlugResolution {
5
5
  /** The library slug, or null when nothing beat the generic skeleton. */
6
6
  slug: KindLoadingSlug | null;
@@ -43,6 +43,9 @@ function resolveLoadingSlugForKind(kind) {
43
43
  if (derived) {
44
44
  return invalidDeclared === void 0 ? { slug: derived, origin: "derived" } : { slug: derived, origin: "derived", invalidDeclared };
45
45
  }
46
+ if (invalidDeclared === void 0 && kindRegistry.schemaState?.(kind) === "pending") {
47
+ return { slug: null, origin: "pending" };
48
+ }
46
49
  return invalidDeclared === void 0 ? GENERIC : { slug: null, origin: "generic", invalidDeclared };
47
50
  }
48
51
  export {
@@ -0,0 +1,4 @@
1
+ import { type LoadingSlugResolution } from "./resolve-loading-slug.js";
2
+ export declare function useLoadingSlugForKind(kind: string | null | undefined): LoadingSlugResolution;
3
+ /** The version is an ARGUMENT so no compiler pass can memoise the answer past an arrival. */
4
+ export declare function readLoadingSlugAtVersion(kind: string | null | undefined, version: number): LoadingSlugResolution;
@@ -0,0 +1,17 @@
1
+ "use client";
2
+ import { useContentIrKindVersion } from "../use-registry-repaint.js";
3
+ import { useEnsureKindRenderable } from "../ensure-kind-renderable.js";
4
+ import { resolveLoadingSlugForKind } from "./resolve-loading-slug.js";
5
+ function useLoadingSlugForKind(kind) {
6
+ const version = useContentIrKindVersion(kind ?? null);
7
+ useEnsureKindRenderable(kind ?? null);
8
+ return readLoadingSlugAtVersion(kind, version);
9
+ }
10
+ function readLoadingSlugAtVersion(kind, version) {
11
+ void version;
12
+ return resolveLoadingSlugForKind(kind);
13
+ }
14
+ export {
15
+ readLoadingSlugAtVersion,
16
+ useLoadingSlugForKind
17
+ };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * RENDER HARNESSES ARE NOT READERS (defect 2026-10-14).
3
+ *
4
+ * A harness — the kind-registry Preview, the Shape Studio — renders every path
5
+ * on purpose: streaming frames, partial payloads, a component landing after
6
+ * first paint. A block that reaches the generic floor INSIDE one is the harness
7
+ * doing its job, not a reader being let down, so it must not file a
8
+ * "a reader got the generic view" sighting. Scoped to the harness's own
9
+ * subtree: a block rendered anywhere else on the same page (a chat dock, a
10
+ * second surface) still files.
11
+ */
12
+ import React from "react";
13
+ export declare function KindRenderHarness({ children }: {
14
+ children: React.ReactNode;
15
+ }): React.JSX.Element;
16
+ /** True when this component renders inside a {@link KindRenderHarness}. */
17
+ export declare function useInsideKindRenderHarness(): boolean;
@@ -0,0 +1,14 @@
1
+ "use client";
2
+ import { jsx } from "react/jsx-runtime";
3
+ import { createContext, useContext } from "react";
4
+ const KindRenderHarnessContext = createContext(false);
5
+ function KindRenderHarness({ children }) {
6
+ return /* @__PURE__ */ jsx(KindRenderHarnessContext.Provider, { value: true, children });
7
+ }
8
+ function useInsideKindRenderHarness() {
9
+ return useContext(KindRenderHarnessContext);
10
+ }
11
+ export {
12
+ KindRenderHarness,
13
+ useInsideKindRenderHarness
14
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-matrx/rich-content",
3
- "version": "0.2.161",
3
+ "version": "0.2.162",
4
4
  "description": "The AI Matrx formatted-text engine: the one markdown renderer, streaming markdown, the block splitter and renderer, the generic blocks, <RichContent> levels, the rich-document action registry and menus, copy, and the selection toolbar — as portable React.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -130,7 +130,6 @@
130
130
  "unist-util-visit": "^5.1.0",
131
131
  "unist-util-visit-parents": "^6.0.2",
132
132
  "yaml": "^2.9.1",
133
- "@ai-matrx/agents": "latest",
134
133
  "@ai-matrx/alchemy": "latest",
135
134
  "@ai-matrx/api": "latest",
136
135
  "@ai-matrx/canvas": "latest",
@@ -139,11 +138,12 @@
139
138
  "@ai-matrx/data": "latest",
140
139
  "@ai-matrx/design-system": "latest",
141
140
  "@ai-matrx/icons": "latest",
142
- "@ai-matrx/kit": "latest",
141
+ "@ai-matrx/agents": "latest",
143
142
  "@ai-matrx/media": "latest",
144
143
  "@ai-matrx/print": "latest",
145
144
  "@ai-matrx/records": "latest",
146
145
  "@ai-matrx/records-ui": "latest",
146
+ "@ai-matrx/kit": "latest",
147
147
  "@ai-matrx/diff": "latest"
148
148
  },
149
149
  "peerDependencies": {
@@ -205,5 +205,5 @@
205
205
  "verify:modules-tarball": "node ./scripts/verify-modules-tarball.mjs",
206
206
  "verify:tarball": "node ./scripts/verify-tarball.mjs"
207
207
  },
208
- "gitHead": "8b9cb7a612f386ad8858deac531fe5db8778490d"
208
+ "gitHead": "3260b81ccf1721d1a6a0ba08b238fdfa7d609ba5"
209
209
  }