agent-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 (65) hide show
  1. package/bin/agent-canvas.js +14 -0
  2. package/bin/agent-canvas.ts +377 -0
  3. package/daemon/build.ts +185 -0
  4. package/daemon/client/AnnotationProvider.tsx +185 -0
  5. package/daemon/client/AnnotationSidebar.tsx +314 -0
  6. package/daemon/client/App.tsx +611 -0
  7. package/daemon/client/CompareView.tsx +333 -0
  8. package/daemon/client/FileBrowser.tsx +188 -0
  9. package/daemon/client/FileIcon.tsx +76 -0
  10. package/daemon/client/FileViewer.tsx +207 -0
  11. package/daemon/client/PlanRenderer.tsx +665 -0
  12. package/daemon/client/Popover.tsx +285 -0
  13. package/daemon/client/ResponsePreview.tsx +260 -0
  14. package/daemon/client/RevisionSelect.tsx +97 -0
  15. package/daemon/client/SessionContext.tsx +3 -0
  16. package/daemon/client/SessionSwitcher.tsx +72 -0
  17. package/daemon/client/annotationContext.ts +114 -0
  18. package/daemon/client/components/Callout.tsx +25 -0
  19. package/daemon/client/components/Checklist.tsx +37 -0
  20. package/daemon/client/components/Choice.tsx +164 -0
  21. package/daemon/client/components/CodeBlock.tsx +35 -0
  22. package/daemon/client/components/Diff.tsx +65 -0
  23. package/daemon/client/components/FilePreview.tsx +88 -0
  24. package/daemon/client/components/Image.tsx +45 -0
  25. package/daemon/client/components/Mermaid.tsx +277 -0
  26. package/daemon/client/components/Note.tsx +13 -0
  27. package/daemon/client/components/Priority.tsx +19 -0
  28. package/daemon/client/components/ResponseNote.tsx +24 -0
  29. package/daemon/client/components/Section.tsx +32 -0
  30. package/daemon/client/components/Table.tsx +35 -0
  31. package/daemon/client/components/Task.tsx +75 -0
  32. package/daemon/client/components/UserInput.tsx +180 -0
  33. package/daemon/client/components/index.ts +15 -0
  34. package/daemon/client/domDiff.ts +336 -0
  35. package/daemon/client/exportMarkdown.ts +267 -0
  36. package/daemon/client/generateMarkdown.ts +194 -0
  37. package/daemon/client/highlightRange.ts +233 -0
  38. package/daemon/client/index.html +26 -0
  39. package/daemon/client/lcs.ts +19 -0
  40. package/daemon/client/popoverPosition.ts +31 -0
  41. package/daemon/client/runtime.ts +89 -0
  42. package/daemon/client/styles.css +306 -0
  43. package/daemon/client/theme.css +66 -0
  44. package/daemon/client/unifiedDiff.ts +604 -0
  45. package/daemon/dist/client.css +2 -0
  46. package/daemon/dist/client.js +68 -0
  47. package/daemon/dist/components.js +5 -0
  48. package/daemon/dist/index.html +108 -0
  49. package/daemon/dist/jsx-dev-runtime-shim.js +17 -0
  50. package/daemon/dist/jsx-runtime-shim.js +17 -0
  51. package/daemon/dist/react-dom-client-shim.js +4 -0
  52. package/daemon/dist/react-dom-shim.js +4 -0
  53. package/daemon/dist/react-shim.js +4 -0
  54. package/daemon/dist/runtime.js +1 -0
  55. package/daemon/dist/theme.css +66 -0
  56. package/daemon/src/compiler.ts +92 -0
  57. package/daemon/src/paths.ts +22 -0
  58. package/daemon/src/server.ts +448 -0
  59. package/daemon/src/session.ts +274 -0
  60. package/daemon/src/watcher.ts +59 -0
  61. package/hooks/session-start.sh +11 -0
  62. package/package.json +39 -0
  63. package/skills/canvas/SKILL.md +174 -0
  64. package/skills/canvas/components.md +349 -0
  65. package/skills/canvas/flows.md +406 -0
