@schlessera/brain-ui-react 0.34.0 → 0.34.1

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 (157) hide show
  1. package/README.md +5 -0
  2. package/dist/components/activity/activity-page.d.ts.map +1 -1
  3. package/dist/components/activity/activity-page.js +8 -149
  4. package/dist/components/activity/activity-page.js.map +1 -1
  5. package/dist/components/activity/activity-rollups.d.ts +5 -0
  6. package/dist/components/activity/activity-rollups.d.ts.map +1 -0
  7. package/dist/components/activity/activity-rollups.js +39 -0
  8. package/dist/components/activity/activity-rollups.js.map +1 -0
  9. package/dist/components/activity/activity-run-detail.d.ts +11 -0
  10. package/dist/components/activity/activity-run-detail.d.ts.map +1 -0
  11. package/dist/components/activity/activity-run-detail.js +111 -0
  12. package/dist/components/activity/activity-run-detail.js.map +1 -0
  13. package/dist/components/activity/activity-run-list.d.ts +15 -0
  14. package/dist/components/activity/activity-run-list.d.ts.map +1 -0
  15. package/dist/components/activity/activity-run-list.js +25 -0
  16. package/dist/components/activity/activity-run-list.js.map +1 -0
  17. package/dist/components/chat/brain-markdown-code.d.ts +4 -0
  18. package/dist/components/chat/brain-markdown-code.d.ts.map +1 -0
  19. package/dist/components/chat/brain-markdown-code.js +47 -0
  20. package/dist/components/chat/brain-markdown-code.js.map +1 -0
  21. package/dist/components/chat/brain-markdown-entities.d.ts +14 -0
  22. package/dist/components/chat/brain-markdown-entities.d.ts.map +1 -0
  23. package/dist/components/chat/brain-markdown-entities.js +93 -0
  24. package/dist/components/chat/brain-markdown-entities.js.map +1 -0
  25. package/dist/components/chat/brain-markdown-links.d.ts +36 -0
  26. package/dist/components/chat/brain-markdown-links.d.ts.map +1 -0
  27. package/dist/components/chat/brain-markdown-links.js +176 -0
  28. package/dist/components/chat/brain-markdown-links.js.map +1 -0
  29. package/dist/components/chat/brain-markdown-share.d.ts +15 -0
  30. package/dist/components/chat/brain-markdown-share.d.ts.map +1 -0
  31. package/dist/components/chat/brain-markdown-share.js +40 -0
  32. package/dist/components/chat/brain-markdown-share.js.map +1 -0
  33. package/dist/components/chat/brain-markdown.d.ts +1 -34
  34. package/dist/components/chat/brain-markdown.d.ts.map +1 -1
  35. package/dist/components/chat/brain-markdown.js +9 -402
  36. package/dist/components/chat/brain-markdown.js.map +1 -1
  37. package/dist/components/chat/composer.d.ts.map +1 -1
  38. package/dist/components/chat/composer.js +14 -1
  39. package/dist/components/chat/composer.js.map +1 -1
  40. package/dist/components/chat/use-markdown-highlight.d.ts +12 -0
  41. package/dist/components/chat/use-markdown-highlight.d.ts.map +1 -0
  42. package/dist/components/chat/use-markdown-highlight.js +56 -0
  43. package/dist/components/chat/use-markdown-highlight.js.map +1 -0
  44. package/dist/components/connectivity/connection-gate.d.ts.map +1 -1
  45. package/dist/components/connectivity/connection-gate.js +41 -7
  46. package/dist/components/connectivity/connection-gate.js.map +1 -1
  47. package/dist/components/connectivity/connection-state.d.ts +12 -0
  48. package/dist/components/connectivity/connection-state.d.ts.map +1 -0
  49. package/dist/components/connectivity/connection-state.js +23 -0
  50. package/dist/components/connectivity/connection-state.js.map +1 -0
  51. package/dist/components/graph/graph-canvas-lazy.d.ts +2 -0
  52. package/dist/components/graph/graph-canvas-lazy.d.ts.map +1 -0
  53. package/dist/components/graph/graph-canvas-lazy.js +4 -0
  54. package/dist/components/graph/graph-canvas-lazy.js.map +1 -0
  55. package/dist/components/graph/graph-controls.js +4 -1
  56. package/dist/components/graph/graph-controls.js.map +1 -1
  57. package/dist/components/graph/graph-maintenance.d.ts +8 -0
  58. package/dist/components/graph/graph-maintenance.d.ts.map +1 -0
  59. package/dist/components/graph/graph-maintenance.js +115 -0
  60. package/dist/components/graph/graph-maintenance.js.map +1 -0
  61. package/dist/components/graph/graph-page.d.ts.map +1 -1
  62. package/dist/components/graph/graph-page.js +11 -306
  63. package/dist/components/graph/graph-page.js.map +1 -1
  64. package/dist/components/graph/graph-scene.d.ts +8 -0
  65. package/dist/components/graph/graph-scene.d.ts.map +1 -0
  66. package/dist/components/graph/graph-scene.js +208 -0
  67. package/dist/components/graph/graph-scene.js.map +1 -0
  68. package/dist/components/graph/graph-spinner.d.ts +2 -0
  69. package/dist/components/graph/graph-spinner.d.ts.map +1 -0
  70. package/dist/components/graph/graph-spinner.js +6 -0
  71. package/dist/components/graph/graph-spinner.js.map +1 -0
  72. package/dist/hooks/use-vpn-status.d.ts +5 -0
  73. package/dist/hooks/use-vpn-status.d.ts.map +1 -1
  74. package/dist/hooks/use-vpn-status.js +61 -29
  75. package/dist/hooks/use-vpn-status.js.map +1 -1
  76. package/dist/hooks/use-websocket.d.ts +3 -2
  77. package/dist/hooks/use-websocket.d.ts.map +1 -1
  78. package/dist/hooks/use-websocket.js +61 -252
  79. package/dist/hooks/use-websocket.js.map +1 -1
  80. package/dist/hooks/websocket-handlers/activity.d.ts +5 -0
  81. package/dist/hooks/websocket-handlers/activity.d.ts.map +1 -0
  82. package/dist/hooks/websocket-handlers/activity.js +10 -0
  83. package/dist/hooks/websocket-handlers/activity.js.map +1 -0
  84. package/dist/hooks/websocket-handlers/chat.d.ts +17 -0
  85. package/dist/hooks/websocket-handlers/chat.d.ts.map +1 -0
  86. package/dist/hooks/websocket-handlers/chat.js +175 -0
  87. package/dist/hooks/websocket-handlers/chat.js.map +1 -0
  88. package/dist/hooks/websocket-handlers/connection.d.ts +6 -0
  89. package/dist/hooks/websocket-handlers/connection.d.ts.map +1 -0
  90. package/dist/hooks/websocket-handlers/connection.js +27 -0
  91. package/dist/hooks/websocket-handlers/connection.js.map +1 -0
  92. package/dist/hooks/websocket-handlers/index.d.ts +4 -0
  93. package/dist/hooks/websocket-handlers/index.d.ts.map +1 -0
  94. package/dist/hooks/websocket-handlers/index.js +19 -0
  95. package/dist/hooks/websocket-handlers/index.js.map +1 -0
  96. package/dist/hooks/websocket-handlers/mask.d.ts +4 -0
  97. package/dist/hooks/websocket-handlers/mask.d.ts.map +1 -0
  98. package/dist/hooks/websocket-handlers/mask.js +14 -0
  99. package/dist/hooks/websocket-handlers/mask.js.map +1 -0
  100. package/dist/hooks/websocket-handlers/provider.d.ts +4 -0
  101. package/dist/hooks/websocket-handlers/provider.d.ts.map +1 -0
  102. package/dist/hooks/websocket-handlers/provider.js +31 -0
  103. package/dist/hooks/websocket-handlers/provider.js.map +1 -0
  104. package/dist/hooks/websocket-handlers/types.d.ts +24 -0
  105. package/dist/hooks/websocket-handlers/types.d.ts.map +1 -0
  106. package/dist/hooks/websocket-handlers/types.js +2 -0
  107. package/dist/hooks/websocket-handlers/types.js.map +1 -0
  108. package/dist/stores/activity-store-helpers.d.ts +50 -0
  109. package/dist/stores/activity-store-helpers.d.ts.map +1 -0
  110. package/dist/stores/activity-store-helpers.js +143 -0
  111. package/dist/stores/activity-store-helpers.js.map +1 -0
  112. package/dist/stores/activity-store-types.d.ts +48 -0
  113. package/dist/stores/activity-store-types.d.ts.map +1 -0
  114. package/dist/stores/activity-store-types.js +2 -0
  115. package/dist/stores/activity-store-types.js.map +1 -0
  116. package/dist/stores/activity-store.d.ts +2 -78
  117. package/dist/stores/activity-store.d.ts.map +1 -1
  118. package/dist/stores/activity-store.js +22 -142
  119. package/dist/stores/activity-store.js.map +1 -1
  120. package/dist/stores/connection-store.d.ts +14 -2
  121. package/dist/stores/connection-store.d.ts.map +1 -1
  122. package/dist/stores/connection-store.js +17 -0
  123. package/dist/stores/connection-store.js.map +1 -1
  124. package/dist/styles.css +1 -1
  125. package/package.json +2 -2
  126. package/src/components/activity/activity-page.tsx +6 -477
  127. package/src/components/activity/activity-rollups.tsx +94 -0
  128. package/src/components/activity/activity-run-detail.tsx +301 -0
  129. package/src/components/activity/activity-run-list.tsx +97 -0
  130. package/src/components/chat/brain-markdown-code.tsx +60 -0
  131. package/src/components/chat/brain-markdown-entities.tsx +116 -0
  132. package/src/components/chat/brain-markdown-links.tsx +247 -0
  133. package/src/components/chat/brain-markdown-share.ts +47 -0
  134. package/src/components/chat/brain-markdown.tsx +27 -535
  135. package/src/components/chat/composer.tsx +14 -1
  136. package/src/components/chat/use-markdown-highlight.ts +63 -0
  137. package/src/components/connectivity/connection-gate.tsx +64 -13
  138. package/src/components/connectivity/connection-state.ts +37 -0
  139. package/src/components/graph/graph-canvas-lazy.tsx +4 -0
  140. package/src/components/graph/graph-controls.tsx +4 -1
  141. package/src/components/graph/graph-maintenance.tsx +304 -0
  142. package/src/components/graph/graph-page.tsx +11 -840
  143. package/src/components/graph/graph-scene.tsx +548 -0
  144. package/src/components/graph/graph-spinner.tsx +9 -0
  145. package/src/hooks/use-vpn-status.ts +64 -31
  146. package/src/hooks/use-websocket.ts +70 -273
  147. package/src/hooks/websocket-handlers/activity.ts +13 -0
  148. package/src/hooks/websocket-handlers/chat.ts +210 -0
  149. package/src/hooks/websocket-handlers/connection.ts +30 -0
  150. package/src/hooks/websocket-handlers/index.ts +59 -0
  151. package/src/hooks/websocket-handlers/mask.ts +17 -0
  152. package/src/hooks/websocket-handlers/provider.ts +35 -0
  153. package/src/hooks/websocket-handlers/types.ts +27 -0
  154. package/src/stores/activity-store-helpers.ts +164 -0
  155. package/src/stores/activity-store-types.ts +55 -0
  156. package/src/stores/activity-store.ts +21 -217
  157. package/src/stores/connection-store.ts +33 -2
