@fabrika/canvas-kernel 0.1.44 → 0.2.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 (66) hide show
  1. package/README.md +33 -67
  2. package/index.ts +19 -83
  3. package/package.json +68 -72
  4. package/src/client/AnnotationProvider.tsx +227 -268
  5. package/src/client/AnnotationSidebar.tsx +392 -338
  6. package/src/client/FileIcon.tsx +106 -65
  7. package/src/client/PlanRenderer.tsx +607 -551
  8. package/src/client/RenderErrorContext.ts +7 -7
  9. package/src/client/ResponsePreview.tsx +192 -173
  10. package/src/client/annotationDom.ts +43 -97
  11. package/src/client/annotationDraft.ts +98 -92
  12. package/src/client/appContext.tsx +40 -40
  13. package/src/client/components/Callout.tsx +20 -20
  14. package/src/client/components/Checklist.tsx +28 -28
  15. package/src/client/components/Choice.tsx +178 -159
  16. package/src/client/components/CodeBlock.tsx +26 -26
  17. package/src/client/components/Diff.tsx +17 -17
  18. package/src/client/components/FilePreview.tsx +99 -74
  19. package/src/client/components/Image.tsx +41 -39
  20. package/src/client/components/Markdown.tsx +33 -32
  21. package/src/client/components/Mermaid.tsx +592 -542
  22. package/src/client/components/Note.tsx +8 -8
  23. package/src/client/components/Priority.tsx +11 -11
  24. package/src/client/components/ResponseNote.tsx +17 -15
  25. package/src/client/components/Section.tsx +28 -26
  26. package/src/client/components/Table.tsx +34 -29
  27. package/src/client/components/Task.tsx +58 -60
  28. package/src/client/components/UserInput.tsx +169 -152
  29. package/src/client/components/index.ts +16 -16
  30. package/src/client/components/renderMarkdownChildren.tsx +86 -68
  31. package/src/client/diffLines.ts +51 -39
  32. package/src/client/generateMarkdown.ts +197 -191
  33. package/src/client/hostContext.tsx +34 -34
  34. package/src/client/index.ts +43 -125
  35. package/src/client/preact-all.ts +4 -4
  36. package/src/client/runtime.ts +58 -111
  37. package/src/client/styles.css +1 -74
  38. package/src/engine/compiler.ts +316 -313
  39. package/src/engine/paths.ts +44 -44
  40. package/src/engine/session.ts +623 -618
  41. package/src/engine/watcher.ts +63 -60
  42. package/src/engine/websocket.ts +123 -121
  43. package/annotate.ts +0 -94
  44. package/daemon.ts +0 -46
  45. package/src/client/AnnotationDraftFooter.tsx +0 -76
  46. package/src/client/AnnotationEditor.tsx +0 -320
  47. package/src/client/AnnotationList.tsx +0 -190
  48. package/src/client/Popover.tsx +0 -361
  49. package/src/client/SessionContext.tsx +0 -3
  50. package/src/client/annotationContext.ts +0 -130
  51. package/src/client/annotationMarkdown.ts +0 -102
  52. package/src/client/annotationTarget.ts +0 -73
  53. package/src/client/blockTarget.ts +0 -127
  54. package/src/client/highlightRange.ts +0 -262
  55. package/src/client/popoverPosition.ts +0 -46
  56. package/src/client/regionTarget.ts +0 -180
  57. package/src/client/testing/dom.ts +0 -58
  58. package/src/client/textTarget.ts +0 -31
  59. package/src/client/useRegionAnnotation.tsx +0 -188
  60. package/src/client/useTextAnnotation.tsx +0 -194
  61. package/src/client/utils.ts +0 -33
  62. package/src/engine/channel.ts +0 -278
  63. package/src/engine/http-utils.ts +0 -14
  64. package/src/engine/lifecycle.ts +0 -302
  65. package/src/engine/router.ts +0 -16
  66. package/src/engine/waiter.ts +0 -188