@@ -0,0 +1,72 @@
1
+ import React, { useEffect, useState, useRef } from "react";
2
+
3
+ interface SessionInfo {
4
+ id: string;
5
+ version: number;
6
+ updatedAt: string;
7
+ }
8
+
9
+ interface SessionSwitcherProps {
10
+ currentSessionId: string;
11
+ }
12
+
13
+ export function SessionSwitcher({ currentSessionId }: SessionSwitcherProps) {
14
+ const [sessions, setSessions] = useState<SessionInfo[]>([]);
15
+ const [open, setOpen] = useState(false);
16
+ const ref = useRef<HTMLDivElement>(null);
17
+
18
+ useEffect(() => {
19
+ const fetchSessions = async () => {
20
+ try {
21
+ const res = await fetch("/api/sessions");
22
+ setSessions(await res.json() as SessionInfo[]);
23
+ } catch {}
24
+ };
25
+ fetchSessions();
26
+ const interval = setInterval(fetchSessions, 5000);
27
+ return () => clearInterval(interval);
28
+ }, []);
29
+
30
+ useEffect(() => {
31
+ if (!open) return;
32
+ const handler = (e: MouseEvent) => {
33
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
34
+ };
35
+ document.addEventListener("mousedown", handler);
36
+ return () => document.removeEventListener("mousedown", handler);
37
+ }, [open]);
38
+
39
+ const otherSessions = sessions.filter((s) => s.id !== currentSessionId);
40
+ const displayName = currentSessionId.length > 30 ? currentSessionId.slice(0, 30) + "..." : currentSessionId;
41
+
42
+ return (
43
+ <div className="relative" ref={ref}>
44
+ <button
45
+ onClick={() => otherSessions.length > 0 && setOpen(!open)}
46
+ className="flex items-center gap-1.5 font-body text-body font-medium text-text-primary hover:text-text-secondary transition-colors"
47
+ >
48
+ <span>{displayName}</span>
49
+ {otherSessions.length > 0 && (
50
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" className={`text-text-tertiary transition-transform duration-200 ${open ? "rotate-180" : ""}`}>
51
+ <path d="M3 5l3 3 3-3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
52
+ </svg>
53
+ )}
54
+ </button>
55
+
56
+ {open && otherSessions.length > 0 && (
57
+ <div className="absolute top-full left-0 mt-2 w-64 bg-bg-elevated border border-border-hover rounded-lg shadow-lg z-50 py-1 overflow-hidden">
58
+ {otherSessions.map((s) => (
59
+ <a
60
+ key={s.id}
61
+ href={`/s/${s.id}`}
62
+ className="flex items-center gap-2.5 px-4 py-2.5 text-body font-body text-text-secondary hover:bg-bg-surface hover:text-text-primary transition-colors"
63
+ >
64
+ <span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${s.currentRevision > 0 ? "bg-accent-green" : "bg-border-hover"}`} />
65
+ <span className="truncate">{s.id}</span>
66
+ </a>
67
+ ))}
68
+ </div>
69
+ )}
70
+ </div>
71
+ );
72
+ }
@@ -0,0 +1,114 @@
1
+ import type { AnnotationContext } from "./AnnotationProvider";
2
+
3
+ /**
4
+ * Extract disambiguation context from a Range:
5
+ * - surrounding text (before/after the selection)
6
+ * - hierarchy path (Section title → Task label)
7
+ */
8
+ export function extractContext(range: Range, planContainer: HTMLElement): AnnotationContext {
9
+ const before = getSurroundingText(range, "before", 60);
10
+ const after = getSurroundingText(range, "after", 60);
11
+ const hierarchy = getHierarchy(range.startContainer, planContainer);
12
+
13
+ return { before, after, hierarchy };
14
+ }
15
+
16
+ function getSurroundingText(range: Range, direction: "before" | "after", maxChars: number): string {
17
+ const block = getBlockParent(direction === "before" ? range.startContainer : range.endContainer);
18
+ if (!block) return "";
19
+
20
+ let text = "";
21
+
22
+ if (direction === "before") {
23
+ const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
24
+ let node: Text | null;
25
+ while ((node = walker.nextNode() as Text | null)) {
26
+ if (node === range.startContainer) {
27
+ text += (node.textContent || "").slice(0, range.startOffset);
28
+ break;
29
+ }
30
+ if (node.parentElement?.hasAttribute("data-annotation-id")) continue;
31
+ text += node.textContent || "";
32
+ }
33
+ if (text.length > maxChars) {
34
+ text = text.slice(-maxChars);
35
+ const spaceIdx = text.indexOf(" ");
36
+ if (spaceIdx > 0) text = text.slice(spaceIdx + 1);
37
+ }
38
+ } else {
39
+ const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
40
+ let found = false;
41
+ let node: Text | null;
42
+ while ((node = walker.nextNode() as Text | null)) {
43
+ if (node === range.endContainer) {
44
+ text += (node.textContent || "").slice(range.endOffset);
45
+ found = true;
46
+ continue;
47
+ }
48
+ if (found) {
49
+ if (node.parentElement?.hasAttribute("data-annotation-id")) continue;
50
+ text += node.textContent || "";
51
+ }
52
+ }
53
+ if (text.length > maxChars) {
54
+ text = text.slice(0, maxChars);
55
+ const spaceIdx = text.lastIndexOf(" ");
56
+ if (spaceIdx > 0) text = text.slice(0, spaceIdx);
57
+ }
58
+ }
59
+
60
+ return text.trim();
61
+ }
62
+
63
+ function getBlockParent(node: Node): Element | null {
64
+ const blockTags = new Set(["DIV", "P", "LI", "PRE", "BLOCKQUOTE", "SECTION", "ARTICLE"]);
65
+ let el = node.parentElement;
66
+ while (el) {
67
+ if (blockTags.has(el.tagName)) return el;
68
+ el = el.parentElement;
69
+ }
70
+ return null;
71
+ }
72
+
73
+ function getHierarchy(node: Node, planContainer: HTMLElement): string[] {
74
+ const path: string[] = [];
75
+ let el = node instanceof Element ? node : node.parentElement;
76
+
77
+ while (el && el !== planContainer) {
78
+ const taskId = el.getAttribute?.("data-task-id");
79
+ if (taskId) {
80
+ const label = el.querySelector(".font-semibold");
81
+ if (label) path.unshift(label.textContent?.trim() || taskId);
82
+ }
83
+
84
+ if (el.tagName === "DIV" && el.querySelector(":scope > button .font-heading, :scope > div .font-heading")) {
85
+ const heading = el.querySelector(".font-heading");
86
+ if (heading) path.unshift(heading.textContent?.trim() || "");
87
+ }
88
+
89
+ el = el.parentElement;
90
+ }
91
+
92
+ return path;
93
+ }
94
+
95
+ /**
96
+ * Format a snippet with surrounding context for disambiguation.
97
+ * Short snippets get before/after context.
98
+ */
99
+ export function formatSnippetInContext(ann: { snippet: string; context?: AnnotationContext }): string {
100
+ const snippet = ann.snippet.trim();
101
+ const ctx = ann.context;
102
+
103
+ const isShort = snippet.length < 30;
104
+
105
+ if (ctx && isShort && (ctx.before || ctx.after)) {
106
+ const parts: string[] = [];
107
+ if (ctx.before) parts.push(`...${ctx.before} `);
108
+ parts.push(snippet);
109
+ if (ctx.after) parts.push(` ${ctx.after}...`);
110
+ return parts.join("");
111
+ }
112
+
113
+ return snippet;
114
+ }
@@ -0,0 +1,25 @@
1
+ import React from "react";
2
+
3
+ interface CalloutProps {
4
+ type?: "info" | "warning" | "danger" | "tip";
5
+ children?: React.ReactNode;
6
+ }
7
+
8
+ const config = {
9
+ info: { bg: "bg-accent-blue-muted", icon: "i", iconColor: "text-accent-blue" },
10
+ warning: { bg: "bg-accent-amber-muted", icon: "!", iconColor: "text-accent-amber" },
11
+ danger: { bg: "bg-accent-red-muted", icon: "\u2715", iconColor: "text-accent-red" },
12
+ tip: { bg: "bg-accent-green-muted", icon: "\u2713", iconColor: "text-accent-green" },
13
+ };
14
+
15
+ export function Callout({ type = "info", children }: CalloutProps) {
16
+ const cfg = config[type];
17
+ return (
18
+ <div className={`${cfg.bg} relative rounded-lg px-5 py-4 mt-3 flex gap-3 items-start`} data-md="callout" data-md-type={type}>
19
+ <span className={`${cfg.iconColor} opacity-70 text-base leading-relaxed flex-shrink-0 mt-px`}>
20
+ {cfg.icon}
21
+ </span>
22
+ <div className="text-body text-text-secondary leading-relaxed">{children}</div>
23
+ </div>
24
+ );
25
+ }
@@ -0,0 +1,37 @@
1
+ import React from "react";
2
+
3
+ interface ChecklistItem {
4
+ label: string;
5
+ checked: boolean;
6
+ }
7
+
8
+ interface ChecklistProps {
9
+ items: ChecklistItem[];
10
+ }
11
+
12
+ export function Checklist({ items }: ChecklistProps) {
13
+ return (
14
+ <ul className="space-y-1 mt-3" data-md="checklist">
15
+ {items.map((item, i) => (
16
+ <li key={i} data-md="checklist-item" data-md-label={item.label} className="relative flex items-start gap-2.5 py-1">
17
+ <span className={`w-4 h-4 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5 transition-all ${
18
+ item.checked
19
+ ? "bg-accent-green"
20
+ : "border-[1.5px] border-border-hover"
21
+ }`}>
22
+ {item.checked && (
23
+ <svg width="10" height="10" viewBox="0 0 10 10" fill="none">
24
+ <path d="M2.5 5L4.5 7L7.5 3" style={{ stroke: "var(--color-text-inverse)" }} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
25
+ </svg>
26
+ )}
27
+ </span>
28
+ <span className={`text-body font-body ${
29
+ item.checked ? "text-text-tertiary line-through decoration-border-hover" : "text-text-secondary"
30
+ }`}>
31
+ {item.label}
32
+ </span>
33
+ </li>
34
+ ))}
35
+ </ul>
36
+ );
37
+ }
@@ -0,0 +1,164 @@
1
+ import React, { useEffect, useRef, useState } from "react";
2
+ import { useAnnotations } from "#canvas/runtime";
3
+ import { ResponseNote } from "./ResponseNote";
4
+
5
+ /** Radio — pick one option */
6
+ interface ChoiceProps {
7
+ id: string;
8
+ label: string;
9
+ options: string[];
10
+ required?: boolean;
11
+ }
12
+
13
+ export function Choice({ id, label, options, required }: ChoiceProps) {
14
+ const { responses, setResponse } = useAnnotations();
15
+ const current = responses.get(id);
16
+ const selected = current?.value as string | undefined;
17
+ const note = current?.note || "";
18
+ const [showNote, setShowNote] = useState(false);
19
+ const wrapperRef = useRef<HTMLDivElement>(null);
20
+
21
+ useEffect(() => {
22
+ if (!responses.has(id)) {
23
+ setResponse(id, { id, type: "radio", label, value: null, options, required });
24
+ }
25
+ }, [id]);
26
+
27
+ const handleSelect = (opt: string) => {
28
+ setResponse(id, { ...current!, value: opt });
29
+ };
30
+
31
+ // Keyboard selection via custom event from PlanRenderer
32
+ useEffect(() => {
33
+ const el = wrapperRef.current;
34
+ if (!el) return;
35
+ const handler = (e: Event) => {
36
+ const target = (e.target as HTMLElement).closest("[data-md-label]");
37
+ const opt = target?.getAttribute("data-md-label");
38
+ if (opt && options.includes(opt)) handleSelect(opt);
39
+ };
40
+ el.addEventListener("kb-select", handler);
41
+ return () => el.removeEventListener("kb-select", handler);
42
+ });
43
+
44
+ const showError = current?.required && !selected;
45
+
46
+ return (
47
+ <div ref={wrapperRef} data-md="choice" data-md-label={label} className="-mx-4 px-4 py-3 my-1 rounded-lg transition-colors duration-150 hover:bg-bg-input">
48
+ <div className="flex items-baseline gap-2 mb-2">
49
+ <span className="text-[13px] font-body font-medium text-text-primary">{label}</span>
50
+ {required && <span className="text-[10px] text-accent-red font-body">*</span>}
51
+ </div>
52
+ <div className="space-y-1">
53
+ {options.map((opt) => (
54
+ <label
55
+ key={opt}
56
+ data-md="choice-option"
57
+ data-md-label={opt}
58
+ onClick={() => handleSelect(opt)}
59
+ className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
60
+ selected === opt
61
+ ? "bg-highlight-selected"
62
+ : "hover:bg-bg-elevated"
63
+ }`}
64
+ >
65
+ <span className={`w-4 h-4 rounded-full border-[1.5px] flex items-center justify-center flex-shrink-0 transition-all ${
66
+ selected === opt
67
+ ? "border-accent-amber bg-accent-amber"
68
+ : "border-border-strong"
69
+ }`}>
70
+ {selected === opt && <span className="w-1.5 h-1.5 rounded-full bg-text-inverse" />}
71
+ </span>
72
+ <span className="text-[13px] font-body text-text-secondary">{opt}</span>
73
+ </label>
74
+ ))}
75
+ </div>
76
+ <ResponseNote show={showNote} note={note} onToggle={() => setShowNote(!showNote)} onChange={(n) => setResponse(id, { ...current!, note: n })} />
77
+ {showError && <p className="text-[11px] text-accent-red font-body mt-1">Please select an option.</p>}
78
+ </div>
79
+ );
80
+ }
81
+
82
+ /** Checkbox — pick multiple options */
83
+ interface MultiChoiceProps {
84
+ id: string;
85
+ label: string;
86
+ options: string[];
87
+ required?: boolean;
88
+ }
89
+
90
+ export function MultiChoice({ id, label, options, required }: MultiChoiceProps) {
91
+ const { responses, setResponse } = useAnnotations();
92
+ const current = responses.get(id);
93
+ const selected: string[] = (current?.value as string[]) || [];
94
+ const note = current?.note || "";
95
+ const [showNote, setShowNote] = useState(false);
96
+ const wrapperRef = useRef<HTMLDivElement>(null);
97
+
98
+ useEffect(() => {
99
+ if (!responses.has(id)) {
100
+ setResponse(id, { id, type: "checkbox", label, value: [], options, required });
101
+ }
102
+ }, [id]);
103
+
104
+ const toggle = (opt: string) => {
105
+ const next = selected.includes(opt) ? selected.filter((o) => o !== opt) : [...selected, opt];
106
+ setResponse(id, { ...current!, value: next });
107
+ };
108
+
109
+ // Keyboard selection via custom event from PlanRenderer
110
+ useEffect(() => {
111
+ const el = wrapperRef.current;
112
+ if (!el) return;
113
+ const handler = (e: Event) => {
114
+ const target = (e.target as HTMLElement).closest("[data-md-label]");
115
+ const opt = target?.getAttribute("data-md-label");
116
+ if (opt && options.includes(opt)) toggle(opt);
117
+ };
118
+ el.addEventListener("kb-select", handler);
119
+ return () => el.removeEventListener("kb-select", handler);
120
+ });
121
+
122
+ const showError = current?.required && selected.length === 0;
123
+
124
+ return (
125
+ <div ref={wrapperRef} data-md="multichoice" data-md-label={label} className="-mx-4 px-4 py-3 my-1 rounded-lg transition-colors duration-150 hover:bg-bg-input">
126
+ <div className="flex items-baseline gap-2 mb-2">
127
+ <span className="text-[13px] font-body font-medium text-text-primary">{label}</span>
128
+ {required && <span className="text-[10px] text-accent-red font-body">*</span>}
129
+ </div>
130
+ <div className="space-y-1">
131
+ {options.map((opt) => {
132
+ const checked = selected.includes(opt);
133
+ return (
134
+ <label
135
+ key={opt}
136
+ data-md="multichoice-option"
137
+ data-md-label={opt}
138
+ onClick={() => toggle(opt)}
139
+ className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
140
+ checked ? "bg-highlight-selected" : "hover:bg-bg-elevated"
141
+ }`}
142
+ >
143
+ <span className={`w-4 h-4 rounded flex items-center justify-center flex-shrink-0 transition-all ${
144
+ checked
145
+ ? "bg-accent-amber"
146
+ : "border-[1.5px] border-border-strong"
147
+ }`}>
148
+ {checked && (
149
+ <svg width="10" height="10" viewBox="0 0 10 10" fill="none">
150
+ <path d="M2.5 5L4.5 7L7.5 3" style={{ stroke: "var(--color-text-inverse)" }} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
151
+ </svg>
152
+ )}
153
+ </span>
154
+ <span className="text-[13px] font-body text-text-secondary">{opt}</span>
155
+ </label>
156
+ );
157
+ })}
158
+ </div>
159
+ <ResponseNote show={showNote} note={note} onToggle={() => setShowNote(!showNote)} onChange={(n) => setResponse(id, { ...current!, note: n })} />
160
+ {showError && <p className="text-[11px] text-accent-red font-body mt-1">Please select at least one option.</p>}
161
+ </div>
162
+ );
163
+ }
164
+
@@ -0,0 +1,35 @@
1
+ import React, { useEffect, useRef } from "react";
2
+
3
+ interface CodeBlockProps {
4
+ language?: string;
5
+ children?: React.ReactNode;
6
+ }
7
+
8
+ export function CodeBlock({ language, children }: CodeBlockProps) {
9
+ const code = typeof children === "string" ? children : String(children ?? "");
10
+ const codeRef = useRef<HTMLElement>(null);
11
+
12
+ useEffect(() => {
13
+ const el = codeRef.current;
14
+ const hljs = (window as any).hljs;
15
+ if (!el || !hljs) return;
16
+ // Reset previous highlighting
17
+ el.removeAttribute("data-highlighted");
18
+ el.className = language ? `language-${language}` : "";
19
+ el.textContent = code;
20
+ hljs.highlightElement(el);
21
+ }, [code, language]);
22
+
23
+ return (
24
+ <div className="relative mt-3 group/code" data-md="codeblock" data-md-language={language || ""}>
25
+ {language && (
26
+ <span className="absolute top-2 right-3 text-tiny text-text-tertiary opacity-0 group-hover/code:opacity-60 transition-opacity duration-150 z-10 font-mono">
27
+ {language}
28
+ </span>
29
+ )}
30
+ <pre className="bg-bg-code rounded-md p-4 overflow-x-auto text-code font-mono leading-relaxed">
31
+ <code ref={codeRef} className={language ? `language-${language}` : ""}>{code}</code>
32
+ </pre>
33
+ </div>
34
+ );
35
+ }
@@ -0,0 +1,65 @@
1
+ import React from "react";
2
+
3
+ interface DiffProps {
4
+ before: string;
5
+ after: string;
6
+ language?: string;
7
+ }
8
+
9
+ export function Diff({ before, after, language }: DiffProps) {
10
+ const beforeLines = before.split("\n");
11
+ const afterLines = after.split("\n");
12
+ const lcs = computeLCS(beforeLines, afterLines);
13
+ const diffLines: Array<{ type: "same" | "removed" | "added"; line: string }> = [];
14
+
15
+ let bi = 0, ai = 0, li = 0;
16
+ while (bi < beforeLines.length || ai < afterLines.length) {
17
+ if (li < lcs.length && bi < beforeLines.length && ai < afterLines.length
18
+ && beforeLines[bi] === lcs[li] && afterLines[ai] === lcs[li]) {
19
+ diffLines.push({ type: "same", line: beforeLines[bi] }); bi++; ai++; li++;
20
+ } else if (bi < beforeLines.length && (li >= lcs.length || beforeLines[bi] !== lcs[li])) {
21
+ diffLines.push({ type: "removed", line: beforeLines[bi] }); bi++;
22
+ } else if (ai < afterLines.length) {
23
+ diffLines.push({ type: "added", line: afterLines[ai] }); ai++;
24
+ }
25
+ }
26
+
27
+ return (
28
+ <div className="mt-3 bg-bg-code rounded-md overflow-hidden group/diff" data-md="diff" data-md-language={language || ""}>
29
+ {language && (
30
+ <div className="px-4 py-2 text-meta font-mono text-text-tertiary opacity-0 group-hover/diff:opacity-60 transition-opacity">
31
+ diff ({language})
32
+ </div>
33
+ )}
34
+ <pre className="px-4 pb-3 overflow-x-auto text-code font-mono">
35
+ {diffLines.map((d, i) => (
36
+ <div key={i} className={
37
+ d.type === "removed" ? "bg-accent-red-muted text-accent-red" :
38
+ d.type === "added" ? "bg-accent-green-muted text-accent-green" :
39
+ "text-text-tertiary"
40
+ }>
41
+ <span className="select-none w-6 inline-block text-center opacity-60">
42
+ {d.type === "removed" ? "-" : d.type === "added" ? "+" : " "}
43
+ </span>
44
+ {d.line}
45
+ </div>
46
+ ))}
47
+ </pre>
48
+ </div>
49
+ );
50
+ }
51
+
52
+ function computeLCS(a: string[], b: string[]): string[] {
53
+ const m = a.length, n = b.length;
54
+ const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
55
+ for (let i = 1; i <= m; i++)
56
+ for (let j = 1; j <= n; j++)
57
+ dp[i][j] = a[i-1] === b[j-1] ? dp[i-1][j-1]+1 : Math.max(dp[i-1][j], dp[i][j-1]);
58
+ const result: string[] = [];
59
+ let i = m, j = n;
60
+ while (i > 0 && j > 0) {
61
+ if (a[i-1] === b[j-1]) { result.unshift(a[i-1]); i--; j--; }
62
+ else if (dp[i-1][j] > dp[i][j-1]) i--; else j--;
63
+ }
64
+ return result;
65
+ }
@@ -0,0 +1,88 @@
1
+ import React, { useEffect, useState, useContext, useRef } from "react";
2
+ import { SessionContext, ActiveViewCtx } from "#canvas/runtime";
3
+
4
+ interface FilePreviewProps {
5
+ path: string;
6
+ lines?: [number, number];
7
+ /** Injected by compiler at build time — skip runtime fetch when present */
8
+ __content?: string;
9
+ }
10
+
11
+ const EXT_TO_LANG: Record<string, string> = {
12
+ ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript",
13
+ py: "python", rs: "rust", go: "go", rb: "ruby", java: "java",
14
+ json: "json", yaml: "yaml", yml: "yaml", md: "markdown",
15
+ css: "css", html: "html", sh: "bash", bash: "bash", toml: "toml",
16
+ sql: "sql", xml: "xml", c: "c", cpp: "cpp", h: "c",
17
+ };
18
+
19
+ export function FilePreview({ path, lines, __content }: FilePreviewProps) {
20
+ const sessionId = useContext(SessionContext);
21
+ const { setActiveView } = useContext(ActiveViewCtx);
22
+ const [content, setContent] = useState<string | null>(__content ?? null);
23
+ const [language, setLanguage] = useState(() => {
24
+ const ext = path.split(".").pop() || "";
25
+ return EXT_TO_LANG[ext] || "text";
26
+ });
27
+ const [error, setError] = useState<string | null>(null);
28
+ const [loading, setLoading] = useState(!__content);
29
+ const codeRef = useRef<HTMLElement>(null);
30
+
31
+ useEffect(() => {
32
+ if (__content !== undefined) return; // already resolved at compile time
33
+ setLoading(true);
34
+ setError(null);
35
+ fetch(`/api/file?session=${sessionId}&path=${encodeURIComponent(path)}`)
36
+ .then((r) => r.json())
37
+ .then((data: any) => {
38
+ if (data.error) {
39
+ setError(data.error);
40
+ } else {
41
+ let text = data.content;
42
+ if (lines) {
43
+ const allLines = text.split("\n");
44
+ text = allLines.slice(lines[0] - 1, lines[1]).join("\n");
45
+ }
46
+ setContent(text);
47
+ setLanguage(data.language || "text");
48
+ }
49
+ })
50
+ .catch(() => setError("Failed to fetch file"))
51
+ .finally(() => setLoading(false));
52
+ }, [sessionId, path, lines?.[0], lines?.[1], __content]);
53
+
54
+ // Highlight after content loads
55
+ useEffect(() => {
56
+ const el = codeRef.current;
57
+ const hljs = (window as any).hljs;
58
+ if (!el || !hljs || !content) return;
59
+ el.removeAttribute("data-highlighted");
60
+ el.className = language ? `language-${language}` : "";
61
+ el.textContent = content;
62
+ hljs.highlightElement(el);
63
+ }, [content, language]);
64
+
65
+ if (loading) {
66
+ return <div className="text-text-tertiary text-body mt-3 bg-bg-code rounded-md p-4">Loading {path}...</div>;
67
+ }
68
+
69
+ if (error) {
70
+ return <div className="text-accent-red text-body mt-3 bg-accent-red-muted rounded-md p-4">{path}: {error}</div>;
71
+ }
72
+
73
+ return (
74
+ <div className="mt-3 bg-bg-code rounded-md overflow-hidden" data-md="filepreview" data-md-path={path} {...(lines ? { "data-md-lines": `${lines[0]}-${lines[1]}` } : {})}>
75
+ <div className="px-4 py-2 flex items-center justify-between">
76
+ <button
77
+ className="font-mono text-meta text-text-tertiary hover:text-text-secondary hover:underline cursor-pointer text-left"
78
+ onClick={() => setActiveView({ type: "file", path })}
79
+ title={`Open ${path}`}
80
+ >{path}</button>
81
+ {lines && <span className="font-mono text-meta text-text-tertiary opacity-60">L{lines[0]}–{lines[1]}</span>}
82
+ </div>
83
+ <pre className="px-4 pb-3 overflow-x-auto text-code font-mono !bg-transparent" style={{ background: "transparent" }}>
84
+ <code ref={codeRef} className={language ? `language-${language}` : ""}>{content}</code>
85
+ </pre>
86
+ </div>
87
+ );
88
+ }
@@ -0,0 +1,45 @@
1
+ import React, { useContext, useState } from "react";
2
+ import { SessionContext } from "#canvas/runtime";
3
+
4
+ interface ImageProps {
5
+ src: string;
6
+ alt?: string;
7
+ caption?: string;
8
+ width?: number | string;
9
+ height?: number | string;
10
+ }
11
+
12
+ export function ImageView({ src, alt, caption, width, height }: ImageProps) {
13
+ const sessionId = useContext(SessionContext);
14
+ const [error, setError] = useState(false);
15
+
16
+ const imgSrc = `/api/image?session=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(src)}`;
17
+
18
+ if (error) {
19
+ return (
20
+ <div className="mt-3 text-accent-red text-body bg-accent-red-muted rounded-md p-4">
21
+ {src}: Failed to load image
22
+ </div>
23
+ );
24
+ }
25
+
26
+ return (
27
+ <figure className="mt-3 relative" data-md="image" data-md-src={src}>
28
+ <div className="rounded-md overflow-hidden border border-border-subtle bg-bg-code inline-block">
29
+ <img
30
+ src={imgSrc}
31
+ alt={alt || src}
32
+ onError={() => setError(true)}
33
+ className="block max-w-full h-auto"
34
+ {...(width ? { width } : {})}
35
+ {...(height ? { height } : {})}
36
+ />
37
+ </div>
38
+ {caption && (
39
+ <figcaption className="mt-1.5 text-meta text-text-tertiary font-body">
40
+ {caption}
41
+ </figcaption>
42
+ )}
43
+ </figure>
44
+ );
45
+ }