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.
- package/CHANGELOG.md +38 -0
- package/LICENSE +21 -0
- package/Readme.md +865 -0
- package/dist/canvas/global.css +3173 -0
- package/dist/canvas/index.js +183 -0
- package/dist/json-render/index.css +2 -0
- package/dist/json-render/index.js +389 -0
- package/dist/types/cli/agent.d.ts +13 -0
- package/dist/types/cli/index.d.ts +2 -0
- package/dist/types/cli/watch.d.ts +5 -0
- package/dist/types/client/App.d.ts +1 -0
- package/dist/types/client/canvas/AttentionHistory.d.ts +1 -0
- package/dist/types/client/canvas/AttentionToast.d.ts +1 -0
- package/dist/types/client/canvas/CanvasNode.d.ts +8 -0
- package/dist/types/client/canvas/CanvasViewport.d.ts +8 -0
- package/dist/types/client/canvas/CommandPalette.d.ts +4 -0
- package/dist/types/client/canvas/ContextMenu.d.ts +24 -0
- package/dist/types/client/canvas/ContextPinBar.d.ts +1 -0
- package/dist/types/client/canvas/ContextPinHud.d.ts +1 -0
- package/dist/types/client/canvas/DockedNode.d.ts +4 -0
- package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
- package/dist/types/client/canvas/ExpandedNodeOverlay.d.ts +1 -0
- package/dist/types/client/canvas/FocusFieldLayer.d.ts +1 -0
- package/dist/types/client/canvas/Minimap.d.ts +23 -0
- package/dist/types/client/canvas/SelectionBar.d.ts +1 -0
- package/dist/types/client/canvas/ShortcutOverlay.d.ts +3 -0
- package/dist/types/client/canvas/SnapshotPanel.d.ts +7 -0
- package/dist/types/client/canvas/snap-guides.d.ts +23 -0
- package/dist/types/client/canvas/use-node-drag.d.ts +15 -0
- package/dist/types/client/canvas/use-node-resize.d.ts +15 -0
- package/dist/types/client/canvas/use-pan-zoom.d.ts +16 -0
- package/dist/types/client/ext-app/bridge.d.ts +161 -0
- package/dist/types/client/icons.d.ts +70 -0
- package/dist/types/client/index.d.ts +1 -0
- package/dist/types/client/nodes/ContextNode.d.ts +34 -0
- package/dist/types/client/nodes/ExtAppFrame.d.ts +18 -0
- package/dist/types/client/nodes/FileNode.d.ts +5 -0
- package/dist/types/client/nodes/GroupNode.d.ts +6 -0
- package/dist/types/client/nodes/ImageNode.d.ts +10 -0
- package/dist/types/client/nodes/InlineFormatBar.d.ts +7 -0
- package/dist/types/client/nodes/InlineMarkdownEditor.d.ts +14 -0
- package/dist/types/client/nodes/LedgerNode.d.ts +4 -0
- package/dist/types/client/nodes/MarkdownNode.d.ts +6 -0
- package/dist/types/client/nodes/McpAppNode.d.ts +4 -0
- package/dist/types/client/nodes/MdFormatBar.d.ts +8 -0
- package/dist/types/client/nodes/PromptNode.d.ts +5 -0
- package/dist/types/client/nodes/ResponseNode.d.ts +5 -0
- package/dist/types/client/nodes/StatusNode.d.ts +4 -0
- package/dist/types/client/nodes/StatusSummary.d.ts +4 -0
- package/dist/types/client/nodes/TraceNode.d.ts +4 -0
- package/dist/types/client/nodes/WebpageNode.d.ts +5 -0
- package/dist/types/client/nodes/image-warnings.d.ts +6 -0
- package/dist/types/client/nodes/inline-editor-commands.d.ts +11 -0
- package/dist/types/client/nodes/md-format.d.ts +25 -0
- package/dist/types/client/state/attention-bridge.d.ts +3 -0
- package/dist/types/client/state/attention-store.d.ts +25 -0
- package/dist/types/client/state/canvas-store.d.ts +74 -0
- package/dist/types/client/state/intent-bridge.d.ts +158 -0
- package/dist/types/client/state/sse-bridge.d.ts +5 -0
- package/dist/types/client/theme/tokens.d.ts +27 -0
- package/dist/types/client/types.d.ts +40 -0
- package/dist/types/client/utils/ext-app-tool-result.d.ts +1 -0
- package/dist/types/client/utils/placement.d.ts +1 -0
- package/dist/types/client/utils/platform.d.ts +2 -0
- package/dist/types/json-render/catalog.d.ts +815 -0
- package/dist/types/json-render/charts/components.d.ts +54 -0
- package/dist/types/json-render/charts/definitions.d.ts +103 -0
- package/dist/types/json-render/charts/extra-components.d.ts +58 -0
- package/dist/types/json-render/charts/extra-definitions.d.ts +181 -0
- package/dist/types/json-render/renderer/index.d.ts +16 -0
- package/dist/types/json-render/schema.d.ts +46 -0
- package/dist/types/json-render/server.d.ts +55 -0
- package/dist/types/mcp/server.d.ts +22 -0
- package/dist/types/server/agent-context.d.ts +21 -0
- package/dist/types/server/artifact-paths.d.ts +3 -0
- package/dist/types/server/canvas-operations.d.ts +154 -0
- package/dist/types/server/canvas-provenance.d.ts +13 -0
- package/dist/types/server/canvas-schema.d.ts +49 -0
- package/dist/types/server/canvas-serialization.d.ts +25 -0
- package/dist/types/server/canvas-state.d.ts +174 -0
- package/dist/types/server/canvas-validation.d.ts +33 -0
- package/dist/types/server/chart-template.d.ts +29 -0
- package/dist/types/server/code-graph.d.ts +67 -0
- package/dist/types/server/context-cards.d.ts +24 -0
- package/dist/types/server/diagram-presets.d.ts +28 -0
- package/dist/types/server/ext-app-call-registry.d.ts +16 -0
- package/dist/types/server/ext-app-tool-result.d.ts +1 -0
- package/dist/types/server/file-watcher.d.ts +16 -0
- package/dist/types/server/index.d.ts +243 -0
- package/dist/types/server/mcp-app-candidate.d.ts +25 -0
- package/dist/types/server/mcp-app-host.d.ts +65 -0
- package/dist/types/server/mcp-app-runtime.d.ts +47 -0
- package/dist/types/server/mutation-history.d.ts +105 -0
- package/dist/types/server/placement.d.ts +37 -0
- package/dist/types/server/server.d.ts +103 -0
- package/dist/types/server/spatial-analysis.d.ts +87 -0
- package/dist/types/server/trace-manager.d.ts +48 -0
- package/dist/types/server/web-artifacts.d.ts +50 -0
- package/dist/types/server/webpage-node.d.ts +25 -0
- package/dist/types/shared/auto-arrange.d.ts +29 -0
- package/dist/types/shared/ext-app-tool-result.d.ts +9 -0
- package/dist/types/shared/placement.d.ts +26 -0
- package/dist/types/shared/semantic-attention.d.ts +97 -0
- package/package.json +109 -0
- package/skills/data-analysis/SKILL.md +324 -0
- package/skills/doc-coauthoring/SKILL.md +375 -0
- package/skills/frontend-design/SKILL.md +45 -0
- package/skills/json-render-codegen/SKILL.md +112 -0
- package/skills/json-render-core/SKILL.md +265 -0
- package/skills/json-render-ink/SKILL.md +273 -0
- package/skills/json-render-mcp/SKILL.md +132 -0
- package/skills/json-render-react/SKILL.md +264 -0
- package/skills/json-render-shadcn/SKILL.md +159 -0
- package/skills/playwright-cli/SKILL.md +67 -0
- package/skills/pmx-canvas/SKILL.md +668 -0
- package/skills/pmx-canvas/evals/evals.json +186 -0
- package/skills/pmx-canvas-testing/SKILL.md +78 -0
- package/skills/published-consumer-e2e/SKILL.md +43 -0
- package/skills/published-consumer-e2e/scripts/run-published-consumer-e2e.sh +241 -0
- package/skills/web-artifacts-builder/SKILL.md +80 -0
- package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +167 -0
- package/skills/web-artifacts-builder/scripts/init-artifact.sh +425 -0
- package/skills/web-artifacts-builder/scripts/shadcn-components.tar.gz +0 -0
- package/skills/web-design-guidelines/SKILL.md +39 -0
- package/src/cli/agent.ts +2144 -0
- package/src/cli/index.ts +622 -0
- package/src/cli/watch.ts +88 -0
- package/src/client/App.tsx +507 -0
- package/src/client/canvas/AttentionHistory.tsx +81 -0
- package/src/client/canvas/AttentionToast.tsx +19 -0
- package/src/client/canvas/CanvasNode.tsx +363 -0
- package/src/client/canvas/CanvasViewport.tsx +590 -0
- package/src/client/canvas/CommandPalette.tsx +302 -0
- package/src/client/canvas/ContextMenu.tsx +601 -0
- package/src/client/canvas/ContextPinBar.tsx +25 -0
- package/src/client/canvas/ContextPinHud.tsx +22 -0
- package/src/client/canvas/DockedNode.tsx +66 -0
- package/src/client/canvas/EdgeLayer.tsx +280 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +260 -0
- package/src/client/canvas/FocusFieldLayer.tsx +107 -0
- package/src/client/canvas/Minimap.tsx +301 -0
- package/src/client/canvas/SelectionBar.tsx +69 -0
- package/src/client/canvas/ShortcutOverlay.tsx +69 -0
- package/src/client/canvas/SnapshotPanel.tsx +236 -0
- package/src/client/canvas/snap-guides.ts +170 -0
- package/src/client/canvas/use-node-drag.ts +51 -0
- package/src/client/canvas/use-node-resize.ts +59 -0
- package/src/client/canvas/use-pan-zoom.ts +191 -0
- package/src/client/ext-app/bridge.ts +542 -0
- package/src/client/icons.tsx +424 -0
- package/src/client/index.tsx +7 -0
- package/src/client/nodes/ContextNode.tsx +412 -0
- package/src/client/nodes/ExtAppFrame.tsx +509 -0
- package/src/client/nodes/FileNode.tsx +256 -0
- package/src/client/nodes/GroupNode.tsx +39 -0
- package/src/client/nodes/ImageNode.tsx +160 -0
- package/src/client/nodes/InlineFormatBar.tsx +169 -0
- package/src/client/nodes/InlineMarkdownEditor.tsx +123 -0
- package/src/client/nodes/LedgerNode.tsx +37 -0
- package/src/client/nodes/MarkdownNode.tsx +359 -0
- package/src/client/nodes/McpAppNode.tsx +85 -0
- package/src/client/nodes/MdFormatBar.tsx +109 -0
- package/src/client/nodes/PromptNode.tsx +597 -0
- package/src/client/nodes/ResponseNode.tsx +153 -0
- package/src/client/nodes/StatusNode.tsx +84 -0
- package/src/client/nodes/StatusSummary.tsx +38 -0
- package/src/client/nodes/TraceNode.tsx +120 -0
- package/src/client/nodes/WebpageNode.tsx +288 -0
- package/src/client/nodes/image-warnings.ts +95 -0
- package/src/client/nodes/inline-editor-commands.ts +37 -0
- package/src/client/nodes/md-format.ts +206 -0
- package/src/client/state/attention-bridge.ts +328 -0
- package/src/client/state/attention-store.ts +73 -0
- package/src/client/state/canvas-store.ts +631 -0
- package/src/client/state/intent-bridge.ts +315 -0
- package/src/client/state/sse-bridge.ts +965 -0
- package/src/client/theme/global.css +3173 -0
- package/src/client/theme/tokens.ts +72 -0
- package/src/client/types-shims.d.ts +5 -0
- package/src/client/types.ts +81 -0
- package/src/client/utils/ext-app-tool-result.ts +4 -0
- package/src/client/utils/placement.ts +4 -0
- package/src/client/utils/platform.ts +2 -0
- package/src/json-render/catalog.ts +256 -0
- package/src/json-render/charts/components.tsx +198 -0
- package/src/json-render/charts/definitions.ts +81 -0
- package/src/json-render/charts/extra-components.tsx +267 -0
- package/src/json-render/charts/extra-definitions.ts +145 -0
- package/src/json-render/renderer/index.css +174 -0
- package/src/json-render/renderer/index.tsx +86 -0
- package/src/json-render/schema.ts +62 -0
- package/src/json-render/server.ts +597 -0
- package/src/mcp/server.ts +1377 -0
- package/src/server/agent-context.ts +242 -0
- package/src/server/artifact-paths.ts +17 -0
- package/src/server/canvas-operations.ts +1279 -0
- package/src/server/canvas-provenance.ts +243 -0
- package/src/server/canvas-schema.ts +432 -0
- package/src/server/canvas-serialization.ts +95 -0
- package/src/server/canvas-state.ts +1134 -0
- package/src/server/canvas-validation.ts +114 -0
- package/src/server/chart-template.ts +449 -0
- package/src/server/code-graph.ts +370 -0
- package/src/server/context-cards.ts +31 -0
- package/src/server/diagram-presets.ts +71 -0
- package/src/server/ext-app-call-registry.ts +77 -0
- package/src/server/ext-app-tool-result.ts +4 -0
- package/src/server/file-watcher.ts +121 -0
- package/src/server/index.ts +647 -0
- package/src/server/mcp-app-candidate.ts +174 -0
- package/src/server/mcp-app-host.ts +814 -0
- package/src/server/mcp-app-runtime.ts +459 -0
- package/src/server/mutation-history.ts +350 -0
- package/src/server/placement.ts +125 -0
- package/src/server/server.ts +3846 -0
- package/src/server/spatial-analysis.ts +356 -0
- package/src/server/trace-manager.ts +333 -0
- package/src/server/web-artifacts/scripts/bundle-artifact.sh +167 -0
- package/src/server/web-artifacts/scripts/init-artifact.sh +426 -0
- package/src/server/web-artifacts/scripts/shadcn-components.tar.gz +0 -0
- package/src/server/web-artifacts.ts +442 -0
- package/src/server/webpage-node.ts +328 -0
- package/src/shared/auto-arrange.ts +439 -0
- package/src/shared/ext-app-tool-result.ts +76 -0
- package/src/shared/placement.ts +81 -0
- 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
|
+
}
|