@@ -1,50 +1,62 @@
1
1
  export interface DiffLine {
2
- type: "same" | "removed" | "added";
3
- line: string;
2
+ type: 'same' | 'removed' | 'added'
3
+ line: string
4
4
  }
5
5
 
6
6
  /** Line-level diff of two texts, longest-common-subsequence based. */
7
7
  export function buildDiffLines(before: string, after: string): DiffLine[] {
8
- const beforeLines = before.split("\n");
9
- const afterLines = after.split("\n");
10
- const lcs = computeLCS(beforeLines, afterLines);
11
- const diffLines: DiffLine[] = [];
8
+ const beforeLines = before.split('\n')
9
+ const afterLines = after.split('\n')
10
+ const lcs = computeLCS(beforeLines, afterLines)
11
+ const diffLines: DiffLine[] = []
12
12
 
13
- let bi = 0, ai = 0, li = 0;
14
- while (bi < beforeLines.length || ai < afterLines.length) {
15
- // split() never leaves holes, so "in range" and "defined" are the same test.
16
- const beforeLine = beforeLines[bi];
17
- const afterLine = afterLines[ai];
18
- if (li < lcs.length && beforeLine !== undefined && afterLine !== undefined
19
- && beforeLine === lcs[li] && afterLine === lcs[li]) {
20
- diffLines.push({ type: "same", line: beforeLine }); bi++; ai++; li++;
21
- } else if (beforeLine !== undefined && (li >= lcs.length || beforeLine !== lcs[li])) {
22
- diffLines.push({ type: "removed", line: beforeLine }); bi++;
23
- } else if (afterLine !== undefined) {
24
- diffLines.push({ type: "added", line: afterLine }); ai++;
25
- }
26
- }
13
+ let bi = 0, ai = 0, li = 0
14
+ while (bi < beforeLines.length || ai < afterLines.length) {
15
+ // split() never leaves holes, so "in range" and "defined" are the same test.
16
+ const beforeLine = beforeLines[bi]
17
+ const afterLine = afterLines[ai]
18
+ if (
19
+ li < lcs.length && beforeLine !== undefined && afterLine !== undefined
20
+ && beforeLine === lcs[li] && afterLine === lcs[li]
21
+ ) {
22
+ diffLines.push({ type: 'same', line: beforeLine })
23
+ bi++
24
+ ai++
25
+ li++
26
+ } else if (beforeLine !== undefined && (li >= lcs.length || beforeLine !== lcs[li])) {
27
+ diffLines.push({ type: 'removed', line: beforeLine })
28
+ bi++
29
+ } else if (afterLine !== undefined) {
30
+ diffLines.push({ type: 'added', line: afterLine })
31
+ ai++
32
+ }
33
+ }
27
34
 
28
- return diffLines;
35
+ return diffLines
29
36
  }
30
37
 
