pmx-canvas 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 (226) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/LICENSE +21 -0
  3. package/Readme.md +865 -0
  4. package/dist/canvas/global.css +3173 -0
  5. package/dist/canvas/index.js +183 -0
  6. package/dist/json-render/index.css +2 -0
  7. package/dist/json-render/index.js +389 -0
  8. package/dist/types/cli/agent.d.ts +13 -0
  9. package/dist/types/cli/index.d.ts +2 -0
  10. package/dist/types/cli/watch.d.ts +5 -0
  11. package/dist/types/client/App.d.ts +1 -0
  12. package/dist/types/client/canvas/AttentionHistory.d.ts +1 -0
  13. package/dist/types/client/canvas/AttentionToast.d.ts +1 -0
  14. package/dist/types/client/canvas/CanvasNode.d.ts +8 -0
  15. package/dist/types/client/canvas/CanvasViewport.d.ts +8 -0
  16. package/dist/types/client/canvas/CommandPalette.d.ts +4 -0
  17. package/dist/types/client/canvas/ContextMenu.d.ts +24 -0
  18. package/dist/types/client/canvas/ContextPinBar.d.ts +1 -0
  19. package/dist/types/client/canvas/ContextPinHud.d.ts +1 -0
  20. package/dist/types/client/canvas/DockedNode.d.ts +4 -0
  21. package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
  22. package/dist/types/client/canvas/ExpandedNodeOverlay.d.ts +1 -0
  23. package/dist/types/client/canvas/FocusFieldLayer.d.ts +1 -0
  24. package/dist/types/client/canvas/Minimap.d.ts +23 -0
  25. package/dist/types/client/canvas/SelectionBar.d.ts +1 -0
  26. package/dist/types/client/canvas/ShortcutOverlay.d.ts +3 -0
  27. package/dist/types/client/canvas/SnapshotPanel.d.ts +7 -0
  28. package/dist/types/client/canvas/snap-guides.d.ts +23 -0
  29. package/dist/types/client/canvas/use-node-drag.d.ts +15 -0
  30. package/dist/types/client/canvas/use-node-resize.d.ts +15 -0
  31. package/dist/types/client/canvas/use-pan-zoom.d.ts +16 -0
  32. package/dist/types/client/ext-app/bridge.d.ts +161 -0
  33. package/dist/types/client/icons.d.ts +70 -0
  34. package/dist/types/client/index.d.ts +1 -0
  35. package/dist/types/client/nodes/ContextNode.d.ts +34 -0
  36. package/dist/types/client/nodes/ExtAppFrame.d.ts +18 -0
  37. package/dist/types/client/nodes/FileNode.d.ts +5 -0
  38. package/dist/types/client/nodes/GroupNode.d.ts +6 -0
  39. package/dist/types/client/nodes/ImageNode.d.ts +10 -0
  40. package/dist/types/client/nodes/InlineFormatBar.d.ts +7 -0
  41. package/dist/types/client/nodes/InlineMarkdownEditor.d.ts +14 -0
  42. package/dist/types/client/nodes/LedgerNode.d.ts +4 -0
  43. package/dist/types/client/nodes/MarkdownNode.d.ts +6 -0
  44. package/dist/types/client/nodes/McpAppNode.d.ts +4 -0
  45. package/dist/types/client/nodes/MdFormatBar.d.ts +8 -0
  46. package/dist/types/client/nodes/PromptNode.d.ts +5 -0
  47. package/dist/types/client/nodes/ResponseNode.d.ts +5 -0
  48. package/dist/types/client/nodes/StatusNode.d.ts +4 -0
  49. package/dist/types/client/nodes/StatusSummary.d.ts +4 -0
  50. package/dist/types/client/nodes/TraceNode.d.ts +4 -0
  51. package/dist/types/client/nodes/WebpageNode.d.ts +5 -0
  52. package/dist/types/client/nodes/image-warnings.d.ts +6 -0
  53. package/dist/types/client/nodes/inline-editor-commands.d.ts +11 -0
  54. package/dist/types/client/nodes/md-format.d.ts +25 -0
  55. package/dist/types/client/state/attention-bridge.d.ts +3 -0
  56. package/dist/types/client/state/attention-store.d.ts +25 -0
  57. package/dist/types/client/state/canvas-store.d.ts +74 -0
  58. package/dist/types/client/state/intent-bridge.d.ts +158 -0
  59. package/dist/types/client/state/sse-bridge.d.ts +5 -0
  60. package/dist/types/client/theme/tokens.d.ts +27 -0
  61. package/dist/types/client/types.d.ts +40 -0
  62. package/dist/types/client/utils/ext-app-tool-result.d.ts +1 -0
  63. package/dist/types/client/utils/placement.d.ts +1 -0
  64. package/dist/types/client/utils/platform.d.ts +2 -0
  65. package/dist/types/json-render/catalog.d.ts +815 -0
  66. package/dist/types/json-render/charts/components.d.ts +54 -0
  67. package/dist/types/json-render/charts/definitions.d.ts +103 -0
  68. package/dist/types/json-render/charts/extra-components.d.ts +58 -0
  69. package/dist/types/json-render/charts/extra-definitions.d.ts +181 -0
  70. package/dist/types/json-render/renderer/index.d.ts +16 -0
  71. package/dist/types/json-render/schema.d.ts +46 -0
  72. package/dist/types/json-render/server.d.ts +55 -0
  73. package/dist/types/mcp/server.d.ts +22 -0
  74. package/dist/types/server/agent-context.d.ts +21 -0
  75. package/dist/types/server/artifact-paths.d.ts +3 -0
  76. package/dist/types/server/canvas-operations.d.ts +154 -0
  77. package/dist/types/server/canvas-provenance.d.ts +13 -0
  78. package/dist/types/server/canvas-schema.d.ts +49 -0
  79. package/dist/types/server/canvas-serialization.d.ts +25 -0
  80. package/dist/types/server/canvas-state.d.ts +174 -0
  81. package/dist/types/server/canvas-validation.d.ts +33 -0
  82. package/dist/types/server/chart-template.d.ts +29 -0
  83. package/dist/types/server/code-graph.d.ts +67 -0
  84. package/dist/types/server/context-cards.d.ts +24 -0
  85. package/dist/types/server/diagram-presets.d.ts +28 -0
  86. package/dist/types/server/ext-app-call-registry.d.ts +16 -0
  87. package/dist/types/server/ext-app-tool-result.d.ts +1 -0
  88. package/dist/types/server/file-watcher.d.ts +16 -0
  89. package/dist/types/server/index.d.ts +243 -0
  90. package/dist/types/server/mcp-app-candidate.d.ts +25 -0
  91. package/dist/types/server/mcp-app-host.d.ts +65 -0
  92. package/dist/types/server/mcp-app-runtime.d.ts +47 -0
  93. package/dist/types/server/mutation-history.d.ts +105 -0
  94. package/dist/types/server/placement.d.ts +37 -0
  95. package/dist/types/server/server.d.ts +103 -0
  96. package/dist/types/server/spatial-analysis.d.ts +87 -0
  97. package/dist/types/server/trace-manager.d.ts +48 -0
  98. package/dist/types/server/web-artifacts.d.ts +50 -0
  99. package/dist/types/server/webpage-node.d.ts +25 -0
  100. package/dist/types/shared/auto-arrange.d.ts +29 -0
  101. package/dist/types/shared/ext-app-tool-result.d.ts +9 -0
  102. package/dist/types/shared/placement.d.ts +26 -0
  103. package/dist/types/shared/semantic-attention.d.ts +97 -0
  104. package/package.json +109 -0
  105. package/skills/data-analysis/SKILL.md +324 -0
  106. package/skills/doc-coauthoring/SKILL.md +375 -0
  107. package/skills/frontend-design/SKILL.md +45 -0
  108. package/skills/json-render-codegen/SKILL.md +112 -0
  109. package/skills/json-render-core/SKILL.md +265 -0
  110. package/skills/json-render-ink/SKILL.md +273 -0
  111. package/skills/json-render-mcp/SKILL.md +132 -0
  112. package/skills/json-render-react/SKILL.md +264 -0
  113. package/skills/json-render-shadcn/SKILL.md +159 -0
  114. package/skills/playwright-cli/SKILL.md +67 -0
  115. package/skills/pmx-canvas/SKILL.md +668 -0
  116. package/skills/pmx-canvas/evals/evals.json +186 -0
  117. package/skills/pmx-canvas-testing/SKILL.md +78 -0
  118. package/skills/published-consumer-e2e/SKILL.md +43 -0
  119. package/skills/published-consumer-e2e/scripts/run-published-consumer-e2e.sh +241 -0
  120. package/skills/web-artifacts-builder/SKILL.md +80 -0
  121. package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +167 -0
  122. package/skills/web-artifacts-builder/scripts/init-artifact.sh +425 -0
  123. package/skills/web-artifacts-builder/scripts/shadcn-components.tar.gz +0 -0
  124. package/skills/web-design-guidelines/SKILL.md +39 -0
  125. package/src/cli/agent.ts +2144 -0
  126. package/src/cli/index.ts +622 -0
  127. package/src/cli/watch.ts +88 -0
  128. package/src/client/App.tsx +507 -0
  129. package/src/client/canvas/AttentionHistory.tsx +81 -0
  130. package/src/client/canvas/AttentionToast.tsx +19 -0
  131. package/src/client/canvas/CanvasNode.tsx +363 -0
  132. package/src/client/canvas/CanvasViewport.tsx +590 -0
  133. package/src/client/canvas/CommandPalette.tsx +302 -0
  134. package/src/client/canvas/ContextMenu.tsx +601 -0
  135. package/src/client/canvas/ContextPinBar.tsx +25 -0
  136. package/src/client/canvas/ContextPinHud.tsx +22 -0
  137. package/src/client/canvas/DockedNode.tsx +66 -0
  138. package/src/client/canvas/EdgeLayer.tsx +280 -0
  139. package/src/client/canvas/ExpandedNodeOverlay.tsx +260 -0
  140. package/src/client/canvas/FocusFieldLayer.tsx +107 -0
  141. package/src/client/canvas/Minimap.tsx +301 -0
  142. package/src/client/canvas/SelectionBar.tsx +69 -0
  143. package/src/client/canvas/ShortcutOverlay.tsx +69 -0
  144. package/src/client/canvas/SnapshotPanel.tsx +236 -0
  145. package/src/client/canvas/snap-guides.ts +170 -0
  146. package/src/client/canvas/use-node-drag.ts +51 -0
  147. package/src/client/canvas/use-node-resize.ts +59 -0
  148. package/src/client/canvas/use-pan-zoom.ts +191 -0
  149. package/src/client/ext-app/bridge.ts +542 -0
  150. package/src/client/icons.tsx +424 -0
  151. package/src/client/index.tsx +7 -0
  152. package/src/client/nodes/ContextNode.tsx +412 -0
  153. package/src/client/nodes/ExtAppFrame.tsx +509 -0
  154. package/src/client/nodes/FileNode.tsx +256 -0
  155. package/src/client/nodes/GroupNode.tsx +39 -0
  156. package/src/client/nodes/ImageNode.tsx +160 -0
  157. package/src/client/nodes/InlineFormatBar.tsx +169 -0
  158. package/src/client/nodes/InlineMarkdownEditor.tsx +123 -0
  159. package/src/client/nodes/LedgerNode.tsx +37 -0
  160. package/src/client/nodes/MarkdownNode.tsx +359 -0
  161. package/src/client/nodes/McpAppNode.tsx +85 -0
  162. package/src/client/nodes/MdFormatBar.tsx +109 -0
  163. package/src/client/nodes/PromptNode.tsx +597 -0
  164. package/src/client/nodes/ResponseNode.tsx +153 -0
  165. package/src/client/nodes/StatusNode.tsx +84 -0
  166. package/src/client/nodes/StatusSummary.tsx +38 -0
  167. package/src/client/nodes/TraceNode.tsx +120 -0
  168. package/src/client/nodes/WebpageNode.tsx +288 -0
  169. package/src/client/nodes/image-warnings.ts +95 -0
  170. package/src/client/nodes/inline-editor-commands.ts +37 -0
  171. package/src/client/nodes/md-format.ts +206 -0
  172. package/src/client/state/attention-bridge.ts +328 -0
  173. package/src/client/state/attention-store.ts +73 -0
  174. package/src/client/state/canvas-store.ts +631 -0
  175. package/src/client/state/intent-bridge.ts +315 -0
  176. package/src/client/state/sse-bridge.ts +965 -0
  177. package/src/client/theme/global.css +3173 -0
  178. package/src/client/theme/tokens.ts +72 -0
  179. package/src/client/types-shims.d.ts +5 -0
  180. package/src/client/types.ts +81 -0
  181. package/src/client/utils/ext-app-tool-result.ts +4 -0
  182. package/src/client/utils/placement.ts +4 -0
  183. package/src/client/utils/platform.ts +2 -0
  184. package/src/json-render/catalog.ts +256 -0
  185. package/src/json-render/charts/components.tsx +198 -0
  186. package/src/json-render/charts/definitions.ts +81 -0
  187. package/src/json-render/charts/extra-components.tsx +267 -0
  188. package/src/json-render/charts/extra-definitions.ts +145 -0
  189. package/src/json-render/renderer/index.css +174 -0
  190. package/src/json-render/renderer/index.tsx +86 -0
  191. package/src/json-render/schema.ts +62 -0
  192. package/src/json-render/server.ts +597 -0
  193. package/src/mcp/server.ts +1377 -0
  194. package/src/server/agent-context.ts +242 -0
  195. package/src/server/artifact-paths.ts +17 -0
  196. package/src/server/canvas-operations.ts +1279 -0
  197. package/src/server/canvas-provenance.ts +243 -0
  198. package/src/server/canvas-schema.ts +432 -0
  199. package/src/server/canvas-serialization.ts +95 -0
  200. package/src/server/canvas-state.ts +1134 -0
  201. package/src/server/canvas-validation.ts +114 -0
  202. package/src/server/chart-template.ts +449 -0
  203. package/src/server/code-graph.ts +370 -0
  204. package/src/server/context-cards.ts +31 -0
  205. package/src/server/diagram-presets.ts +71 -0
  206. package/src/server/ext-app-call-registry.ts +77 -0
  207. package/src/server/ext-app-tool-result.ts +4 -0
  208. package/src/server/file-watcher.ts +121 -0
  209. package/src/server/index.ts +647 -0
  210. package/src/server/mcp-app-candidate.ts +174 -0
  211. package/src/server/mcp-app-host.ts +814 -0
  212. package/src/server/mcp-app-runtime.ts +459 -0
  213. package/src/server/mutation-history.ts +350 -0
  214. package/src/server/placement.ts +125 -0
  215. package/src/server/server.ts +3846 -0
  216. package/src/server/spatial-analysis.ts +356 -0
  217. package/src/server/trace-manager.ts +333 -0
  218. package/src/server/web-artifacts/scripts/bundle-artifact.sh +167 -0
  219. package/src/server/web-artifacts/scripts/init-artifact.sh +426 -0
  220. package/src/server/web-artifacts/scripts/shadcn-components.tar.gz +0 -0
  221. package/src/server/web-artifacts.ts +442 -0
  222. package/src/server/webpage-node.ts +328 -0
  223. package/src/shared/auto-arrange.ts +439 -0
  224. package/src/shared/ext-app-tool-result.ts +76 -0
  225. package/src/shared/placement.ts +81 -0
  226. package/src/shared/semantic-attention.ts +598 -0