@@ -1,176 +1,35 @@
1
- import { apiBase } from "../../lib/backend.js";
2
- import React, {
3
- memo,
4
- useEffect,
5
- useMemo,
6
- useRef,
7
- useSyncExternalStore,
8
- } from "react";
9
- import Markdown, { type Options as MarkdownOptions } from "react-markdown";
10
- import remarkGfm from "remark-gfm";
1
+ import React, { memo, useEffect, useMemo } from "react";
2
+ import Markdown from "react-markdown";
3
+
11
4
  import {
12
5
  classifyRepoPath,
13
- isInternalRepoDir,
14
- isInternalRepoPath,
15
6
  useFileStore,
16
7
  } from "../../stores/file-store.js";
17
- import { useUIStore } from "../../stores/ui-store.js";
18
- import { ShareBlock, type ShareBlockFormat } from "./share-block.js";
19
- import { CopyButton } from "./copy-button.js";
8
+ import { ShareBlock } from "./share-block.js";
20
9
  import { ZoomableImage } from "../images/zoomable-image.js";
21
- import { MermaidBlock } from "./mermaid-block.js";
22
-
23
- const ENTITY_TAGS: Record<string, string> = {
24
- co: "entity-co",
25
- p: "entity-p",
26
- proj: "entity-proj",
27
- ev: "entity-ev",
28
- d: "entity-d",
29
- st: "entity-st",
30
- f: "entity-f",
31
- };
32
-
33
- // Use a unicode marker that won't appear in normal text to wrap entities
34
- // so they survive markdown parsing without relying on rehypeRaw
35
- const ENTITY_START = "\u200B\u200B"; // zero-width spaces as delimiter
36
- const ENTITY_SEP = "\u200B";
37
- const ENTITY_END = "\u200B\u200B\u200B";
38
-
39
- function renderEntityTags(md: string): string {
40
- // Convert entity tags to marker-based format:
41
- // START tag SEP content END (START/SEP/END are ZWSP runs, see above)
42
- let result = md.replace(
43
- /<(co|p|proj|ev|d|st|f)>([\s\S]*?)<\/\1>/g,
44
- (_match, tag, content) => `${ENTITY_START}${tag}${ENTITY_SEP}${content}${ENTITY_END}`
45
- );
46
- // Clean up any orphaned/unmatched entity tags
47
- result = result.replace(/<\/?(?:co|p|proj|ev|d|st|f)>/g, "");
48
-
49
- // Log and strip any remaining non-standard HTML-like tags the LLM may have output
50
- result = result.replace(/<\/?([a-z][a-z0-9]*)\b[^>]*>/gi, (match, tag) => {
51
- const allowed = [
52
- "span", "strong", "em", "b", "i", "a", "br", "hr", "p",
53
- "h1", "h2", "h3", "h4", "h5", "h6",
54
- "ul", "ol", "li", "code", "pre", "blockquote",
55
- "table", "thead", "tbody", "tr", "th", "td",
56
- "div", "img", "del", "sup", "sub",
57
- ];
58
- if (allowed.includes(tag.toLowerCase())) return match;
59
- console.warn("[entity-tags] Stripping unrecognized tag:", match);
60
- return "";
61
- });
62
-
63
- return result;
64
- }
65
-
66
- /**
67
- * Turn a markdown image source into something the browser can actually load.
68
- *
69
- * Anything already absolute — a `data:` URI, an http(s) URL, or a path that is
70
- * already an API route — passes through. A repo-relative path is rewritten to
71
- * the files endpoint, which is where brain content is served from.
72
- */
73
- export function repoImageSrc(src: string): string {
74
- const trimmed = src.trim();
75
- if (!trimmed) return src;
76
- if (/^(data:|blob:|https?:\/\/)/i.test(trimmed)) return trimmed;
77
- if (trimmed.startsWith("/api/")) return trimmed;
78
- const rel = trimmed.replace(/^\.\//, "").replace(/^\/+/, "");
79
- return `${apiBase()}/files/content?path=${encodeURIComponent(rel)}&raw=1`;
80
- }
81
-
82
- /**
83
- * Parse entity markers in a text string. Each entity content is run through
84
- * `innerTransform` so file-path linkification (or any other inline transform)
85
- * applies inside `<f>...</f>` spans too.
86
- */
87
- function renderEntitiesInText(
88
- text: string,
89
- innerTransform: (s: string) => React.ReactNode = (s) => s
90
- ): React.ReactNode[] {
91
- const pattern = new RegExp(
92
- `${ENTITY_START}(co|p|proj|ev|d|st|f)${ENTITY_SEP}(.*?)${ENTITY_END}`,
93
- "g"
94
- );
95
- const nodes: React.ReactNode[] = [];
96
- let lastIndex = 0;
97
- let match;
98
- let key = 0;
99
-
100
- while ((match = pattern.exec(text)) !== null) {
101
- if (match.index > lastIndex) {
102
- nodes.push(innerTransform(text.slice(lastIndex, match.index)));
103
- }
104
- const cls = ENTITY_TAGS[match[1]] || "";
105
- nodes.push(
106
- <span key={key++} className={cls}>
107
- {innerTransform(match[2])}
108
- </span>
109
- );
110
- lastIndex = pattern.lastIndex;
111
- }
112
- if (lastIndex < text.length) {
113
- nodes.push(innerTransform(text.slice(lastIndex)));
114
- }
115
- return nodes;
116
- }
117
-
118
- /** Concatenate all text descendants of a React node tree. */
119
- function extractText(node: React.ReactNode): string {
120
- if (typeof node === "string") return node;
121
- if (typeof node === "number") return String(node);
122
- if (Array.isArray(node)) return node.map(extractText).join("");
123
- if (React.isValidElement(node)) {
124
- return extractText((node.props as { children?: React.ReactNode }).children);
125
- }
126
- return "";
127
- }
128
-
129
- /**
130
- * If a <pre>'s children are a ```mermaid / ```mmd fence, recover the raw
131
- * diagram source. Text is extracted recursively because rehype-highlight
132
- * and the linkify pass may have wrapped parts of it in elements.
133
- */
134
- function mermaidSourceFrom(children: React.ReactNode): string | null {
135
- for (const child of React.Children.toArray(children)) {
136
- if (!React.isValidElement(child)) continue;
137
- const props = child.props as { className?: string; children?: React.ReactNode };
138
- if (typeof props.className === "string" && /\blanguage-(?:mermaid|mmd)\b/.test(props.className)) {
139
- return extractText(props.children).replace(/\n$/, "");
140
- }
141
- }
142
- return null;
143
- }
144
-
145
- function MarkdownPre({ children, ...props }: React.ComponentPropsWithoutRef<"pre">) {
146
- const mermaid = mermaidSourceFrom(children);
147
- if (mermaid !== null) return <MermaidBlock source={mermaid} />;
148
- return <CodePre {...props}>{children}</CodePre>;
149
- }
150
-
151
- function CodePre({ children, ...props }: React.ComponentPropsWithoutRef<"pre">) {
152
- const ref = useRef<HTMLPreElement>(null);
153
- return (
154
- <div className="group relative">
155
- <pre
156
- ref={ref}
157
- className="overflow-x-auto rounded-lg border border-border bg-surface p-4 font-[family-name:var(--font-mono)] text-[13px] leading-relaxed"
158
- {...props}
159
- >
160
- {children}
161
- </pre>
162
- <CopyButton getText={() => ref.current?.textContent ?? ""} />
163
- </div>
164
- );
165
- }
166
-
167
- function MarkdownTable({ children, ...props }: React.ComponentPropsWithoutRef<"table">) {
168
- return (
169
- <div className="overflow-x-auto rounded-lg border border-border">
170
- <table {...props}>{children}</table>
171
- </div>
172
- );
173
- }
10
+ import { MarkdownPre, MarkdownTable } from "./brain-markdown-code.js";
11
+ import {
12
+ processChildText,
13
+ renderEntityTags,
14
+ } from "./brain-markdown-entities.js";
15
+ import {
16
+ DirLink,
17
+ FileLink,
18
+ repoImageSrc,
19
+ } from "./brain-markdown-links.js";
20
+ import { splitShareBlocks } from "./brain-markdown-share.js";
21
+ import {
22
+ REMARK_PLUGINS,
23
+ useRehypePlugins,
24
+ } from "./use-markdown-highlight.js";
25
+
26
+ export {
27
+ DirLink,
28
+ FileLink,
29
+ WikiLink,
30
+ linkifyPaths,
31
+ repoImageSrc,
32
+ } from "./brain-markdown-links.js";
174
33
 
175
34
  interface BrainMarkdownProps {
176
35
  content: string;
@@ -178,53 +37,6 @@ interface BrainMarkdownProps {
178
37
  entityTags?: boolean;
179
38
  fileLinks?: boolean;
180
39
  }
181
-
182
- type ShareSegment = { kind: "share"; format: ShareBlockFormat; title?: string; body: string };
183
- type TextSegment = { kind: "text"; text: string };
184
- type Segment = TextSegment | ShareSegment;
185
-
186
- const SHARE_BLOCK_RE = /<share\b([^>]*)>([\s\S]*?)<\/share>/gi;
187
- const VALID_FORMATS: ReadonlySet<ShareBlockFormat> = new Set([
188
- "image",
189
- "pdf",
190
- "text",
191
- "markdown",
192
- "richtext",
193
- ]);
194
-
195
- function parseShareAttrs(raw: string): { format: ShareBlockFormat; title?: string } {
196
- const attrs: Record<string, string> = {};
197
- const re = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g;
198
- let m: RegExpExecArray | null;
199
- while ((m = re.exec(raw)) !== null) {
200
- attrs[m[1].toLowerCase()] = m[2] ?? m[3] ?? m[4] ?? "";
201
- }
202
- const format = (attrs.format ?? "image").toLowerCase() as ShareBlockFormat;
203
- return {
204
- format: VALID_FORMATS.has(format) ? format : "image",
205
- title: attrs.title,
206
- };
207
- }
208
-
209
- function splitShareBlocks(input: string): Segment[] {
210
- const out: Segment[] = [];
211
- let last = 0;
212
- let m: RegExpExecArray | null;
213
- SHARE_BLOCK_RE.lastIndex = 0;
214
- while ((m = SHARE_BLOCK_RE.exec(input)) !== null) {
215
- if (m.index > last) {
216
- out.push({ kind: "text", text: input.slice(last, m.index) });
217
- }
218
- const { format, title } = parseShareAttrs(m[1] ?? "");
219
- out.push({ kind: "share", format, title, body: m[2].trim() });
220
- last = SHARE_BLOCK_RE.lastIndex;
221
- }
222
- if (last < input.length) {
223
- out.push({ kind: "text", text: input.slice(last) });
224
- }
225
- return out;
226
- }
227
-
228
40
  /** Wrap any element's render to process entity markers + file paths in its text children */
229
41
  function withTextProcessing<T extends keyof React.JSX.IntrinsicElements>(
230
42
  Tag: T,
@@ -234,67 +46,6 @@ function withTextProcessing<T extends keyof React.JSX.IntrinsicElements>(
234
46
  React.createElement(Tag, props as any, processChildText(children, opts))
235
47
  );
236
48
  }
237
-
238
- /**
239
- * Plugin lists, hoisted out of render.
240
- *
241
- * react-markdown builds a fresh unified processor on every render and reruns
242
- * the whole parse — there is no internal memoization to lean on. Fresh array
243
- * literals in the JSX made that unavoidable; module constants let the memo
244
- * below actually hold.
245
- */
246
- const REMARK_PLUGINS = [remarkGfm];
247
- const NO_REHYPE_PLUGINS: NonNullable<MarkdownOptions["rehypePlugins"]> = [];
248
-
249
- /**
250
- * Syntax highlighting arrives after first paint.
251
- *
252
- * rehype-highlight pulls in highlight.js, 166 KB of the entry bundle — more
253
- * than every markdown parser here put together — and nothing on the first paint
254
- * needs it. It is fetched once, on the first mount of a highlighting renderer;
255
- * until it lands, code blocks render as plain text and then gain their colours.
256
- * In practice it is loaded long before the first assistant message arrives.
257
- *
258
- * The module-level cache means the fetch happens once per page, not once per
259
- * message, and useSyncExternalStore is what lets every mounted renderer pick up
260
- * the plugin the moment it does.
261
- */
262
- let highlightPlugins: NonNullable<MarkdownOptions["rehypePlugins"]> | null = null;
263
- let highlightRequested = false;
264
- const highlightListeners = new Set<() => void>();
265
-
266
- function subscribeHighlight(onChange: () => void): () => void {
267
- highlightListeners.add(onChange);
268
- return () => {
269
- highlightListeners.delete(onChange);
270
- };
271
- }
272
-
273
- function readHighlightPlugins(): NonNullable<MarkdownOptions["rehypePlugins"]> {
274
- return highlightPlugins ?? NO_REHYPE_PLUGINS;
275
- }
276
-
277
- function useRehypePlugins(enabled: boolean) {
278
- const plugins = useSyncExternalStore(
279
- subscribeHighlight,
280
- readHighlightPlugins,
281
- readHighlightPlugins
282
- );
283
- useEffect(() => {
284
- if (!enabled || highlightRequested) return;
285
- highlightRequested = true;
286
- import("rehype-highlight")
287
- .then((mod) => {
288
- highlightPlugins = [mod.default];
289
- for (const listener of highlightListeners) listener();
290
- })
291
- .catch(() => {
292
- // Soft-fail: code blocks stay readable, just uncoloured.
293
- });
294
- }, [enabled]);
295
- return enabled ? plugins : NO_REHYPE_PLUGINS;
296
- }
297
-
298
49
  /**
299
50
  * Memoized because rendering it means parsing markdown, and the transcript
300
51
  * re-renders whenever anything about the surrounding message changes. All four
@@ -429,262 +180,3 @@ const BrainMarkdownInner = memo(function BrainMarkdownInner({ content, className
429
180
  </div>
430
181
  );
431
182
  });
432
-
433
- /** Click target for a repo-relative file reference. Opens the file panel + viewer. */
434
- export function FileLink({ path, children }: { path: string; children?: React.ReactNode }) {
435
- const openFile = useFileStore((s) => s.openFile);
436
- const setFilePanelOpen = useUIStore((s) => s.setFilePanelOpen);
437
- return (
438
- <a
439
- href={`#/files/${path}`}
440
- onClick={(e) => {
441
- e.preventDefault();
442
- setFilePanelOpen(true);
443
- void openFile(path);
444
- }}
445
- className="brain-file-link"
446
- title={path}
447
- >
448
- {children ?? path}
449
- </a>
450
- );
451
- }
452
-
453
- /**
454
- * Resolve an obsidian-style `[[target]]` to a repo-relative path using the
455
- * wikilink map. Targets containing `/` are treated as paths directly; bare
456
- * slugs are looked up by basename. An `#anchor` suffix is stripped before
457
- * lookup.
458
- */
459
- function resolveWikilinkTarget(
460
- raw: string,
461
- slugMap: Record<string, string>
462
- ): string | null {
463
- const hashIdx = raw.indexOf("#");
464
- const noAnchor = hashIdx >= 0 ? raw.slice(0, hashIdx).trim() : raw.trim();
465
- if (!noAnchor) return null;
466
- if (noAnchor.includes("/")) {
467
- if (noAnchor.endsWith("/")) return null;
468
- return /\.[a-z0-9]{1,8}$/i.test(noAnchor) ? noAnchor : `${noAnchor}.md`;
469
- }
470
- return slugMap[noAnchor.toLowerCase()] ?? null;
471
- }
472
-
473
- /** Click target for `[[slug]]` / `[[slug|Label]]`. */
474
- export function WikiLink({
475
- target,
476
- label,
477
- }: {
478
- target: string;
479
- label?: string;
480
- }) {
481
- const slugMap = useFileStore((s) => s.wikilinkMap);
482
- const loaded = useFileStore((s) => s.wikilinkLoaded);
483
- const openFile = useFileStore((s) => s.openFile);
484
- const setFilePanelOpen = useUIStore((s) => s.setFilePanelOpen);
485
-
486
- const resolved = resolveWikilinkTarget(target, slugMap);
487
- const display = label ?? target;
488
-
489
- if (!resolved) {
490
- return (
491
- <span
492
- className="brain-wiki-link brain-wiki-link--unresolved"
493
- title={loaded ? `Unresolved wikilink: ${target}` : "Resolving…"}
494
- >
495
- {display}
496
- </span>
497
- );
498
- }
499
-
500
- return (
501
- <a
502
- href={`#/files/${resolved}`}
503
- onClick={(e) => {
504
- e.preventDefault();
505
- setFilePanelOpen(true);
506
- void openFile(resolved);
507
- }}
508
- className="brain-file-link brain-wiki-link"
509
- title={resolved}
510
- >
511
- {display}
512
- </a>
513
- );
514
- }
515
-
516
- /**
517
- * Click target for a repo-relative directory reference. Opens the file
518
- * panel, expands the tree down to the directory, and highlights it so
519
- * the user sees its contents.
520
- */
521
- export function DirLink({ path, children }: { path: string; children?: React.ReactNode }) {
522
- const openDir = useFileStore((s) => s.openDir);
523
- const setFilePanelOpen = useUIStore((s) => s.setFilePanelOpen);
524
- const trimmed = path.replace(/\/+$/, "");
525
- const display = `${trimmed}/`;
526
- return (
527
- <a
528
- href={`#/files/${display}`}
529
- onClick={(e) => {
530
- e.preventDefault();
531
- setFilePanelOpen(true);
532
- void openDir(trimmed);
533
- }}
534
- className="brain-file-link brain-file-link--dir"
535
- title={display}
536
- >
537
- {children ?? display}
538
- </a>
539
- );
540
- }
541
-
542
- // Bare-path tokens in prose. Three passes:
543
- // - files: "notes/projects/foo.md" — ends with .ext
544
- // - dirs: "notes/projects/post-templates/" — ends with /
545
- // - wikilinks: "[[slug]]" or "[[slug|Display]]" — obsidian-style
546
- const BARE_FILE_RE =
547
- /\b([a-z][a-z0-9_-]*(?:\/[a-z0-9._-]+)+\.[a-z0-9]{1,8})\b/gi;
548
- const BARE_DIR_RE =
549
- /\b([a-z][a-z0-9_-]*(?:\/[a-z0-9._-]+)+\/)(?=$|[\s,;:!?)\]])/gi;
550
- const WIKILINK_RE = /\[\[([^\[\]\n|]+?)(?:\|([^\[\]\n]+?))?\]\]/g;
551
-
552
- interface PathMatch {
553
- start: number;
554
- end: number;
555
- kind: "file" | "dir" | "wikilink";
556
- /** For file/dir kinds. */
557
- path?: string;
558
- /** Raw target inside the brackets (may include `#anchor`). For wikilink. */
559
- target?: string;
560
- /** Display label override (after the pipe). For wikilink. */
561
- label?: string;
562
- }
563
-
564
- function scanBarePaths(text: string): PathMatch[] {
565
- const out: PathMatch[] = [];
566
- BARE_FILE_RE.lastIndex = 0;
567
- let m: RegExpExecArray | null;
568
- while ((m = BARE_FILE_RE.exec(text)) !== null) {
569
- if (isInternalRepoPath(m[1])) {
570
- out.push({
571
- start: m.index,
572
- end: BARE_FILE_RE.lastIndex,
573
- path: m[1],
574
- kind: "file",
575
- });
576
- }
577
- }
578
- BARE_DIR_RE.lastIndex = 0;
579
- while ((m = BARE_DIR_RE.exec(text)) !== null) {
580
- if (isInternalRepoDir(m[1])) {
581
- out.push({
582
- start: m.index,
583
- end: BARE_DIR_RE.lastIndex,
584
- path: m[1],
585
- kind: "dir",
586
- });
587
- }
588
- }
589
- WIKILINK_RE.lastIndex = 0;
590
- while ((m = WIKILINK_RE.exec(text)) !== null) {
591
- out.push({
592
- start: m.index,
593
- end: WIKILINK_RE.lastIndex,
594
- kind: "wikilink",
595
- target: m[1].trim(),
596
- label: m[2]?.trim(),
597
- });
598
- }
599
- out.sort((a, b) => a.start - b.start);
600
-
601
- // Drop overlapping matches — keep the first (earlier) when ranges collide
602
- const merged: PathMatch[] = [];
603
- let cursor = 0;
604
- for (const cand of out) {
605
- if (cand.start >= cursor) {
606
- merged.push(cand);
607
- cursor = cand.end;
608
- }
609
- }
610
- return merged;
611
- }
612
-
613
- function renderBarePathsInText(text: string): React.ReactNode[] {
614
- const matches = scanBarePaths(text);
615
- if (matches.length === 0) return [text];
616
- const nodes: React.ReactNode[] = [];
617
- let last = 0;
618
- let key = 0;
619
- for (const m of matches) {
620
- if (m.start > last) nodes.push(text.slice(last, m.start));
621
- if (m.kind === "file" && m.path) {
622
- nodes.push(
623
- <FileLink key={`p${key++}`} path={m.path}>
624
- {m.path}
625
- </FileLink>
626
- );
627
- } else if (m.kind === "dir" && m.path) {
628
- nodes.push(
629
- <DirLink key={`p${key++}`} path={m.path.replace(/\/+$/, "")}>
630
- {m.path}
631
- </DirLink>
632
- );
633
- } else if (m.kind === "wikilink" && m.target) {
634
- nodes.push(
635
- <WikiLink key={`p${key++}`} target={m.target} label={m.label} />
636
- );
637
- }
638
- last = m.end;
639
- }
640
- if (last < text.length) nodes.push(text.slice(last));
641
- return nodes;
642
- }
643
-
644
- /**
645
- * Public helper: turn any raw text blob into React nodes with bare-path
646
- * tokens replaced by FileLink elements. Use this in non-markdown surfaces
647
- * (tool outputs, streaming logs, plain user text).
648
- */
649
- export function linkifyPaths(text: string): React.ReactNode {
650
- if (!text) return text;
651
- const parts = renderBarePathsInText(text);
652
- if (parts.length === 1 && typeof parts[0] === "string") return parts[0];
653
- return <>{parts}</>;
654
- }
655
-
656
- /** Process a text string for entity markers and/or bare file paths. */
657
- function transformTextString(
658
- text: string,
659
- opts: { entityTags: boolean; fileLinks: boolean }
660
- ): React.ReactNode {
661
- const linkify = opts.fileLinks
662
- ? (s: string): React.ReactNode => {
663
- if (!s) return s;
664
- const parts = renderBarePathsInText(s);
665
- if (parts.length === 1 && typeof parts[0] === "string") return parts[0];
666
- return <>{parts}</>;
667
- }
668
- : (s: string): React.ReactNode => s;
669
-
670
- if (opts.entityTags && text.includes(ENTITY_START)) {
671
- return <>{renderEntitiesInText(text, opts.fileLinks ? (s) => linkify(s) : undefined)}</>;
672
- }
673
-
674
- return linkify(text);
675
- }
676
-
677
- /** Recursively process React children to expand entity markers / file paths in text nodes */
678
- function processChildText(children: React.ReactNode, opts: { entityTags: boolean; fileLinks: boolean }): React.ReactNode {
679
- if (typeof children === "string") {
680
- return transformTextString(children, opts);
681
- }
682
- if (Array.isArray(children)) {
683
- return children.map((child, i) =>
684
- typeof child === "string"
685
- ? <React.Fragment key={i}>{transformTextString(child, opts)}</React.Fragment>
686
- : child
687
- );
688
- }
689
- return children;
690
- }
@@ -14,6 +14,7 @@ import type { ClientMessage } from "@schlessera/brain-ui-sdk/protocol";
14
14
  import { useChatStore, activeChat } from "../../stores/chat-store.js";