31
38
  export function computeLCS(a: string[], b: string[]): string[] {
32
- const m = a.length, n = b.length;
33
- // Rows are fully filled before use; the `?? 0` fallbacks only satisfy the
34
- // checker, which cannot see that every index here is in range.
35
- const dp: number[][] = Array.from({ length: m + 1 }, () => new Array<number>(n + 1).fill(0));
36
- for (let i = 1; i <= m; i++) {
37
- const row = dp[i] ?? [];
38
- const prev = dp[i - 1] ?? [];
39
- for (let j = 1; j <= n; j++)
40
- row[j] = a[i-1] === b[j-1] ? (prev[j-1] ?? 0) + 1 : Math.max(prev[j] ?? 0, row[j-1] ?? 0);
41
- }
42
- const result: string[] = [];
43
- let i = m, j = n;
44
- while (i > 0 && j > 0) {
45
- const line = a[i-1];
46
- if (line !== undefined && line === b[j-1]) { result.unshift(line); i--; j--; }
47
- else if ((dp[i-1]?.[j] ?? 0) > (dp[i]?.[j-1] ?? 0)) i--; else j--;
48
- }
49
- return result;
39
+ const m = a.length, n = b.length
40
+ // Rows are fully filled before use; the `?? 0` fallbacks only satisfy the
41
+ // checker, which cannot see that every index here is in range.
42
+ const dp: number[][] = Array.from({ length: m + 1 }, () => new Array<number>(n + 1).fill(0))
43
+ for (let i = 1; i <= m; i++) {
44
+ const row = dp[i] ?? []
45
+ const prev = dp[i - 1] ?? []
46
+ for (let j = 1; j <= n; j++) {
47
+ row[j] = a[i - 1] === b[j - 1] ? (prev[j - 1] ?? 0) + 1 : Math.max(prev[j] ?? 0, row[j - 1] ?? 0)
48
+ }
49
+ }
50
+ const result: string[] = []
51
+ let i = m, j = n
52
+ while (i > 0 && j > 0) {
53
+ const line = a[i - 1]
54
+ if (line !== undefined && line === b[j - 1]) {
55
+ result.unshift(line)
56
+ i--
57
+ j--
58
+ } else if ((dp[i - 1]?.[j] ?? 0) > (dp[i]?.[j - 1] ?? 0)) i--
59
+ else j--
60
+ }
61
+ return result
50
62
  }
@@ -1,8 +1,7 @@
1
1
  // The canvas document format: revisions, responses, required fields, sections.
2
2
  // Rendering one annotation is host-agnostic and lives in ./annotationMarkdown.
3
- import type { Annotation, PlanResponse, FeedbackEntry } from "./AnnotationProvider";
4
- import { renderAnnotation } from "./annotationMarkdown";
5
- import { RESPONSE_ANNOTATION_PATH } from "./utils";
3
+ import { renderAnnotation, RESPONSE_ANNOTATION_PATH } from '@fabrika/annotations'
4
+ import type { Annotation, FeedbackEntry, PlanResponse } from './AnnotationProvider'
6
5
 
7
6
  /**
8
7
  * Drop answers whose question is not in this revision's canvas. A draft carried
@@ -14,14 +13,14 @@ import { RESPONSE_ANNOTATION_PATH } from "./utils";
14
13
  * point in this draft, never the currently-mounted ones — see canPruneResponses.
15
14
  */
16
15
  export function pruneStaleResponses(
17
- responses: Map<string, PlanResponse>,
18
- seenResponseIds: ReadonlySet<string>,
16
+ responses: Map<string, PlanResponse>,
17
+ seenResponseIds: ReadonlySet<string>,
19
18
  ): Map<string, PlanResponse> {
20
- const out = new Map<string, PlanResponse>();
21
- for (const [id, r] of responses) {
22
- if (seenResponseIds.has(id)) out.set(id, r);
23
- }
24
- return out;
19
+ const out = new Map<string, PlanResponse>()
20
+ for (const [id, r] of responses) {
21
+ if (seenResponseIds.has(id)) out.set(id, r)
22
+ }
23
+ return out
25
24
  }
26
25
 
27
26
  /**
@@ -33,218 +32,225 @@ export function pruneStaleResponses(
33
32
  * pruning early would throw away every answer the author gave.
34
33
  */
35
34
  export function canPruneResponses(
36
- canvasFiles: readonly string[] | undefined,
37
- renderedCanvases: ReadonlySet<string>,
35
+ canvasFiles: readonly string[] | undefined,
36
+ renderedCanvases: ReadonlySet<string>,
38
37
  ): boolean {
39
- if (!canvasFiles || canvasFiles.length === 0) return false;
40
- return canvasFiles.every((filename) => renderedCanvases.has(filename));
38
+ if (!canvasFiles || canvasFiles.length === 0) return false
39
+ return canvasFiles.every((filename) => renderedCanvases.has(filename))
41
40
  }
42
41
 
43
42
  /**
44
43
  * Generate human-readable markdown feedback from annotations and responses.
45
44
  */