@@ -0,0 +1,109 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
2
+ import { isMac } from '../utils/platform';
3
+ import { type FormatAction, FORMAT_ACTIONS, getSelectionRect } from './md-format';
4
+
5
+ const PRIMARY_ACTIONS = FORMAT_ACTIONS.filter((a) => a.shortcut);
6
+ const SECONDARY_ACTIONS = FORMAT_ACTIONS.filter((a) => !a.shortcut);
7
+
8
+ /**
9
+ * Floating format toolbar that appears above text selections in markdown textareas.
10
+ */
11
+ export function MdFormatBar({ textareaRef }: { textareaRef: { current: HTMLTextAreaElement | null } }) {
12
+ const [visible, setVisible] = useState(false);
13
+ const [pos, setPos] = useState({ top: 0, left: 0 });
14
+ const barRef = useRef<HTMLDivElement>(null);
15
+ const hideTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
16
+
17
+ const updatePosition = useCallback(() => {
18
+ const ta = textareaRef.current;
19
+ if (!ta || ta.selectionStart === ta.selectionEnd) {
20
+ setVisible(false);
21
+ return;
22
+ }
23
+ const rect = getSelectionRect(ta);
24
+ if (!rect) {
25
+ setVisible(false);
26
+ return;
27
+ }
28
+ const barWidth = 380;
29
+ const left = Math.max(8, Math.min(rect.left + rect.width / 2 - barWidth / 2, window.innerWidth - barWidth - 8));
30
+ setPos({ top: rect.top - 40, left });
31
+ setVisible(true);
32
+ }, [textareaRef]);
33
+
34
+ useEffect(() => {
35
+ const ta = textareaRef.current;
36
+ if (!ta) return;
37
+
38
+ const handleSelect = () => {
39
+ if (hideTimeout.current) clearTimeout(hideTimeout.current);
40
+ hideTimeout.current = setTimeout(updatePosition, 50);
41
+ };
42
+
43
+ const handleBlur = () => {
44
+ hideTimeout.current = setTimeout(() => setVisible(false), 200);
45
+ };
46
+
47
+ const handleKeyUp = (e: KeyboardEvent) => {
48
+ if (e.shiftKey || e.key.startsWith('Arrow')) handleSelect();
49
+ };
50
+
51
+ ta.addEventListener('select', handleSelect);
52
+ ta.addEventListener('mouseup', handleSelect);
53
+ ta.addEventListener('keyup', handleKeyUp);
54
+ ta.addEventListener('blur', handleBlur);
55
+
56
+ return () => {
57
+ ta.removeEventListener('select', handleSelect);
58
+ ta.removeEventListener('mouseup', handleSelect);
59
+ ta.removeEventListener('keyup', handleKeyUp);
60
+ ta.removeEventListener('blur', handleBlur);
61
+ if (hideTimeout.current) clearTimeout(hideTimeout.current);
62
+ };
63
+ }, [textareaRef, updatePosition]);
64
+
65
+ const runAction = useCallback((action: FormatAction) => {
66
+ const ta = textareaRef.current;
67
+ if (ta) {
68
+ action.action(ta);
69
+ ta.focus();
70
+ }
71
+ }, [textareaRef]);
72
+
73
+ if (!visible) return null;
74
+
75
+ const modLabel = isMac ? '⌘' : 'Ctrl';
76
+
77
+ return (
78
+ <div
79
+ ref={barRef}
80
+ class="md-format-bar"
81
+ style={{ top: `${pos.top}px`, left: `${pos.left}px` }}
82
+ onMouseDown={(e) => e.preventDefault()}
83
+ >
84
+ {PRIMARY_ACTIONS.map((a) => (
85
+ <button
86
+ key={a.key}
87
+ type="button"
88
+ class={`md-format-btn md-format-btn-${a.key}`}
89
+ title={`${a.label} (${modLabel}+${a.shortcut!.toUpperCase()})`}
90
+ onClick={() => runAction(a)}
91
+ >
92
+ {a.icon}
93
+ </button>
94
+ ))}
95
+ <div class="md-format-divider" />
96
+ {SECONDARY_ACTIONS.map((a) => (
97
+ <button
98
+ key={a.key}
99
+ type="button"
100
+ class={`md-format-btn md-format-btn-${a.key}`}
101
+ title={a.label}
102
+ onClick={() => runAction(a)}
103
+ >
104
+ {a.icon}
105
+ </button>
106
+ ))}
107
+ </div>
108
+ );
109
+ }
@@ -0,0 +1,597 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
2
+ import { updateNodeData } from '../state/canvas-store';
3
+ import {
4
+ fetchSlashCommands,
5
+ renderMarkdown,
6
+ submitCanvasPrompt,
7
+ submitThreadReply,
8
+ } from '../state/intent-bridge';
9
+ import type { CanvasNodeState } from '../types';
10
+
11
+ // Cached slash commands — fetched once on first use.
12
+ let cachedCommands: Array<{ name: string; description: string }> | null = null;
13
+ async function getCommands() {
14
+ if (!cachedCommands) cachedCommands = await fetchSlashCommands();
15
+ return cachedCommands;
16
+ }
17
+
18
+ /** Find the best matching slash command for a query prefix. */
19
+ function matchSlashCommand(
20
+ query: string,
21
+ commands: Array<{ name: string }>,
22
+ ): string | null {
23
+ if (!query) return null;
24
+ const lower = query.toLowerCase();
25
+ const exact = commands.find((c) => c.name.toLowerCase() === lower);
26
+ if (exact) return exact.name;
27
+ const prefix = commands.filter((c) => c.name.toLowerCase().startsWith(lower));
28
+ return prefix.length === 1 ? prefix[0].name : null;
29
+ }
30
+
31
+ interface ThreadTurn {
32
+ role: 'user' | 'assistant';
33
+ text: string;
34
+ status?: string;
35
+ }
36
+
37
+ /** Strip dangerous HTML from rendered markdown to prevent XSS. */
38
+ function sanitizeHtml(html: string): string {
39
+ return html
40
+ .replace(/<script[\s>][\s\S]*?<\/script>/gi, '')
41
+ .replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, '')
42
+ .replace(/<object[\s>][\s\S]*?<\/object>/gi, '')
43
+ .replace(/<embed[\s>][\s\S]*?(?:\/>|<\/embed>)/gi, '')
44
+ .replace(/<link[\s>][\s\S]*?(?:\/>|<\/link>)/gi, '')
45
+ .replace(/\bon\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
46
+ .replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href="#"')
47
+ .replace(/href\s*=\s*'javascript:[^']*'/gi, "href='#'");
48
+ }
49
+
50
+ function RenderedMarkdown({ html }: { html: string }) {
51
+ const containerRef = useRef<HTMLDivElement>(null);
52
+
53
+ useEffect(() => {
54
+ const container = containerRef.current;
55
+ if (!container) return;
56
+ container.replaceChildren();
57
+ if (!html) return;
58
+
59
+ const template = document.createElement('template');
60
+ template.innerHTML = html;
61
+ container.append(template.content.cloneNode(true));
62
+ }, [html]);
63
+
64
+ return <div ref={containerRef} />;
65
+ }
66
+
67
+ function getThreadTurnKey(turn: ThreadTurn, index: number): string {
68
+ return `${turn.role}:${turn.status ?? 'none'}:${index}:${turn.text}`;
69
+ }
70
+
71
+ function ContextBadge({ count }: { count: number }) {
72
+ if (count === 0) return null;
73
+ return (
74
+ <div
75
+ style={{
76
+ padding: '4px 8px',
77
+ fontSize: '11px',
78
+ color: 'var(--c-accent)',
79
+ background: 'rgba(70,182,255,0.08)',
80
+ borderRadius: 'var(--radius-sm)',
81
+ flexShrink: 0,
82
+ }}
83
+ >
84
+ {'\u2726'} {count} context node{count !== 1 ? 's' : ''} attached
85
+ </div>
86
+ );
87
+ }
88
+
89
+ function ErrorBanner({ message, onDismiss }: { message: string | null; onDismiss: () => void }) {
90
+ if (!message) return null;
91
+ return (
92
+ <div
93
+ style={{
94
+ padding: '4px 8px',
95
+ fontSize: '11px',
96
+ color: 'var(--c-danger)',
97
+ background: 'rgba(255,80,80,0.08)',
98
+ borderRadius: 'var(--radius-sm)',
99
+ display: 'flex',
100
+ alignItems: 'center',
101
+ gap: '6px',
102
+ flexShrink: 0,
103
+ }}
104
+ >
105
+ <span style={{ flex: 1 }}>{message}</span>
106
+ <button
107
+ type="button"
108
+ onClick={onDismiss}
109
+ style={{
110
+ background: 'none',
111
+ border: 'none',
112
+ color: 'var(--c-danger)',
113
+ cursor: 'pointer',
114
+ fontSize: '13px',
115
+ padding: '0 2px',
116
+ }}
117
+ >
118
+ {'\u00d7'}
119
+ </button>
120
+ </div>
121
+ );
122
+ }
123
+
124
+ export function PromptNode({
125
+ node,
126
+ expanded = false,
127
+ }: { node: CanvasNodeState; expanded?: boolean }) {
128
+ // Backward compat: old canvas states have flat { text, status } data.
129
+ // New threads use { turns[], threadStatus }. Detect format by presence of turns array.
130
+ const turns: ThreadTurn[] = Array.isArray(node.data.turns)
131
+ ? (node.data.turns as ThreadTurn[])
132
+ : [];
133
+ const isLegacy = turns.length === 0;
134
+ const legacyText = (node.data.text as string) || '';
135
+ const legacyStatus = (node.data.status as string) || 'draft';
136
+
137
+ const threadStatus = isLegacy ? legacyStatus : (node.data.threadStatus as string) || 'draft';
138
+ const isDraft = threadStatus === 'draft';
139
+ const isPending = threadStatus === 'pending' || threadStatus === 'sending';
140
+ const isStreaming = threadStatus === 'streaming';
141
+ const isAnswered = threadStatus === 'answered';
142
+
143
+ const [draft, setDraft] = useState('');
144
+ const [replyDraft, setReplyDraft] = useState('');
145
+ const [error, setError] = useState<string | null>(null);
146
+ const [renderedTurns, setRenderedTurns] = useState<Map<number, string>>(new Map());
147
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
148
+ const bodyRef = useRef<HTMLDivElement>(null);
149
+ // Lightweight scroll key: tracks turn count + last turn length bucket (every 200 chars)
150
+ // to avoid re-scrolling on every single token delta.
151
+ const lastTurn = turns[turns.length - 1];
152
+ const threadScrollKey = `${turns.length}:${lastTurn?.role ?? ''}:${lastTurn?.status ?? ''}:${Math.floor((lastTurn?.text?.length ?? 0) / 200)}`;
153
+
154
+ // Auto-focus textarea when expanded
155
+ useEffect(() => {
156
+ if (expanded && isDraft && textareaRef.current) {
157
+ textareaRef.current.focus();
158
+ }
159
+ }, [expanded, isDraft]);
160
+
161
+ // Auto-scroll to bottom when turns change, with smooth behavior
162
+ useEffect(() => {
163
+ if (!threadScrollKey) return;
164
+ if (bodyRef.current) {
165
+ bodyRef.current.scrollTo({
166
+ top: bodyRef.current.scrollHeight,
167
+ behavior: 'smooth',
168
+ });
169
+ }
170
+ }, [threadScrollKey]);
171
+
172
+ // Debounced markdown rendering for assistant turns.
173
+ // During streaming, coalesce rapid deltas and re-render at most every 400ms
174
+ // to keep formatting visible without per-token render overhead.
175
+ // Completed turns render immediately.
176
+ const renderTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
177
+ const turnsRef = useRef(turns);
178
+ turnsRef.current = turns;
179
+
180
+ useEffect(() => {
181
+ const hasStreaming = turns.some((t) => t.role === 'assistant' && t.status === 'streaming');
182
+
183
+ const doRender = () => {
184
+ let cancelled = false;
185
+ const current = turnsRef.current;
186
+ const assistantTurns = current
187
+ .map((t, i) => ({ turn: t, index: i }))
188
+ .filter(({ turn }) => turn.role === 'assistant' && turn.text);
189
+
190
+ if (assistantTurns.length === 0) return;
191
+
192
+ Promise.all(
193
+ assistantTurns.map(async ({ turn, index }) => {
194
+ const html = await renderMarkdown(turn.text);
195
+ return { index, html: sanitizeHtml(html) };
196
+ }),
197
+ ).then((results) => {
198
+ if (cancelled) return;
199
+ const next = new Map<number, string>();
200
+ for (const r of results) next.set(r.index, r.html);
201
+ setRenderedTurns(next);
202
+ });
203
+
204
+ return () => { cancelled = true; };
205
+ };
206
+
207
+ if (!hasStreaming) {
208
+ // Not streaming — render immediately, cancel any pending debounce
209
+ if (renderTimerRef.current) {
210
+ clearTimeout(renderTimerRef.current);
211
+ renderTimerRef.current = null;
212
+ }
213
+ return doRender();
214
+ }
215
+
216
+ // Streaming — trailing-edge debounce: reset timer on each delta,
217
+ // render 400ms after the last one.
218
+ if (renderTimerRef.current) {
219
+ clearTimeout(renderTimerRef.current);
220
+ }
221
+ renderTimerRef.current = setTimeout(() => {
222
+ renderTimerRef.current = null;
223
+ doRender();
224
+ }, 400);
225
+
226
+ return () => {
227
+ if (renderTimerRef.current) {
228
+ clearTimeout(renderTimerRef.current);
229
+ renderTimerRef.current = null;
230
+ }
231
+ };
232
+ }, [turns]);
233
+
234
+ const handleSubmit = useCallback(async () => {
235
+ const trimmed = draft.trim();
236
+ if (!trimmed) return;
237
+ const savedDraft = draft;
238
+ setError(null);
239
+ // Transition to turns-based model on first submit
240
+ updateNodeData(node.id, {
241
+ turns: [{ role: 'user', text: trimmed, status: 'pending' }],
242
+ threadStatus: 'pending',
243
+ text: trimmed, // keep for backward compat display
244
+ });
245
+ setDraft('');
246
+ const ctxIds = Array.isArray(node.data.contextNodeIds)
247
+ ? (node.data.contextNodeIds as string[])
248
+ : undefined;
249
+ const result = await submitCanvasPrompt(trimmed, node.position, node.id, ctxIds, node.id);
250
+ if (!result.ok) {
251
+ updateNodeData(node.id, { turns: [], threadStatus: 'draft', text: '', status: 'draft' });
252
+ setDraft(savedDraft);
253
+ setError('Failed to send prompt. Your draft has been restored.');
254
+ }
255
+ }, [draft, node.id, node.position, node.data.contextNodeIds]);
256
+
257
+ const handleReplySubmit = useCallback(async () => {
258
+ const trimmed = replyDraft.trim();
259
+ if (!trimmed) return;
260
+ const savedReply = replyDraft;
261
+ setError(null);
262
+ // Optimistically add user turn
263
+ const currentTurns = Array.isArray(node.data.turns)
264
+ ? [...(node.data.turns as ThreadTurn[])]
265
+ : [];
266
+ currentTurns.push({ role: 'user', text: trimmed, status: 'pending' });
267
+ updateNodeData(node.id, { turns: currentTurns, threadStatus: 'pending' });
268
+ setReplyDraft('');
269
+ const result = await submitThreadReply(node.id, trimmed);
270
+ if (!result.ok) {
271
+ // Remove the optimistic turn
272
+ currentTurns.pop();
273
+ updateNodeData(node.id, { turns: currentTurns, threadStatus: 'answered' });
274
+ setReplyDraft(savedReply);
275
+ setError('Failed to send reply. Your draft has been restored.');
276
+ }
277
+ }, [replyDraft, node.id, node.data.turns]);
278
+
279
+ const handleKeyDown = useCallback(
280
+ (e: KeyboardEvent) => {
281
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
282
+ e.preventDefault();
283
+ handleSubmit();
284
+ return;
285
+ }
286
+ // Tab completes slash commands (e.g. "/rev" → "/review")
287
+ if (e.key === 'Tab' && draft.startsWith('/')) {
288
+ e.preventDefault();
289
+ const query = draft.slice(1).split(/\s/)[0];
290
+ if (query) {
291
+ getCommands().then((cmds) => {
292
+ const match = matchSlashCommand(query, cmds);
293
+ if (match) setDraft(`/${match}`);
294
+ });
295
+ }
296
+ }
297
+ },
298
+ [handleSubmit, draft],
299
+ );
300
+
301
+ const handleReplyKeyDown = useCallback(
302
+ (e: KeyboardEvent) => {
303
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
304
+ e.preventDefault();
305
+ handleReplySubmit();
306
+ return;
307
+ }
308
+ if (e.key === 'Tab' && replyDraft.startsWith('/')) {
309
+ e.preventDefault();
310
+ const query = replyDraft.slice(1).split(/\s/)[0];
311
+ if (query) {
312
+ getCommands().then((cmds) => {
313
+ const match = matchSlashCommand(query, cmds);
314
+ if (match) setReplyDraft(`/${match}`);
315
+ });
316
+ }
317
+ }
318
+ },
319
+ [handleReplySubmit, replyDraft],
320
+ );
321
+
322
+ const ctxCount = Array.isArray(node.data.contextNodeIds)
323
+ ? (node.data.contextNodeIds as string[]).length
324
+ : 0;
325
+
326
+ // ── Draft mode (no turns yet): show initial textarea ──
327
+ if (isDraft && turns.length === 0) {
328
+ return (
329
+ <div
330
+ class="prompt-node-inner"
331
+ style={{ display: 'flex', flexDirection: 'column', height: '100%', gap: '8px' }}
332
+ >
333
+ <ContextBadge count={ctxCount} />
334
+ <ErrorBanner message={error} onDismiss={() => setError(null)} />
335
+ <textarea
336
+ ref={textareaRef}
337
+ value={draft}
338
+ onInput={(e) => setDraft((e.target as HTMLTextAreaElement).value)}
339
+ onKeyDown={handleKeyDown}
340
+ placeholder="Ask the agent something…"
341
+ spellcheck={false}
342
+ style={{
343
+ flex: 1,
344
+ resize: 'none',
345
+ background: 'rgba(10,14,30,0.4)',
346
+ border: '1px solid var(--c-line)',
347
+ borderRadius: 'var(--radius-sm)',
348
+ color: 'var(--c-text)',
349
+ fontFamily: 'var(--font)',
350
+ fontSize: '13px',
351
+ lineHeight: '1.5',
352
+ padding: '8px 10px',
353
+ outline: 'none',
354
+ }}
355
+ />
356
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
357
+ <span style={{ fontSize: '10px', color: 'var(--c-muted)' }}>{'\u2318'}+Enter to send</span>
358
+ <button
359
+ type="button"
360
+ onClick={handleSubmit}
361
+ disabled={!draft.trim()}
362
+ style={{
363
+ padding: '5px 14px',
364
+ fontSize: '12px',
365
+ fontWeight: 600,
366
+ background: draft.trim() ? 'var(--c-accent)' : 'var(--c-line)',
367
+ color: draft.trim() ? 'var(--c-contrast-fg)' : 'var(--c-muted)',
368
+ border: 'none',
369
+ borderRadius: 'var(--radius-sm)',
370
+ cursor: draft.trim() ? 'pointer' : 'default',
371
+ }}
372
+ >
373
+ Send
374
+ </button>
375
+ </div>
376
+ </div>
377
+ );
378
+ }
379
+
380
+ // ── Legacy flat format (old nodes without turns) ──
381
+ if (isLegacy) {
382
+ return (
383
+ <div
384
+ class="prompt-node-inner"
385
+ style={{ display: 'flex', flexDirection: 'column', height: '100%' }}
386
+ >
387
+ <div
388
+ style={{
389
+ flex: 1,
390
+ padding: '2px 0',
391
+ fontSize: '13px',
392
+ lineHeight: '1.55',
393
+ color: 'var(--c-text)',
394
+ whiteSpace: 'pre-wrap',
395
+ wordBreak: 'break-word',
396
+ overflow: 'auto',
397
+ }}
398
+ >
399
+ {legacyText}
400
+ </div>
401
+ <div
402
+ style={{
403
+ display: 'flex',
404
+ justifyContent: 'space-between',
405
+ alignItems: 'center',
406
+ paddingTop: '6px',
407
+ borderTop: '1px solid var(--c-line)',
408
+ marginTop: '4px',
409
+ }}
410
+ >
411
+ <span
412
+ style={{
413
+ fontSize: '10px',
414
+ textTransform: 'uppercase',
415
+ fontWeight: 600,
416
+ color: isPending ? 'var(--c-warn)' : isAnswered ? 'var(--c-ok)' : 'var(--c-muted)',
417
+ }}
418
+ >
419
+ {isPending ? 'Sending…' : isAnswered ? 'Answered' : legacyStatus}
420
+ </span>
421
+ </div>
422
+ </div>
423
+ );
424
+ }
425
+
426
+ // ── Thread view: render all turns ──
427
+ return (
428
+ <div
429
+ class="prompt-node-inner"
430
+ style={{ display: 'flex', flexDirection: 'column', height: '100%' }}
431
+ >
432
+ <ContextBadge count={ctxCount} />
433
+ <ErrorBanner message={error} onDismiss={() => setError(null)} />
434
+
435
+ {/* Conversation turns */}
436
+ <div ref={bodyRef} style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
437
+ {turns.map((turn, i) => (
438
+ <div key={getThreadTurnKey(turn, i)}>
439
+ {i > 0 && <div class="thread-turn-divider" />}
440
+ {turn.role === 'user' ? (
441
+ <div class="thread-turn-user">
442
+ <div class="thread-turn-role"><span class="status-dot" />You</div>
443
+ <div
444
+ style={{
445
+ fontSize: expanded ? '15px' : '13px',
446
+ lineHeight: expanded ? '1.7' : '1.55',
447
+ color: 'var(--c-text)',
448
+ whiteSpace: 'pre-wrap',
449
+ wordBreak: 'break-word',
450
+ }}
451
+ >
452
+ {turn.text}
453
+ </div>
454
+ </div>
455
+ ) : (
456
+ <div class="thread-turn-assistant">
457
+ <div class="thread-turn-role"><span class={`status-dot${turn.status === 'streaming' ? ' pulsing' : ''}`} />PMX</div>
458
+ {turn.status === 'streaming' && !turn.text && (
459
+ <div
460
+ style={{
461
+ height: '2px',
462
+ background: 'linear-gradient(90deg, transparent, var(--c-ok), transparent)',
463
+ animation: 'response-stream-pulse 1.5s ease-in-out infinite',
464
+ borderRadius: '1px',
465
+ marginBottom: '4px',
466
+ }}
467
+ />
468
+ )}
469
+ <div
470
+ class={expanded ? 'md-reader-content' : undefined}
471
+ style={{
472
+ fontSize: expanded ? undefined : '13px',
473
+ lineHeight: expanded ? undefined : '1.55',
474
+ opacity: turn.text ? 1 : 0.4,
475
+ }}
476
+ >
477
+ {renderedTurns.get(i) ? (
478
+ <>
479
+ <RenderedMarkdown html={renderedTurns.get(i) ?? ''} />
480
+ {turn.status === 'streaming' && <span class="streaming-cursor" />}
481
+ </>
482
+ ) : (
483
+ <div style={{ color: 'var(--c-muted)', fontStyle: 'italic' }}>
484
+ {turn.status === 'streaming'
485
+ ? 'Waiting for response…'
486
+ : turn.text || 'Empty response'}
487
+ </div>
488
+ )}
489
+ </div>
490
+ </div>
491
+ )}
492
+ </div>
493
+ ))}
494
+ </div>
495
+
496
+ {/* Footer: status + reply area */}
497
+ <div
498
+ style={{
499
+ flexShrink: 0,
500
+ borderTop: '1px solid var(--c-line)',
501
+ marginTop: '4px',
502
+ paddingTop: '6px',
503
+ }}
504
+ >
505
+ {/* Status bar */}
506
+ <div
507
+ style={{
508
+ display: 'flex',
509
+ justifyContent: 'space-between',
510
+ alignItems: 'center',
511
+ marginBottom: isAnswered ? '6px' : 0,
512
+ }}
513
+ >
514
+ <span
515
+ style={{
516
+ fontSize: '10px',
517
+ textTransform: 'uppercase',
518
+ fontWeight: 600,
519
+ color: isStreaming
520
+ ? 'var(--c-accent)'
521
+ : isPending
522
+ ? 'var(--c-warn)'
523
+ : isAnswered
524
+ ? 'var(--c-ok)'
525
+ : 'var(--c-muted)',
526
+ }}
527
+ >
528
+ {isStreaming
529
+ ? 'Streaming…'
530
+ : isPending
531
+ ? 'Sending…'
532
+ : isAnswered
533
+ ? 'Answered'
534
+ : threadStatus}
535
+ </span>
536
+ <span style={{ fontSize: '10px', color: 'var(--c-muted)' }}>
537
+ {turns.length} turn{turns.length !== 1 ? 's' : ''}
538
+ </span>
539
+ </div>
540
+
541
+ {/* Reply textarea when answered */}
542
+ {isAnswered && (
543
+ <div class="thread-reply-area">
544
+ <textarea
545
+ value={replyDraft}
546
+ onInput={(e) => setReplyDraft((e.target as HTMLTextAreaElement).value)}
547
+ onKeyDown={handleReplyKeyDown}
548
+ placeholder="Reply…"
549
+ spellcheck={false}
550
+ rows={2}
551
+ style={{
552
+ width: '100%',
553
+ resize: 'none',
554
+ background: 'rgba(10,14,30,0.4)',
555
+ border: '1px solid var(--c-line)',
556
+ borderRadius: 'var(--radius-sm)',
557
+ color: 'var(--c-text)',
558
+ fontFamily: 'var(--font)',
559
+ fontSize: '12px',
560
+ lineHeight: '1.5',
561
+ padding: '6px 8px',
562
+ outline: 'none',
563
+ }}
564
+ />
565
+ <div
566
+ style={{
567
+ display: 'flex',
568
+ justifyContent: 'space-between',
569
+ alignItems: 'center',
570
+ marginTop: '4px',
571
+ }}
572
+ >
573
+ <span style={{ fontSize: '10px', color: 'var(--c-muted)' }}>{'\u2318'}+Enter</span>
574
+ <button
575
+ type="button"
576
+ onClick={handleReplySubmit}
577
+ disabled={!replyDraft.trim()}
578
+ style={{
579
+ padding: '3px 10px',
580
+ fontSize: '11px',
581
+ fontWeight: 600,
582
+ background: replyDraft.trim() ? 'var(--c-accent)' : 'var(--c-line)',
583
+ color: replyDraft.trim() ? 'var(--c-contrast-fg)' : 'var(--c-muted)',
584
+ border: 'none',
585
+ borderRadius: 'var(--radius-sm)',
586
+ cursor: replyDraft.trim() ? 'pointer' : 'default',
587
+ }}
588
+ >
589
+ Reply
590
+ </button>
591
+ </div>
592
+ </div>
593
+ )}
594
+ </div>
595
+ </div>
596
+ );
597
+ }