agent-canvas 0.1.43 → 0.1.45

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 (110) hide show
  1. package/bin/lib/commands/daemon.ts +3 -6
  2. package/bin/lib/commands/push.ts +1 -1
  3. package/bin/lib/daemon-lifecycle.ts +65 -72
  4. package/bin/lib/helpers.test.ts +1 -0
  5. package/bin/lib/helpers.ts +30 -74
  6. package/daemon/build.ts +16 -13
  7. package/daemon/client/App.tsx +38 -62
  8. package/daemon/client/CompareView.tsx +4 -4
  9. package/daemon/client/FileBrowser.tsx +4 -4
  10. package/daemon/client/FileViewer.tsx +4 -4
  11. package/daemon/client/OverviewView.tsx +2 -2
  12. package/daemon/client/RevisionSelect.tsx +1 -1
  13. package/daemon/client/clientApi.ts +18 -0
  14. package/daemon/client/components/index.ts +3 -16
  15. package/daemon/client/styles.css +4 -457
  16. package/daemon/dist/client.css +1 -1
  17. package/daemon/dist/client.js +76 -76
  18. package/daemon/dist/components.js +29 -29
  19. package/daemon/dist/runtime.js +1 -1
  20. package/daemon/src/components.ts +13 -0
  21. package/daemon/src/handlers/api.ts +14 -9
  22. package/daemon/src/handlers/files.ts +1 -3
  23. package/daemon/src/handlers/static.ts +1 -1
  24. package/daemon/src/handlers/uploads.ts +1 -3
  25. package/daemon/src/paths.ts +10 -24
  26. package/daemon/src/remote-feedback.test.ts +1 -1
  27. package/daemon/src/remote-feedback.ts +1 -1
  28. package/daemon/src/secrets-api.test.ts +1 -2
  29. package/daemon/src/server.ts +13 -10
  30. package/daemon/src/share-e2e.test.ts +1 -1
  31. package/daemon/src/share.test.ts +1 -1
  32. package/daemon/src/share.ts +1 -1
  33. package/daemon/tsconfig.json +1 -3
  34. package/package.json +10 -2
  35. package/.github/dark.png +0 -0
  36. package/.github/light.png +0 -0
  37. package/.github/workflows/ci.yml +0 -48
  38. package/.github/workflows/deploy-worker-prod.yml +0 -19
  39. package/.github/workflows/deploy-worker-stage.yml +0 -21
  40. package/.github/workflows/deploy-worker.yml +0 -72
  41. package/.github/workflows/release.yml +0 -36
  42. package/CLAUDE.md +0 -86
  43. package/bun.lock +0 -377
  44. package/daemon/client/AnnotationEditor.tsx +0 -315
  45. package/daemon/client/AnnotationProvider.tsx +0 -191
  46. package/daemon/client/AnnotationSidebar.tsx +0 -627
  47. package/daemon/client/FileIcon.tsx +0 -76
  48. package/daemon/client/PlanRenderer.tsx +0 -614
  49. package/daemon/client/Popover.tsx +0 -350
  50. package/daemon/client/RenderErrorContext.ts +0 -11
  51. package/daemon/client/ResponsePreview.tsx +0 -186
  52. package/daemon/client/SessionContext.tsx +0 -3
  53. package/daemon/client/annotationContext.ts +0 -114
  54. package/daemon/client/annotationDom.ts +0 -104
  55. package/daemon/client/annotationDraft.test.ts +0 -149
  56. package/daemon/client/annotationDraft.ts +0 -110
  57. package/daemon/client/compat/preact-all.ts +0 -4
  58. package/daemon/client/components/Callout.tsx +0 -26
  59. package/daemon/client/components/Checklist.tsx +0 -37
  60. package/daemon/client/components/Choice.tsx +0 -176
  61. package/daemon/client/components/CodeBlock.tsx +0 -35
  62. package/daemon/client/components/Diff.tsx +0 -65
  63. package/daemon/client/components/FilePreview.tsx +0 -88
  64. package/daemon/client/components/Image.tsx +0 -45
  65. package/daemon/client/components/Markdown.tsx +0 -44
  66. package/daemon/client/components/Mermaid.tsx +0 -565
  67. package/daemon/client/components/Note.tsx +0 -14
  68. package/daemon/client/components/Priority.tsx +0 -19
  69. package/daemon/client/components/ResponseNote.tsx +0 -19
  70. package/daemon/client/components/Section.tsx +0 -33
  71. package/daemon/client/components/Table.tsx +0 -35
  72. package/daemon/client/components/Task.tsx +0 -76
  73. package/daemon/client/components/UserInput.tsx +0 -174
  74. package/daemon/client/components/renderMarkdownChildren.tsx +0 -97
  75. package/daemon/client/generateMarkdown.ts +0 -273
  76. package/daemon/client/highlightRange.ts +0 -224
  77. package/daemon/client/popoverPosition.ts +0 -31
  78. package/daemon/client/runtime.ts +0 -123
  79. package/daemon/client/theme.css +0 -66
  80. package/daemon/client/useTextAnnotation.tsx +0 -188
  81. package/daemon/client/utils.ts +0 -13
  82. package/daemon/src/compiler.test.ts +0 -57
  83. package/daemon/src/compiler.ts +0 -360
  84. package/daemon/src/handlers/utils.ts +0 -14
  85. package/daemon/src/router.ts +0 -16
  86. package/daemon/src/session-secrets.test.ts +0 -83
  87. package/daemon/src/session.ts +0 -612
  88. package/daemon/src/watcher.ts +0 -71
  89. package/daemon/src/websocket.test.ts +0 -99
  90. package/daemon/src/websocket.ts +0 -211
  91. package/tsconfig.json +0 -14
  92. package/workers/canvas-share/README.md +0 -159
  93. package/workers/canvas-share/oblaka.ts +0 -56
  94. package/workers/canvas-share/package.json +0 -25
  95. package/workers/canvas-share/public/assets/.gitkeep +0 -0
  96. package/workers/canvas-share/public/assets/client.css +0 -2
  97. package/workers/canvas-share/public/assets/client.js +0 -91
  98. package/workers/canvas-share/public/assets/components.js +0 -65
  99. package/workers/canvas-share/public/assets/jsx-runtime.js +0 -3
  100. package/workers/canvas-share/public/assets/preact-compat.js +0 -1
  101. package/workers/canvas-share/public/assets/runtime.js +0 -1
  102. package/workers/canvas-share/public/index.html +0 -385
  103. package/workers/canvas-share/public/screenshot-dark.png +0 -0
  104. package/workers/canvas-share/public/screenshot-light.png +0 -0
  105. package/workers/canvas-share/src/index.test.ts +0 -209
  106. package/workers/canvas-share/src/index.ts +0 -518
  107. package/workers/canvas-share/src/types.ts +0 -119
  108. package/workers/canvas-share/src/util.ts +0 -156
  109. package/workers/canvas-share/src/validation.ts +0 -160
  110. package/workers/canvas-share/tsconfig.json +0 -16
