@tangle-network/ui 11.5.0 → 11.7.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +70 -2
  3. package/dist/chat.d.ts +18 -8
  4. package/dist/chat.js +6 -5
  5. package/dist/{chunk-XROQW5J7.js → chunk-4ENFLPWQ.js} +1 -1
  6. package/dist/{chunk-XPYPZMYT.js → chunk-6RIX7M27.js} +1 -1
  7. package/dist/{chunk-R5GVQDL3.js → chunk-CVX53NCC.js} +7 -5
  8. package/dist/{chunk-ULDNFLIM.js → chunk-EIUE5GV2.js} +10 -14
  9. package/dist/chunk-L57WRIND.js +59 -0
  10. package/dist/chunk-LFVF2HFB.js +19 -0
  11. package/dist/{chunk-7O22XQ4D.js → chunk-PLWRAS5Z.js} +1 -1
  12. package/dist/{chunk-NZLLDHEG.js → chunk-QGTIBEPX.js} +608 -420
  13. package/dist/{chunk-AZWDI2JG.js → chunk-UYTRNR7Z.js} +1 -1
  14. package/dist/{chunk-RKC3GXHE.js → chunk-VDCZHOOO.js} +99 -104
  15. package/dist/{chunk-J2G7KCYH.js → chunk-XT4JMYVB.js} +109 -67
  16. package/dist/{document-editor-pane-MX3FIZSE.js → document-editor-pane-A3JURPW5.js} +1 -1
  17. package/dist/{document-editor-pane-C3cYEDWZ.d.ts → document-editor-pane-IAEQn_Dl.d.ts} +8 -4
  18. package/dist/editor.d.ts +5 -4
  19. package/dist/editor.js +1 -1
  20. package/dist/files.d.ts +1 -1
  21. package/dist/files.js +1 -1
  22. package/dist/hooks.js +6 -5
  23. package/dist/index.d.ts +2 -2
  24. package/dist/index.js +16 -11
  25. package/dist/primitives.d.ts +15 -1
  26. package/dist/primitives.js +5 -1
  27. package/dist/run.d.ts +22 -17
  28. package/dist/run.js +8 -6
  29. package/dist/sdk-hooks.js +6 -5
  30. package/dist/utils.d.ts +6 -0
  31. package/dist/utils.js +1 -1
  32. package/package.json +1 -1
  33. package/src/chat/agent-timeline.stories.tsx +163 -10
  34. package/src/chat/agent-timeline.test.tsx +114 -7
  35. package/src/chat/agent-timeline.tsx +120 -158
  36. package/src/chat/chat-container.test.tsx +59 -0
  37. package/src/chat/user-message.test.tsx +33 -0
  38. package/src/chat/user-message.tsx +13 -8
  39. package/src/editor/document-editor-pane.test.tsx +58 -0
  40. package/src/editor/editor-lazy.test.tsx +102 -0
  41. package/src/editor/editor-lazy.ts +40 -0
  42. package/src/editor/editor-loading.tsx +25 -0
  43. package/src/editor/editor-peers.test.ts +250 -0
  44. package/src/editor/editor-peers.ts +272 -0
  45. package/src/editor/editor-provider.test.tsx +226 -0
  46. package/src/editor/editor-provider.tsx +301 -265
  47. package/src/editor/editor-toolbar.tsx +1 -1
  48. package/src/editor/markdown-document-editor.test.tsx +121 -0
  49. package/src/editor/markdown-document-editor.tsx +114 -82
  50. package/src/editor/tiptap-editor.test.tsx +228 -0
  51. package/src/editor/tiptap-editor.tsx +162 -130
  52. package/src/primitives/index.ts +3 -0
  53. package/src/primitives/text-shimmer.test.tsx +34 -0
  54. package/src/primitives/text-shimmer.tsx +82 -0
  55. package/src/run/inline-thinking-item.stories.tsx +2 -2
  56. package/src/run/inline-thinking-item.tsx +2 -2
  57. package/src/run/inline-tool-item.test.tsx +76 -0
  58. package/src/run/inline-tool-item.tsx +9 -7
  59. package/src/run/run-group.tsx +4 -3
  60. package/src/run/run-row-shell.test.tsx +74 -4
  61. package/src/run/run-row-shell.tsx +81 -53
  62. package/src/run/tool-call-step.tsx +9 -7
  63. package/src/utils/tool-display.test.ts +53 -0
  64. package/src/utils/tool-display.ts +16 -15
  65. package/dist/chunk-LQS34IGP.js +0 -0
  66. /package/dist/{chunk-EJ5NBW7V.js → chunk-LDLOJKPC.js} +0 -0
