devnonla-ui 0.1.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 (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/package.json +66 -0
  4. package/src/alert/Alert.tsx +109 -0
  5. package/src/app/App.tsx +36 -0
  6. package/src/button/Button.tsx +278 -0
  7. package/src/button/ButtonCopy.tsx +60 -0
  8. package/src/calendar/Calendar.tsx +133 -0
  9. package/src/chat/AgentPanel.tsx +203 -0
  10. package/src/chat/common/bgTasks.ts +41 -0
  11. package/src/chat/common/sse.ts +156 -0
  12. package/src/chat/common/types.ts +43 -0
  13. package/src/chat/common/useAgentStream.ts +351 -0
  14. package/src/chat/common/utils.ts +73 -0
  15. package/src/chat/index.ts +56 -0
  16. package/src/chat/message-ui/ChatAgentMessage.tsx +29 -0
  17. package/src/chat/message-ui/ChatError.tsx +24 -0
  18. package/src/chat/message-ui/ChatInput.tsx +162 -0
  19. package/src/chat/message-ui/ChatMarkdown.tsx +128 -0
  20. package/src/chat/message-ui/ChatMarkdownTable.tsx +45 -0
  21. package/src/chat/message-ui/ChatSpinner.tsx +5 -0
  22. package/src/chat/message-ui/ChatThinking.tsx +61 -0
  23. package/src/chat/message-ui/ChatUserMessage.tsx +72 -0
  24. package/src/chat/message-ui/ChatWelcome.tsx +70 -0
  25. package/src/chat/message-ui/MermaidBlock.tsx +266 -0
  26. package/src/chat/message-ui/mermaidFence.ts +55 -0
  27. package/src/chat/message-ui/sanitizeMermaid.ts +135 -0
  28. package/src/chat/tool-ui/BackgroundTaskToolUI.tsx +36 -0
  29. package/src/chat/tool-ui/BackgroundTasksBar.tsx +152 -0
  30. package/src/chat/tool-ui/CallAgentToolUI.tsx +168 -0
  31. package/src/chat/tool-ui/ChatToolCall.tsx +77 -0
  32. package/src/chat/tool-ui/GetCurrentTimeToolUI.tsx +49 -0
  33. package/src/chat/tool-ui/ReadSkillToolUI.tsx +80 -0
  34. package/src/chat/tool-ui/RunJsToolUI.tsx +106 -0
  35. package/src/chat/tool-ui/ToolUiBadge.tsx +10 -0
  36. package/src/chat/tool-ui/ToolUiTrailing.tsx +24 -0
  37. package/src/chat/tool-ui/WebFetchToolUI.tsx +53 -0
  38. package/src/chat/tool-ui/registry.ts +29 -0
  39. package/src/chat/tool-ui/types.ts +22 -0
  40. package/src/checkbox/Checkbox.tsx +50 -0
  41. package/src/codeblock/CodeBlock.tsx +76 -0
  42. package/src/codeblock/highlight.ts +159 -0
  43. package/src/colorpicker/ColorPicker.tsx +45 -0
  44. package/src/datepicker/DatePicker.tsx +481 -0
  45. package/src/desktop/DesktopHeader.tsx +92 -0
  46. package/src/desktop/DesktopIcon.tsx +34 -0
  47. package/src/desktop/DesktopStage.tsx +6 -0
  48. package/src/desktop/DesktopWindow.tsx +504 -0
  49. package/src/desktop/MeadowDesktop.tsx +15 -0
  50. package/src/desktop/MeadowShell.tsx +12 -0
  51. package/src/desktop/MeadowWallpaper.tsx +5 -0
  52. package/src/desktop/assets/desktop-meadow.jpg +0 -0
  53. package/src/desktop/assets.d.ts +9 -0
  54. package/src/desktop/surface.ts +1 -0
  55. package/src/drawer/Drawer.tsx +125 -0
  56. package/src/dropdown/ContextMenu.tsx +97 -0
  57. package/src/dropdown/Dropdown.tsx +139 -0
  58. package/src/dropdown/menuClasses.ts +9 -0
  59. package/src/empty/Empty.tsx +47 -0
  60. package/src/form/Form.tsx +95 -0
  61. package/src/form/FormItem.tsx +145 -0
  62. package/src/form/common/context.ts +13 -0
  63. package/src/form/common/enum.ts +20 -0
  64. package/src/form/common/rules.ts +68 -0
  65. package/src/form/common/types.ts +169 -0
  66. package/src/form/index.ts +35 -0
  67. package/src/form/partials/FormItemHelps.tsx +31 -0
  68. package/src/form/variants/FieldCheckbox.tsx +20 -0
  69. package/src/form/variants/FieldColor.tsx +63 -0
  70. package/src/form/variants/FieldDateTime.tsx +25 -0
  71. package/src/form/variants/FieldHidden.tsx +9 -0
  72. package/src/form/variants/FieldInput.tsx +24 -0
  73. package/src/form/variants/FieldNumber.tsx +28 -0
  74. package/src/form/variants/FieldObject.tsx +29 -0
  75. package/src/form/variants/FieldRadio.tsx +40 -0
  76. package/src/form/variants/FieldRepeater.tsx +62 -0
  77. package/src/form/variants/FieldRepeaterItem.tsx +64 -0
  78. package/src/form/variants/FieldSelect.tsx +27 -0
  79. package/src/form/variants/FieldSelectMultiple.tsx +32 -0
  80. package/src/form/variants/FieldSelectRemote.tsx +66 -0
  81. package/src/form/variants/FieldSwitch.tsx +17 -0
  82. package/src/form/variants/FieldTextarea.tsx +25 -0
  83. package/src/form/variants/FieldTime.tsx +25 -0
  84. package/src/form-layout/FormLayout.tsx +74 -0
  85. package/src/icon/FluentIcon.tsx +28 -0
  86. package/src/icon/fluent.ts +75 -0
  87. package/src/index.ts +214 -0
  88. package/src/input/Input.tsx +371 -0
  89. package/src/input/SearchInput.tsx +73 -0
  90. package/src/lib/cn.ts +6 -0
  91. package/src/lib/placement.ts +44 -0
  92. package/src/lib/sizes.ts +117 -0
  93. package/src/lib/surface.ts +8 -0
  94. package/src/menu/Menu.tsx +98 -0
  95. package/src/message/message.tsx +214 -0
  96. package/src/modal/Modal.tsx +277 -0
  97. package/src/pagination/Pagination.tsx +103 -0
  98. package/src/popconfirm/Popconfirm.tsx +134 -0
  99. package/src/popover/Popover.tsx +116 -0
  100. package/src/scroll/OverlayScroll.tsx +141 -0
  101. package/src/segmented/Segmented.tsx +67 -0
  102. package/src/select/Select.tsx +319 -0
  103. package/src/skeleton/Skeleton.tsx +47 -0
  104. package/src/spin/Spin.tsx +160 -0
  105. package/src/styles.css +897 -0
  106. package/src/switch/Switch.tsx +58 -0
  107. package/src/table/Table.tsx +387 -0
  108. package/src/tag/Tag.tsx +109 -0
  109. package/src/theme.ts +131 -0
  110. package/src/timepicker/TimePicker.tsx +751 -0
  111. package/src/tooltip/Tooltip.tsx +59 -0
@@ -0,0 +1,266 @@
1
+ import { useCallback, useEffect, useId, useRef, useState } from "react";
2
+ import { CodeBlock } from "../../codeblock/CodeBlock";
3
+ import { cn } from "../../lib/cn";
4
+ import { sanitizeMermaid } from "./sanitizeMermaid";
5
+
6
+ export type MermaidBlockProps = {
7
+ children: string;
8
+ className?: string;
9
+ };
10
+
11
+ type MermaidTheme = "light" | "dark";
12
+
13
+ const btnClass = "flex items-center gap-1 px-2 py-1 rounded-lg border border-border text-muted-foreground text-xs cursor-pointer transition-colors hover:text-primary hover:border-primary/30 bg-transparent";
14
+
15
+ function IconSun({ size = 14 }: { size?: number }) {
16
+ return (
17
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
18
+ <circle cx="12" cy="12" r="4" stroke="currentColor" strokeWidth="1.75" />
19
+ <path d="M12 3v2M12 19v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M3 12h2M19 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
20
+ </svg>
21
+ );
22
+ }
23
+
24
+ function IconMoon({ size = 14 }: { size?: number }) {
25
+ return (
26
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
27
+ <path d="M21 14.5A8.5 8.5 0 1 1 12 3a7 7 0 0 0 9 11.5z" stroke="currentColor" strokeWidth="1.75" strokeLinejoin="round" />
28
+ </svg>
29
+ );
30
+ }
31
+
32
+ function IconDownload({ size = 14 }: { size?: number }) {
33
+ return (
34
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
35
+ <path d="M12 4v12M7 12l5 5 5-5M5 20h14" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
36
+ </svg>
37
+ );
38
+ }
39
+
40
+ function IconFullScreen({ size = 14 }: { size?: number }) {
41
+ return (
42
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
43
+ <path d="M8 4H4v4M16 4h4v4M8 20H4v-4M16 20h4v-4" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
44
+ </svg>
45
+ );
46
+ }
47
+
48
+ function IconQuitFullScreen({ size = 16 }: { size?: number }) {
49
+ return (
50
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
51
+ <path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
52
+ </svg>
53
+ );
54
+ }
55
+
56
+ export function MermaidBlock({ children, className }: MermaidBlockProps) {
57
+ const id = useId().replace(/:/g, "_");
58
+ const containerRef = useRef<HTMLDivElement>(null);
59
+ const dialogRef = useRef<HTMLDialogElement>(null);
60
+ const fullscreenRef = useRef<HTMLDivElement>(null);
61
+ const [error, setError] = useState<string | null>(null);
62
+ const [svgContent, setSvgContent] = useState("");
63
+ const [theme, setTheme] = useState<MermaidTheme>("light");
64
+ const [zoom, setZoom] = useState(1);
65
+ const [pan, setPan] = useState({ x: 0, y: 0 });
66
+ const dragRef = useRef({ active: false, startX: 0, startY: 0, panX: 0, panY: 0 });
67
+ const [isDragging, setIsDragging] = useState(false);
68
+
69
+ const isDark = theme === "dark";
70
+ const surfaceClass = isDark ? "bg-[#1e1e1e]" : "bg-card";
71
+
72
+ useEffect(() => {
73
+ let cancelled = false;
74
+
75
+ const render = async () => {
76
+ const { default: mermaid } = await import("mermaid");
77
+ mermaid.initialize({
78
+ startOnLoad: false,
79
+ theme: isDark ? "dark" : "default",
80
+ });
81
+
82
+ const raw = children.trim();
83
+ const renderId = `mermaid-${id}-${theme}`;
84
+
85
+ try {
86
+ document.getElementById(`d${renderId}`)?.remove();
87
+ const { svg } = await mermaid.render(renderId, raw);
88
+ if (!cancelled) {
89
+ setSvgContent(svg);
90
+ setError(null);
91
+ }
92
+ return;
93
+ } catch {
94
+ document.getElementById(`d${renderId}`)?.remove();
95
+ }
96
+
97
+ const sanitized = sanitizeMermaid(raw);
98
+ try {
99
+ const sanitizedId = `${renderId}-s`;
100
+ document.getElementById(sanitizedId)?.remove();
101
+ document.getElementById(`d${sanitizedId}`)?.remove();
102
+ const { svg } = await mermaid.render(sanitizedId, sanitized);
103
+ if (!cancelled) {
104
+ setSvgContent(svg);
105
+ setError(null);
106
+ }
107
+ } catch (err) {
108
+ if (!cancelled) setError(String(err));
109
+ }
110
+ };
111
+
112
+ void render();
113
+ return () => {
114
+ cancelled = true;
115
+ };
116
+ }, [children, id, theme, isDark]);
117
+
118
+ useEffect(() => {
119
+ if (containerRef.current && svgContent) containerRef.current.innerHTML = svgContent;
120
+ }, [svgContent]);
121
+
122
+ useEffect(() => {
123
+ if (dialogRef.current?.open && fullscreenRef.current && svgContent) {
124
+ fullscreenRef.current.innerHTML = svgContent;
125
+ }
126
+ }, [svgContent]);
127
+
128
+ const resetView = useCallback(() => {
129
+ setZoom(1);
130
+ setPan({ x: 0, y: 0 });
131
+ }, []);
132
+
133
+ const toggleTheme = () => setTheme((prev) => (prev === "light" ? "dark" : "light"));
134
+
135
+ const downloadSvg = () => {
136
+ if (!svgContent) return;
137
+ const blob = new Blob([svgContent], { type: "image/svg+xml;charset=utf-8" });
138
+ const url = URL.createObjectURL(blob);
139
+ const a = document.createElement("a");
140
+ a.href = url;
141
+ a.download = "diagram.svg";
142
+ a.click();
143
+ URL.revokeObjectURL(url);
144
+ };
145
+
146
+ const openFullscreen = () => {
147
+ if (dialogRef.current && fullscreenRef.current) {
148
+ fullscreenRef.current.innerHTML = svgContent;
149
+ resetView();
150
+ dialogRef.current.showModal();
151
+ }
152
+ };
153
+
154
+ const closeFullscreen = () => {
155
+ dialogRef.current?.close();
156
+ resetView();
157
+ };
158
+
159
+ const handleWheel = useCallback((e: React.WheelEvent) => {
160
+ e.preventDefault();
161
+ e.stopPropagation();
162
+ setZoom((prev) => {
163
+ const delta = e.deltaY > 0 ? 0.9 : 1.1;
164
+ return Math.min(5, Math.max(0.3, prev * delta));
165
+ });
166
+ }, []);
167
+
168
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
169
+ if ((e.target as HTMLElement).closest("button")) return;
170
+ e.preventDefault();
171
+ dragRef.current = { active: true, startX: e.clientX, startY: e.clientY, panX: 0, panY: 0 };
172
+ setIsDragging(true);
173
+ setPan((prev) => {
174
+ dragRef.current.panX = prev.x;
175
+ dragRef.current.panY = prev.y;
176
+ return prev;
177
+ });
178
+ }, []);
179
+
180
+ const handleMouseMove = useCallback((e: React.MouseEvent) => {
181
+ const d = dragRef.current;
182
+ if (!d.active) return;
183
+ e.preventDefault();
184
+ setPan({ x: d.panX + (e.clientX - d.startX), y: d.panY + (e.clientY - d.startY) });
185
+ }, []);
186
+
187
+ const handleMouseUp = useCallback(() => {
188
+ dragRef.current.active = false;
189
+ setIsDragging(false);
190
+ }, []);
191
+
192
+ const handleMouseLeave = useCallback(() => {
193
+ if (dragRef.current.active) {
194
+ dragRef.current.active = false;
195
+ setIsDragging(false);
196
+ }
197
+ }, []);
198
+
199
+ const isDefaultView = zoom === 1 && pan.x === 0 && pan.y === 0;
200
+
201
+ if (error) {
202
+ return (
203
+ <div className={cn("my-3 rounded-lg bg-accent border border-destructive/30 p-4 text-xs text-destructive", className)}>
204
+ <p className="font-medium mb-1">Mermaid render error</p>
205
+ <pre className="whitespace-pre-wrap text-[11px] opacity-70">{error}</pre>
206
+ </div>
207
+ );
208
+ }
209
+
210
+ if (!svgContent) {
211
+ return <CodeBlock code={children} language="mermaid" wordWrap className={cn("my-3 last:mb-0", className)} />;
212
+ }
213
+
214
+ return (
215
+ <>
216
+ <div className={cn("my-3 last:mb-0 group relative rounded-xl border border-border overflow-hidden", surfaceClass, className)}>
217
+ <div className="absolute top-2 right-2 z-10 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
218
+ <button type="button" onClick={toggleTheme} className={cn(btnClass, surfaceClass)} title={isDark ? "Light theme" : "Dark theme"}>
219
+ {isDark ? <IconSun /> : <IconMoon />}
220
+ </button>
221
+ <button type="button" onClick={downloadSvg} className={cn(btnClass, surfaceClass)} title="Download SVG" disabled={!svgContent}>
222
+ <IconDownload />
223
+ </button>
224
+ <button type="button" onClick={openFullscreen} className={cn(btnClass, surfaceClass)} title="Fullscreen">
225
+ <IconFullScreen />
226
+ </button>
227
+ </div>
228
+ <div ref={containerRef} className="flex justify-center p-6 overflow-x-auto [&_svg]:max-w-full" />
229
+ </div>
230
+
231
+ <dialog
232
+ ref={dialogRef}
233
+ className={cn("m-0 p-0 w-screen h-screen max-w-none max-h-none backdrop:bg-black/40 open:flex open:flex-col", surfaceClass)}
234
+ onKeyDown={(e) => {
235
+ if (e.key === "Escape") closeFullscreen();
236
+ }}
237
+ >
238
+ <div className="fixed top-5 right-5 z-50 flex items-center gap-1.5">
239
+ <button type="button" onClick={toggleTheme} className={cn(btnClass, surfaceClass, "px-3 py-2 text-sm shadow-md")} title={isDark ? "Light theme" : "Dark theme"}>
240
+ {isDark ? <IconSun size={16} /> : <IconMoon size={16} />}
241
+ </button>
242
+ <button type="button" onClick={downloadSvg} className={cn(btnClass, surfaceClass, "px-3 py-2 text-sm shadow-md")} title="Download SVG" disabled={!svgContent}>
243
+ <IconDownload size={16} />
244
+ </button>
245
+ <button type="button" onClick={closeFullscreen} className={cn(btnClass, surfaceClass, "px-3 py-2 text-sm shadow-md")} title="Exit fullscreen">
246
+ <IconQuitFullScreen />
247
+ <span>Exit</span>
248
+ </button>
249
+ </div>
250
+
251
+ <div className={cn("fixed bottom-5 left-1/2 -translate-x-1/2 z-50 flex items-center gap-2 px-3 py-1.5 rounded-full border border-border text-xs text-muted-foreground shadow-md select-none", surfaceClass)}>
252
+ <span>{Math.round(zoom * 100)}%</span>
253
+ {!isDefaultView ? (
254
+ <button type="button" onClick={resetView} className="text-muted-foreground hover:text-primary cursor-pointer transition-colors border-0 bg-transparent p-0 font-[inherit]">
255
+ Reset
256
+ </button>
257
+ ) : null}
258
+ </div>
259
+
260
+ <div onWheel={handleWheel} onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseLeave} onDoubleClick={resetView} className={cn("flex-1 overflow-hidden w-full h-full select-none", isDragging ? "cursor-grabbing" : "cursor-grab")}>
261
+ <div ref={fullscreenRef} className="flex items-center justify-center w-full h-full origin-center [&_svg]:max-w-none [&_svg]:max-h-none pointer-events-none" style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})` }} />
262
+ </div>
263
+ </dialog>
264
+ </>
265
+ );
266
+ }
@@ -0,0 +1,55 @@
1
+ type FenceScan = {
2
+ mermaidCount: number;
3
+ hasUnclosedMermaid: boolean;
4
+ };
5
+
6
+ export function scanMermaidFences(markdown: string): FenceScan {
7
+ let openLang: string | null = null;
8
+ let mermaidCount = 0;
9
+
10
+ for (const line of markdown.split("\n")) {
11
+ const m = /^(```+|~~~+)(.*)$/.exec(line);
12
+ if (!m) continue;
13
+
14
+ const info = m[2].trim();
15
+ if (openLang === null) {
16
+ openLang = (info.split(/\s+/)[0] ?? "").toLowerCase();
17
+ if (openLang === "mermaid") mermaidCount += 1;
18
+ } else if (!info) {
19
+ openLang = null;
20
+ }
21
+ }
22
+
23
+ return { mermaidCount, hasUnclosedMermaid: openLang === "mermaid" };
24
+ }
25
+
26
+ /** Body of the still-open mermaid fence (no closing ```), or null if none. */
27
+ export function getOpenMermaidBody(markdown: string): string | null {
28
+ let openLang: string | null = null;
29
+ let bodyStart = -1;
30
+ const lines = markdown.split("\n");
31
+
32
+ for (let i = 0; i < lines.length; i++) {
33
+ const m = /^(```+|~~~+)(.*)$/.exec(lines[i]!);
34
+ if (!m) continue;
35
+
36
+ const info = m[2]!.trim();
37
+ if (openLang === null) {
38
+ openLang = (info.split(/\s+/)[0] ?? "").toLowerCase();
39
+ bodyStart = openLang === "mermaid" ? i + 1 : -1;
40
+ } else if (!info) {
41
+ openLang = null;
42
+ bodyStart = -1;
43
+ }
44
+ }
45
+
46
+ if (openLang !== "mermaid" || bodyStart < 0) return null;
47
+ return lines.slice(bodyStart).join("\n").replace(/\n$/, "");
48
+ }
49
+
50
+ /** True when this mermaid code block is the still-streaming (unclosed) fence. */
51
+ export function isPendingMermaidBlock(markdown: string, codeText: string, streaming: boolean): boolean {
52
+ if (!streaming) return false;
53
+ const openBody = getOpenMermaidBody(markdown);
54
+ return openBody !== null && openBody === codeText;
55
+ }
@@ -0,0 +1,135 @@
1
+ const SHAPES: [string, string][] = [
2
+ ["([", "])"],
3
+ ["[[", "]]"],
4
+ ["[(", ")]"],
5
+ ["((", "))"],
6
+ ["{{", "}}"],
7
+ ["[/", "/]"],
8
+ ["[\\", "\\]"],
9
+ ["[", "]"],
10
+ ["(", ")"],
11
+ ["{", "}"],
12
+ ];
13
+
14
+ const KEYWORDS = new Set(["graph", "flowchart", "subgraph", "end", "direction", "click", "style", "classDef", "class", "linkStyle", "sequenceDiagram", "classDiagram", "stateDiagram", "erDiagram", "gantt", "pie", "journey", "gitGraph", "mindmap", "timeline", "TB", "TD", "BT", "RL", "LR"]);
15
+
16
+ const ENTITY: Record<string, string> = {
17
+ "(": "#40;",
18
+ ")": "#41;",
19
+ "[": "#91;",
20
+ "]": "#93;",
21
+ "{": "#123;",
22
+ "}": "#125;",
23
+ "<": "#60;",
24
+ ">": "#62;",
25
+ '"': "#34;",
26
+ };
27
+
28
+ const HAS_BRACKET_RE = /[()[\]{}<>]/;
29
+ const HAS_INNER_QUOTE_RE = /"/;
30
+
31
+ function findCloser(line: string, start: number, closer: string): number {
32
+ let inQ = false;
33
+ for (let i = start; i <= line.length - closer.length; i++) {
34
+ if (line[i] === '"' && (i === 0 || line[i - 1] !== "\\")) inQ = !inQ;
35
+ if (!inQ && line.slice(i, i + closer.length) === closer) return i;
36
+ }
37
+ for (let i = start; i <= line.length - closer.length; i++) {
38
+ if (line.slice(i, i + closer.length) === closer) return i;
39
+ }
40
+ return -1;
41
+ }
42
+
43
+ function escapeLabel(raw: string): string {
44
+ let content = raw.trim();
45
+ if (/^".*"$/.test(content)) content = content.slice(1, -1);
46
+ content = content
47
+ .replace(/<[^>]+>/g, " ")
48
+ .replace(/\s{2,}/g, " ")
49
+ .trim();
50
+ let out = "";
51
+ for (const ch of content) out += ENTITY[ch] ?? ch;
52
+ return `"${out}"`;
53
+ }
54
+
55
+ function fixSubgraphTitles(code: string): string {
56
+ return code.replace(/^(\s*subgraph\s+)(?!")(.*\S.*)$/gm, (_, pre: string, title: string) => {
57
+ const t = title.trim();
58
+ if (/^".*"$/.test(t)) return `${pre}${t}`;
59
+ return /\s/.test(t) || HAS_BRACKET_RE.test(t) ? `${pre}"${t}"` : `${pre}${t}`;
60
+ });
61
+ }
62
+
63
+ function fixLine(line: string): string {
64
+ const trimmed = line.trim();
65
+ if (!trimmed || trimmed.startsWith("%%") || /^subgraph\b/.test(trimmed) || trimmed === "end") {
66
+ return line;
67
+ }
68
+
69
+ let result = "";
70
+ let i = 0;
71
+
72
+ while (i < line.length) {
73
+ const m = line.slice(i).match(/^([a-zA-Z_]\w*)/);
74
+ if (!m) {
75
+ result += line[i];
76
+ i++;
77
+ continue;
78
+ }
79
+
80
+ const id = m[1];
81
+ const afterId = i + id.length;
82
+
83
+ if (KEYWORDS.has(id)) {
84
+ result += id;
85
+ i = afterId;
86
+ continue;
87
+ }
88
+
89
+ let opener: string | null = null;
90
+ let closer: string | null = null;
91
+ for (const [op, cl] of SHAPES) {
92
+ if (line.startsWith(op, afterId)) {
93
+ opener = op;
94
+ closer = cl;
95
+ break;
96
+ }
97
+ }
98
+
99
+ if (!opener || !closer) {
100
+ result += id;
101
+ i = afterId;
102
+ continue;
103
+ }
104
+
105
+ const labelStart = afterId + opener.length;
106
+ const closerPos = findCloser(line, labelStart, closer);
107
+
108
+ if (closerPos === -1) {
109
+ result += id + opener;
110
+ i = labelStart;
111
+ continue;
112
+ }
113
+
114
+ const rawLabel = line.slice(labelStart, closerPos);
115
+ const unquoted = rawLabel.replace(/"[^"]*"/g, "");
116
+ if (HAS_BRACKET_RE.test(unquoted) || HAS_INNER_QUOTE_RE.test(unquoted) || /<[^>]+>/.test(rawLabel) || (rawLabel.match(/"/g) ?? []).length > 2) {
117
+ result += id + opener + escapeLabel(rawLabel) + closer;
118
+ } else {
119
+ result += id + opener + rawLabel + closer;
120
+ }
121
+
122
+ i = closerPos + closer.length;
123
+ }
124
+
125
+ return result;
126
+ }
127
+
128
+ /** Auto-fix common Mermaid syntax issues produced by LLMs. */
129
+ export function sanitizeMermaid(raw: string): string {
130
+ let code = raw.trim();
131
+ code = code.replace(/\*\*([^*]+)\*\*/g, "$1");
132
+ code = code.replace(/(?<!\w)\*([^*]+)\*(?!\w)/g, "$1");
133
+ code = fixSubgraphTitles(code);
134
+ return code.split("\n").map(fixLine).join("\n");
135
+ }
@@ -0,0 +1,36 @@
1
+ import { useEffect, useState } from "react";
2
+ import { formatBgElapsed, parseBgTaskRef } from "../common/bgTasks";
3
+ import { formatToolName, timestampMs } from "../common/utils";
4
+ import { ChatSpinner } from "../message-ui/ChatSpinner";
5
+ import { ToolUiBadge } from "./ToolUiBadge";
6
+ import type { ToolUIProps } from "./types";
7
+
8
+ export function BackgroundTaskToolUI({ msg, assistantLabel = "Assistant", assistantColor, showAvatar = false }: ToolUIProps) {
9
+ const ref = parseBgTaskRef(msg.toolOutput);
10
+ const [now, setNow] = useState(() => Date.now());
11
+ const label = formatToolName(ref?.toolName ?? msg.toolLabel ?? msg.toolName ?? "Tool");
12
+
13
+ useEffect(() => {
14
+ const id = setInterval(() => setNow(Date.now()), 1000);
15
+ return () => clearInterval(id);
16
+ }, []);
17
+
18
+ return (
19
+ <div className="mt-1 animate-fadeIn">
20
+ <ToolUiBadge show={showAvatar} label={assistantLabel} color={assistantColor} />
21
+ <div className="px-4 pb-1">
22
+ <div className="flex gap-0">
23
+ <div className="w-0.5 shrink-0 self-stretch rounded-full bg-brand-700" />
24
+ <div className="min-w-0 flex-1 pl-2">
25
+ <div className="flex items-center gap-2 py-1">
26
+ <ChatSpinner />
27
+ <span className="min-w-0 flex-1 truncate text-[14px] font-medium text-foreground">{label}</span>
28
+ <span className="shrink-0 rounded px-1 py-px text-[9px] font-semibold uppercase tracking-wider text-brand-700">Background</span>
29
+ <span className="shrink-0 text-[14px] tabular-nums text-muted-foreground">{formatBgElapsed(timestampMs(msg.timestamp), now)}</span>
30
+ </div>
31
+ </div>
32
+ </div>
33
+ </div>
34
+ </div>
35
+ );
36
+ }
@@ -0,0 +1,152 @@
1
+ import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react";
2
+ import { FluentIcon } from "../../icon/FluentIcon";
3
+ import { cn } from "../../lib/cn";
4
+ import { Modal } from "../../modal/Modal";
5
+ import { Popconfirm } from "../../popconfirm/Popconfirm";
6
+ import type { ChatBgTask } from "../common/bgTasks";
7
+ import { formatBgElapsed } from "../common/bgTasks";
8
+ import { formatToolName } from "../common/utils";
9
+ import { ChatSpinner } from "../message-ui/ChatSpinner";
10
+
11
+ function TaskLogsModal({
12
+ task,
13
+ open,
14
+ onClose,
15
+ }: {
16
+ task: ChatBgTask | null;
17
+ open: boolean;
18
+ onClose: () => void;
19
+ }) {
20
+ const logs = (task?.console ?? "").trim();
21
+ const preRef = useRef<HTMLPreElement>(null);
22
+
23
+ useEffect(() => {
24
+ const el = preRef.current;
25
+ if (el) el.scrollTop = el.scrollHeight;
26
+ }, [logs]);
27
+
28
+ return (
29
+ <Modal open={open} onCancel={onClose} footer={null} title={task ? formatToolName(task.toolName) : "Logs"} width={560}>
30
+ <pre ref={preRef} className="m-0 max-h-[50vh] overflow-auto rounded-md border border-border-subtle bg-muted px-3 py-2 font-mono text-[14px] leading-relaxed whitespace-pre-wrap break-all text-foreground">
31
+ {logs || "Waiting for output…"}
32
+ </pre>
33
+ </Modal>
34
+ );
35
+ }
36
+
37
+ function TaskRow({
38
+ task,
39
+ now,
40
+ cancelling,
41
+ onCancel,
42
+ onLogs,
43
+ }: {
44
+ task: ChatBgTask;
45
+ now: number;
46
+ cancelling: boolean;
47
+ onCancel: () => void;
48
+ onLogs: () => void;
49
+ }) {
50
+ return (
51
+ <div className="flex items-center gap-2 px-2.5 py-1">
52
+ <ChatSpinner className="size-2.5" />
53
+ <div className="min-w-0 flex-1 truncate text-[14px] text-foreground">{formatToolName(task.toolName)}</div>
54
+ <span className="shrink-0 text-[14px] tabular-nums text-muted-foreground">{formatBgElapsed(task.startedAt, now)}</span>
55
+ <button type="button" onClick={onLogs} title="Logs" aria-label={`Logs ${formatToolName(task.toolName)}`} className="inline-flex size-5 shrink-0 items-center justify-center cursor-pointer rounded text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground">
56
+ <FluentIcon name="document-text-24" size={12} />
57
+ </button>
58
+ <Popconfirm title={`Stop ${formatToolName(task.toolName)}?`} okText="Stop" okType="danger" onConfirm={onCancel} getPopupContainer={() => document.body}>
59
+ <button type="button" disabled={cancelling} title="Stop" aria-label={`Stop ${formatToolName(task.toolName)}`} className="inline-flex h-5 shrink-0 items-center rounded px-1.5 text-[10px] cursor-pointer text-muted-foreground transition-colors hover:bg-secondary hover:text-destructive disabled:opacity-40">
60
+ Stop
61
+ </button>
62
+ </Popconfirm>
63
+ </div>
64
+ );
65
+ }
66
+
67
+ export type BackgroundTasksBarProps = {
68
+ children: ReactNode;
69
+ tasks?: ChatBgTask[];
70
+ cancellingIds?: ReadonlySet<string>;
71
+ onCancel?: (taskId: string) => void | Promise<void>;
72
+ };
73
+
74
+ export function BackgroundTasksBar({ children, tasks = [], cancellingIds, onCancel }: BackgroundTasksBarProps) {
75
+ const [open, setOpen] = useState(false);
76
+ const [now, setNow] = useState(() => Date.now());
77
+ const [logsTaskId, setLogsTaskId] = useState<string | null>(null);
78
+ const logsTask = logsTaskId ? (tasks.find((t) => t.taskId === logsTaskId) ?? null) : null;
79
+
80
+ useEffect(() => {
81
+ if (logsTaskId && !tasks.some((t) => t.taskId === logsTaskId)) {
82
+ setLogsTaskId(null);
83
+ }
84
+ }, [tasks, logsTaskId]);
85
+
86
+ useEffect(() => {
87
+ if (tasks.length === 0) {
88
+ setOpen(false);
89
+ return;
90
+ }
91
+ const id = setInterval(() => setNow(Date.now()), 1000);
92
+ return () => clearInterval(id);
93
+ }, [tasks.length]);
94
+
95
+ const listInnerRef = useRef<HTMLDivElement>(null);
96
+ const [listH, setListH] = useState(0);
97
+
98
+ useLayoutEffect(() => {
99
+ const el = listInnerRef.current;
100
+ if (!el) {
101
+ setListH(0);
102
+ return;
103
+ }
104
+ const update = () => setListH(el.scrollHeight);
105
+ update();
106
+ const ro = new ResizeObserver(update);
107
+ ro.observe(el);
108
+ return () => ro.disconnect();
109
+ }, [tasks.length, open]);
110
+
111
+ const label = tasks.length === 1 ? "1 background task" : `${tasks.length} background tasks`;
112
+ const reserve = tasks.length > 0 ? 28 + (open ? listH : 0) : 0;
113
+
114
+ return (
115
+ <>
116
+ <div className="pointer-events-none shrink-0 overflow-hidden transition-[height] duration-200 ease-out" style={{ height: reserve }} aria-hidden />
117
+ <div className="relative">
118
+ {children}
119
+
120
+ {tasks.length > 0 ? (
121
+ <div className="absolute right-5 bottom-[calc(100%)] left-5 z-20 overflow-hidden rounded-t-lg border-x border-t border-border">
122
+ <div className={cn("flex h-7.5 items-center gap-1 px-1", open && "border-b border-border-subtle")}>
123
+ <button type="button" onClick={() => setOpen((v) => !v)} className="flex min-w-0 flex-1 font-medium items-center gap-1 rounded-md px-1.5 py-1 text-[14px] text-muted-foreground/90 transition-colors hover:text-foreground" aria-expanded={open} aria-label={label}>
124
+ {open ? <FluentIcon name="arrow-square-down-24" size={12} /> : <FluentIcon name="arrow-square-down-24" size={12} className="-rotate-90" />}
125
+ <span className="truncate">{label}</span>
126
+ </button>
127
+ </div>
128
+
129
+ <div className={cn("overflow-hidden transition-[max-height] duration-200 ease-out", open ? "max-h-55" : "max-h-0")}>
130
+ <div ref={listInnerRef} className="max-h-55 overflow-y-auto py-0.5">
131
+ {tasks.map((task) => (
132
+ <TaskRow
133
+ key={task.taskId}
134
+ task={task}
135
+ now={now}
136
+ cancelling={cancellingIds?.has(task.taskId) ?? false}
137
+ onCancel={() => {
138
+ void onCancel?.(task.taskId);
139
+ }}
140
+ onLogs={() => setLogsTaskId(task.taskId)}
141
+ />
142
+ ))}
143
+ </div>
144
+ </div>
145
+ </div>
146
+ ) : null}
147
+ </div>
148
+
149
+ <TaskLogsModal task={logsTask} open={Boolean(logsTask)} onClose={() => setLogsTaskId(null)} />
150
+ </>
151
+ );
152
+ }