46
45
  export function generateMarkdown(
47
- annotations: Annotation[],
48
- generalNote: string,
49
- responses?: Map<string, PlanResponse>,
50
- feedbackEntries?: Map<string, FeedbackEntry>,
51
- includedRemoteIds?: Set<string>,
46
+ annotations: Annotation[],
47
+ generalNote: string,
48
+ responses?: Map<string, PlanResponse>,
49
+ feedbackEntries?: Map<string, FeedbackEntry>,
50
+ includedRemoteIds?: Set<string>,
52
51
  ): string {
53
- const parts: string[] = [];
54
- const included = annotations.filter((a) =>
55
- a.source !== "remote" || (includedRemoteIds && includedRemoteIds.has(a.id))
56
- );
57
- const responseAnns = included.filter((a) => a.filePath === RESPONSE_ANNOTATION_PATH);
58
- const canvasAnns = included.filter((a) => !a.filePath);
59
- const fileAnns = included.filter((a) => a.filePath && a.filePath !== RESPONSE_ANNOTATION_PATH);
60
- const resps = responses ? Array.from(responses.values()).filter((r) => hasValue(r)) : [];
61
- const fbEntries = feedbackEntries
62
- ? Array.from(feedbackEntries.values()).filter((e) => e.markdown.trim())
63
- : [];
64
-
65
- // Responses section
66
- const hasResponseSection = resps.length > 0 || fbEntries.length > 0;
67
- if (hasResponseSection) {
68
- parts.push("## Responses");
69
- parts.push("");
70
- for (const r of resps) {
71
- parts.push(renderResponse(r));
72
- }
73
- for (const entry of fbEntries) {
74
- parts.push(entry.markdown.trim());
75
- parts.push("");
76
- }
77
- }
78
-
79
- // Response annotations (on agent response banner)
80
- if (responseAnns.length > 0) {
81
- parts.push("## Annotations on agent response");
82
- parts.push("");
83
- for (const ann of responseAnns) {
84
- parts.push(renderAnnotation(ann));
85
- }
86
- }
87
-
88
- // Canvas annotations grouped by canvas file, then hierarchy
89
- if (canvasAnns.length > 0) {
90
- parts.push("## Canvas annotations");
91
- parts.push("");
92
-
93
- // Group by canvasFile
94
- const byCanvas = new Map<string, Annotation[]>();
95
- for (const ann of canvasAnns) {
96
- const key = ann.canvasFile || "";
97
- if (!byCanvas.has(key)) byCanvas.set(key, []);
98
- byCanvas.get(key)!.push(ann);
99
- }
100
-
101
- const canvasKeys = [...byCanvas.keys()].sort();
102
- const multipleCanvases = canvasKeys.length > 1 || (canvasKeys.length === 1 && canvasKeys[0] !== "");
103
-
104
- for (const canvasKey of canvasKeys) {
105
- const anns = byCanvas.get(canvasKey)!;
106
- if (multipleCanvases && canvasKey) {
107
- parts.push(`### ${canvasKey.replace(/\.jsx$/, "")}`);
108
- parts.push("");
109
- }
110
- const grouped = groupByHierarchy(anns);
111
- parts.push(renderHierarchyGroup(grouped));
112
- }
113
- }
114
-
115
- // File annotations grouped by path
116
- if (fileAnns.length > 0) {
117
- const groups: Record<string, Annotation[]> = {};
118
- for (const ann of fileAnns) {
119
- const key = ann.filePath!;
120
- if (!groups[key]) groups[key] = [];
121
- groups[key].push(ann);
122
- }
123
- parts.push("## File annotations");
124
- parts.push("");
125
- for (const [filePath, anns] of Object.entries(groups)) {
126
- parts.push(`### ${filePath}`);
127
- parts.push("");
128
- for (const ann of anns) {
129
- parts.push(renderAnnotation(ann));
130
- }
131
- }
132
- }
133
-
134
- // General notes
135
- if (generalNote.trim()) {
136
- parts.push("## General notes");
137
- parts.push("");
138
- parts.push(generalNote.trim());
139
- parts.push("");
140
- }
141
-
142
- return parts.join("\n").trim() + "\n";
52
+ const parts: string[] = []
53
+ const included = annotations.filter((a) => a.source !== 'remote' || (includedRemoteIds && includedRemoteIds.has(a.id)))
54
+ const responseAnns = included.filter((a) => a.filePath === RESPONSE_ANNOTATION_PATH)
55
+ const canvasAnns = included.filter((a) => !a.filePath)
56
+ const fileAnns = included.filter((a) => a.filePath && a.filePath !== RESPONSE_ANNOTATION_PATH)
57
+ const resps = responses ? Array.from(responses.values()).filter((r) => hasValue(r)) : []
58
+ const fbEntries = feedbackEntries
59
+ ? Array.from(feedbackEntries.values()).filter((e) => e.markdown.trim())
60
+ : []
61
+
62
+ // Responses section
63
+ const hasResponseSection = resps.length > 0 || fbEntries.length > 0
64
+ if (hasResponseSection) {
65
+ parts.push('## Responses')
66
+ parts.push('')
67
+ for (const r of resps) {
68
+ parts.push(renderResponse(r))
69
+ }
70
+ for (const entry of fbEntries) {
71
+ parts.push(entry.markdown.trim())
72
+ parts.push('')
73
+ }
74
+ }
75
+
76
+ // Response annotations (on agent response banner)
77
+ if (responseAnns.length > 0) {
78
+ parts.push('## Annotations on agent response')
79
+ parts.push('')
80
+ for (const ann of responseAnns) {
81
+ parts.push(renderAnnotation(ann))
82
+ }
83
+ }
84
+
85
+ // Canvas annotations grouped by canvas file, then hierarchy
86
+ if (canvasAnns.length > 0) {
87
+ parts.push('## Canvas annotations')
88
+ parts.push('')
89
+
90
+ // Group by canvasFile
91
+ const byCanvas = new Map<string, Annotation[]>()
92
+ for (const ann of canvasAnns) {
93
+ const key = ann.canvasFile || ''
94
+ if (!byCanvas.has(key)) byCanvas.set(key, [])
95
+ byCanvas.get(key)!.push(ann)
96
+ }
97
+
98
+ const canvasKeys = [...byCanvas.keys()].sort()
99
+ const multipleCanvases = canvasKeys.length > 1 || (canvasKeys.length === 1 && canvasKeys[0] !== '')
100
+
101
+ for (const canvasKey of canvasKeys) {
102
+ const anns = byCanvas.get(canvasKey)!
103
+ if (multipleCanvases && canvasKey) {
104
+ parts.push(`### ${canvasKey.replace(/\.jsx$/, '')}`)
105
+ parts.push('')
106
+ }
107
+ const grouped = groupByHierarchy(anns)
108
+ parts.push(renderHierarchyGroup(grouped))
109
+ }
110
+ }
111
+
112
+ // File annotations grouped by path
113
+ if (fileAnns.length > 0) {
114
+ const groups: Record<string, Annotation[]> = {}
115
+ for (const ann of fileAnns) {
116
+ const key = ann.filePath!
117
+ if (!groups[key]) groups[key] = []
118
+ groups[key].push(ann)
119
+ }
120
+ parts.push('## File annotations')
121
+ parts.push('')
122
+ for (const [filePath, anns] of Object.entries(groups)) {
123
+ parts.push(`### ${filePath}`)
124
+ parts.push('')
125
+ for (const ann of anns) {
126
+ parts.push(renderAnnotation(ann))
127
+ }
128
+ }
129
+ }
130
+
131
+ // General notes
132
+ if (generalNote.trim()) {
133
+ parts.push('## General notes')
134
+ parts.push('')
135
+ parts.push(generalNote.trim())
136
+ parts.push('')
137
+ }
138
+
139
+ return parts.join('\n').trim() + '\n'
143
140
  }
