@elabs-ai/components-ai 4.0.0 → 4.2.0
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/README.md +62 -12
- package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
- package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
- package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
- package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
- package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
- package/dist/_persona-rive-JEG44YHX.js.map +1 -0
- package/dist/index.d.ts +1152 -218
- package/dist/index.js +5601 -3241
- package/dist/index.js.map +1 -1
- package/package.json +30 -15
- package/src/__contract__/audio-visualizer.contract.test.tsx +49 -0
- package/src/__contract__/chat-shell.contract.test.tsx +49 -0
- package/src/__contract__/grouped-parts.contract.test.tsx +49 -0
- package/src/__contract__/image.contract.test.tsx +49 -0
- package/src/__contract__/markdown-view.contract.test.tsx +49 -0
- package/src/__contract__/message-feedback.contract.test.tsx +49 -0
- package/src/__contract__/message-form.contract.test.tsx +49 -0
- package/src/__contract__/message-table.contract.test.tsx +49 -0
- package/src/__contract__/model-provider-logo.contract.test.tsx +49 -0
- package/src/__contract__/persona.contract.test.tsx +49 -0
- package/src/__contract__/prompt-input-effort.contract.test.tsx +49 -0
- package/src/__contract__/prompt-input-mode.contract.test.tsx +49 -0
- package/src/_audio-player-media-chrome.tsx +102 -15
- package/src/_chat-shell-rail.tsx +2 -2
- package/src/_flow-boundary.tsx +68 -49
- package/src/_lazy-boundary-conformance.ts +38 -0
- package/src/_lazy-cjk.test.ts +43 -0
- package/src/_lazy-cjk.ts +73 -0
- package/src/_lazy-engine-boundary.tsx +61 -0
- package/src/_lazy-math.test.ts +63 -0
- package/src/_lazy-math.ts +90 -0
- package/src/_lazy-mermaid-absent.test.ts +53 -0
- package/src/_lazy-mermaid.test.ts +15 -0
- package/src/_lazy-mermaid.ts +24 -1
- package/src/_mermaid-error-panel.test.tsx +50 -0
- package/src/_mermaid-error-panel.tsx +66 -0
- package/src/_persona-rive.tsx +62 -10
- package/src/_streamdown-i18n.ts +94 -22
- package/src/_streamdown-safety.ts +170 -0
- package/src/_theme-scope-store.test.ts +83 -0
- package/src/_theme-scope-store.ts +103 -0
- package/src/agent-event.stories.tsx +97 -0
- package/src/agent-event.test.tsx +145 -0
- package/src/agent-event.tsx +187 -0
- package/src/agent-timeline.stories.tsx +9 -1
- package/src/agent.test.tsx +47 -0
- package/src/agent.tsx +43 -24
- package/src/agentic-workspace.stories.tsx +27 -20
- package/src/artifact.tsx +17 -14
- package/src/asset-preview.test.tsx +40 -0
- package/src/asset-preview.tsx +83 -12
- package/src/attachments.tsx +7 -4
- package/src/audio-player.test.tsx +78 -0
- package/src/audio-player.tsx +243 -56
- package/src/audio-visualizer.stories.tsx +126 -0
- package/src/audio-visualizer.test.tsx +438 -0
- package/src/audio-visualizer.tsx +367 -0
- package/src/canvas.stories.tsx +150 -1
- package/src/chat-shell.stories.tsx +18 -3
- package/src/chat.stories.tsx +16 -2
- package/src/code-block.stories.tsx +9 -1
- package/src/code-block.test.tsx +100 -1
- package/src/code-block.tsx +166 -103
- package/src/commit.tsx +30 -41
- package/src/composer.stories.tsx +372 -9
- package/src/composer.test.tsx +357 -6
- package/src/composer.tsx +217 -35
- package/src/confirmation.stories.tsx +72 -1
- package/src/confirmation.test.tsx +216 -2
- package/src/confirmation.tsx +264 -4
- package/src/context-panel.stories.tsx +9 -1
- package/src/context-panel.tsx +3 -2
- package/src/conversation.stories.tsx +86 -2
- package/src/conversation.test.tsx +66 -0
- package/src/conversation.tsx +52 -9
- package/src/diff-view.stories.tsx +196 -0
- package/src/diff-view.test.tsx +238 -0
- package/src/diff-view.tsx +697 -0
- package/src/environment-variables.tsx +20 -37
- package/src/file-tree.test.tsx +21 -0
- package/src/file-tree.tsx +12 -2
- package/src/gallery.tsx +4 -2
- package/src/index.ts +14 -4
- package/src/inline-citation.tsx +5 -5
- package/src/jsx-preview.stories.tsx +2 -2
- package/src/jsx-preview.tsx +151 -43
- package/src/markdown-view.stories.tsx +92 -1
- package/src/markdown-view.test.tsx +237 -2
- package/src/markdown-view.tsx +157 -6
- package/src/message-compare.stories.tsx +175 -0
- package/src/message-compare.test.tsx +207 -0
- package/src/message-compare.tsx +453 -0
- package/src/message-form.stories.tsx +65 -4
- package/src/message-form.test.tsx +8 -2
- package/src/message-form.tsx +15 -7
- package/src/message-table.stories.tsx +2 -2
- package/src/message-table.test.tsx +7 -0
- package/src/message-table.tsx +8 -4
- package/src/message.stories.tsx +9 -1
- package/src/message.test.tsx +247 -1
- package/src/message.tsx +125 -15
- package/src/microcopy.test.tsx +40 -0
- package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
- package/src/model-provider-logo.test.tsx +109 -0
- package/src/model-provider-logo.tsx +183 -0
- package/src/open-in-chat.tsx +50 -29
- package/src/package-info.tsx +12 -12
- package/src/permission-mode-select.stories.tsx +82 -0
- package/src/permission-mode-select.test.tsx +100 -0
- package/src/permission-mode-select.tsx +137 -0
- package/src/persona-missing-peer.test.tsx +54 -0
- package/src/persona.tsx +68 -22
- package/src/plan.stories.tsx +166 -0
- package/src/plan.test.tsx +267 -0
- package/src/plan.tsx +182 -20
- package/src/prompt-input-effort.stories.tsx +123 -0
- package/src/prompt-input-effort.test.tsx +83 -0
- package/src/prompt-input-effort.tsx +136 -0
- package/src/prompt-input-mode.stories.tsx +108 -0
- package/src/prompt-input-mode.test.tsx +99 -0
- package/src/prompt-input-mode.tsx +169 -0
- package/src/prompt-input-slash.stories.tsx +211 -0
- package/src/prompt-input-slash.test.tsx +262 -0
- package/src/prompt-input-slash.tsx +541 -0
- package/src/prompt-input.stories.tsx +2 -2
- package/src/prompt-input.test.tsx +67 -1
- package/src/prompt-input.tsx +42 -4
- package/src/queue.tsx +4 -4
- package/src/reasoning.tsx +42 -17
- package/src/sandbox.stories.tsx +9 -1
- package/src/sandbox.tsx +3 -3
- package/src/schema-display.test.tsx +56 -0
- package/src/schema-display.tsx +71 -37
- package/src/selection-toolbar.stories.tsx +9 -1
- package/src/session-header.stories.tsx +128 -0
- package/src/session-header.test.tsx +138 -0
- package/src/session-header.tsx +243 -0
- package/src/session-status-bar.stories.tsx +73 -0
- package/src/session-status-bar.test.tsx +94 -0
- package/src/session-status-bar.tsx +165 -0
- package/src/snippet.stories.tsx +9 -1
- package/src/snippet.test.tsx +6 -2
- package/src/snippet.tsx +14 -34
- package/src/speech-input.test.tsx +109 -0
- package/src/speech-input.tsx +31 -3
- package/src/stack-trace.tsx +24 -36
- package/src/streamdown-i18n.test.tsx +24 -1
- package/src/task.stories.tsx +13 -3
- package/src/templates-ai-assistant.stories.tsx +21 -1
- package/src/test-results.tsx +47 -27
- package/src/token-usage.stories.tsx +24 -0
- package/src/token-usage.test.tsx +92 -0
- package/src/{context.tsx → token-usage.tsx} +73 -59
- package/src/tool-result-card.stories.tsx +9 -1
- package/src/tool.stories.tsx +13 -3
- package/src/tool.test.tsx +65 -0
- package/src/tool.tsx +73 -30
- package/src/transcription.tsx +1 -1
- package/src/turn-status.stories.tsx +124 -0
- package/src/turn-status.test.tsx +74 -0
- package/src/turn-status.tsx +174 -0
- package/src/use-audio-level.ts +104 -0
- package/src/voice-selector.tsx +5 -5
- package/src/web-preview.test.tsx +51 -1
- package/src/web-preview.tsx +46 -10
- package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
- package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
- package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
- package/src/_interactive-terminal-xterm.ts +0 -32
- package/src/blocks-ai-composer.stories.tsx +0 -83
- package/src/context.stories.tsx +0 -16
- package/src/interactive-terminal.stories.tsx +0 -165
- package/src/interactive-terminal.test.tsx +0 -448
- package/src/interactive-terminal.tsx +0 -444
- package/src/model-selector.test.tsx +0 -55
- package/src/model-selector.tsx +0 -225
- package/src/terminal.tsx +0 -244
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// GENERATED by scripts/gen-contract-tests.mjs — do not edit; re-run the generator instead.
|
|
2
|
+
/**
|
|
3
|
+
* Contract probe for PromptInputMode (`packages/ai/src/prompt-input-mode.tsx`), derived from its
|
|
4
|
+
* `Default` story (packages/ai/src/prompt-input-mode.stories.tsx). See scripts/gen-contract-tests.mjs.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
7
|
+
import { render, cleanup } from "@testing-library/react";
|
|
8
|
+
import { createRef } from "react";
|
|
9
|
+
import * as stories from "../prompt-input-mode.stories";
|
|
10
|
+
import knownFailuresJson from "../../../../scripts/check/contract-known-failures.json";
|
|
11
|
+
|
|
12
|
+
afterEach(cleanup);
|
|
13
|
+
|
|
14
|
+
// Every component here has its own prop/ref/element shape; a generated probe
|
|
15
|
+
// stays generic on purpose (loosely typed, not untyped — see
|
|
16
|
+
// scripts/gen-contract-tests.mjs) rather than re-deriving each one.
|
|
17
|
+
const KNOWN_FAILURES: Record<string, string> = knownFailuresJson;
|
|
18
|
+
const meta = stories.default as { component?: unknown; args?: Record<string, unknown> };
|
|
19
|
+
const Default = (stories as { Default?: { args?: Record<string, unknown> } }).Default;
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- probe target; see comment above
|
|
21
|
+
const Component = meta.component as any;
|
|
22
|
+
const args = { ...(meta.args ?? {}), ...(Default?.args ?? {}) };
|
|
23
|
+
const isForwardRefComponent = Component?.["$$typeof"] === Symbol.for("react.forward_ref");
|
|
24
|
+
|
|
25
|
+
/** Wrap a known, tracked failure in `it.fails` so fixing it forces the key's removal. */
|
|
26
|
+
function contractIt(assertion: string, name: string, fn: () => void) {
|
|
27
|
+
const key = `ai-composer-promptinputmode--default|jsdom|jsdom|${assertion}`;
|
|
28
|
+
const reason = KNOWN_FAILURES[key];
|
|
29
|
+
if (reason) return it.fails(`${name} (known failure: ${reason})`, fn);
|
|
30
|
+
return it(name, fn);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("PromptInputMode contract", () => {
|
|
34
|
+
it.skipIf(!isForwardRefComponent)("forwards a ref to a DOM element", () => {
|
|
35
|
+
const ref = createRef<Element>();
|
|
36
|
+
render(<Component {...args} ref={ref} />);
|
|
37
|
+
expect(ref.current).toBeInstanceOf(Element);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
contractIt("className", "merges a caller className onto the root", () => {
|
|
41
|
+
const { container } = render(<Component {...args} className="contract-probe" />);
|
|
42
|
+
expect(container.querySelector(".contract-probe")).not.toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
contractIt("data-slot", 'exposes data-slot="prompt-input-mode" on its root', () => {
|
|
46
|
+
const { container } = render(<Component {...args} />);
|
|
47
|
+
expect(container.querySelector('[data-slot="prompt-input-mode"]')).not.toBeNull();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -10,8 +10,11 @@
|
|
|
10
10
|
* never render an `AudioPlayer`. Keeping every media-chrome *value* import in
|
|
11
11
|
* this module, reached only through
|
|
12
12
|
* `lazy(() => import("./_audio-player-media-chrome"))`, confines it to its own
|
|
13
|
-
* chunk. `audio-player.tsx`
|
|
14
|
-
*
|
|
13
|
+
* chunk. `audio-player.tsx` owns the public prop types, and — since issue #101
|
|
14
|
+
* — owns them as OWNED types (`AudioPlayerPartProps` and its ten aliases) that
|
|
15
|
+
* never reference `media-chrome/react`'s own types; this module still imports
|
|
16
|
+
* the REAL media-chrome values (below) and, at the bottom of the file, proves
|
|
17
|
+
* every owned type stays assignable to its real counterpart.
|
|
15
18
|
*
|
|
16
19
|
* See ADR 0019 and `pnpm heavy-deps:check`.
|
|
17
20
|
*
|
|
@@ -21,20 +24,23 @@
|
|
|
21
24
|
*/
|
|
22
25
|
import { Button, ButtonGroup, ButtonGroupText } from "@elabs-ai/components-ui";
|
|
23
26
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
24
|
-
import {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
27
|
+
// A NAMED import (`import { MediaController } from "media-chrome/react"`) is a
|
|
28
|
+
// static ESM binding a bundler must resolve at build time. Now that
|
|
29
|
+
// `media-chrome` is a genuinely optional peer (issue #33), a consumer who has
|
|
30
|
+
// not installed it hits that resolution at the worst possible time: Vite's own
|
|
31
|
+
// optional-peer-dependency handling swaps in a build-time stub with no
|
|
32
|
+
// exports, and Rollup's static named-export check then fails the WHOLE APP
|
|
33
|
+
// BUILD — not a runtime error any `.catch()`/error boundary could ever see
|
|
34
|
+
// (confirmed against `fixtures/consumer-smoke`'s real Vite build). A namespace
|
|
35
|
+
// import defers every one of these to a plain property lookup, which Rollup
|
|
36
|
+
// does not statically validate, so the build always succeeds; a genuinely
|
|
37
|
+
// missing peer instead resolves the destructured names below to `undefined`,
|
|
38
|
+
// and the guard turns that into a render-phase throw `LazyEngineBoundary`
|
|
39
|
+
// already catches (see `persona.tsx`'s identical pattern).
|
|
40
|
+
import * as MediaChromeReactModule from "media-chrome/react";
|
|
41
|
+
import type { ComponentProps, CSSProperties } from "react";
|
|
37
42
|
|
|
43
|
+
import type { AssertAssignable } from "./_lazy-boundary-conformance";
|
|
38
44
|
import type {
|
|
39
45
|
AudioPlayerControlBarProps,
|
|
40
46
|
AudioPlayerDurationDisplayProps,
|
|
@@ -48,6 +54,28 @@ import type {
|
|
|
48
54
|
AudioPlayerVolumeRangeProps,
|
|
49
55
|
} from "./audio-player";
|
|
50
56
|
|
|
57
|
+
const {
|
|
58
|
+
MediaControlBar,
|
|
59
|
+
MediaController,
|
|
60
|
+
MediaDurationDisplay,
|
|
61
|
+
MediaMuteButton,
|
|
62
|
+
MediaPlayButton,
|
|
63
|
+
MediaSeekBackwardButton,
|
|
64
|
+
MediaSeekForwardButton,
|
|
65
|
+
MediaTimeDisplay,
|
|
66
|
+
MediaTimeRange,
|
|
67
|
+
MediaVolumeRange,
|
|
68
|
+
} = MediaChromeReactModule;
|
|
69
|
+
|
|
70
|
+
// The peer is genuinely absent (Vite's build-time stub, or any other bundler
|
|
71
|
+
// that resolves an optional peer to an empty module) — surface a message
|
|
72
|
+
// `isModuleNotFoundMessage` recognizes, so the caller's `renderMissing` still
|
|
73
|
+
// gets the actionable "install media-chrome" copy rather than a raw
|
|
74
|
+
// "MediaController is not a valid JSX element" crash.
|
|
75
|
+
if (!MediaController) {
|
|
76
|
+
throw new Error("Cannot find module 'media-chrome'");
|
|
77
|
+
}
|
|
78
|
+
|
|
51
79
|
export const AudioPlayer = ({ children, style, ...props }: AudioPlayerProps) => (
|
|
52
80
|
<MediaController
|
|
53
81
|
audio
|
|
@@ -144,3 +172,62 @@ export const AudioPlayerVolumeRange = ({ className, ...props }: AudioPlayerVolum
|
|
|
144
172
|
<MediaVolumeRange className={cn("", className)} {...props} />
|
|
145
173
|
</ButtonGroupText>
|
|
146
174
|
);
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Conformance assertions (issue #101): compile-time proof that every OWNED
|
|
178
|
+
* type declared in `audio-player.tsx` stays assignable to its REAL
|
|
179
|
+
* `media-chrome/react` counterpart — this module still has the real values
|
|
180
|
+
* (and therefore the real types) in scope, since it is the one place they are
|
|
181
|
+
* allowed to be (see the module doc comment above). `AssertAssignable`'s type
|
|
182
|
+
* parameter is constrained (`TOwned extends TReal`), so if a future
|
|
183
|
+
* media-chrome release narrows an element's props in a way the owned type no
|
|
184
|
+
* longer satisfies, ONE of these ten lines fails to typecheck — caught by
|
|
185
|
+
* `pnpm --filter @elabs-ai/components-ai typecheck` locally, never shipped as
|
|
186
|
+
* a silent mismatch to a consumer using the real component underneath.
|
|
187
|
+
*
|
|
188
|
+
* `AssertAssignable` itself is shared with `_persona-rive.tsx` via
|
|
189
|
+
* `_lazy-boundary-conformance.ts` — see that module's doc comment for what
|
|
190
|
+
* this check can and cannot prove (it is one-directional: it cannot catch the
|
|
191
|
+
* owned type being NARROWER than the real one, which is exactly what these
|
|
192
|
+
* owned types are — see the CHANGELOG's "Breaking (types)" entry).
|
|
193
|
+
*/
|
|
194
|
+
export type _AudioPlayerPropsConformance = AssertAssignable<
|
|
195
|
+
AudioPlayerProps,
|
|
196
|
+
Omit<ComponentProps<typeof MediaController>, "audio">
|
|
197
|
+
>;
|
|
198
|
+
export type _AudioPlayerControlBarPropsConformance = AssertAssignable<
|
|
199
|
+
AudioPlayerControlBarProps,
|
|
200
|
+
ComponentProps<typeof MediaControlBar>
|
|
201
|
+
>;
|
|
202
|
+
export type _AudioPlayerPlayButtonPropsConformance = AssertAssignable<
|
|
203
|
+
AudioPlayerPlayButtonProps,
|
|
204
|
+
ComponentProps<typeof MediaPlayButton>
|
|
205
|
+
>;
|
|
206
|
+
export type _AudioPlayerSeekBackwardButtonPropsConformance = AssertAssignable<
|
|
207
|
+
AudioPlayerSeekBackwardButtonProps,
|
|
208
|
+
ComponentProps<typeof MediaSeekBackwardButton>
|
|
209
|
+
>;
|
|
210
|
+
export type _AudioPlayerSeekForwardButtonPropsConformance = AssertAssignable<
|
|
211
|
+
AudioPlayerSeekForwardButtonProps,
|
|
212
|
+
ComponentProps<typeof MediaSeekForwardButton>
|
|
213
|
+
>;
|
|
214
|
+
export type _AudioPlayerTimeDisplayPropsConformance = AssertAssignable<
|
|
215
|
+
AudioPlayerTimeDisplayProps,
|
|
216
|
+
ComponentProps<typeof MediaTimeDisplay>
|
|
217
|
+
>;
|
|
218
|
+
export type _AudioPlayerTimeRangePropsConformance = AssertAssignable<
|
|
219
|
+
AudioPlayerTimeRangeProps,
|
|
220
|
+
ComponentProps<typeof MediaTimeRange>
|
|
221
|
+
>;
|
|
222
|
+
export type _AudioPlayerDurationDisplayPropsConformance = AssertAssignable<
|
|
223
|
+
AudioPlayerDurationDisplayProps,
|
|
224
|
+
ComponentProps<typeof MediaDurationDisplay>
|
|
225
|
+
>;
|
|
226
|
+
export type _AudioPlayerMuteButtonPropsConformance = AssertAssignable<
|
|
227
|
+
AudioPlayerMuteButtonProps,
|
|
228
|
+
ComponentProps<typeof MediaMuteButton>
|
|
229
|
+
>;
|
|
230
|
+
export type _AudioPlayerVolumeRangePropsConformance = AssertAssignable<
|
|
231
|
+
AudioPlayerVolumeRangeProps,
|
|
232
|
+
ComponentProps<typeof MediaVolumeRange>
|
|
233
|
+
>;
|
package/src/_chat-shell-rail.tsx
CHANGED
|
@@ -4,8 +4,8 @@ import { FileText, Sparkles } from "lucide-react";
|
|
|
4
4
|
export function ContextPanelRail() {
|
|
5
5
|
return (
|
|
6
6
|
<div className="flex h-full flex-col bg-surface">
|
|
7
|
-
<div className="flex h-12 items-center border-b px-4 text-
|
|
8
|
-
<div className="space-y-2 p-3 text-
|
|
7
|
+
<div className="flex h-12 items-center border-b px-4 text-body font-semibold">Context</div>
|
|
8
|
+
<div className="space-y-2 p-3 text-body">
|
|
9
9
|
<div className="flex items-center gap-2 rounded-md border bg-card p-2">
|
|
10
10
|
<FileText className="size-4 text-muted-foreground" /> Deploy log — wk 23
|
|
11
11
|
</div>
|
package/src/_flow-boundary.tsx
CHANGED
|
@@ -119,65 +119,84 @@ export const EdgeTemporary = ({
|
|
|
119
119
|
);
|
|
120
120
|
};
|
|
121
121
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
122
|
+
/**
|
|
123
|
+
* The point on `node` where an edge of `handleType` should attach, plus the side
|
|
124
|
+
* it leaves from.
|
|
125
|
+
*
|
|
126
|
+
* Read from React Flow's **measured** `handleBounds` — the DOM box of the
|
|
127
|
+
* painted dot — and taken at the box's OUTER edge, which is React Flow's own
|
|
128
|
+
* convention (the anchor lands on the dot's rim, which keeps a `markerEnd`
|
|
129
|
+
* visible).
|
|
130
|
+
*
|
|
131
|
+
* The two fallbacks matter as much as the happy path. `preferred` is only a
|
|
132
|
+
* preference: `nodeTypes` is an open prop, so a consumer node may legitimately
|
|
133
|
+
* put its handles on the top and bottom, and the shipped `Node`'s left/right
|
|
134
|
+
* pair is a convenience rather than a constraint. And `handleBounds` is empty
|
|
135
|
+
* until React Flow's first measurement pass, which on this canvas is a real
|
|
136
|
+
* window because the engine arrives in a lazy chunk (ADR 0019). Both used to
|
|
137
|
+
* resolve to `[0, 0]` — the CANVAS ORIGIN — so the edge was drawn hundreds of
|
|
138
|
+
* pixels from either node (measured at 498.8px in the `VerticalHandles` story).
|
|
139
|
+
* Falling back to any handle of the right type, and then to the node's own
|
|
140
|
+
* border, keeps the line on the node in every case.
|
|
141
|
+
*/
|
|
142
|
+
const getHandleAnchor = (
|
|
143
|
+
node: InternalNode<FlowNode>,
|
|
144
|
+
handleType: "source" | "target",
|
|
145
|
+
preferred: Position,
|
|
146
|
+
) => {
|
|
147
|
+
const bounds = node.internals.handleBounds?.[handleType] ?? [];
|
|
148
|
+
const handle = bounds.find((h) => h.position === preferred) ?? bounds[0];
|
|
149
|
+
const origin = node.internals.positionAbsolute;
|
|
125
150
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
151
|
+
if (handle) {
|
|
152
|
+
// Offset to the handle box's outer edge on its own side; the other axis is
|
|
153
|
+
// centred.
|
|
154
|
+
const offsetX =
|
|
155
|
+
handle.position === Position.Left
|
|
156
|
+
? 0
|
|
157
|
+
: handle.position === Position.Right
|
|
158
|
+
? handle.width
|
|
159
|
+
: handle.width / 2;
|
|
160
|
+
const offsetY =
|
|
161
|
+
handle.position === Position.Top
|
|
162
|
+
? 0
|
|
163
|
+
: handle.position === Position.Bottom
|
|
164
|
+
? handle.height
|
|
165
|
+
: handle.height / 2;
|
|
129
166
|
|
|
130
|
-
|
|
131
|
-
|
|
167
|
+
return {
|
|
168
|
+
position: handle.position,
|
|
169
|
+
x: origin.x + handle.x + offsetX,
|
|
170
|
+
y: origin.y + handle.y + offsetY,
|
|
171
|
+
};
|
|
132
172
|
}
|
|
133
173
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
offsetX = handle.width;
|
|
147
|
-
break;
|
|
148
|
-
}
|
|
149
|
-
case Position.Top: {
|
|
150
|
-
offsetY = 0;
|
|
151
|
-
break;
|
|
152
|
-
}
|
|
153
|
-
case Position.Bottom: {
|
|
154
|
-
offsetY = handle.height;
|
|
155
|
-
break;
|
|
156
|
-
}
|
|
157
|
-
default: {
|
|
158
|
-
throw new Error(`Invalid handle position: ${handlePosition}`);
|
|
159
|
-
}
|
|
174
|
+
// No measured handle at all — anchor on the node's own border midpoint.
|
|
175
|
+
const width = node.measured.width ?? 0;
|
|
176
|
+
const height = node.measured.height ?? 0;
|
|
177
|
+
switch (preferred) {
|
|
178
|
+
case Position.Left:
|
|
179
|
+
return { position: preferred, x: origin.x, y: origin.y + height / 2 };
|
|
180
|
+
case Position.Right:
|
|
181
|
+
return { position: preferred, x: origin.x + width, y: origin.y + height / 2 };
|
|
182
|
+
case Position.Top:
|
|
183
|
+
return { position: preferred, x: origin.x + width / 2, y: origin.y };
|
|
184
|
+
default:
|
|
185
|
+
return { position: preferred, x: origin.x + width / 2, y: origin.y + height };
|
|
160
186
|
}
|
|
161
|
-
|
|
162
|
-
const x = node.internals.positionAbsolute.x + handle.x + offsetX;
|
|
163
|
-
const y = node.internals.positionAbsolute.y + handle.y + offsetY;
|
|
164
|
-
|
|
165
|
-
return [x, y] as const;
|
|
166
187
|
};
|
|
167
188
|
|
|
168
189
|
const getEdgeParams = (source: InternalNode<FlowNode>, target: InternalNode<FlowNode>) => {
|
|
169
|
-
const
|
|
170
|
-
const
|
|
171
|
-
const targetPos = Position.Left;
|
|
172
|
-
const [tx, ty] = getHandleCoordsByPosition(target, targetPos);
|
|
190
|
+
const from = getHandleAnchor(source, "source", Position.Right);
|
|
191
|
+
const to = getHandleAnchor(target, "target", Position.Left);
|
|
173
192
|
|
|
174
193
|
return {
|
|
175
|
-
sourcePos,
|
|
176
|
-
sx,
|
|
177
|
-
sy,
|
|
178
|
-
targetPos,
|
|
179
|
-
tx,
|
|
180
|
-
ty,
|
|
194
|
+
sourcePos: from.position,
|
|
195
|
+
sx: from.x,
|
|
196
|
+
sy: from.y,
|
|
197
|
+
targetPos: to.position,
|
|
198
|
+
tx: to.x,
|
|
199
|
+
ty: to.y,
|
|
181
200
|
};
|
|
182
201
|
};
|
|
183
202
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `AssertAssignable` — the shared compile-time conformance-check helper for
|
|
3
|
+
* issue #101's "own the type locally" pattern (see `persona.tsx`,
|
|
4
|
+
* `audio-player.tsx`, and the ADR 0019 amendment). Every `@lazy-boundary`
|
|
5
|
+
* sibling module that owns a structurally-compatible mirror of an optional
|
|
6
|
+
* peer's type (`_persona-rive.tsx`, `_audio-player-media-chrome.tsx`) uses
|
|
7
|
+
* THIS declaration rather than redeclaring it locally — previously the same
|
|
8
|
+
* three-line type was declared verbatim in both files (round-1 validator
|
|
9
|
+
* finding F5), which is exactly the kind of drift this repo's "a convention
|
|
10
|
+
* ships with its teeth" rule (@.claude/rules/quality-gates.md) exists to
|
|
11
|
+
* avoid: a future edit to one copy (e.g. relaxing the constraint) could
|
|
12
|
+
* silently diverge from the other with no gate to catch it.
|
|
13
|
+
*
|
|
14
|
+
* Purely a type-level helper — no runtime value, no import of anything heavy
|
|
15
|
+
* or peer-owned — so it is safe for a `@lazy-boundary` module to import it
|
|
16
|
+
* statically without pulling the peer back into the entry chunk, and safe for
|
|
17
|
+
* anything else in the package to import it too.
|
|
18
|
+
*
|
|
19
|
+
* ## What this assertion can, and cannot, prove (round-1 validator finding F4)
|
|
20
|
+
*
|
|
21
|
+
* `AssertAssignable<TOwned extends TReal, TReal>` only typechecks when
|
|
22
|
+
* `TOwned` is assignable TO `TReal` — i.e. it proves the owned mirror is a
|
|
23
|
+
* SUPERTYPE-OR-EQUAL of the real peer type (every value the real type can
|
|
24
|
+
* produce is accepted by the owned type), never the reverse. That is
|
|
25
|
+
* sufficient to prove the mirror is SAFE to use in place of the real type
|
|
26
|
+
* wherever the real type is only ever produced (an event object Rive hands
|
|
27
|
+
* back, a prop object media-chrome reads) — but it is a ONE-DIRECTIONAL
|
|
28
|
+
* check, so it cannot detect the owned type merely being NARROWER than the
|
|
29
|
+
* real one. `PersonaRiveEvent["data"]` (`unknown`) and the `AudioPlayer*`
|
|
30
|
+
* part-prop types (ordinary HTML attributes only, no per-element instance
|
|
31
|
+
* members, no `ref`) are both real, deliberate narrowings versus their real
|
|
32
|
+
* peer counterparts — see the CHANGELOG's "Breaking (types)" entry — and this
|
|
33
|
+
* assertion passes for both, exactly as designed: a narrower type is still a
|
|
34
|
+
* valid supertype-direction match. Don't read a green
|
|
35
|
+
* `_*Conformance` type as "identical shape to the peer"; read it as "safe to
|
|
36
|
+
* substitute here", which is the property issue #101 actually needed.
|
|
37
|
+
*/
|
|
38
|
+
export type AssertAssignable<_TOwned extends TReal, TReal> = true;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { renderHook, waitFor } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { needsCjkPlugin, preloadCjk, useLazyCjkPlugin } from "./_lazy-cjk";
|
|
5
|
+
|
|
6
|
+
const fakeCjkPlugin = {
|
|
7
|
+
name: "cjk",
|
|
8
|
+
remarkPlugins: [],
|
|
9
|
+
remarkPluginsAfter: [],
|
|
10
|
+
remarkPluginsBefore: [],
|
|
11
|
+
type: "cjk",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
vi.mock("@streamdown/cjk", () => ({ cjk: fakeCjkPlugin }));
|
|
15
|
+
|
|
16
|
+
describe("needsCjkPlugin", () => {
|
|
17
|
+
it("returns false for plain ASCII text", () => {
|
|
18
|
+
expect(needsCjkPlugin("plain text")).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("matches CJK Unified Ideographs, Hiragana, Katakana and Hangul", () => {
|
|
22
|
+
expect(needsCjkPlugin("你好")).toBe(true);
|
|
23
|
+
expect(needsCjkPlugin("こんにちは")).toBe(true);
|
|
24
|
+
expect(needsCjkPlugin("コンニチハ")).toBe(true);
|
|
25
|
+
expect(needsCjkPlugin("안녕하세요")).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("useLazyCjkPlugin", () => {
|
|
30
|
+
it("returns undefined when the text has no CJK codepoints", () => {
|
|
31
|
+
const { result } = renderHook(() => useLazyCjkPlugin("plain text"));
|
|
32
|
+
expect(result.current).toBeUndefined();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("loads and returns the plugin once the text needs CJK handling", async () => {
|
|
36
|
+
const { result } = renderHook(() => useLazyCjkPlugin("你好,世界"));
|
|
37
|
+
await waitFor(() => expect(result.current).toBe(fakeCjkPlugin));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("preloadCjk resolves without throwing", () => {
|
|
41
|
+
expect(() => preloadCjk()).not.toThrow();
|
|
42
|
+
});
|
|
43
|
+
});
|
package/src/_lazy-cjk.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lazy-loaded replacement for `@streamdown/cjk`'s eager `cjk` export.
|
|
5
|
+
*
|
|
6
|
+
* Same rationale and mechanism as `_lazy-math.ts` — `remark-cjk-friendly` +
|
|
7
|
+
* `remark-cjk-friendly-gfm-strikethrough` load only once `needsCjkPlugin(text)`
|
|
8
|
+
* sees a CJK codepoint, instead of shipping in every consumer's entry chunk.
|
|
9
|
+
* See `_lazy-math.ts` for why a remark `Pluggable` cannot be deferred the way
|
|
10
|
+
* `DiagramPlugin` defers Mermaid's render, and for the one-paint literal-text
|
|
11
|
+
* cost this trades for it.
|
|
12
|
+
*/
|
|
13
|
+
import type { CjkPlugin } from "@streamdown/cjk";
|
|
14
|
+
import { useEffect, useState } from "react";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Hiragana, Katakana, CJK Unified Ideographs (+ Extension A), Hangul Syllables,
|
|
18
|
+
* CJK punctuation and halfwidth Katakana — the ranges `remark-cjk-friendly`
|
|
19
|
+
* exists to handle (emphasis/strikethrough boundary rules around CJK text).
|
|
20
|
+
*/
|
|
21
|
+
// The range's first codepoint is the ideographic full-width space, not
|
|
22
|
+
// accidental whitespace — a deliberate boundary character, not noise.
|
|
23
|
+
// eslint-disable-next-line no-irregular-whitespace -- see comment above
|
|
24
|
+
const CJK_RANGE_RE = /[ -〿-ヿ㐀-䶿一-鿿가-힣・-゚]/u;
|
|
25
|
+
|
|
26
|
+
/** Does `text` contain a CJK codepoint? */
|
|
27
|
+
export function needsCjkPlugin(text: string): boolean {
|
|
28
|
+
return CJK_RANGE_RE.test(text);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Module-level singleton: the plugin loads at most once. */
|
|
32
|
+
let cachedPlugin: CjkPlugin | undefined;
|
|
33
|
+
let loadPromise: Promise<CjkPlugin> | undefined;
|
|
34
|
+
|
|
35
|
+
function loadCjkPlugin(): Promise<CjkPlugin> {
|
|
36
|
+
loadPromise ??= import("@streamdown/cjk").then((mod) => {
|
|
37
|
+
cachedPlugin = mod.cjk;
|
|
38
|
+
return cachedPlugin;
|
|
39
|
+
});
|
|
40
|
+
return loadPromise;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Start fetching the CJK plugin ahead of time (e.g. once a conversation's
|
|
45
|
+
* locale is known to be CJK). Optional; rendering CJK text loads it either way.
|
|
46
|
+
*/
|
|
47
|
+
export const preloadCjk = (): void => {
|
|
48
|
+
void loadCjkPlugin();
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Returns the loaded `CjkPlugin` once `text` needs one, else `undefined`
|
|
53
|
+
* (Streamdown treats an absent `plugins.cjk` slot as "no CJK handling" — plain
|
|
54
|
+
* remark-gfm boundary rules apply until the plugin arrives).
|
|
55
|
+
*/
|
|
56
|
+
export function useLazyCjkPlugin(text: string): CjkPlugin | undefined {
|
|
57
|
+
const needed = needsCjkPlugin(text);
|
|
58
|
+
const [plugin, setPlugin] = useState<CjkPlugin | undefined>(cachedPlugin);
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (!needed || cachedPlugin) return;
|
|
62
|
+
let cancelled = false;
|
|
63
|
+
loadCjkPlugin().then((loaded) => {
|
|
64
|
+
if (!cancelled) setPlugin(loaded);
|
|
65
|
+
});
|
|
66
|
+
return () => {
|
|
67
|
+
cancelled = true;
|
|
68
|
+
};
|
|
69
|
+
}, [needed]);
|
|
70
|
+
|
|
71
|
+
if (!needed) return undefined;
|
|
72
|
+
return plugin ?? cachedPlugin;
|
|
73
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Component, type ErrorInfo, type ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
export interface LazyEngineBoundaryProps {
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
/**
|
|
8
|
+
* Called once a wrapped `lazy()` import rejects (an optional peer that is
|
|
9
|
+
* not installed, or any other load-time failure). Return what to render in
|
|
10
|
+
* its place — the boundary owns no default visual, since the right
|
|
11
|
+
* stand-in differs by surface (an orb placeholder for `Persona`, `null`
|
|
12
|
+
* for an `AudioPlayer` sub-control).
|
|
13
|
+
*/
|
|
14
|
+
renderMissing: (error: unknown) => ReactNode;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface LazyEngineBoundaryState {
|
|
18
|
+
// Wrapped in an object so `{ value: undefined }` (a boundary that caught
|
|
19
|
+
// an error whose value happens to be `undefined`) is distinguishable from
|
|
20
|
+
// "nothing caught yet" (`error: null`).
|
|
21
|
+
error: { value: unknown } | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Catches a `React.lazy()` load failure — an optional peer dependency that is
|
|
26
|
+
* not installed (issue #33), or any other dynamic-import rejection — and
|
|
27
|
+
* renders a caller-supplied fallback instead of unmounting the tree.
|
|
28
|
+
*
|
|
29
|
+
* `Suspense` alone does not do this: it only covers the PENDING state. A
|
|
30
|
+
* REJECTED lazy import throws during render, and with no boundary above it
|
|
31
|
+
* that throw propagates to the nearest ancestor boundary React DOES find —
|
|
32
|
+
* by default, the whole app. This is the one place in `@elabs-ai/components-ai`
|
|
33
|
+
* that needs a real render-phase error boundary; Mermaid and the interactive
|
|
34
|
+
* terminal fail via an awaited promise instead (Streamdown's `errorComponent`
|
|
35
|
+
* and a `.catch()` on the mount effect, respectively), which don't need one.
|
|
36
|
+
*/
|
|
37
|
+
export class LazyEngineBoundary extends Component<
|
|
38
|
+
LazyEngineBoundaryProps,
|
|
39
|
+
LazyEngineBoundaryState
|
|
40
|
+
> {
|
|
41
|
+
override state: LazyEngineBoundaryState = { error: null };
|
|
42
|
+
|
|
43
|
+
static getDerivedStateFromError(error: unknown): LazyEngineBoundaryState {
|
|
44
|
+
return { error: { value: error } };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
override componentDidCatch(error: unknown, info: ErrorInfo): void {
|
|
48
|
+
console.error(
|
|
49
|
+
"[@elabs-ai/components-ai] a lazy engine failed to load:",
|
|
50
|
+
error,
|
|
51
|
+
info.componentStack,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
override render(): ReactNode {
|
|
56
|
+
if (this.state.error) {
|
|
57
|
+
return this.props.renderMissing(this.state.error.value);
|
|
58
|
+
}
|
|
59
|
+
return this.props.children;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { renderHook, waitFor } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { needsMathPlugin, preloadMath, useLazyMathPlugin } from "./_lazy-math";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The KaTeX-carrying `@streamdown/math` module is mocked so this file tests
|
|
8
|
+
* the lazy wrapper's *behaviour*. That the real dependency is genuinely
|
|
9
|
+
* absent from the entry chunk is a property of the BUILD, not of jsdom — see
|
|
10
|
+
* `pnpm check --rule eager-heavy-deps`, which
|
|
11
|
+
* asserts no `packages/ai/src` module statically imports `@streamdown/math`
|
|
12
|
+
* or `katex`.
|
|
13
|
+
*/
|
|
14
|
+
const fakeMathPlugin = {
|
|
15
|
+
name: "katex",
|
|
16
|
+
rehypePlugin: vi.fn(),
|
|
17
|
+
remarkPlugin: vi.fn(),
|
|
18
|
+
type: "math",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
vi.mock("@streamdown/math", () => ({ math: fakeMathPlugin }));
|
|
22
|
+
|
|
23
|
+
describe("needsMathPlugin", () => {
|
|
24
|
+
it("matches $$block$$ and $inline$ delimiters", () => {
|
|
25
|
+
expect(needsMathPlugin("plain text")).toBe(false);
|
|
26
|
+
expect(needsMathPlugin("$$x^2$$")).toBe(true);
|
|
27
|
+
expect(needsMathPlugin("the answer is $x + 1$ today")).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("matches \\( \\) and \\[ \\] delimiters", () => {
|
|
31
|
+
expect(needsMathPlugin("\\(x^2\\)")).toBe(true);
|
|
32
|
+
expect(needsMathPlugin("\\[x^2\\]")).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("does not false-positive on a bare dollar amount", () => {
|
|
36
|
+
// A single, unpaired `$` (no closing delimiter on the same line) is not math.
|
|
37
|
+
expect(needsMathPlugin("that costs $5")).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("useLazyMathPlugin", () => {
|
|
42
|
+
it("returns undefined when the text has no math delimiters", () => {
|
|
43
|
+
const { result } = renderHook(() => useLazyMathPlugin("plain text"));
|
|
44
|
+
expect(result.current).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("loads and returns the plugin once the text needs math", async () => {
|
|
48
|
+
const { result } = renderHook(() => useLazyMathPlugin("$$x^2$$"));
|
|
49
|
+
await waitFor(() => expect(result.current).toBe(fakeMathPlugin));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("preloadMath resolves without throwing", () => {
|
|
53
|
+
expect(() => preloadMath()).not.toThrow();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("serves a second, simultaneous consumer off the same cached load", async () => {
|
|
57
|
+
const first = renderHook(() => useLazyMathPlugin("$$x^2$$"));
|
|
58
|
+
const second = renderHook(() => useLazyMathPlugin("$y^2$"));
|
|
59
|
+
|
|
60
|
+
await waitFor(() => expect(first.result.current).toBe(fakeMathPlugin));
|
|
61
|
+
await waitFor(() => expect(second.result.current).toBe(fakeMathPlugin));
|
|
62
|
+
});
|
|
63
|
+
});
|