@@ -1,104 +0,0 @@
1
- import type { Annotation } from "./AnnotationProvider";
2
- import type { ActiveView } from "./App";
3
-
4
- /** All blocks that can be annotated via block-level comments */
5
- const ANNOTATABLE_SELECTOR = "[data-md='item'], [data-md='section'], [data-md='table'] tbody tr, [data-md='callout'], [data-md='note'], [data-md='checklist-item'], [data-md='image']";
6
-
7
- /** Extract snippet identifier for a block element (mirrors PlanRenderer's getBlockSnippet) */
8
- function getBlockSnippet(block: HTMLElement): string | null {
9
- const md = block.getAttribute("data-md");
10
- if (md === "item") {
11
- const label = block.getAttribute("data-md-label");
12
- return label ? `[Item] ${label}` : null;
13
- }
14
- if (md === "section") {
15
- const title = block.getAttribute("data-md-title");
16
- return title ? `[Section] ${title}` : null;
17
- }
18
- if (md === "callout") {
19
- const type = block.getAttribute("data-md-type") || "info";
20
- const text = block.textContent?.trim().slice(0, 60) || "Callout";
21
- return `[Callout:${type}] ${text}`;
22
- }
23
- if (md === "note") {
24
- const text = block.textContent?.trim().slice(0, 60) || "Note";
25
- return `[Note] ${text}`;
26
- }
27
- if (block.tagName === "TR") {
28
- const cells = Array.from(block.querySelectorAll("td")).map((td) => td.textContent?.trim()).filter(Boolean);
29
- return cells.length ? `[Row] ${cells.join(" | ")}` : null;
30
- }
31
- if (md === "checklist-item") {
32
- const label = block.getAttribute("data-md-label");
33
- return label ? `[Checklist] ${label}` : null;
34
- }
35
- if (md === "choice-option" || md === "multichoice-option") {
36
- const label = block.getAttribute("data-md-label");
37
- return label ? `[Option] ${label}` : null;
38
- }
39
- if (md === "userinput") {
40
- const label = block.getAttribute("data-md-label");
41
- return label ? `[Input] ${label}` : null;
42
- }
43
- if (md === "rangeinput") {
44
- const label = block.getAttribute("data-md-label");
45
- return label ? `[Range] ${label}` : null;
46
- }
47
- if (md === "image") {
48
- const src = block.getAttribute("data-md-src");
49
- return src ? `[Image] ${src}` : null;
50
- }
51
- return null;
52
- }
53
-
54
- /**
55
- * Find the DOM element for an annotation — either an inline mark (data-annotation-id)
56
- * or a block element matched by snippet.
57
- */
58
- export function findAnnotationElement(ann: Annotation): HTMLElement | null {
59
- // Try inline mark first
60
- const mark = document.querySelector(`[data-annotation-id="${ann.id}"]`) as HTMLElement | null;
61
- if (mark) return mark;
62
-
63
- // For block annotations (snippet starts with "["), find by matching snippet against blocks
64
- if (ann.snippet.startsWith("[")) {
65
- for (const el of document.querySelectorAll(ANNOTATABLE_SELECTOR)) {
66
- if (getBlockSnippet(el as HTMLElement) === ann.snippet) {
67
- return el as HTMLElement;
68
- }
69
- }
70
- }
71
-
72
- return null;
73
- }
74
-
75
- /** Flash/pulse the element briefly — blinks bg via CSS animation */
76
- function flashElement(el: HTMLElement) {
77
- el.classList.remove("ann-flash");
78
- void el.offsetWidth; // force reflow to restart
79
- el.classList.add("ann-flash");
80
- el.addEventListener("animationend", () => { el.classList.remove("ann-flash"); }, { once: true });
81
- }
82
-
83
- /**
84
- * Scroll the canvas/file view to an annotation element and flash it.
85
- * Handles file annotations by switching view first.
86
- */
87
- export function scrollToAnnotation(ann: Annotation, setActiveView: (view: ActiveView) => void) {
88
- if (ann.filePath) {
89
- setActiveView({ type: "file", path: ann.filePath });
90
- setTimeout(() => {
91
- const el = findAnnotationElement(ann);
92
- if (el) {
93
- el.scrollIntoView({ behavior: "smooth", block: "center" });
94
- flashElement(el);
95
- }
96
- }, 150);
97
- } else {
98
- const el = findAnnotationElement(ann);
99
- if (el) {
100
- el.scrollIntoView({ behavior: "smooth", block: "center" });
101
- flashElement(el);
102
- }
103
- }
104
- }
@@ -1,149 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { carryUnsubmittedDraft, clearPersistedDraft } from "./annotationDraft";
3
-
4
- class MemoryStorage {
5
- private readonly values = new Map<string, string>();
6
-
7
- getItem(key: string): string | null {
8
- return this.values.get(key) ?? null;
9
- }
10
-
11
- setItem(key: string, value: string): void {
12
- this.values.set(key, value);
13
- }
14
-
15
- removeItem(key: string): void {
16
- this.values.delete(key);
17
- }
18
- }
19
-
20
- const sessionId = "session";
21
- const key = (revision: number) => `canvas:${sessionId}:rev:${revision}`;
22
-
23
- describe("carryUnsubmittedDraft", () => {
24
- test("clears a submitted draft and its carry marker", () => {
25
- const storage = new MemoryStorage();
26
- storage.setItem(key(2), JSON.stringify({ generalNote: "Submitted" }));
27
- storage.setItem(`${key(2)}:draft-handled`, "1");
28
-
29
- clearPersistedDraft(storage, sessionId, 2);
30
-
31
- expect(storage.getItem(key(2))).toBeNull();
32
- expect(storage.getItem(`${key(2)}:draft-handled`)).toBeNull();
33
- });
34
-
35
- test("carries the latest unsent draft into a new revision", () => {
36
- const storage = new MemoryStorage();
37
- storage.setItem(key(2), JSON.stringify({
38
- annotations: [{ id: "annotation-1" }],
39
- generalNote: "Review in progress",
40
- responses: [["choice", { value: "keep" }]],
41
- feedbackEntries: [["old", { markdown: "stale generated content" }]],
42
- }));
43
- storage.setItem(key(3), JSON.stringify({
44
- annotations: [],
45
- generalNote: "",
46
- responses: [["new-choice", { value: null }]],
47
- feedbackEntries: [["new", { markdown: "generated content" }]],
48
- }));
49
-
50
- const sourceRevision = carryUnsubmittedDraft(storage, sessionId, 3, [
51
- { revision: 1, hasFeedback: true },
52
- { revision: 2, hasFeedback: false },
53
- { revision: 3, hasFeedback: false },
54
- ]);
55
-
56
- expect(sourceRevision).toBe(2);
57
- expect(JSON.parse(storage.getItem(key(3)) ?? "")).toEqual({
58
- annotations: [{ id: "annotation-1" }],
59
- generalNote: "Review in progress",
60
- responses: [["choice", { value: "keep" }]],
61
- feedbackEntries: [],
62
- });
63
- });
64
-
65
- test("does not cross a submitted revision", () => {
66
- const storage = new MemoryStorage();
67
- storage.setItem(key(1), JSON.stringify({
68
- annotations: [{ id: "stale-annotation" }],
69
- generalNote: "",
70
- responses: [],
71
- }));
72
-
73
- expect(carryUnsubmittedDraft(storage, sessionId, 3, [
74
- { revision: 1, hasFeedback: false },
75
- { revision: 2, hasFeedback: true },
76
- { revision: 3, hasFeedback: false },
77
- ])).toBeNull();
78
- expect(storage.getItem(key(3))).toBeNull();
79
- });
80
-
81
- test("does not overwrite meaningful target data", () => {
82
- const storage = new MemoryStorage();
83
- storage.setItem(key(2), JSON.stringify({
84
- annotations: [{ id: "old-annotation" }],
85
- generalNote: "",
86
- responses: [],
87
- }));
88
- const current = JSON.stringify({
89
- annotations: [{ id: "current-annotation" }],
90
- generalNote: "",
91
- responses: [],
92
- });
93
- storage.setItem(key(3), current);
94
-
95
- expect(carryUnsubmittedDraft(storage, sessionId, 3, [
96
- { revision: 2, hasFeedback: false },
97
- { revision: 3, hasFeedback: false },
98
- ])).toBeNull();
99
- expect(storage.getItem(key(3))).toBe(current);
100
- });
101
-
102
- test("skips empty intermediate revision state", () => {
103
- const storage = new MemoryStorage();
104
- storage.setItem(key(2), JSON.stringify({
105
- annotations: [],
106
- generalNote: "Still drafting",
107
- responses: [],
108
- }));
109
- storage.setItem(key(3), JSON.stringify({
110
- annotations: [],
111
- generalNote: "",
112
- responses: [["choice", { value: [] }]],
113
- feedbackEntries: [["generated", { markdown: "not user input" }]],
114
- }));
115
-
116
- expect(carryUnsubmittedDraft(storage, sessionId, 4, [
117
- { revision: 1, hasFeedback: true },
118
- { revision: 2, hasFeedback: false },
119
- { revision: 3, hasFeedback: false },
120
- { revision: 4, hasFeedback: false },
121
- ])).toBe(2);
122
- });
123
-
124
- test("does not resurrect a carried draft after it is cleared", () => {
125
- const storage = new MemoryStorage();
126
- storage.setItem(key(2), JSON.stringify({
127
- annotations: [{ id: "annotation-1" }],
128
- generalNote: "",
129
- responses: [],
130
- }));
131
- const revisions = [
132
- { revision: 1, hasFeedback: true },
133
- { revision: 2, hasFeedback: false },
134
- { revision: 3, hasFeedback: false },
135
- ];
136
-
137
- expect(carryUnsubmittedDraft(storage, sessionId, 3, revisions)).toBe(2);
138
- const cleared = JSON.stringify({ annotations: [], generalNote: "", responses: [] });
139
- storage.setItem(key(3), cleared);
140
-
141
- expect(carryUnsubmittedDraft(storage, sessionId, 3, revisions)).toBeNull();
142
- expect(storage.getItem(key(3))).toBe(cleared);
143
- expect(carryUnsubmittedDraft(storage, sessionId, 4, [
144
- ...revisions,
145
- { revision: 4, hasFeedback: false },
146
- ])).toBeNull();
147
- expect(storage.getItem(key(4))).toBeNull();
148
- });
149
- });
@@ -1,110 +0,0 @@
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
- }
10
-
11
- interface RemovableAnnotationStorage {
12
- removeItem(key: string): void;
13
- }
14
-
15
- function storageKey(sessionId: string, revision: number): string {
16
- return `canvas:${sessionId}:rev:${revision}`;
17
- }
18
-
19
- function handledKey(sessionId: string, revision: number): string {
20
- return `${storageKey(sessionId, revision)}:draft-handled`;
21
- }
22
-
23
- export function clearPersistedDraft(
24
- storage: RemovableAnnotationStorage,
25
- sessionId: string,
26
- revision: number,
27
- ): void {
28
- try {
29
- storage.removeItem(storageKey(sessionId, revision));
30
- storage.removeItem(handledKey(sessionId, revision));
31
- } catch {}
32
- }
33
-
34
- function parseState(raw: string | null): object | null {
35
- if (!raw) return null;
36
- try {
37
- const value: unknown = JSON.parse(raw);
38
- return typeof value === "object" && value !== null && !Array.isArray(value)
39
- ? value
40
- : null;
41
- } catch {
42
- return null;
43
- }
44
- }
45
-
46
- function hasResponseValue(entry: unknown): boolean {
47
- if (!Array.isArray(entry) || entry.length < 2) return false;
48
- const response: unknown = entry[1];
49
- if (typeof response !== "object" || response === null || Array.isArray(response)) return false;
50
-
51
- const note = Reflect.get(response, "note");
52
- if (typeof note === "string" && note.trim().length > 0) return true;
53
-
54
- const value = Reflect.get(response, "value");
55
- if (typeof value === "string") return value.trim().length > 0;
56
- if (Array.isArray(value)) return value.length > 0;
57
- return value !== null && value !== undefined;
58
- }
59
-
60
- function hasDraftContent(state: object | null): boolean {
61
- if (!state) return false;
62
- const annotations = Reflect.get(state, "annotations");
63
- if (Array.isArray(annotations) && annotations.length > 0) return true;
64
-
65
- const generalNote = Reflect.get(state, "generalNote");
66
- if (typeof generalNote === "string" && generalNote.trim().length > 0) return true;
67
-
68
- const responses = Reflect.get(state, "responses");
69
- return Array.isArray(responses) && responses.some(hasResponseValue);
70
- }
71
-
72
- /** Carry an unsent draft forward without crossing submitted revisions. */
73
- export function carryUnsubmittedDraft(
74
- storage: AnnotationStorage,
75
- sessionId: string,
76
- targetRevision: number,
77
- revisions: RevisionState[],
78
- ): number | null {
79
- try {
80
- const targetKey = storageKey(sessionId, targetRevision);
81
- if (hasDraftContent(parseState(storage.getItem(targetKey)))) {
82
- storage.setItem(handledKey(sessionId, targetRevision), "1");
83
- return null;
84
- }
85
- if (storage.getItem(handledKey(sessionId, targetRevision)) !== null) return null;
86
-
87
- const previous = revisions
88
- .filter((revision) => revision.revision < targetRevision)
89
- .sort((left, right) => right.revision - left.revision);
90
-
91
- for (const revision of previous) {
92
- if (revision.hasFeedback) return null;
93
-
94
- const source = parseState(storage.getItem(storageKey(sessionId, revision.revision)));
95
- if (!hasDraftContent(source)) {
96
- if (storage.getItem(handledKey(sessionId, revision.revision)) !== null) return null;
97
- continue;
98
- }
99
-
100
- storage.setItem(targetKey, JSON.stringify({
101
- ...source,
102
- feedbackEntries: [],
103
- }));
104
- storage.setItem(handledKey(sessionId, targetRevision), "1");
105
- return revision.revision;
106
- }
107
- } catch {}
108
-
109
- return null;
110
- }
@@ -1,4 +0,0 @@
1
- export * from "preact/compat";
2
- export { default } from "preact/compat";
3
- export { jsx, jsxs } from "preact/jsx-runtime";
4
- export { createRoot, hydrateRoot } from "preact/compat/client";
@@ -1,26 +0,0 @@
1
- import React from "react";
2
- import { renderMarkdownChildren } from "./renderMarkdownChildren";
3
-
4
- interface CalloutProps {
5
- type?: "info" | "warning" | "danger" | "tip";
6
- children?: React.ReactNode;
7
- }
8
-
9
- const config = {
10
- info: { bg: "bg-accent-blue-muted", icon: "i", iconColor: "text-accent-blue" },
11
- warning: { bg: "bg-accent-amber-muted", icon: "!", iconColor: "text-accent-amber" },
12
- danger: { bg: "bg-accent-red-muted", icon: "\u2715", iconColor: "text-accent-red" },
13
- tip: { bg: "bg-accent-green-muted", icon: "\u2713", iconColor: "text-accent-green" },
14
- };
15
-
16
- export function Callout({ type = "info", children }: CalloutProps) {
17
- const cfg = config[type];
18
- return (
19
- <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}>
20
- <span className={`${cfg.iconColor} opacity-70 text-base leading-relaxed flex-shrink-0 mt-px`}>
21
- {cfg.icon}
22
- </span>
23
- <div className="text-body text-text-secondary leading-relaxed">{renderMarkdownChildren(children)}</div>
24
- </div>
25
- );
26
- }
@@ -1,37 +0,0 @@
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
- }
@@ -1,176 +0,0 @@
1
- import React, { useEffect, useMemo, useRef, useState } from "react";
2
- import { useAnnotations } from "#canvas/runtime";
3
- import { ResponseNote } from "./ResponseNote";
4
-
5
- export type ChoiceOption = string | { value: string; label?: string };
6
-
7
- type NormalizedOption = { value: string; label: string };
8
-
9
- function normalizeOptions(options: ChoiceOption[]): NormalizedOption[] {
10
- return options.map((opt) =>
11
- typeof opt === "string"
12
- ? { value: opt, label: opt }
13
- : { value: opt.value, label: opt.label ?? opt.value },
14
- );
15
- }
16
-
17
- /** Radio — pick one option */
18
- interface ChoiceProps {
19
- id: string;
20
- label: string;
21
- options: ChoiceOption[];
22
- required?: boolean;
23
- }
24
-
25
- export function Choice({ id, label, options, required }: ChoiceProps) {
26
- const { responses, setResponse } = useAnnotations();
27
- const normalized = useMemo(() => normalizeOptions(options), [options]);
28
- const current = responses.get(id);
29
- const selected = current?.value as string | undefined;
30
- const note = current?.note || "";
31
- const [showNote, setShowNote] = useState(false);
32
- const wrapperRef = useRef<HTMLDivElement>(null);
33
-
34
- if (!responses.has(id)) {
35
- setResponse(id, { id, type: "radio", label, value: null, options: normalized.map((o) => o.value), required });
36
- }
37
-
38
- const handleSelect = (value: string) => {
39
- setResponse(id, { ...current!, value });
40
- };
41
-
42
- // Keyboard selection via custom event from PlanRenderer
43
- useEffect(() => {
44
- const el = wrapperRef.current;
45
- if (!el) return;
46
- const handler = (e: Event) => {
47
- const target = (e.target as HTMLElement).closest("[data-md-label]");
48
- const labelAttr = target?.getAttribute("data-md-label");
49
- const match = normalized.find((o) => o.label === labelAttr);
50
- if (match) handleSelect(match.value);
51
- };
52
- el.addEventListener("kb-select", handler);
53
- return () => el.removeEventListener("kb-select", handler);
54
- }, [normalized, handleSelect]);
55
-
56
- const showError = current?.required && !selected;
57
-
58
- return (
59
- <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">
60
- <div className="flex items-baseline gap-2 mb-2">
61
- <span className="text-[13px] font-body font-medium text-text-primary">{label}</span>
62
- {required && <span className="text-[10px] text-accent-red font-body">*</span>}
63
- </div>
64
- <div className="space-y-1">
65
- {normalized.map(({ value, label: optLabel }) => (
66
- <label
67
- key={value}
68
- data-md="choice-option"
69
- data-md-label={optLabel}
70
- onClick={() => handleSelect(value)}
71
- className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
72
- selected === value
73
- ? "bg-highlight-selected"
74
- : "hover:bg-bg-elevated"
75
- }`}
76
- >
77
- <span className={`w-4 h-4 rounded-full border-[1.5px] flex items-center justify-center flex-shrink-0 transition-all ${
78
- selected === value
79
- ? "border-accent-amber bg-accent-amber"
80
- : "border-border-strong"
81
- }`}>
82
- {selected === value && <span className="w-1.5 h-1.5 rounded-full bg-text-inverse" />}
83
- </span>
84
- <span className="text-[13px] font-body text-text-secondary">{optLabel}</span>
85
- </label>
86
- ))}
87
- </div>
88
- <ResponseNote show={showNote} note={note} onToggle={() => setShowNote(!showNote)} onChange={(n) => setResponse(id, { ...current!, note: n })} />
89
- {showError && <p className="text-[11px] text-accent-red font-body mt-1">Please select an option.</p>}
90
- </div>
91
- );
92
- }
93
-
94
- /** Checkbox — pick multiple options */
95
- interface MultiChoiceProps {
96
- id: string;
97
- label: string;
98
- options: ChoiceOption[];
99
- required?: boolean;
100
- }
101
-
102
- export function MultiChoice({ id, label, options, required }: MultiChoiceProps) {
103
- const { responses, setResponse } = useAnnotations();
104
- const normalized = useMemo(() => normalizeOptions(options), [options]);
105
- const current = responses.get(id);
106
- const selected: string[] = (current?.value as string[]) || [];
107
- const note = current?.note || "";
108
- const [showNote, setShowNote] = useState(false);
109
- const wrapperRef = useRef<HTMLDivElement>(null);
110
-
111
- if (!responses.has(id)) {
112
- setResponse(id, { id, type: "checkbox", label, value: [], options: normalized.map((o) => o.value), required });
113
- }
114
-
115
- const toggle = (value: string) => {
116
- const next = selected.includes(value) ? selected.filter((o) => o !== value) : [...selected, value];
117
- setResponse(id, { ...current!, value: next });
118
- };
119
-
120
- // Keyboard selection via custom event from PlanRenderer
121
- useEffect(() => {
122
- const el = wrapperRef.current;
123
- if (!el) return;
124
- const handler = (e: Event) => {
125
- const target = (e.target as HTMLElement).closest("[data-md-label]");
126
- const labelAttr = target?.getAttribute("data-md-label");
127
- const match = normalized.find((o) => o.label === labelAttr);
128
- if (match) toggle(match.value);
129
- };
130
- el.addEventListener("kb-select", handler);
131
- return () => el.removeEventListener("kb-select", handler);
132
- }, [normalized, toggle]);
133
-
134
- const showError = current?.required && selected.length === 0;
135
-
136
- return (
137
- <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">
138
- <div className="flex items-baseline gap-2 mb-2">
139
- <span className="text-[13px] font-body font-medium text-text-primary">{label}</span>
140
- {required && <span className="text-[10px] text-accent-red font-body">*</span>}
141
- </div>
142
- <div className="space-y-1">
143
- {normalized.map(({ value, label: optLabel }) => {
144
- const checked = selected.includes(value);
145
- return (
146
- <label
147
- key={value}
148
- data-md="multichoice-option"
149
- data-md-label={optLabel}
150
- onClick={() => toggle(value)}
151
- className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
152
- checked ? "bg-highlight-selected" : "hover:bg-bg-elevated"
153
- }`}
154
- >
155
- <span className={`w-4 h-4 rounded flex items-center justify-center flex-shrink-0 transition-all ${
156
- checked
157
- ? "bg-accent-amber"
158
- : "border-[1.5px] border-border-strong"
159
- }`}>
160
- {checked && (
161
- <svg width="10" height="10" viewBox="0 0 10 10" fill="none">
162
- <path d="M2.5 5L4.5 7L7.5 3" style={{ stroke: "var(--color-text-inverse)" }} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
163
- </svg>
164
- )}
165
- </span>
166
- <span className="text-[13px] font-body text-text-secondary">{optLabel}</span>
167
- </label>
168
- );
169
- })}
170
- </div>
171
- <ResponseNote show={showNote} note={note} onToggle={() => setShowNote(!showNote)} onChange={(n) => setResponse(id, { ...current!, note: n })} />
172
- {showError && <p className="text-[11px] text-accent-red font-body mt-1">Please select at least one option.</p>}
173
- </div>
174
- );
175
- }
176
-
@@ -1,35 +0,0 @@
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
- }