144
141
 
145
142
  export function hasValue(r: PlanResponse): boolean {
146
- if (r.note?.trim()) return true;
147
- if (r.value === null || r.value === undefined) return false;
148
- if (r.type === "text" && !(r.value as string).trim()) return false;
149
- if (r.type === "checkbox" && (r.value as string[]).length === 0) return false;
150
- return true;
143
+ if (r.note?.trim()) return true
144
+ if (r.value === null || r.value === undefined) return false
145
+ if (r.type === 'text' && !(r.value as string).trim()) return false
146
+ if (r.type === 'checkbox' && (r.value as string[]).length === 0) return false
147
+ return true
151
148
  }
152
149
 
153
150
  export function getMissingRequired(responses: Map<string, PlanResponse>): PlanResponse[] {
154
- const missing: PlanResponse[] = [];
155
- for (const r of responses.values()) {
156
- if (!r.required) continue;
157
- if (r.value === null || r.value === undefined) { missing.push(r); continue; }
158
- if (r.type === "text" && !(r.value as string).trim()) { missing.push(r); continue; }
159
- if (r.type === "checkbox" && (r.value as string[]).length === 0) { missing.push(r); continue; }
160
- }
161
- return missing;
151
+ const missing: PlanResponse[] = []
152
+ for (const r of responses.values()) {
153
+ if (!r.required) continue
154
+ if (r.value === null || r.value === undefined) {
155
+ missing.push(r)
156
+ continue
157
+ }
158
+ if (r.type === 'text' && !(r.value as string).trim()) {
159
+ missing.push(r)
160
+ continue
161
+ }
162
+ if (r.type === 'checkbox' && (r.value as string[]).length === 0) {
163
+ missing.push(r)
164
+ continue
165
+ }
166
+ }
167
+ return missing
162
168
  }