@@ -0,0 +1,121 @@
1
+ import { render, screen, waitFor } from "@testing-library/react";
2
+ import { Component, type ReactNode } from "react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { MarkdownDocumentEditor } from "./markdown-document-editor";
5
+ import type * as EditorPeers from "./editor-peers";
6
+
7
+ class ErrorTrap extends Component<{ children: ReactNode }, { message: string | null }> {
8
+ state = { message: null as string | null };
9
+
10
+ static getDerivedStateFromError(error: Error) {
11
+ return { message: error.message };
12
+ }
13
+
14
+ render() {
15
+ return this.state.message ?? this.props.children;
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Imports the editor with a fresh module graph, so its module-scope `lazy`
21
+ * has not resolved yet. `loadDocumentEditorPeers` is replaced by `load`.
22
+ */
23
+ async function freshEditor(load: () => Promise<EditorPeers.DocumentEditorPeers>) {
24
+ vi.resetModules();
25
+ vi.doMock("./editor-peers", async () => ({
26
+ ...(await vi.importActual<typeof EditorPeers>("./editor-peers")),
27
+ loadDocumentEditorPeers: load,
28
+ }));
29
+ return (await import("./markdown-document-editor")).MarkdownDocumentEditor;
30
+ }
31
+
32
+ afterEach(() => {
33
+ vi.doUnmock("./editor-peers");
34
+ vi.resetModules();
35
+ });
36
+
37
+ describe("MarkdownDocumentEditor", () => {
38
+ it("renders the real tiptap surface once the peers load", async () => {
39
+ const { container } = render(
40
+ <MarkdownDocumentEditor value={"# Title\n\nBody text."} />,
41
+ );
42
+
43
+ await waitFor(() => {
44
+ expect(container.querySelector(".ProseMirror")).not.toBeNull();
45
+ });
46
+
47
+ const surface = container.querySelector(".ProseMirror");
48
+ expect(surface?.querySelector("h1")?.textContent).toBe("Title");
49
+ expect(surface?.textContent).toContain("Body text.");
50
+ // StarterKit arrives through `peers.starterKit.default`. A broken hand-off
51
+ // leaves the document without the heading node that StarterKit configures.
52
+ expect(screen.getByTitle(/^Bold/)).toBeInTheDocument();
53
+ });
54
+
55
+ it("holds a placeholder while the peers are still loading", async () => {
56
+ let releasePeers: (() => void) | null = null;
57
+ const gate = new Promise<void>((resolve) => {
58
+ releasePeers = resolve;
59
+ });
60
+ const HeldEditor = await freshEditor(async () => {
61
+ await gate;
62
+ const actual = await vi.importActual<typeof EditorPeers>("./editor-peers");
63
+ return actual.loadDocumentEditorPeers();
64
+ });
65
+
66
+ const { container } = render(<HeldEditor value="# Title" />);
67
+
68
+ // A live region, so a screen reader hears that the editor is still coming.
69
+ expect(screen.getByRole("status")).toHaveTextContent("Loading editor…");
70
+ expect(container.querySelector(".ProseMirror")).toBeNull();
71
+
72
+ releasePeers?.();
73
+
74
+ await waitFor(() => {
75
+ expect(container.querySelector(".ProseMirror h1")?.textContent).toBe("Title");
76
+ });
77
+ expect(screen.queryByText("Loading editor…")).toBeNull();
78
+ });
79
+
80
+ it("applies a new value prop to the loaded editor without reporting a change", async () => {
81
+ const onChange = vi.fn();
82
+ const { container, rerender } = render(
83
+ <MarkdownDocumentEditor value="start" onChange={onChange} />,
84
+ );
85
+
86
+ await waitFor(() => {
87
+ expect(container.querySelector(".ProseMirror")).not.toBeNull();
88
+ });
89
+
90
+ // The editor reports once when it parses the initial content. What must
91
+ // not grow is the count across a value-prop write: pushing that back
92
+ // through onChange makes a controlled parent echo its own write.
93
+ const callsAfterMount = onChange.mock.calls.length;
94
+
95
+ rerender(<MarkdownDocumentEditor value="# Changed" onChange={onChange} />);
96
+
97
+ await waitFor(() => {
98
+ expect(container.querySelector(".ProseMirror h1")?.textContent).toBe("Changed");
99
+ });
100
+ expect(onChange).toHaveBeenCalledTimes(callsAfterMount);
101
+ });
102
+
103
+ it("fails with the install list when the peers cannot be loaded", async () => {
104
+ const missing = "install @tiptap/react and @tiptap/starter-kit";
105
+ const LoadFailingEditor = await freshEditor(async () => {
106
+ throw new Error(missing);
107
+ });
108
+
109
+ render(
110
+ <ErrorTrap>
111
+ <LoadFailingEditor value="start" />
112
+ </ErrorTrap>,
113
+ );
114
+
115
+ // Loud, and only here: the consumer's build already succeeded without the
116
+ // peers, so this render is the first place the gap can surface.
117
+ await waitFor(() => {
118
+ expect(screen.getByText(missing)).toBeInTheDocument();
119
+ });
120
+ });
121
+ });
@@ -1,9 +1,11 @@
1
1
  "use client";
2
2
 
3
- import { type Editor, EditorContent, useEditor } from "@tiptap/react";
4
- import StarterKit from "@tiptap/starter-kit";
5
- import { useEffect, useMemo, useRef } from "react";
3
+ import type { Editor } from "@tiptap/react";
4
+ import { type ComponentType, Suspense, useEffect, useMemo, useRef } from "react";
6
5
  import { cn } from "../lib/utils";
6
+ import { retryableLazyEditor } from "./editor-lazy";
7
+ import { EditorLoadingPlaceholder } from "./editor-loading";
8
+ import { type DocumentEditorPeers, loadDocumentEditorPeers } from "./editor-peers";
7
9
  import { EditorToolbar } from "./editor-toolbar";
8
10
  import {
9
11
  htmlToMarkdown,
@@ -22,89 +24,100 @@ export interface MarkdownDocumentEditorProps {
22
24
  onReady?: (editor: Editor) => void;
23
25
  }
24
26
 
25
- export function MarkdownDocumentEditor({
26
- value = "",
27
- placeholder = "Start writing...",
28
- readOnly = false,
29
- autoFocus = false,
30
- className,
31
- contentClassName,
32
- onChange,
33
- onReady,
34
- }: MarkdownDocumentEditorProps) {
35
- const initialHtml = useMemo(() => markdownToHtml(value), []);
36
- const lastAppliedMarkdownRef = useRef(normalizeMarkdown(value));
37
-
38
- const editor = useEditor({
39
- extensions: [
40
- StarterKit.configure({
41
- codeBlock: {
42
- HTMLAttributes: {
43
- class: "hljs",
27
+ /**
28
+ * Builds the local markdown editor against loaded tiptap namespaces. The
29
+ * peers arrive as an argument so that this module reaches them through
30
+ * type-only imports, which a bundler erases.
31
+ */
32
+ export function createMarkdownDocumentEditor(
33
+ peers: DocumentEditorPeers,
34
+ ): ComponentType<MarkdownDocumentEditorProps> {
35
+ const { EditorContent, useEditor } = peers.react;
36
+ const StarterKit = peers.starterKit.default;
37
+
38
+ return function MarkdownDocumentEditor({
39
+ value = "",
40
+ placeholder = "Start writing...",
41
+ readOnly = false,
42
+ autoFocus = false,
43
+ className,
44
+ contentClassName,
45
+ onChange,
46
+ onReady,
47
+ }: MarkdownDocumentEditorProps) {
48
+ const initialHtml = useMemo(() => markdownToHtml(value), []);
49
+ const lastAppliedMarkdownRef = useRef(normalizeMarkdown(value));
50
+
51
+ const editor = useEditor({
52
+ extensions: [
53
+ StarterKit.configure({
54
+ codeBlock: {
55
+ HTMLAttributes: {
56
+ class: "hljs",
57
+ },
44
58
  },
59
+ }),
60
+ ],
61
+ content: initialHtml,
62
+ editable: !readOnly,
63
+ autofocus: autoFocus,
64
+ editorProps: {
65
+ attributes: {
66
+ class: cn(
67
+ "prose prose-sm sm:prose-base max-w-none focus:outline-none",
68
+ "prose-headings:text-foreground prose-p:text-foreground prose-li:text-foreground",
69
+ "prose-strong:text-foreground prose-code:text-foreground prose-pre:text-foreground",
70
+ contentClassName,
71
+ ),
72
+ "data-placeholder": placeholder,
45
73
  },
46
- }),
47
- ],
48
- content: initialHtml,
49
- editable: !readOnly,
50
- autofocus: autoFocus,
51
- editorProps: {
52
- attributes: {
53
- class: cn(
54
- "prose prose-sm sm:prose-base max-w-none focus:outline-none",
55
- "prose-headings:text-foreground prose-p:text-foreground prose-li:text-foreground",
56
- "prose-strong:text-foreground prose-code:text-foreground prose-pre:text-foreground",
57
- contentClassName,
58
- ),
59
- "data-placeholder": placeholder,
60
74
  },
61
- },
62
- onUpdate: ({ editor: currentEditor }) => {
63
- const nextMarkdown = normalizeMarkdown(htmlToMarkdown(currentEditor.getHTML()));
64
- lastAppliedMarkdownRef.current = nextMarkdown;
65
- onChange?.(nextMarkdown, currentEditor);
66
- },
67
- onCreate: ({ editor: currentEditor }) => {
68
- onReady?.(currentEditor);
69
- },
70
- });
71
-
72
- useEffect(() => {
73
- if (editor) {
74
- editor.setEditable(!readOnly);
75
- }
76
- }, [editor, readOnly]);
77
-
78
- useEffect(() => {
79
- if (!editor) {
80
- return;
81
- }
82
-
83
- const normalizedValue = normalizeMarkdown(value);
84
- if (normalizedValue === lastAppliedMarkdownRef.current) {
85
- return;
86
- }
87
-
88
- editor.commands.setContent(markdownToHtml(value), { emitUpdate: false });
89
- lastAppliedMarkdownRef.current = normalizedValue;
90
- }, [editor, value]);
75
+ onUpdate: ({ editor: currentEditor }) => {
76
+ const nextMarkdown = normalizeMarkdown(htmlToMarkdown(currentEditor.getHTML()));
77
+ lastAppliedMarkdownRef.current = nextMarkdown;
78
+ onChange?.(nextMarkdown, currentEditor);
79
+ },
80
+ onCreate: ({ editor: currentEditor }) => {
81
+ onReady?.(currentEditor);
82
+ },
83
+ });
91
84
 
92
- return (
93
- <div
94
- className={cn(
95
- "flex min-h-[14rem] w-full flex-col overflow-hidden rounded-lg border border-border bg-background",
96
- className,
97
- )}
98
- >
99
- <EditorToolbar
100
- editor={editor}
101
- className="border-border bg-card px-2 py-2"
102
- />
103
- <div className="min-h-0 flex-1 overflow-auto px-5 py-4">
104
- <EditorContent editor={editor} />
105
- </div>
85
+ useEffect(() => {
86
+ if (editor) {
87
+ editor.setEditable(!readOnly);
88
+ }
89
+ }, [editor, readOnly]);
90
+
91
+ useEffect(() => {
92
+ if (!editor) {
93
+ return;
94
+ }
95
+
96
+ const normalizedValue = normalizeMarkdown(value);
97
+ if (normalizedValue === lastAppliedMarkdownRef.current) {
98
+ return;
99
+ }
106
100
 
107
- <style>{`
101
+ editor.commands.setContent(markdownToHtml(value), { emitUpdate: false });
102
+ lastAppliedMarkdownRef.current = normalizedValue;
103
+ }, [editor, value]);
104
+
105
+ return (
106
+ <div
107
+ className={cn(
108
+ "flex min-h-[14rem] w-full flex-col overflow-hidden rounded-lg border border-border bg-background",
109
+ className,
110
+ )}
111
+ >
112
+ <EditorToolbar
113
+ editor={editor}
114
+ className="border-border bg-card px-2 py-2"
115
+ />
116
+ <div className="min-h-0 flex-1 overflow-auto px-5 py-4">
117
+ <EditorContent editor={editor} />
118
+ </div>
119
+
120
+ <style>{`
108
121
  .ProseMirror p.is-editor-empty:first-child::before {
109
122
  content: attr(data-placeholder);
110
123
  float: left;
@@ -132,6 +145,25 @@ export function MarkdownDocumentEditor({
132
145
  padding: 0;
133
146
  }
134
147
  `}</style>
135
- </div>
148
+ </div>
149
+ );
150
+ };
151
+ }
152
+
153
+ const lazyMarkdownDocumentEditor = retryableLazyEditor(async () =>
154
+ createMarkdownDocumentEditor(await loadDocumentEditorPeers()),
155
+ );
156
+
157
+ /** Local markdown editor. Loads its tiptap peers on first render. */
158
+ export function MarkdownDocumentEditor(props: MarkdownDocumentEditorProps) {
159
+ const LazyMarkdownDocumentEditor = lazyMarkdownDocumentEditor();
160
+ return (
161
+ <Suspense
162
+ fallback={
163
+ <EditorLoadingPlaceholder className={cn("min-h-[14rem]", props.className)} />
164
+ }
165
+ >
166
+ <LazyMarkdownDocumentEditor {...props} />
167
+ </Suspense>
136
168
  );
137
169
  }
@@ -0,0 +1,228 @@
1
+ import { render, screen, waitFor } from "@testing-library/react";
2
+ import { Component, type ReactNode } from "react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { Doc } from "yjs";
5
+ import type * as EditorPeers from "./editor-peers";
6
+ import { createEditorProvider } from "./editor-provider";
7
+ import { createTiptapEditor } from "./tiptap-editor";
8
+
9
+ class ErrorTrap extends Component<{ children: ReactNode }, { message: string | null }> {
10
+ state = { message: null as string | null };
11
+
12
+ static getDerivedStateFromError(error: Error) {
13
+ return { message: error.message };
14
+ }
15
+
16
+ render() {
17
+ return this.state.message ?? this.props.children;
18
+ }
19
+ }
20
+
21
+ class StubHocuspocusProvider {
22
+ static instances: StubHocuspocusProvider[] = [];
23
+
24
+ awareness = {
25
+ clientID: 1,
26
+ getStates: () => new Map(),
27
+ getLocalState: () => ({}),
28
+ setLocalStateField: vi.fn(),
29
+ on: vi.fn(),
30
+ off: vi.fn(),
31
+ };
32
+ connect = vi.fn();
33
+ disconnect = vi.fn();
34
+ destroy = vi.fn();
35
+
36
+ constructor(readonly options: { document: Doc }) {
37
+ StubHocuspocusProvider.instances.push(this);
38
+ }
39
+ }
40
+
41
+ /** A tiptap extension records what the editor configured it with. */
42
+ function stubExtension(name: string) {
43
+ const calls: Record<string, unknown>[] = [];
44
+ return {
45
+ calls,
46
+ default: {
47
+ configure(options: Record<string, unknown>) {
48
+ calls.push(options);
49
+ return { name, options };
50
+ },
51
+ },
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Only the members the collaborative editor calls. yjs is the real package,
57
+ * because the provider and the editor must agree on one document.
58
+ */
59
+ function collaborationStubs() {
60
+ const starterKit = stubExtension("starterKit");
61
+ const collaboration = stubExtension("collaboration");
62
+ const collaborationCaret = stubExtension("collaborationCaret");
63
+ const editorOptions: Record<string, unknown>[] = [];
64
+ const editor = {
65
+ setEditable: vi.fn(),
66
+ isEmpty: true,
67
+ commands: { setContent: vi.fn() },
68
+ };
69
+
70
+ const peers = {
71
+ react: {
72
+ useEditor: (options: Record<string, unknown>) => {
73
+ editorOptions.push(options);
74
+ return editor;
75
+ },
76
+ EditorContent: () => <div data-testid="editor-content" />,
77
+ },
78
+ starterKit,
79
+ collaboration,
80
+ collaborationCaret,
81
+ hocuspocus: { HocuspocusProvider: StubHocuspocusProvider },
82
+ yjs: { Doc },
83
+ } as unknown as EditorPeers.CollaborationPeers;
84
+
85
+ return { peers, starterKit, collaboration, collaborationCaret, editorOptions, editor };
86
+ }
87
+
88
+ function renderCollaborativeEditor(stubs: ReturnType<typeof collaborationStubs>) {
89
+ const EditorProvider = createEditorProvider(stubs.peers);
90
+ const TiptapEditor = createTiptapEditor(stubs.peers);
91
+ return render(
92
+ <EditorProvider
93
+ websocketUrl="wss://collab.example/ws"
94
+ documentName="doc:readme"
95
+ token="jwt"
96
+ user={{ name: "Ada" }}
97
+ >
98
+ <TiptapEditor />
99
+ </EditorProvider>,
100
+ );
101
+ }
102
+
103
+ afterEach(() => {
104
+ StubHocuspocusProvider.instances = [];
105
+ vi.doUnmock("./editor-peers");
106
+ vi.resetModules();
107
+ });
108
+
109
+ describe("createTiptapEditor", () => {
110
+ it("renders the editor surface from the loaded namespaces", () => {
111
+ renderCollaborativeEditor(collaborationStubs());
112
+
113
+ expect(screen.getByTestId("editor-content")).toBeInTheDocument();
114
+ expect(screen.getByText("Connecting...")).toBeInTheDocument();
115
+ });
116
+
117
+ it("gives Collaboration the document fragment the provider shares", () => {
118
+ const stubs = collaborationStubs();
119
+ renderCollaborativeEditor(stubs);
120
+
121
+ const [provider] = StubHocuspocusProvider.instances;
122
+ // The extensions rebuild once the transport arrives, so read the last
123
+ // configuration: the transport and the editor must bind to one Y.Doc, or
124
+ // remote edits never reach the surface.
125
+ expect(stubs.collaboration.calls.at(-1)?.fragment).toBe(
126
+ provider.options.document.getXmlFragment("prosemirror"),
127
+ );
128
+ });
129
+
130
+ it("gives CollaborationCaret the provider and a named user", () => {
131
+ const stubs = collaborationStubs();
132
+ renderCollaborativeEditor(stubs);
133
+
134
+ const [provider] = StubHocuspocusProvider.instances;
135
+ // The caret joins only once the provider carries awareness.
136
+ expect(stubs.collaborationCaret.calls).toHaveLength(1);
137
+ const [caretOptions] = stubs.collaborationCaret.calls;
138
+ expect(caretOptions.provider).toBe(provider);
139
+ // The stub awareness carries no local user yet, so the editor names the
140
+ // fallback rather than passing undefined into the caret renderer.
141
+ expect(caretOptions.user).toEqual({ name: "Anonymous", color: "#808080" });
142
+ });
143
+
144
+ it("passes every configured extension to the editor", () => {
145
+ const stubs = collaborationStubs();
146
+ renderCollaborativeEditor(stubs);
147
+
148
+ expect(stubs.starterKit.calls.at(-1)).toMatchObject({ history: false });
149
+ const options = stubs.editorOptions.at(-1);
150
+ expect(
151
+ (options?.extensions as { name: string }[]).map((one) => one.name),
152
+ ).toEqual(["starterKit", "collaboration", "collaborationCaret"]);
153
+ });
154
+ });
155
+
156
+ describe("TiptapEditor", () => {
157
+ it("holds a placeholder while the peers are still loading", async () => {
158
+ let releasePeers: (() => void) | null = null;
159
+ const gate = new Promise<void>((resolve) => {
160
+ releasePeers = resolve;
161
+ });
162
+ const stubs = collaborationStubs();
163
+ vi.resetModules();
164
+ vi.doMock("./editor-peers", async () => ({
165
+ ...(await vi.importActual<typeof EditorPeers>("./editor-peers")),
166
+ loadCollaborationPeers: async () => {
167
+ await gate;
168
+ return stubs.peers;
169
+ },
170
+ }));
171
+ const { TiptapEditor } = await import("./tiptap-editor");
172
+ // Both modules come from the reset graph, so the provider and the editor
173
+ // share one EditorContext. A provider built from the loaded peers
174
+ // directly, because the exported EditorProvider waits on the same gate and
175
+ // would render nothing at all.
176
+ const freshProvider = await import("./editor-provider");
177
+ const ResolvedProvider = freshProvider.createEditorProvider(stubs.peers);
178
+
179
+ render(
180
+ <ResolvedProvider
181
+ websocketUrl="wss://collab.example/ws"
182
+ documentName="doc:readme"
183
+ token="jwt"
184
+ user={{ name: "Ada" }}
185
+ >
186
+ <TiptapEditor />
187
+ </ResolvedProvider>,
188
+ );
189
+
190
+ expect(screen.getByText("Loading editor…")).toBeInTheDocument();
191
+ expect(screen.queryByTestId("editor-content")).toBeNull();
192
+
193
+ releasePeers?.();
194
+
195
+ await waitFor(() => {
196
+ expect(screen.getByTestId("editor-content")).toBeInTheDocument();
197
+ });
198
+ expect(screen.queryByText("Loading editor…")).toBeNull();
199
+ });
200
+
201
+ it("fails with the install list when the collaboration peers cannot be loaded", async () => {
202
+ const missing =
203
+ "@tangle-network/ui/editor needs its optional collaboration peers. Install yjs.";
204
+ vi.resetModules();
205
+ vi.doMock("./editor-peers", async () => {
206
+ const actual = await vi.importActual<typeof EditorPeers>("./editor-peers");
207
+ return {
208
+ ...actual,
209
+ loadCollaborationPeers: async () => {
210
+ throw new actual.MissingEditorPeersError(missing);
211
+ },
212
+ };
213
+ });
214
+ const { TiptapEditor } = await import("./tiptap-editor");
215
+
216
+ // The wrapper reaches its loader before it reads the editor context, so
217
+ // the install list surfaces without a provider around it.
218
+ render(
219
+ <ErrorTrap>
220
+ <TiptapEditor />
221
+ </ErrorTrap>,
222
+ );
223
+
224
+ await waitFor(() => {
225
+ expect(screen.getByText(missing)).toBeInTheDocument();
226
+ });
227
+ });
228
+ });