15
15
  import { uiConfig } from "../../config.js";
16
16
  import { useConnectionStore } from "../../stores/connection-store.js";
17
+ import { deriveConnectionIssue } from "../connectivity/connection-state.js";
17
18
  import { useProviderStore } from "../../stores/provider-store.js";
18
19
  import {
19
20
  fileToAttachment,
@@ -75,6 +76,14 @@ export function Composer({ send }: { send: (msg: ClientMessage) => void }) {
75
76
  const isStreaming = useChatStore((s) => activeChat(s).isStreaming);
76
77
  const sessionId = useChatStore((s) => s.activeSessionId);
77
78
  const wsStatus = useConnectionStore((s) => s.wsStatus);
79
+ const vpnStatus = useConnectionStore((s) => s.vpnStatus);
80
+ const handshakeFailures = useConnectionStore((s) => s.handshakeFailures);
81
+ const lastCloseCode = useConnectionStore((s) => s.lastCloseCode);
82
+ const connectionIssue = deriveConnectionIssue({
83
+ vpnStatus,
84
+ handshakeFailures,
85
+ lastCloseCode,
86
+ });
78
87
 
79
88
  const providers = useProviderStore((s) => s.available);
80
89
  const selectedProviderId = useProviderStore((s) => s.selectedId);
@@ -461,7 +470,11 @@ export function Composer({ send }: { send: (msg: ClientMessage) => void }) {
461
470
  }}
462
471
  placeholder={
463
472
  wsStatus !== "connected"
464
- ? "Connecting..."
473
+ ? connectionIssue === "capacity"
474
+ ? "Server connection limit reached"
475
+ : connectionIssue === "refused"
476
+ ? "Server refused the connection"
477
+ : "Connecting..."
465
478
  : uiConfig.composerPlaceholder
466
479
  }
467
480
  disabled={wsStatus !== "connected"}
@@ -0,0 +1,63 @@
1
+ import { useEffect, useSyncExternalStore } from "react";
2
+ import type { Options as MarkdownOptions } from "react-markdown";
3
+ import remarkGfm from "remark-gfm";
4
+
5
+ /**
6
+ * Plugin lists, hoisted out of render.
7
+ *
8
+ * react-markdown builds a fresh unified processor on every render and reruns
9
+ * the whole parse — there is no internal memoization to lean on. Fresh array
10
+ * literals in the JSX made that unavoidable; module constants let the memo
11
+ * below actually hold.
12
+ */
13
+ export const REMARK_PLUGINS = [remarkGfm];
14
+ const NO_REHYPE_PLUGINS: NonNullable<MarkdownOptions["rehypePlugins"]> = [];
15
+
16
+ /**
17
+ * Syntax highlighting arrives after first paint.
18
+ *
19
+ * rehype-highlight pulls in highlight.js, 166 KB of the entry bundle — more
20
+ * than every markdown parser here put together — and nothing on the first paint
21
+ * needs it. It is fetched once, on the first mount of a highlighting renderer;
22
+ * until it lands, code blocks render as plain text and then gain their colours.
23
+ * In practice it is loaded long before the first assistant message arrives.
24
+ *
25
+ * The module-level cache means the fetch happens once per page, not once per
26
+ * message, and useSyncExternalStore is what lets every mounted renderer pick up
27
+ * the plugin the moment it does.
28
+ */
29
+ let highlightPlugins: NonNullable<MarkdownOptions["rehypePlugins"]> | null = null;
30
+ let highlightRequested = false;
31
+ const highlightListeners = new Set<() => void>();
32
+
33
+ function subscribeHighlight(onChange: () => void): () => void {
34
+ highlightListeners.add(onChange);
35
+ return () => {
36
+ highlightListeners.delete(onChange);
37
+ };
38
+ }
39
+
40
+ function readHighlightPlugins(): NonNullable<MarkdownOptions["rehypePlugins"]> {
41
+ return highlightPlugins ?? NO_REHYPE_PLUGINS;
42
+ }
43
+
44
+ export function useRehypePlugins(enabled: boolean) {
45
+ const plugins = useSyncExternalStore(
46
+ subscribeHighlight,
47
+ readHighlightPlugins,
48
+ readHighlightPlugins
49
+ );
50
+ useEffect(() => {
51
+ if (!enabled || highlightRequested) return;
52
+ highlightRequested = true;
53
+ import("rehype-highlight")
54
+ .then((mod) => {
55
+ highlightPlugins = [mod.default];
56
+ for (const listener of highlightListeners) listener();
57
+ })
58
+ .catch(() => {
59
+ // Soft-fail: code blocks stay readable, just uncoloured.
60
+ });
61
+ }, [enabled]);
62
+ return enabled ? plugins : NO_REHYPE_PLUGINS;
63
+ }