163
169
 
164
170
  export function getMissingRequiredFeedback(entries: Map<string, FeedbackEntry>): FeedbackEntry[] {
165
- const missing: FeedbackEntry[] = [];
166
- for (const entry of entries.values()) {
167
- if (entry.required && !entry.markdown.trim()) {
168
- missing.push(entry);
169
- }
170
- }
171
- return missing;
171
+ const missing: FeedbackEntry[] = []
172
+ for (const entry of entries.values()) {
173
+ if (entry.required && !entry.markdown.trim()) {
174
+ missing.push(entry)
175
+ }
176
+ }
177
+ return missing
172
178
  }
173
179
 
174
180
  /** Returns list of missing required field labels, or empty if all valid */
175
181
  export function getMissingRequiredLabels(responses: Map<string, PlanResponse>, feedbackEntries: Map<string, FeedbackEntry>): string[] {
176
- const missingResponses = getMissingRequired(responses);
177
- const missingFeedback = getMissingRequiredFeedback(feedbackEntries);
178
- return [
179
- ...missingResponses.map((r) => r.label),
180
- ...missingFeedback.map((e) => e.label || e.id),
181
- ];
182
+ const missingResponses = getMissingRequired(responses)
183
+ const missingFeedback = getMissingRequiredFeedback(feedbackEntries)
184
+ return [
185
+ ...missingResponses.map((r) => r.label),
186
+ ...missingFeedback.map((e) => e.label || e.id),
187
+ ]
182
188
  }
183
189
 
