@fabrika/canvas-kernel 0.1.44

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 (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -0
  3. package/annotate.ts +94 -0
  4. package/daemon.ts +46 -0
  5. package/index.ts +84 -0
  6. package/package.json +74 -0
  7. package/src/client/AnnotationDraftFooter.tsx +76 -0
  8. package/src/client/AnnotationEditor.tsx +320 -0
  9. package/src/client/AnnotationList.tsx +190 -0
  10. package/src/client/AnnotationProvider.tsx +294 -0
  11. package/src/client/AnnotationSidebar.tsx +374 -0
  12. package/src/client/FileIcon.tsx +76 -0
  13. package/src/client/PlanRenderer.tsx +587 -0
  14. package/src/client/Popover.tsx +361 -0
  15. package/src/client/RenderErrorContext.ts +11 -0
  16. package/src/client/ResponsePreview.tsx +186 -0
  17. package/src/client/SessionContext.tsx +3 -0
  18. package/src/client/annotationContext.ts +130 -0
  19. package/src/client/annotationDom.ts +115 -0
  20. package/src/client/annotationDraft.ts +130 -0
  21. package/src/client/annotationMarkdown.ts +102 -0
  22. package/src/client/annotationTarget.ts +73 -0
  23. package/src/client/appContext.tsx +61 -0
  24. package/src/client/blockTarget.ts +127 -0
  25. package/src/client/components/Callout.tsx +27 -0
  26. package/src/client/components/Checklist.tsx +37 -0
  27. package/src/client/components/Choice.tsx +178 -0
  28. package/src/client/components/CodeBlock.tsx +35 -0
  29. package/src/client/components/Diff.tsx +36 -0
  30. package/src/client/components/FilePreview.tsx +88 -0
  31. package/src/client/components/Image.tsx +48 -0
  32. package/src/client/components/Markdown.tsx +44 -0
  33. package/src/client/components/Mermaid.tsx +576 -0
  34. package/src/client/components/Note.tsx +14 -0
  35. package/src/client/components/Priority.tsx +19 -0
  36. package/src/client/components/ResponseNote.tsx +19 -0
  37. package/src/client/components/Section.tsx +33 -0
  38. package/src/client/components/Table.tsx +35 -0
  39. package/src/client/components/Task.tsx +76 -0
  40. package/src/client/components/UserInput.tsx +176 -0
  41. package/src/client/components/index.ts +18 -0
  42. package/src/client/components/renderMarkdownChildren.tsx +100 -0
  43. package/src/client/diffLines.ts +50 -0
  44. package/src/client/generateMarkdown.ts +250 -0
  45. package/src/client/highlightRange.ts +262 -0
  46. package/src/client/hostContext.tsx +55 -0
  47. package/src/client/index.ts +128 -0
  48. package/src/client/popoverPosition.ts +46 -0
  49. package/src/client/preact-all.ts +4 -0
  50. package/src/client/regionTarget.ts +180 -0
  51. package/src/client/runtime.ts +149 -0
  52. package/src/client/styles.css +492 -0
  53. package/src/client/testing/dom.ts +58 -0
  54. package/src/client/textTarget.ts +31 -0
  55. package/src/client/theme.css +66 -0
  56. package/src/client/useRegionAnnotation.tsx +188 -0
  57. package/src/client/useTextAnnotation.tsx +194 -0
  58. package/src/client/utils.ts +33 -0
  59. package/src/engine/channel.ts +278 -0
  60. package/src/engine/compiler.ts +415 -0
  61. package/src/engine/http-utils.ts +14 -0
  62. package/src/engine/lifecycle.ts +302 -0
  63. package/src/engine/paths.ts +63 -0
  64. package/src/engine/router.ts +16 -0
  65. package/src/engine/session.ts +678 -0
  66. package/src/engine/waiter.ts +188 -0
  67. package/src/engine/watcher.ts +84 -0
  68. package/src/engine/websocket.ts +159 -0
@@ -0,0 +1,130 @@
1
+ import type { AnnotationContext } from "./AnnotationProvider";
2
+ import { isIndexedText } from "./highlightRange";
3
+
4
+ /**
5
+ * Extract disambiguation context from a Range:
6
+ * - surrounding text (before/after the selection)
7
+ * - hierarchy path (Section title → Task label)
8
+ */
9
+ export function extractContext(range: Range, planContainer: HTMLElement): AnnotationContext {
10
+ const before = getSurroundingText(range, "before", 60);
11
+ const after = getSurroundingText(range, "after", 60);
12
+ const hierarchy = getHierarchy(range.startContainer, planContainer);
13
+
14
+ return { before, after, hierarchy };
15
+ }
16
+
17
+ function getSurroundingText(range: Range, direction: "before" | "after", maxChars: number): string {
18
+ const block = getBlockParent(direction === "before" ? range.startContainer : range.endContainer);
19
+ if (!block) return "";
20
+
21
+ let text = "";
22
+
23
+ if (direction === "before") {
24
+ const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
25
+ let node: Text | null;
26
+ while ((node = walker.nextNode() as Text | null)) {
27
+ if (node === range.startContainer) {
28
+ text += (node.textContent || "").slice(0, range.startOffset);
29
+ break;
30
+ }
31
+ if (!isIndexedText(node)) continue;
32
+ text += node.textContent || "";
33
+ }
34
+ if (text.length > maxChars) {
35
+ text = text.slice(-maxChars);
36
+ const spaceIdx = text.indexOf(" ");
37
+ if (spaceIdx > 0) text = text.slice(spaceIdx + 1);
38
+ }
39
+ } else {
40
+ const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
41
+ let found = false;
42
+ let node: Text | null;
43
+ while ((node = walker.nextNode() as Text | null)) {
44
+ if (node === range.endContainer) {
45
+ text += (node.textContent || "").slice(range.endOffset);
46
+ found = true;
47
+ continue;
48
+ }
49
+ if (found) {
50
+ if (!isIndexedText(node)) continue;
51
+ text += node.textContent || "";
52
+ }
53
+ }
54
+ if (text.length > maxChars) {
55
+ text = text.slice(0, maxChars);
56
+ const spaceIdx = text.lastIndexOf(" ");
57
+ if (spaceIdx > 0) text = text.slice(0, spaceIdx);
58
+ }
59
+ }
60
+
61
+ return text.trim();
62
+ }
63
+
64
+ function getBlockParent(node: Node): Element | null {
65
+ const blockTags = new Set(["DIV", "P", "LI", "PRE", "BLOCKQUOTE", "SECTION", "ARTICLE"]);
66
+ let el = node.parentElement;
67
+ while (el) {
68
+ if (blockTags.has(el.tagName)) return el;
69
+ el = el.parentElement;
70
+ }
71
+ return null;
72
+ }
73
+
74
+ function getHierarchy(node: Node, planContainer: HTMLElement): string[] {
75
+ const path: string[] = [];
76
+ let el = node instanceof Element ? node : node.parentElement;
77
+
78
+ while (el && el !== planContainer) {
79
+ const taskId = el.getAttribute?.("data-task-id");
80
+ if (taskId) {
81
+ const label = el.querySelector(".font-semibold");
82
+ if (label) path.unshift(label.textContent?.trim() || taskId);
83
+ }
84
+
85
+ const heading = ownHeading(el);
86
+ if (heading) path.unshift(heading);
87
+
88
+ el = el.parentElement;
89
+ }
90
+
91
+ return path;
92
+ }
93
+
94
+ /**
95
+ * The heading this block owns, if any. Section carries its title as an
96
+ * attribute; hosts that render their own headings only have the markup, and
97
+ * there the heading must sit directly under the block's own header row —
98
+ * anything deeper belongs to a nested block. Detecting one heading and then
99
+ * reading another is what put a neighbouring section's title in the path.
100
+ */
101
+ function ownHeading(el: Element): string | null {
102
+ if (el.getAttribute("data-md") === "section") {
103
+ const title = el.getAttribute("data-md-title");
104
+ if (title) return title;
105
+ }
106
+ if (el.tagName !== "DIV") return null;
107
+ const heading = el.querySelector(":scope > button > .font-heading, :scope > div > .font-heading");
108
+ return heading?.textContent?.trim() || null;
109
+ }
110
+
111
+ /**
112
+ * Format a snippet with surrounding context for disambiguation.
113
+ * Short snippets get before/after context.
114
+ */
115
+ export function formatSnippetInContext(ann: { snippet: string; context?: AnnotationContext }): string {
116
+ const snippet = ann.snippet.trim();
117
+ const ctx = ann.context;
118
+
119
+ const isShort = snippet.length < 30;
120
+
121
+ if (ctx && isShort && (ctx.before || ctx.after)) {
122
+ const parts: string[] = [];
123
+ if (ctx.before) parts.push(`...${ctx.before} `);
124
+ parts.push(`**${snippet}**`);
125
+ if (ctx.after) parts.push(` ${ctx.after}...`);
126
+ return parts.join("");
127
+ }
128
+
129
+ return snippet;
130
+ }
@@ -0,0 +1,115 @@
1
+ import type { Annotation } from "./AnnotationProvider";
2
+ import type { ActiveView } from "./appContext";
3
+ import { sealTarget, type SealedAnnotationTarget, type TargetAnnotation } from "./annotationTarget";
4
+ import { blockTarget } from "./blockTarget";
5
+ import { regionTarget } from "./regionTarget";
6
+ import { textTarget } from "./textTarget";
7
+ import { fileAnnotationPath } from "./utils";
8
+
9
+ // The block selectors and the block snippet live with the block target now;
10
+ // re-exported here because this is the path every caller already imports.
11
+ export { ANNOTATABLE_SELECTOR, BLOCK_SELECTOR, getBlockSnippet } from "./blockTarget";
12
+
13
+ /** Every locator strategy the kernel knows, cheapest and most specific first.
14
+ * Text claims any snippet at all, so it stays last. */
15
+ export const ANNOTATION_TARGETS: SealedAnnotationTarget[] = [
16
+ sealTarget(regionTarget),
17
+ sealTarget(blockTarget),
18
+ sealTarget(textTarget),
19
+ ];
20
+
21
+ /**
22
+ * The element a snippet names inside `root`.
23
+ *
24
+ * Every target is asked, not just the first one that recognises the string: a
25
+ * file annotation on a line that happens to read like a block snippet must
26
+ * still fall through to the target that can actually resolve it.
27
+ */
28
+ export function findSnippetElement(snippet: string, root: ParentNode): HTMLElement | null {
29
+ for (const target of ANNOTATION_TARGETS) {
30
+ const el = target.find(snippet, root);
31
+ if (el) return el;
32
+ }
33
+ return null;
34
+ }
35
+
36
+ /**
37
+ * Put every annotation's own decoration back after a render — inline marks,
38
+ * region overlays. Each target is asked about each annotation; a snippet is not
39
+ * owned exclusively, and every restore is idempotent, so asking all of them is
40
+ * both correct and free.
41
+ */
42
+ export function restoreAnnotationTargets(root: HTMLElement, annotations: readonly TargetAnnotation[]): void {
43
+ for (const ann of annotations) {
44
+ if (!ann.snippet) continue;
45
+ for (const target of ANNOTATION_TARGETS) {
46
+ target.restore(root, ann);
47
+ }
48
+ }
49
+ }
50
+
51
+ /** The snippet as a reader should see it. Only a target that encodes something
52
+ * other than readable text has anything to say; the rest already read fine. */
53
+ export function describeSnippet(snippet: string): string {
54
+ for (const target of ANNOTATION_TARGETS) {
55
+ const described = target.describe(snippet);
56
+ if (described !== null) return described;
57
+ }
58
+ return snippet;
59
+ }
60
+
61
+ /**
62
+ * Find the DOM element for an annotation — the decoration this kernel drew for
63
+ * it, or failing that whatever its snippet names.
64
+ */
65
+ export function findAnnotationElement(ann: Annotation): HTMLElement | null {
66
+ // Decoration wins over the snippet: a mark or a region box names the exact
67
+ // run that was annotated, where a snippet only names something that reads the
68
+ // same today.
69
+ const decoration = document.querySelector(`[data-annotation-id="${ann.id}"]`);
70
+ if (decoration instanceof HTMLElement) return decoration;
71
+
72
+ // A snippet only identifies its target within its own canvas, and the
73
+ // overview mounts every canvas at once — searching the whole document lets an
74
+ // identical block in a different canvas win on document order. No container
75
+ // for the canvas means it is not on screen, which is a miss, not a reason to
76
+ // look elsewhere.
77
+ const root = ann.canvasFile
78
+ ? document.querySelector(`[data-canvas-file="${ann.canvasFile}"]`)
79
+ : document;
80
+ if (!root) return null;
81
+
82
+ return findSnippetElement(ann.snippet, root);
83
+ }
84
+
85
+ /** Flash/pulse the element briefly — blinks bg via CSS animation */
86
+ function flashElement(el: HTMLElement) {
87
+ el.classList.remove("ann-flash");
88
+ void el.offsetWidth; // force reflow to restart
89
+ el.classList.add("ann-flash");
90
+ el.addEventListener("animationend", () => { el.classList.remove("ann-flash"); }, { once: true });
91
+ }
92
+
93
+ /**
94
+ * Scroll the canvas/file view to an annotation element and flash it.
95
+ * Handles file annotations by switching view first.
96
+ */
97
+ export function scrollToAnnotation(ann: Annotation, setActiveView: (view: ActiveView) => void) {
98
+ const filePath = fileAnnotationPath(ann);
99
+ if (filePath) {
100
+ setActiveView({ type: "file", path: filePath });
101
+ setTimeout(() => {
102
+ const el = findAnnotationElement(ann);
103
+ if (el) {
104
+ el.scrollIntoView({ behavior: "smooth", block: "center" });
105
+ flashElement(el);
106
+ }
107
+ }, 150);
108
+ } else {
109
+ const el = findAnnotationElement(ann);
110
+ if (el) {
111
+ el.scrollIntoView({ behavior: "smooth", block: "center" });
112
+ flashElement(el);
113
+ }
114
+ }
115
+ }
@@ -0,0 +1,130 @@
1
+ interface RevisionState {
2
+ revision: number;
3
+ hasFeedback: boolean;
4
+ }
5
+
6
+ interface AnnotationStorage {
7
+ getItem(key: string): string | null;
8
+ setItem(key: string, value: string): void;
9
+ removeItem(key: string): void;
10
+ }
11
+
12
+ interface RemovableAnnotationStorage {
13
+ removeItem(key: string): void;
14
+ }
15
+
16
+ export type AnnotationDraftPhase = "current" | "next";
17
+
18
+ export function annotationDraftKey(
19
+ sessionId: string,
20
+ revision: number,
21
+ phase: AnnotationDraftPhase = "current",
22
+ ): string {
23
+ const base = `canvas:${sessionId}:rev:${revision}`;
24
+ return phase === "next" ? `${base}:next-draft` : base;
25
+ }
26
+
27
+ function handledKey(sessionId: string, revision: number): string {
28
+ return `${annotationDraftKey(sessionId, revision)}:draft-handled`;
29
+ }
30
+
31
+ export function clearPersistedDraft(
32
+ storage: RemovableAnnotationStorage,
33
+ sessionId: string,
34
+ revision: number,
35
+ ): void {
36
+ try {
37
+ storage.removeItem(annotationDraftKey(sessionId, revision));
38
+ storage.removeItem(handledKey(sessionId, revision));
39
+ } catch {}
40
+ }
41
+
42
+ function parseState(raw: string | null): object | null {
43
+ if (!raw) return null;
44
+ try {
45
+ const value: unknown = JSON.parse(raw);
46
+ return typeof value === "object" && value !== null && !Array.isArray(value)
47
+ ? value
48
+ : null;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ function hasResponseValue(entry: unknown): boolean {
55
+ if (!Array.isArray(entry) || entry.length < 2) return false;
56
+ const response: unknown = entry[1];
57
+ if (typeof response !== "object" || response === null || Array.isArray(response)) return false;
58
+
59
+ const note = Reflect.get(response, "note");
60
+ if (typeof note === "string" && note.trim().length > 0) return true;
61
+
62
+ const value = Reflect.get(response, "value");
63
+ if (typeof value === "string") return value.trim().length > 0;
64
+ if (Array.isArray(value)) return value.length > 0;
65
+ return value !== null && value !== undefined;
66
+ }
67
+
68
+ function hasDraftContent(state: object | null): boolean {
69
+ if (!state) return false;
70
+ const annotations = Reflect.get(state, "annotations");
71
+ if (Array.isArray(annotations) && annotations.length > 0) return true;
72
+
73
+ const generalNote = Reflect.get(state, "generalNote");
74
+ if (typeof generalNote === "string" && generalNote.trim().length > 0) return true;
75
+
76
+ const responses = Reflect.get(state, "responses");
77
+ return Array.isArray(responses) && responses.some(hasResponseValue);
78
+ }
79
+
80
+ /** Carry an unsent draft forward without crossing submitted revisions. */
81
+ export function carryUnsubmittedDraft(
82
+ storage: AnnotationStorage,
83
+ sessionId: string,
84
+ targetRevision: number,
85
+ revisions: RevisionState[],
86
+ ): number | null {
87
+ try {
88
+ const targetKey = annotationDraftKey(sessionId, targetRevision);
89
+ if (hasDraftContent(parseState(storage.getItem(targetKey)))) {
90
+ storage.setItem(handledKey(sessionId, targetRevision), "1");
91
+ return null;
92
+ }
93
+ if (storage.getItem(handledKey(sessionId, targetRevision)) !== null) return null;
94
+
95
+ const previous = revisions
96
+ .filter((revision) => revision.revision < targetRevision)
97
+ .sort((left, right) => right.revision - left.revision);
98
+
99
+ for (const revision of previous) {
100
+ if (revision.hasFeedback) {
101
+ const sourceKey = annotationDraftKey(sessionId, revision.revision, "next");
102
+ const source = parseState(storage.getItem(sourceKey));
103
+ if (!hasDraftContent(source)) return null;
104
+
105
+ storage.setItem(targetKey, JSON.stringify({
106
+ ...source,
107
+ feedbackEntries: [],
108
+ }));
109
+ storage.setItem(handledKey(sessionId, targetRevision), "1");
110
+ storage.removeItem(sourceKey);
111
+ return revision.revision;
112
+ }
113
+
114
+ const source = parseState(storage.getItem(annotationDraftKey(sessionId, revision.revision)));
115
+ if (!hasDraftContent(source)) {
116
+ if (storage.getItem(handledKey(sessionId, revision.revision)) !== null) return null;
117
+ continue;
118
+ }
119
+
120
+ storage.setItem(targetKey, JSON.stringify({
121
+ ...source,
122
+ feedbackEntries: [],
123
+ }));
124
+ storage.setItem(handledKey(sessionId, targetRevision), "1");
125
+ return revision.revision;
126
+ }
127
+ } catch {}
128
+
129
+ return null;
130
+ }
@@ -0,0 +1,102 @@
1
+ import type { AnnotationAttachment, AnnotationContext } from "./runtime";
2
+ import { formatSnippetInContext } from "./annotationContext";
3
+ import { describeSnippet } from "./annotationDom";
4
+
5
+ /**
6
+ * What rendering one annotation reads off it. Every `Annotation` satisfies this,
7
+ * and so can a record that is not one: how a single annotation reads as markdown
8
+ * is the half of feedback that belongs to no particular document format.
9
+ */
10
+ export interface RenderableAnnotation {
11
+ snippet: string;
12
+ note: string;
13
+ /** Set when the annotation points into a file. With `context.lineStart` it
14
+ * switches the quote to numbered source lines. */
15
+ filePath?: string;
16
+ context?: AnnotationContext;
17
+ /** Attached images as bare paths/URLs — what the local editor writes. */
18
+ images?: string[];
19
+ /** Attached files carried on the wire — what a shared canvas sends back.
20
+ * A host may populate either field; both are rendered. */
21
+ attachments?: AnnotationAttachment[];
22
+ }
23
+
24
+ /**
25
+ * One annotation as markdown: what was annotated, quoted, then the note and any
26
+ * attachments under it. Ends on a blank line so entries stack.
27
+ *
28
+ * The caller owns everything around it — headings, grouping, ordering.
29
+ */
30
+ export function renderAnnotation(ann: RenderableAnnotation): string {
31
+ const lines: string[] = [];
32
+ const snippet = ann.snippet.trim();
33
+ const ctx = ann.context;
34
+ // A snippet that does not read as text — a drawn region — says what it points
35
+ // at in words instead, since quoting its encoding tells an agent nothing.
36
+ const described = describeSnippet(snippet);
37
+
38
+ if (described !== snippet) {
39
+ lines.push(`> ${described}`);
40
+ } else if (ann.filePath && ctx && ctx.lineStart != null) {
41
+ // File annotation with line numbers
42
+ const lineStart = ctx.lineStart;
43
+ const lineEnd = ctx.lineEnd ?? lineStart;
44
+ const snippetLines = snippet.split("\n");
45
+ const isShort = snippet.length < 30 && lineStart === lineEnd;
46
+
47
+ if (isShort && (ctx.before || ctx.after)) {
48
+ // Short snippet on single line — show full line context
49
+ const expanded = formatSnippetInContext(ann);
50
+ lines.push(`> L${lineStart}: ${expanded}`);
51
+ } else if (snippetLines.length <= 6) {
52
+ for (let i = 0; i < snippetLines.length; i++) {
53
+ lines.push(`> ${lineStart + i} | ${snippetLines[i]}`);
54
+ }
55
+ } else {
56
+ for (let i = 0; i < 3; i++) {
57
+ lines.push(`> ${lineStart + i} | ${snippetLines[i]}`);
58
+ }
59
+ lines.push(`> ... (${snippetLines.length} lines)`);
60
+ for (let i = snippetLines.length - 3; i < snippetLines.length; i++) {
61
+ lines.push(`> ${lineStart + i} | ${snippetLines[i]}`);
62
+ }
63
+ }
64
+ } else {
65
+ // Plan annotations or file annotations without line info
66
+ const context = formatSnippetInContext(ann);
67
+ if (snippet.split("\n").length <= 3) {
68
+ lines.push(`> ${context.split("\n").join("\n> ")}`);
69
+ } else {
70
+ const snippetLines = snippet.split("\n");
71
+ lines.push(`> ${snippetLines[0]}`);
72
+ lines.push(`> ... (${snippetLines.length} lines)`);
73
+ lines.push(`> ${snippetLines[snippetLines.length - 1]}`);
74
+ }
75
+ }
76
+
77
+ // Comment
78
+ if (ann.note.trim()) {
79
+ lines.push("");
80
+ lines.push(ann.note.trim());
81
+ }
82
+
83
+ // Attached images. Both fields are read: the local editor writes `images`,
84
+ // a shared canvas's reviewer comes back with `attachments`, and an agent that
85
+ // sees only one of them is looking at feedback with a hole in it.
86
+ const attached = [...(ann.images ?? [])];
87
+ const seen = new Set(attached);
88
+ for (const attachment of ann.attachments ?? []) {
89
+ if (seen.has(attachment.url)) continue;
90
+ seen.add(attachment.url);
91
+ attached.push(attachment.url);
92
+ }
93
+ if (attached.length) {
94
+ lines.push("");
95
+ for (const url of attached) {
96
+ lines.push(`![screenshot](${url})`);
97
+ }
98
+ }
99
+
100
+ lines.push("");
101
+ return lines.join("\n");
102
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * How a snippet names something on the page.
3
+ *
4
+ * `Annotation.snippet` is the only field that ties an annotation to a domain —
5
+ * everything else (draft persistence, sidebar, popover, notes, attachments,
6
+ * markdown, submit) works on any annotation whatever it points at. A target is
7
+ * that one tie, pulled out as a strategy: give the kernel a new target and it
8
+ * can annotate a new kind of thing without another line changing.
9
+ */
10
+
11
+ /** What a target may read off the annotation it resolves — DOM identity, not feedback. */
12
+ export interface TargetAnnotation {
13
+ id: string;
14
+ snippet: string;
15
+ context?: { before: string; after: string; hierarchy: string[] };
16
+ }
17
+
18
+ export interface AnnotationTarget<L extends object> {
19
+ /** Names the strategy. */
20
+ readonly kind: string;
21
+ /** Read a snippet this target wrote. Null means the snippet is not its own. */
22
+ parse(snippet: string): L | null;
23
+ /** Write the locator back into the snippet key. The exact inverse of `parse`. */
24
+ format(locator: L): string;
25
+ /**
26
+ * The element the locator names, searched inside `root`. Targets whose
27
+ * annotation lives in DOM this target itself drew — an inline mark, an
28
+ * overlay box — return null here and are resolved by their annotation id
29
+ * instead; see findAnnotationElement.
30
+ */
31
+ find(locator: L, root: ParentNode): HTMLElement | null;
32
+ /**
33
+ * Put this annotation's own decoration back into `root`, if it is not there
34
+ * already. Called after every render, for every annotation, so it must be
35
+ * idempotent and cheap on a snippet it does not own.
36
+ */
37
+ restore?(locator: L, root: HTMLElement, ann: TargetAnnotation): void;
38
+ /** A one-line label a reader (or a coding agent) can act on. */
39
+ describe?(locator: L): string;
40
+ }
41
+
42
+ /** An AnnotationTarget with its locator type sealed inside, so targets whose
43
+ * locators have nothing in common can share one list. */
44
+ export interface SealedAnnotationTarget {
45
+ readonly kind: string;
46
+ owns(snippet: string): boolean;
47
+ find(snippet: string, root: ParentNode): HTMLElement | null;
48
+ restore(root: HTMLElement, ann: TargetAnnotation): void;
49
+ describe(snippet: string): string | null;
50
+ }
51
+
52
+ /** Close over the locator type, so parse and find can never be called with
53
+ * a locator the other one did not produce. */
54
+ export function sealTarget<L extends object>(target: AnnotationTarget<L>): SealedAnnotationTarget {
55
+ return {
56
+ kind: target.kind,
57
+ owns: (snippet) => target.parse(snippet) !== null,
58
+ find: (snippet, root) => {
59
+ const locator = target.parse(snippet);
60
+ return locator === null ? null : target.find(locator, root);
61
+ },
62
+ restore: (root, ann) => {
63
+ const locator = target.parse(ann.snippet);
64
+ if (locator === null) return;
65
+ target.restore?.(locator, root, ann);
66
+ },
67
+ describe: (snippet) => {
68
+ const locator = target.parse(snippet);
69
+ if (locator === null || !target.describe) return null;
70
+ return target.describe(locator);
71
+ },
72
+ };
73
+ }
@@ -0,0 +1,61 @@
1
+ import { createContext } from "react";
2
+ import type { ActiveView } from "#canvas/runtime";
3
+
4
+ /**
5
+ * Shell state the annotation surface reads but does not own.
6
+ *
7
+ * The kernel ships no app shell — each host builds its own chrome — but the
8
+ * reusable annotation surface still needs to know which revision is on screen
9
+ * and how to navigate. The host provides both contexts around the surface.
10
+ */
11
+
12
+ export type { ActiveView };
13
+
14
+ export interface CanvasFileInfo {
15
+ filename: string;
16
+ diffStats?: { added: number; removed: number };
17
+ }
18
+
19
+ export interface RevisionInfo {
20
+ revision: number;
21
+ label?: string;
22
+ canvasFiles: CanvasFileInfo[];
23
+ createdAt: string;
24
+ hasFeedback: boolean;
25
+ feedbackConsumed: boolean;
26
+ response?: string;
27
+ }
28
+
29
+ export const ActiveViewContext = createContext<{
30
+ activeView: ActiveView;
31
+ setActiveView: (v: ActiveView) => void;
32
+ openFiles: string[];
33
+ closeFile: (path: string) => void;
34
+ canvasFiles: string[];
35
+ }>({
36
+ activeView: { type: "overview" },
37
+ setActiveView: () => {},
38
+ openFiles: [],
39
+ closeFile: () => {},
40
+ canvasFiles: [],
41
+ });
42
+
43
+ export const RevisionContext = createContext<{
44
+ currentRevision: number;
45
+ selectedRevision: number;
46
+ revisions: RevisionInfo[];
47
+ setSelectedRevision: (rev: number) => void;
48
+ isReadOnly: boolean;
49
+ compareRevision: { left: number; right: number } | null;
50
+ setCompareRevision: (rev: { left: number; right: number } | null) => void;
51
+ agentWatching: boolean;
52
+ }>({
53
+ currentRevision: 1,
54
+ selectedRevision: 1,
55
+ revisions: [],
56
+ setSelectedRevision: () => {},
57
+ isReadOnly: false,
58
+ compareRevision: null,
59
+ setCompareRevision: () => {},
60
+ agentWatching: false,
61
+ });