184
190
  function renderResponse(r: PlanResponse): string {
185
- const lines: string[] = [];
186
- lines.push(`**${r.label}**`);
187
-
188
- switch (r.type) {
189
- case "radio":
190
- case "select":
191
- lines.push(`Answer: ${r.value}`);
192
- break;
193
- case "checkbox":
194
- for (const v of r.value as string[]) {
195
- lines.push(`- [x] ${v}`);
196
- }
197
- break;
198
- case "text":
199
- lines.push("");
200
- lines.push((r.value as string).trim());
201
- break;
202
- case "range":
203
- lines.push(`Answer: ${r.value}`);
204
- break;
205
- }
206
-
207
- if (r.note?.trim()) {
208
- lines.push("");
209
- lines.push(`Note: ${r.note.trim()}`);
210
- }
211
-
212
- lines.push("");
213
- return lines.join("\n");
191
+ const lines: string[] = []
192
+ lines.push(`**${r.label}**`)
193
+
194
+ switch (r.type) {
195
+ case 'radio':
196
+ case 'select':
197
+ lines.push(`Answer: ${r.value}`)
198
+ break
199
+ case 'checkbox':
200
+ for (const v of r.value as string[]) {
201
+ lines.push(`- [x] ${v}`)
202
+ }
203
+ break
204
+ case 'text':
205
+ lines.push('')
206
+ lines.push((r.value as string).trim())
207
+ break
208
+ case 'range':
209
+ lines.push(`Answer: ${r.value}`)
210
+ break
211
+ }
212
+
213
+ if (r.note?.trim()) {
214
+ lines.push('')
215
+ lines.push(`Note: ${r.note.trim()}`)
216
+ }
217
+
218
+ lines.push('')
219
+ return lines.join('\n')
214
220
  }
215
221
 
216
222
  interface HierarchyNode {
217
- label: string;
218
- annotations: Annotation[];
219
- children: Map<string, HierarchyNode>;
223
+ label: string
224
+ annotations: Annotation[]
225
+ children: Map<string, HierarchyNode>
220
226
  }
221
227
 
222
228
  function groupByHierarchy(annotations: Annotation[]): HierarchyNode {
223
- const root: HierarchyNode = { label: "", annotations: [], children: new Map() };
224
- for (const ann of annotations) {
225
- const path = ann.context?.hierarchy || [];
226
- let current = root;
227
- for (const segment of path) {
228
- if (!current.children.has(segment)) {
229
- current.children.set(segment, { label: segment, annotations: [], children: new Map() });
230
- }
231
- current = current.children.get(segment)!;
232
- }
233
- current.annotations.push(ann);
234
- }
235
- return root;
229
+ const root: HierarchyNode = { label: '', annotations: [], children: new Map() }
230
+ for (const ann of annotations) {
231
+ const path = ann.context?.hierarchy || []
232
+ let current = root
233
+ for (const segment of path) {
234
+ if (!current.children.has(segment)) {
235
+ current.children.set(segment, { label: segment, annotations: [], children: new Map() })
236
+ }
237
+ current = current.children.get(segment)!
238
+ }
239
+ current.annotations.push(ann)
240
+ }
241
+ return root
236
242
  }
237
243
 
238
244
  function renderHierarchyGroup(node: HierarchyNode, depth: number = 0): string {
239
- const parts: string[] = [];
240
- for (const ann of node.annotations) {
241
- parts.push(renderAnnotation(ann));
242
- }
243
- for (const [, child] of node.children) {
244
- const prefix = depth === 0 ? "###" : "####";
245
- parts.push(`${prefix} ${child.label}`);
246
- parts.push("");
247
- parts.push(renderHierarchyGroup(child, depth + 1));
248
- }
249
- return parts.join("\n");
245
+ const parts: string[] = []
246
+ for (const ann of node.annotations) {
247
+ parts.push(renderAnnotation(ann))
248
+ }
249
+ for (const [, child] of node.children) {
250
+ const prefix = depth === 0 ? '###' : '####'
251
+ parts.push(`${prefix} ${child.label}`)
252
+ parts.push('')
253
+ parts.push(renderHierarchyGroup(child, depth + 1))
254
+ }
255
+ return parts.join('\n')
250
256
  }
@@ -1,4 +1,4 @@
1
- import { createContext, useContext } from "react";
1
+ import { createContext, useContext } from 'react'
2
2
 
3
3
  /**
4
4
  * What the annotation surface needs from the host serving it.
@@ -9,47 +9,47 @@ import { createContext, useContext } from "react";
9
9
  * provide their own.
10
10
  */
11
11
  export interface CanvasHost {
12
- /** Session the canvas belongs to. */
13
- sessionId: string;
14
- /** Read-only share view — local-only affordances stay hidden. */
15
- isShared: boolean;
16
- /** Host can read project files (feedback bodies, file browser). */
17
- fsAvailable: boolean;
18
- /** Endpoint accepting annotation image uploads, or `null` for a host that
19
- * has none — the attach affordances then stay hidden rather than posting
20
- * into the void. Required, not optional, so no host can be silent about it. */
21
- uploadUrl: (() => string) | null;
22
- /** Fetch and `import()` a compiled canvas module. */
23
- loadCanvasModule: (filename: string, revision?: number) => Promise<any>;
12
+ /** Session the canvas belongs to. */
13
+ sessionId: string
14
+ /** Read-only share view — local-only affordances stay hidden. */
15
+ isShared: boolean
16
+ /** Host can read project files (feedback bodies, file browser). */
17
+ fsAvailable: boolean
18
+ /** Endpoint accepting annotation image uploads, or `null` for a host that
19
+ * has none — the attach affordances then stay hidden rather than posting
20
+ * into the void. Required, not optional, so no host can be silent about it. */
21
+ uploadUrl: (() => string) | null
22
+ /** Fetch and `import()` a compiled canvas module. */
23
+ loadCanvasModule: (filename: string, revision?: number) => Promise<any>
24
24
  }
25
25
 
26
26
  /** Local mode carries the session id as the `/s/:id` path suffix. */
27
27
  function localSessionId(): string {
28
- if (typeof window === "undefined") return "";
29
- return window.location.pathname.replace("/s/", "");
28
+ if (typeof window === 'undefined') return ''
29
+ return window.location.pathname.replace('/s/', '')
30
30
  }
31
31
 
32
32
  export const localCanvasHost: CanvasHost = {
33
- get sessionId() {
34
- return localSessionId();
35
- },
36
- isShared: false,
37
- fsAvailable: true,
38
- // No endpoint by default. The only hosts that reach this fallback are the
39
- // ones that configured nothing, which are exactly the ones least likely to
40
- // serve an upload route — claiming one on their behalf is how every host got
41
- // a paperclip that posts into the void. A host with uploads names its own.
42
- uploadUrl: null,
43
- loadCanvasModule: (filename, revision) => {
44
- const jsName = filename.replace(/\.jsx$/, ".js");
45
- const query = revision ? `?rev=${revision}&t=${Date.now()}` : `?t=${Date.now()}`;
46
- const url = `/api/session/${localSessionId()}/canvas/${encodeURIComponent(jsName)}${query}`;
47
- return import(/* @vite-ignore */ url);
48
- },
49
- };
33
+ get sessionId() {
34
+ return localSessionId()
35
+ },
36
+ isShared: false,
37
+ fsAvailable: true,
38
+ // No endpoint by default. The only hosts that reach this fallback are the
39
+ // ones that configured nothing, which are exactly the ones least likely to
40
+ // serve an upload route — claiming one on their behalf is how every host got
41
+ // a paperclip that posts into the void. A host with uploads names its own.
42
+ uploadUrl: null,
43
+ loadCanvasModule: (filename, revision) => {
44
+ const jsName = filename.replace(/\.jsx$/, '.js')
45
+ const query = revision ? `?rev=${revision}&t=${Date.now()}` : `?t=${Date.now()}`
46
+ const url = `/api/session/${localSessionId()}/canvas/${encodeURIComponent(jsName)}${query}`
47
+ return import(/* @vite-ignore */ url)
48
+ },
49
+ }
50
50
 
51
- export const CanvasHostContext = createContext<CanvasHost>(localCanvasHost);
51
+ export const CanvasHostContext = createContext<CanvasHost>(localCanvasHost)
52
52
 
53
53
  export function useCanvasHost(): CanvasHost {
54
- return useContext(CanvasHostContext);
54
+ return useContext(CanvasHostContext)
55
55
  }