@plannotator/ui 0.22.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 (305) hide show
  1. package/README.md +60 -0
  2. package/assets/diff-options.png +0 -0
  3. package/assets/icon-codex.png +0 -0
  4. package/assets/look-flat.png +0 -0
  5. package/assets/look-grid.png +0 -0
  6. package/assets/review-sections.png +0 -0
  7. package/assets/review-tree.png +0 -0
  8. package/assets/workspaces.webp +0 -0
  9. package/components/AISettingsTab.tsx +145 -0
  10. package/components/ActionMenu.tsx +101 -0
  11. package/components/AgentControls.tsx +216 -0
  12. package/components/AgentsTab.tsx +1294 -0
  13. package/components/AnnotationPanel.tsx +731 -0
  14. package/components/AnnotationSidebar.tsx +86 -0
  15. package/components/AnnotationToolbar.tsx +323 -0
  16. package/components/AnnotationToolstrip.tsx +355 -0
  17. package/components/ApproveDropdown.tsx +170 -0
  18. package/components/AttachmentsButton.tsx +410 -0
  19. package/components/BlockRenderer.tsx +163 -0
  20. package/components/BorderTrail.tsx +38 -0
  21. package/components/CodeFilePicker.tsx +64 -0
  22. package/components/CodeFilePopout.tsx +602 -0
  23. package/components/CodePathValidationContext.tsx +14 -0
  24. package/components/CommentPopover.tsx +512 -0
  25. package/components/CompletionOverlay.tsx +99 -0
  26. package/components/ConfirmDialog.tsx +128 -0
  27. package/components/DocBadges.tsx +231 -0
  28. package/components/EditorAnnotationCard.tsx +65 -0
  29. package/components/ExportModal.tsx +565 -0
  30. package/components/FloatingQuickLabelPicker.tsx +144 -0
  31. package/components/GitHubIcon.tsx +16 -0
  32. package/components/GitLabIcon.tsx +30 -0
  33. package/components/GraphvizBlock.tsx +511 -0
  34. package/components/ImageAnnotator/Canvas.tsx +118 -0
  35. package/components/ImageAnnotator/Toolbar.tsx +218 -0
  36. package/components/ImageAnnotator/index.tsx +272 -0
  37. package/components/ImageAnnotator/types.ts +39 -0
  38. package/components/ImageAnnotator/utils.ts +147 -0
  39. package/components/ImageThumbnail.tsx +126 -0
  40. package/components/ImportModal.tsx +146 -0
  41. package/components/InlineMarkdown.tsx +1094 -0
  42. package/components/KeyboardShortcuts.tsx +208 -0
  43. package/components/Landing.tsx +496 -0
  44. package/components/ListItemBody.tsx +62 -0
  45. package/components/ListMarker.tsx +73 -0
  46. package/components/LookAndFeelAnnouncementDialog.tsx +230 -0
  47. package/components/MarkdownEditor.tsx +44 -0
  48. package/components/MenuVersionSection.tsx +91 -0
  49. package/components/MermaidBlock.tsx +579 -0
  50. package/components/ModeToggle.tsx +68 -0
  51. package/components/OpenInAppButton.tsx +299 -0
  52. package/components/OverlayScrollArea.tsx +82 -0
  53. package/components/PermissionModeSetup.tsx +92 -0
  54. package/components/PinpointOverlay.tsx +99 -0
  55. package/components/PlanAIAnnouncementDialog.tsx +187 -0
  56. package/components/PlanHeaderMenu.tsx +299 -0
  57. package/components/PopoutDialog.tsx +91 -0
  58. package/components/Popover.tsx +27 -0
  59. package/components/ProviderIcons.tsx +51 -0
  60. package/components/PullRequestIcon.tsx +12 -0
  61. package/components/QuickLabelDropdown.tsx +63 -0
  62. package/components/RenderedMarkdown.tsx +54 -0
  63. package/components/RepoIcon.tsx +12 -0
  64. package/components/ResizeHandle.tsx +86 -0
  65. package/components/ReviewAgentsIcon.tsx +18 -0
  66. package/components/SearchableSelect.tsx +157 -0
  67. package/components/Settings.tsx +2220 -0
  68. package/components/SparklesIcon.tsx +45 -0
  69. package/components/StickyHeaderLane.tsx +260 -0
  70. package/components/TableOfContents.tsx +157 -0
  71. package/components/TaterSpritePullup.tsx +34 -0
  72. package/components/TaterSpriteRunning.tsx +54 -0
  73. package/components/TaterSpriteSitting.tsx +37 -0
  74. package/components/TextShimmer.tsx +57 -0
  75. package/components/ThemeProvider.tsx +162 -0
  76. package/components/ThemeTab.tsx +139 -0
  77. package/components/ToolbarButtons.tsx +116 -0
  78. package/components/Tooltip.tsx +45 -0
  79. package/components/Viewer.tsx +977 -0
  80. package/components/ai/AIProviderBar.tsx +95 -0
  81. package/components/ai/DocumentAIChatPanel.tsx +313 -0
  82. package/components/blocks/AlertBlock.tsx +58 -0
  83. package/components/blocks/Callout.tsx +60 -0
  84. package/components/blocks/CodeBlock.tsx +75 -0
  85. package/components/blocks/HtmlBlock.tsx +122 -0
  86. package/components/blocks/MathBlock.tsx +36 -0
  87. package/components/blocks/TableBlock.tsx +146 -0
  88. package/components/blocks/TablePopout.tsx +279 -0
  89. package/components/blocks/TableToolbar.tsx +153 -0
  90. package/components/blocks/proseBody.tsx +102 -0
  91. package/components/core/button.tsx +44 -0
  92. package/components/core/textarea.tsx +26 -0
  93. package/components/diagramLanguages.ts +14 -0
  94. package/components/goal-setup/GoalSetupSurface.tsx +1354 -0
  95. package/components/html-viewer/HtmlViewer.tsx +386 -0
  96. package/components/html-viewer/bridge-script.ts +505 -0
  97. package/components/html-viewer/index.ts +1 -0
  98. package/components/html-viewer/useHtmlAnnotation.ts +400 -0
  99. package/components/icons/AgentIcons.tsx +66 -0
  100. package/components/icons/AppIcon.tsx +56 -0
  101. package/components/icons/MessagesIcon.tsx +11 -0
  102. package/components/icons/ObsidianIcons.tsx +208 -0
  103. package/components/icons/app/android-studio.svg +369 -0
  104. package/components/icons/app/antigravity.svg +97 -0
  105. package/components/icons/app/cursor.svg +16 -0
  106. package/components/icons/app/file-explorer.svg +20 -0
  107. package/components/icons/app/finder.png +0 -0
  108. package/components/icons/app/ghostty.svg +13 -0
  109. package/components/icons/app/iterm2.svg +13 -0
  110. package/components/icons/app/powershell.svg +14 -0
  111. package/components/icons/app/sublime-text.svg +17 -0
  112. package/components/icons/app/terminal.png +0 -0
  113. package/components/icons/app/textmate.png +0 -0
  114. package/components/icons/app/vscode.svg +39 -0
  115. package/components/icons/app/warp.png +0 -0
  116. package/components/icons/app/xcode.png +0 -0
  117. package/components/icons/app/zed-dark.svg +15 -0
  118. package/components/icons/app/zed.svg +15 -0
  119. package/components/icons/themeIcons.tsx +47 -0
  120. package/components/mermaidSvg.ts +33 -0
  121. package/components/plan-diff/PlanCleanDiffView.tsx +884 -0
  122. package/components/plan-diff/PlanDiffBadge.tsx +48 -0
  123. package/components/plan-diff/PlanDiffModeSwitcher.tsx +103 -0
  124. package/components/plan-diff/PlanDiffViewer.tsx +214 -0
  125. package/components/plan-diff/PlanRawDiffView.tsx +102 -0
  126. package/components/plan-diff/VSCodeIcon.tsx +132 -0
  127. package/components/settings/HooksTab.tsx +208 -0
  128. package/components/sidebar/ArchiveBrowser.tsx +98 -0
  129. package/components/sidebar/CountBadge.tsx +12 -0
  130. package/components/sidebar/FileBrowser.tsx +498 -0
  131. package/components/sidebar/MessagesBrowser.tsx +109 -0
  132. package/components/sidebar/SidebarContainer.tsx +353 -0
  133. package/components/sidebar/SidebarTabs.tsx +155 -0
  134. package/components/sidebar/VersionBrowser.tsx +141 -0
  135. package/components/types.d.ts +7 -0
  136. package/components/ui/badge.tsx +44 -0
  137. package/components/ui/button.tsx +83 -0
  138. package/components/ui/card.tsx +58 -0
  139. package/components/ui/dialog.tsx +106 -0
  140. package/components/ui/dropdown-menu.tsx +240 -0
  141. package/components/ui/state-pill.tsx +45 -0
  142. package/components/ui/tabs.tsx +46 -0
  143. package/components/ui/textarea.tsx +25 -0
  144. package/config/configStore.ts +214 -0
  145. package/config/index.ts +4 -0
  146. package/config/reviewView.ts +42 -0
  147. package/config/settings.ts +310 -0
  148. package/config/useConfig.ts +20 -0
  149. package/configure.ts +69 -0
  150. package/globals.d.ts +14 -0
  151. package/hooks/pfm/useCodeFilePopout.ts +111 -0
  152. package/hooks/useAIChat.ts +532 -0
  153. package/hooks/useAIProviderConfig.ts +115 -0
  154. package/hooks/useActiveSection.ts +78 -0
  155. package/hooks/useAgentJobs.ts +306 -0
  156. package/hooks/useAgentSettings.ts +579 -0
  157. package/hooks/useAgents.ts +90 -0
  158. package/hooks/useAnnotationDraft.ts +523 -0
  159. package/hooks/useAnnotationHighlighter.ts +1170 -0
  160. package/hooks/useArchive.ts +171 -0
  161. package/hooks/useAutoClose.ts +104 -0
  162. package/hooks/useCodeAnnotationDraft.ts +194 -0
  163. package/hooks/useCodeFilePopout.ts +1 -0
  164. package/hooks/useDismissOnOutsideAndEscape.ts +50 -0
  165. package/hooks/useDraggable.ts +108 -0
  166. package/hooks/useEditorAnnotations.ts +64 -0
  167. package/hooks/useExternalAnnotationHighlights.ts +105 -0
  168. package/hooks/useExternalAnnotations.ts +268 -0
  169. package/hooks/useFileBrowser.ts +402 -0
  170. package/hooks/useInputMethodSwitch.ts +89 -0
  171. package/hooks/useIsMobile.ts +17 -0
  172. package/hooks/useLinkedDoc.ts +494 -0
  173. package/hooks/useOverlayViewport.ts +36 -0
  174. package/hooks/usePinpoint.ts +182 -0
  175. package/hooks/usePlanDiff.ts +176 -0
  176. package/hooks/usePrintMode.ts +27 -0
  177. package/hooks/useResizablePanel.ts +173 -0
  178. package/hooks/useScrollViewport.ts +38 -0
  179. package/hooks/useSharing.ts +441 -0
  180. package/hooks/useSidebar.ts +55 -0
  181. package/hooks/useUpdateCheck.ts +132 -0
  182. package/hooks/useValidatedCodePaths.ts +94 -0
  183. package/icons/GitUser.tsx +17 -0
  184. package/lib/utils.ts +11 -0
  185. package/package.json +117 -0
  186. package/plannotator.webp +0 -0
  187. package/print.css +427 -0
  188. package/shortcuts/code-review/ai.shortcuts.ts +25 -0
  189. package/shortcuts/code-review/allFilesDiff.shortcuts.ts +46 -0
  190. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +30 -0
  191. package/shortcuts/code-review/fileTree.shortcuts.ts +35 -0
  192. package/shortcuts/code-review/prComments.shortcuts.ts +18 -0
  193. package/shortcuts/code-review/suggestionModal.shortcuts.ts +25 -0
  194. package/shortcuts/code-review/tourDialog.shortcuts.ts +18 -0
  195. package/shortcuts/core.ts +399 -0
  196. package/shortcuts/index.ts +21 -0
  197. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +25 -0
  198. package/shortcuts/plan-review/annotationToolbar.shortcuts.ts +39 -0
  199. package/shortcuts/plan-review/commentPopover.shortcuts.ts +21 -0
  200. package/shortcuts/plan-review/goalSetup.shortcuts.ts +10 -0
  201. package/shortcuts/plan-review/imageAnnotator.shortcuts.ts +42 -0
  202. package/shortcuts/plan-review/inputMethod.shortcuts.ts +30 -0
  203. package/shortcuts/plan-review/sidebar.shortcuts.ts +34 -0
  204. package/shortcuts/plan-review/viewer.shortcuts.ts +24 -0
  205. package/shortcuts/runtime.ts +259 -0
  206. package/sprite_package_additional/index.html +34 -0
  207. package/sprite_package_additional/sprite.png +0 -0
  208. package/sprite_package_new/index.html +34 -0
  209. package/sprite_package_new/sprite.png +0 -0
  210. package/sprite_package_pulluphang/index.html +34 -0
  211. package/sprite_package_pulluphang/sprite.png +0 -0
  212. package/styles.css +1 -0
  213. package/theme.css +893 -0
  214. package/themes/adwaita.css +62 -0
  215. package/themes/andromeeda.css +66 -0
  216. package/themes/aurora-x.css +66 -0
  217. package/themes/ayu-dark.css +66 -0
  218. package/themes/caffeine.css +113 -0
  219. package/themes/catppuccin.css +62 -0
  220. package/themes/claude-plus.css +60 -0
  221. package/themes/cursor-hc.css +34 -0
  222. package/themes/cursor-midnight.css +34 -0
  223. package/themes/cursor.css +62 -0
  224. package/themes/dark-plus.css +66 -0
  225. package/themes/doom-64.css +109 -0
  226. package/themes/dracula.css +33 -0
  227. package/themes/everforest-hard.css +62 -0
  228. package/themes/everforest-soft.css +62 -0
  229. package/themes/everforest.css +62 -0
  230. package/themes/github.css +66 -0
  231. package/themes/gruvbox.css +62 -0
  232. package/themes/houston.css +66 -0
  233. package/themes/kanagawa-dragon.css +34 -0
  234. package/themes/kanagawa-lotus.css +34 -0
  235. package/themes/kanagawa-wave.css +34 -0
  236. package/themes/laserwave.css +66 -0
  237. package/themes/material.css +62 -0
  238. package/themes/min.css +66 -0
  239. package/themes/monokai-pro.css +34 -0
  240. package/themes/neutral.css +59 -0
  241. package/themes/night-owl.css +66 -0
  242. package/themes/nord.css +66 -0
  243. package/themes/one-dark-pro.css +66 -0
  244. package/themes/one-light.css +66 -0
  245. package/themes/paulmillr.css +34 -0
  246. package/themes/plannotator.css +60 -0
  247. package/themes/plastic.css +66 -0
  248. package/themes/poimandres.css +66 -0
  249. package/themes/quantum-rose.css +109 -0
  250. package/themes/red.css +66 -0
  251. package/themes/rose-pine.css +62 -0
  252. package/themes/simple.css +124 -0
  253. package/themes/slack.css +66 -0
  254. package/themes/snazzy-light.css +66 -0
  255. package/themes/soft-pop.css +60 -0
  256. package/themes/solar-dusk.css +109 -0
  257. package/themes/solarized.css +66 -0
  258. package/themes/synthwave-84.css +34 -0
  259. package/themes/terminal.css +62 -0
  260. package/themes/tinacious.css +57 -0
  261. package/themes/tokyo-night.css +62 -0
  262. package/themes/vesper.css +62 -0
  263. package/themes/vitesse-black.css +66 -0
  264. package/themes/vitesse.css +62 -0
  265. package/types.ts +264 -0
  266. package/utils/agentSwitch.ts +71 -0
  267. package/utils/aiChatFormat.ts +33 -0
  268. package/utils/aiPrompt.ts +29 -0
  269. package/utils/aiProvider.ts +217 -0
  270. package/utils/anchors.ts +11 -0
  271. package/utils/annotateAgentTerminal.ts +29 -0
  272. package/utils/annotationHelpers.ts +101 -0
  273. package/utils/bear.ts +59 -0
  274. package/utils/blockTargeting.ts +240 -0
  275. package/utils/callback.ts +99 -0
  276. package/utils/commentContent.ts +8 -0
  277. package/utils/defaultNotesApp.ts +20 -0
  278. package/utils/diffFonts.ts +33 -0
  279. package/utils/editorMode.ts +31 -0
  280. package/utils/fileBrowser.ts +40 -0
  281. package/utils/generateId.ts +7 -0
  282. package/utils/generateIdentity.ts +29 -0
  283. package/utils/identity.ts +112 -0
  284. package/utils/inlineTransforms.ts +41 -0
  285. package/utils/inputMethod.ts +17 -0
  286. package/utils/lookAndFeelAnnouncement.ts +18 -0
  287. package/utils/obsidian.ts +201 -0
  288. package/utils/octarine.ts +53 -0
  289. package/utils/parser.ts +991 -0
  290. package/utils/permissionMode.ts +77 -0
  291. package/utils/planAIAnnouncement.ts +17 -0
  292. package/utils/planAgentInstructions.ts +137 -0
  293. package/utils/planDiffEngine.ts +590 -0
  294. package/utils/planSave.ts +49 -0
  295. package/utils/platform.ts +11 -0
  296. package/utils/quickLabels.ts +77 -0
  297. package/utils/reviewAgentInstructions.ts +182 -0
  298. package/utils/sanitizeHtml.ts +45 -0
  299. package/utils/sharing.ts +350 -0
  300. package/utils/slugify.ts +37 -0
  301. package/utils/storage.ts +141 -0
  302. package/utils/themeRegistry.ts +567 -0
  303. package/utils/uiPreferences.ts +34 -0
  304. package/utils/upload.ts +56 -0
  305. package/utils/wideMode.ts +48 -0
@@ -0,0 +1,1354 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ Check,
4
+ ChevronDown,
5
+ Copy,
6
+ Edit3,
7
+ MessageSquare,
8
+ Plus,
9
+ TestTube2,
10
+ Trash2,
11
+ X,
12
+ } from 'lucide-react';
13
+ import type {
14
+ GoalSetupBundle,
15
+ GoalSetupFactResult,
16
+ GoalSetupFactsBundle,
17
+ GoalSetupInterviewBundle,
18
+ GoalSetupQuestion,
19
+ GoalSetupQuestionAnswer,
20
+ } from '@plannotator/core/goal-setup';
21
+ import { ConfirmDialog } from '../ConfirmDialog';
22
+ import { CommentPopover } from '../CommentPopover';
23
+ import { Button } from '../core/button';
24
+ import { Textarea } from '../core/textarea';
25
+
26
+ interface GoalSetupSurfaceProps {
27
+ bundle: GoalSetupBundle;
28
+ maxWidth?: number | null;
29
+ onSubmitted?: () => void;
30
+ onActionStateChange?: (state: GoalSetupActionState) => void;
31
+ }
32
+
33
+ type SubmitState = 'idle' | 'submitting' | 'submitted' | 'error';
34
+
35
+ export interface GoalSetupSurfaceHandle {
36
+ submit: () => void;
37
+ }
38
+
39
+ export interface GoalSetupActionState {
40
+ canSubmit: boolean;
41
+ isSubmitting: boolean;
42
+ submitted: boolean;
43
+ submitLabel: string;
44
+ }
45
+
46
+ function cx(...classes: Array<string | false | null | undefined>): string {
47
+ return classes.filter(Boolean).join(' ');
48
+ }
49
+
50
+ async function submitGoalSetup(payload: unknown): Promise<void> {
51
+ const response = await fetch('/api/goal-setup/submit', {
52
+ method: 'POST',
53
+ headers: { 'Content-Type': 'application/json' },
54
+ body: JSON.stringify(payload),
55
+ });
56
+
57
+ if (!response.ok) {
58
+ let message = 'Submission failed';
59
+ try {
60
+ const body = await response.json();
61
+ if (typeof body.error === 'string') message = body.error;
62
+ } catch {
63
+ // Keep the generic message.
64
+ }
65
+ throw new Error(message);
66
+ }
67
+ }
68
+
69
+ async function copyGoalSetupText(text: string): Promise<void> {
70
+ if (!navigator.clipboard?.writeText) {
71
+ throw new Error('Clipboard is unavailable in this browser');
72
+ }
73
+ await navigator.clipboard.writeText(text);
74
+ }
75
+
76
+ function useGoalSetupCopy(onError: (message: string) => void) {
77
+ const [copied, setCopied] = useState<string | null>(null);
78
+
79
+ useEffect(() => {
80
+ if (!copied) return;
81
+ const timeout = window.setTimeout(() => setCopied(null), 1400);
82
+ return () => window.clearTimeout(timeout);
83
+ }, [copied]);
84
+
85
+ const copy = useCallback(
86
+ async (id: string, text: string) => {
87
+ try {
88
+ await copyGoalSetupText(text);
89
+ onError('');
90
+ setCopied(id);
91
+ } catch (err) {
92
+ onError(err instanceof Error ? err.message : 'Copy failed');
93
+ }
94
+ },
95
+ [onError]
96
+ );
97
+
98
+ return { copied, copy };
99
+ }
100
+
101
+ export const GoalSetupSurface = React.forwardRef<GoalSetupSurfaceHandle, GoalSetupSurfaceProps>(({
102
+ bundle,
103
+ maxWidth = 880,
104
+ onSubmitted,
105
+ onActionStateChange,
106
+ }, ref) => (
107
+ <div
108
+ className="w-full"
109
+ style={maxWidth == null ? undefined : { maxWidth }}
110
+ >
111
+ {bundle.stage === 'interview' ? (
112
+ <InterviewSurface
113
+ ref={ref}
114
+ bundle={bundle}
115
+ onSubmitted={onSubmitted}
116
+ onActionStateChange={onActionStateChange}
117
+ />
118
+ ) : (
119
+ <FactsSurface
120
+ ref={ref}
121
+ bundle={bundle}
122
+ onSubmitted={onSubmitted}
123
+ onActionStateChange={onActionStateChange}
124
+ />
125
+ )}
126
+ </div>
127
+ ));
128
+ GoalSetupSurface.displayName = 'GoalSetupSurface';
129
+
130
+ const InterviewSurface = React.forwardRef<GoalSetupSurfaceHandle, {
131
+ bundle: GoalSetupInterviewBundle;
132
+ onSubmitted?: () => void;
133
+ onActionStateChange?: (state: GoalSetupActionState) => void;
134
+ }>(({ bundle, onSubmitted, onActionStateChange }, ref) => {
135
+ const [answers, setAnswers] = useState<Record<string, GoalSetupQuestionAnswer>>(() =>
136
+ Object.fromEntries(
137
+ bundle.questions.map((question) => [
138
+ question.id,
139
+ {
140
+ questionId: question.id,
141
+ selectedOptionIds: [],
142
+ customAnswer: '',
143
+ note: '',
144
+ answer: '',
145
+ completed: false,
146
+ },
147
+ ])
148
+ )
149
+ );
150
+ const [activeQuestionId, setActiveQuestionId] = useState<string | null>(
151
+ () => bundle.questions[0]?.id ?? null
152
+ );
153
+ const [skippedIds, setSkippedIds] = useState<Set<string>>(new Set());
154
+ const [noteOpenForId, setNoteOpenForId] = useState<string | null>(null);
155
+ const [recommendationActionForId, setRecommendationActionForId] = useState<{
156
+ id: string;
157
+ nonce: number;
158
+ } | null>(null);
159
+ const questionRefs = useRef(new Map<string, HTMLDivElement>());
160
+ const didAutoFocus = useRef(false);
161
+ const [submitState, setSubmitState] = useState<SubmitState>('idle');
162
+ const [error, setError] = useState('');
163
+ const { copied, copy } = useGoalSetupCopy(setError);
164
+
165
+ const focusQuestionControl = useCallback(
166
+ (questionId: string, options?: { scroll?: boolean }) => {
167
+ requestAnimationFrame(() => {
168
+ const row = questionRefs.current.get(questionId);
169
+ if (!row) return;
170
+ if (options?.scroll) row.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
171
+ const focusTarget = row.querySelector<HTMLElement>('.goal-answer-focus');
172
+ if (focusTarget) requestAnimationFrame(() => focusTarget.focus());
173
+ });
174
+ },
175
+ []
176
+ );
177
+
178
+ useEffect(() => {
179
+ if (didAutoFocus.current || !activeQuestionId) return;
180
+ didAutoFocus.current = true;
181
+ focusQuestionControl(activeQuestionId);
182
+ }, [activeQuestionId, focusQuestionControl]);
183
+
184
+ const answerList = useMemo(
185
+ () =>
186
+ bundle.questions.map((question) => {
187
+ const answer = answers[question.id];
188
+ const completed = hasAnswer(question, answer);
189
+ return {
190
+ ...answer,
191
+ answer: buildAnswerText(question, answer),
192
+ completed,
193
+ skipped: skippedIds.has(question.id) && !completed,
194
+ };
195
+ }),
196
+ [answers, bundle.questions, skippedIds]
197
+ );
198
+
199
+ const completedCount = answerList.filter((answer) => answer.completed).length;
200
+ const [showIncompleteWarning, setShowIncompleteWarning] = useState(false);
201
+
202
+ const copyInterviewJson = useCallback(() => {
203
+ copy('interview-json', JSON.stringify(buildInterviewCopyPayload(bundle, answerList), null, 2));
204
+ }, [answerList, bundle, copy]);
205
+
206
+ const copyInterviewMarkdown = useCallback(() => {
207
+ copy('interview-markdown', formatInterviewMarkdown(bundle, answerList));
208
+ }, [answerList, bundle, copy]);
209
+
210
+ const incompleteQuestions = useMemo(
211
+ () =>
212
+ bundle.questions.filter(
213
+ (q) => q.required !== false && !hasAnswer(q, answers[q.id]) && !skippedIds.has(q.id)
214
+ ),
215
+ [bundle.questions, answers, skippedIds]
216
+ );
217
+ const skippedQuestions = useMemo(
218
+ () => bundle.questions.filter((q) => skippedIds.has(q.id) && !hasAnswer(q, answers[q.id])),
219
+ [bundle.questions, answers, skippedIds]
220
+ );
221
+
222
+ const updateAnswer = useCallback(
223
+ (questionId: string, patch: Partial<GoalSetupQuestionAnswer>) => {
224
+ const answerFieldsChanged =
225
+ Object.prototype.hasOwnProperty.call(patch, 'answer') ||
226
+ Object.prototype.hasOwnProperty.call(patch, 'customAnswer') ||
227
+ Object.prototype.hasOwnProperty.call(patch, 'selectedOptionIds');
228
+ setAnswers((current) => ({
229
+ ...current,
230
+ [questionId]: {
231
+ ...current[questionId],
232
+ ...patch,
233
+ },
234
+ }));
235
+ setSkippedIds((current) => {
236
+ if (!answerFieldsChanged || !current.has(questionId)) return current;
237
+ const next = new Set(current);
238
+ next.delete(questionId);
239
+ return next;
240
+ });
241
+ },
242
+ []
243
+ );
244
+
245
+ const doSubmit = async () => {
246
+ if (submitState === 'submitting') return;
247
+ setShowIncompleteWarning(false);
248
+ setSubmitState('submitting');
249
+ setError('');
250
+ try {
251
+ await submitGoalSetup({
252
+ stage: 'interview',
253
+ title: bundle.title,
254
+ goalSlug: bundle.goalSlug,
255
+ answers: answerList,
256
+ });
257
+ setSubmitState('submitted');
258
+ onSubmitted?.();
259
+ } catch (err) {
260
+ setError(err instanceof Error ? err.message : 'Submission failed');
261
+ setSubmitState('error');
262
+ }
263
+ };
264
+
265
+ const handleSubmit = () => {
266
+ if (submitState === 'submitting') return;
267
+ if (incompleteQuestions.length > 0 || skippedQuestions.length > 0) {
268
+ setShowIncompleteWarning(true);
269
+ return;
270
+ }
271
+ doSubmit();
272
+ };
273
+
274
+ React.useImperativeHandle(ref, () => ({ submit: handleSubmit }), [handleSubmit]);
275
+
276
+ useEffect(() => {
277
+ onActionStateChange?.({
278
+ canSubmit: true,
279
+ isSubmitting: submitState === 'submitting',
280
+ submitted: submitState === 'submitted',
281
+ submitLabel: 'Submit Answers',
282
+ });
283
+ }, [onActionStateChange, submitState]);
284
+
285
+ const advance = useCallback(
286
+ (delta: 1 | -1) => {
287
+ const ids = bundle.questions.map((q) => q.id);
288
+ if (ids.length === 0) return;
289
+ const currentIndex = activeQuestionId ? ids.indexOf(activeQuestionId) : -1;
290
+ const nextIndex =
291
+ currentIndex === -1
292
+ ? delta === 1 ? 0 : ids.length - 1
293
+ : Math.max(0, Math.min(ids.length - 1, currentIndex + delta));
294
+ const nextId = ids[nextIndex];
295
+ setActiveQuestionId(nextId);
296
+ focusQuestionControl(nextId, { scroll: true });
297
+ },
298
+ [bundle.questions, activeQuestionId, focusQuestionControl]
299
+ );
300
+
301
+ const skipQuestion = useCallback(
302
+ (questionId: string) => {
303
+ setSkippedIds((current) => {
304
+ const next = new Set(current);
305
+ next.add(questionId);
306
+ return next;
307
+ });
308
+ advance(1);
309
+ },
310
+ [advance]
311
+ );
312
+
313
+ useEffect(() => {
314
+ const handleKeyDown = (event: KeyboardEvent) => {
315
+
316
+ // Ctrl+letter shortcuts — work from anywhere, including inside text fields
317
+ if (event.ctrlKey && !event.metaKey && !event.altKey && activeQuestionId) {
318
+ const key = event.key.toLowerCase();
319
+ if (key === 'u') {
320
+ event.preventDefault();
321
+ setRecommendationActionForId({ id: activeQuestionId, nonce: Date.now() });
322
+ return;
323
+ }
324
+ if (key === 'k') {
325
+ event.preventDefault();
326
+ skipQuestion(activeQuestionId);
327
+ return;
328
+ }
329
+ if (key === 'j') {
330
+ event.preventDefault();
331
+ setNoteOpenForId(activeQuestionId);
332
+ requestAnimationFrame(() => {
333
+ const row = questionRefs.current.get(activeQuestionId);
334
+ const noteInput = row?.querySelector<HTMLElement>('.goal-note-textarea');
335
+ if (noteInput) requestAnimationFrame(() => noteInput.focus());
336
+ });
337
+ return;
338
+ }
339
+ }
340
+
341
+ // Escape — blur current input so shortcuts work again
342
+ if (event.key === 'Escape') {
343
+ const el = event.target;
344
+ if (el instanceof HTMLElement && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA')) {
345
+ event.preventDefault();
346
+ el.blur();
347
+ }
348
+ return;
349
+ }
350
+
351
+ // Tab / Shift+Tab — advance between questions
352
+ if (event.key === 'Tab') {
353
+ const target = event.target;
354
+ if (!(target instanceof HTMLElement)) return;
355
+ if (!target.closest('.goal-row')) return;
356
+ event.preventDefault();
357
+ advance(event.shiftKey ? -1 : 1);
358
+ return;
359
+ }
360
+
361
+ // Number keys — toggle options on the active question
362
+ if (!activeQuestionId) return;
363
+ const el = event.target;
364
+ if (el instanceof HTMLElement && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable)) return;
365
+
366
+ const activeQuestion = bundle.questions.find((q) => q.id === activeQuestionId);
367
+ if (!activeQuestion?.options?.length) return;
368
+ const mode = activeQuestion.answerMode || 'text';
369
+ const isMulti = mode === 'multi' || mode === 'multi-custom';
370
+
371
+ // "/" — focus the custom input for the active question
372
+ if (event.key === '/' && (mode === 'custom' || mode === 'single-custom' || mode === 'multi-custom')) {
373
+ event.preventDefault();
374
+ const row = questionRefs.current.get(activeQuestionId);
375
+ const customInput = row?.querySelector<HTMLElement>('.goal-custom-input');
376
+ customInput?.focus();
377
+ return;
378
+ }
379
+
380
+ const digit = parseInt(event.key, 10);
381
+ if (isNaN(digit) || digit < 1 || digit > activeQuestion.options.length) return;
382
+ event.preventDefault();
383
+
384
+ const option = activeQuestion.options[digit - 1];
385
+ const current = answers[activeQuestionId];
386
+ if (isMulti) {
387
+ updateAnswer(activeQuestionId, {
388
+ selectedOptionIds: current.selectedOptionIds.includes(option.id)
389
+ ? current.selectedOptionIds.filter((id) => id !== option.id)
390
+ : [...current.selectedOptionIds, option.id],
391
+ });
392
+ } else {
393
+ updateAnswer(activeQuestionId, {
394
+ selectedOptionIds: current.selectedOptionIds.includes(option.id) ? [] : [option.id],
395
+ customAnswer: '',
396
+ });
397
+ }
398
+ };
399
+
400
+ window.addEventListener('keydown', handleKeyDown);
401
+ return () => window.removeEventListener('keydown', handleKeyDown);
402
+ }, [advance, activeQuestionId, bundle.questions, answers, updateAnswer, skipQuestion]);
403
+
404
+ return (
405
+ <section className="w-full">
406
+ <ConfirmDialog
407
+ isOpen={showIncompleteWarning}
408
+ onClose={() => setShowIncompleteWarning(false)}
409
+ onConfirm={doSubmit}
410
+ variant="warning"
411
+ title="Submit with incomplete answers?"
412
+ message={
413
+ <>
414
+ {skippedQuestions.length > 0 && (
415
+ <span>{skippedQuestions.length} skipped question{skippedQuestions.length !== 1 ? 's' : ''} will be sent as skipped, including any notes. </span>
416
+ )}
417
+ {incompleteQuestions.length > 0 && (
418
+ <span>{incompleteQuestions.length} required question{incompleteQuestions.length !== 1 ? 's' : ''} {incompleteQuestions.length !== 1 ? 'are' : 'is'} still unanswered. </span>
419
+ )}
420
+ <span>The agent will work with whatever you've provided.</span>
421
+ </>
422
+ }
423
+ confirmText="Submit anyway"
424
+ cancelText="Go back"
425
+ showCancel
426
+ />
427
+
428
+ <div className="goal-shell" data-has-active={activeQuestionId !== null ? 'true' : 'false'}>
429
+ <header className="mb-3 flex flex-wrap items-start justify-between gap-3">
430
+ <div className="min-w-0">
431
+ <div className="text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
432
+ Goal interview
433
+ </div>
434
+ <h1 className="mt-0.5 truncate text-lg font-medium leading-tight text-foreground">
435
+ {bundle.title || 'Answer the setup questions'}
436
+ </h1>
437
+ </div>
438
+ <div className="flex shrink-0 items-center gap-2">
439
+ <div className="flex items-center gap-1">
440
+ <GoalCopyButton
441
+ onClick={copyInterviewJson}
442
+ copied={copied === 'interview-json'}
443
+ format="JSON"
444
+ title="Copy all questions and answers as raw JSON"
445
+ />
446
+ <GoalCopyButton
447
+ onClick={copyInterviewMarkdown}
448
+ copied={copied === 'interview-markdown'}
449
+ format="Markdown"
450
+ title="Copy all questions and answers as markdown"
451
+ />
452
+ </div>
453
+ <div className="font-mono text-[11px] text-muted-foreground">
454
+ {completedCount}/{bundle.questions.length} answered
455
+ </div>
456
+ </div>
457
+ </header>
458
+
459
+ {error && (
460
+ <div className="mb-3 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
461
+ {error}
462
+ </div>
463
+ )}
464
+ {bundle.questions.map((question) => {
465
+ const answer = answers[question.id];
466
+ const isActive = activeQuestionId === question.id;
467
+ const complete = hasAnswer(question, answer);
468
+ const skipped = skippedIds.has(question.id) && !complete;
469
+ const noteSummary = answer.note?.trim();
470
+ const summary = skipped
471
+ ? noteSummary ? `Skipped · ${noteSummary}` : 'Skipped'
472
+ : complete
473
+ ? buildAnswerText(question, answer).replace(/\s*\n+\s*/g, ' · ').trim()
474
+ : '';
475
+
476
+ return (
477
+ <div
478
+ key={question.id}
479
+ ref={(node) => {
480
+ if (node) questionRefs.current.set(question.id, node);
481
+ else questionRefs.current.delete(question.id);
482
+ }}
483
+ className={cx(
484
+ 'goal-row',
485
+ isActive && 'active',
486
+ complete && 'answered',
487
+ skipped && 'skipped'
488
+ )}
489
+ >
490
+ <button
491
+ type="button"
492
+ onClick={() => {
493
+ setActiveQuestionId((current) =>
494
+ current === question.id ? null : question.id
495
+ );
496
+ }}
497
+ tabIndex={-1}
498
+ aria-expanded={isActive}
499
+ className="flex w-full items-center gap-2.5 px-3 py-2 text-left focus-visible:outline-none"
500
+ >
501
+ <StatusDot complete={complete} skipped={skipped} />
502
+ <span className="min-w-0 flex-1">
503
+ <span className="block text-[13.5px] font-medium leading-snug text-foreground">
504
+ {question.prompt}
505
+ </span>
506
+ {!isActive && summary && (
507
+ <span className={cx(
508
+ 'mt-0.5 block truncate text-[12px] leading-snug',
509
+ skipped ? 'text-warning' : 'text-muted-foreground'
510
+ )}>
511
+ {summary}
512
+ </span>
513
+ )}
514
+ </span>
515
+ {question.required === false && !complete && !skipped && (
516
+ <span className="shrink-0 text-[9px] font-medium uppercase tracking-wider text-muted-foreground">
517
+ opt
518
+ </span>
519
+ )}
520
+ {skipped && <Check className="h-3.5 w-3.5 shrink-0 text-warning" />}
521
+ {complete && !skipped && <Check className="h-3.5 w-3.5 shrink-0 text-success/85" />}
522
+ <ChevronDown
523
+ className={cx(
524
+ 'h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-150',
525
+ isActive && 'rotate-180'
526
+ )}
527
+ />
528
+ </button>
529
+
530
+ <div className={cx('goal-question-body', isActive && 'expanded')}>
531
+ <div className="goal-question-body-inner">
532
+ <div className="goal-row-divider" />
533
+ <QuestionAnswerControls
534
+ question={question}
535
+ answer={answer}
536
+ onChange={(patch) => updateAnswer(question.id, patch)}
537
+ noteOpen={noteOpenForId === question.id}
538
+ onNoteOpenChange={(open) =>
539
+ setNoteOpenForId(open ? question.id : null)
540
+ }
541
+ onSkip={() => skipQuestion(question.id)}
542
+ recommendationCommand={
543
+ recommendationActionForId?.id === question.id
544
+ ? recommendationActionForId
545
+ : null
546
+ }
547
+ />
548
+ </div>
549
+ </div>
550
+ </div>
551
+ );
552
+ })}
553
+ </div>
554
+
555
+ <GoalShortcutPill />
556
+ </section>
557
+ );
558
+ });
559
+ InterviewSurface.displayName = 'InterviewSurface';
560
+
561
+ const QuestionAnswerControls: React.FC<{
562
+ question: GoalSetupQuestion;
563
+ answer: GoalSetupQuestionAnswer;
564
+ onChange: (patch: Partial<GoalSetupQuestionAnswer>) => void;
565
+ noteOpen: boolean;
566
+ onNoteOpenChange: (open: boolean) => void;
567
+ onSkip: () => void;
568
+ recommendationCommand: { nonce: number } | null;
569
+ }> = ({ question, answer, onChange, noteOpen, onNoteOpenChange, onSkip, recommendationCommand }) => {
570
+ const mode = question.answerMode || 'text';
571
+ const options = question.options || [];
572
+ const supportsOptions = mode === 'single' || mode === 'multi' || mode === 'single-custom' || mode === 'multi-custom';
573
+ const supportsCustom = mode === 'custom' || mode === 'single-custom' || mode === 'multi-custom';
574
+ const supportsText = mode === 'text';
575
+ const isMulti = mode === 'multi' || mode === 'multi-custom';
576
+ const textValue = supportsText ? answer.answer : answer.customAnswer;
577
+ const showTextArea = supportsText || mode === 'custom';
578
+ const showCustomOption = supportsOptions && supportsCustom;
579
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
580
+ const hasAnyAnswer = hasAnswer(question, answer);
581
+ const canUseRecommendation = showTextArea || supportsCustom || Boolean(question.recommendedOptionIds?.length);
582
+
583
+ useEffect(() => {
584
+ if (answer.note && !noteOpen) {
585
+ onNoteOpenChange(true);
586
+ }
587
+ // eslint-disable-next-line react-hooks/exhaustive-deps
588
+ }, [answer.note]);
589
+
590
+ const toggleOption = (optionId: string) => {
591
+ if (isMulti) {
592
+ onChange({
593
+ selectedOptionIds: answer.selectedOptionIds.includes(optionId)
594
+ ? answer.selectedOptionIds.filter((id) => id !== optionId)
595
+ : [...answer.selectedOptionIds, optionId],
596
+ });
597
+ return;
598
+ }
599
+ onChange({
600
+ selectedOptionIds: answer.selectedOptionIds.includes(optionId) ? [] : [optionId],
601
+ customAnswer: '',
602
+ });
603
+ };
604
+
605
+ const updateTextValue = (value: string) => {
606
+ supportsText
607
+ ? onChange({ answer: value })
608
+ : onChange({ customAnswer: value });
609
+ };
610
+
611
+ const updateCustomOption = (value: string) => {
612
+ onChange({
613
+ customAnswer: value,
614
+ ...(mode === 'single-custom' && value.trim() ? { selectedOptionIds: [] } : {}),
615
+ });
616
+ };
617
+
618
+ const applyRecommendation = () => {
619
+ const patch: Partial<GoalSetupQuestionAnswer> = {};
620
+ const hasRecommendedOptions = Boolean(question.recommendedOptionIds?.length && supportsOptions);
621
+ if (question.recommendedOptionIds?.length && supportsOptions) {
622
+ patch.selectedOptionIds = isMulti
623
+ ? question.recommendedOptionIds
624
+ : [question.recommendedOptionIds[0]];
625
+ if (!isMulti) patch.customAnswer = '';
626
+ }
627
+ if (question.recommendedAnswer) {
628
+ if (supportsText) {
629
+ patch.answer = question.recommendedAnswer;
630
+ } else if (mode === 'custom') {
631
+ patch.customAnswer = question.recommendedAnswer;
632
+ } else if (supportsCustom && !hasRecommendedOptions) {
633
+ patch.customAnswer = question.recommendedAnswer;
634
+ if (mode === 'single-custom') patch.selectedOptionIds = [];
635
+ }
636
+ }
637
+ if (Object.keys(patch).length === 0) return;
638
+ onChange(patch);
639
+ if (showTextArea) requestAnimationFrame(() => textareaRef.current?.focus());
640
+ };
641
+
642
+ useEffect(() => {
643
+ if (!recommendationCommand) return;
644
+ applyRecommendation();
645
+ // eslint-disable-next-line react-hooks/exhaustive-deps
646
+ }, [recommendationCommand?.nonce]);
647
+
648
+ return (
649
+ <div className="px-3 pb-3 pt-2.5">
650
+ {question.description && (
651
+ <p className="mb-2.5 text-[12.5px] leading-snug text-muted-foreground">
652
+ {question.description}
653
+ </p>
654
+ )}
655
+ {(question.recommendedAnswer || question.recommendedOptionIds?.length) && !hasAnyAnswer && (
656
+ <div className="mb-2.5 flex items-start gap-2.5 rounded-md bg-muted/30 px-3 py-2">
657
+ <div className="min-w-0 flex-1">
658
+ <RecommendedBadge label="Recommended" />
659
+ <p className="mt-1 text-[13px] leading-relaxed text-muted-foreground">
660
+ {question.recommendedAnswer || question.recommendedOptionIds!.map((id) =>
661
+ question.options?.find((o) => o.id === id)?.label ?? id
662
+ ).join(', ')}
663
+ </p>
664
+ </div>
665
+ {canUseRecommendation && (
666
+ <button
667
+ type="button"
668
+ onClick={applyRecommendation}
669
+ className="mt-0.5 shrink-0 rounded-md bg-primary/15 px-2.5 py-1 text-[12px] font-medium text-primary hover:bg-primary/25"
670
+ >
671
+ Use
672
+ </button>
673
+ )}
674
+ </div>
675
+ )}
676
+
677
+ {supportsOptions && options.length > 0 && (
678
+ <div className="mb-2.5 space-y-0.5">
679
+ {options.map((option, optionIndex) => {
680
+ const selected = answer.selectedOptionIds.includes(option.id);
681
+ return (
682
+ <button
683
+ key={option.id}
684
+ type="button"
685
+ onClick={() => toggleOption(option.id)}
686
+ className={cx(
687
+ 'goal-answer-focus flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left transition-colors',
688
+ selected
689
+ ? 'bg-primary/10 text-foreground'
690
+ : 'text-foreground/85 hover:bg-muted/30'
691
+ )}
692
+ >
693
+ <span
694
+ className={cx(
695
+ 'flex h-3.5 w-3.5 flex-none items-center justify-center transition-colors',
696
+ isMulti ? 'rounded-sm' : 'rounded-full',
697
+ selected
698
+ ? 'bg-primary text-primary-foreground'
699
+ : 'border-[1.5px] border-muted-foreground/35'
700
+ )}
701
+ >
702
+ {selected && <Check className="h-2 w-2" strokeWidth={3} />}
703
+ </span>
704
+ <span className="min-w-0 flex-1 text-[13px] leading-snug">
705
+ <span className="font-medium">{option.label}</span>
706
+ {option.description && (
707
+ <span className="text-muted-foreground"> — {option.description}</span>
708
+ )}
709
+ </span>
710
+ <kbd className="goal-shortcut-pill ml-1 flex h-4 w-4 shrink-0 items-center justify-center rounded border border-border/40 bg-muted/40 font-mono text-[10px] text-muted-foreground">{optionIndex + 1}</kbd>
711
+ </button>
712
+ );
713
+ })}
714
+ {showCustomOption && (
715
+ <label
716
+ className={cx(
717
+ 'flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 transition-colors',
718
+ answer.customAnswer.trim()
719
+ ? 'bg-primary/10'
720
+ : 'hover:bg-muted/30'
721
+ )}
722
+ >
723
+ <span
724
+ className={cx(
725
+ 'flex h-3.5 w-3.5 flex-none items-center justify-center transition-colors',
726
+ isMulti ? 'rounded-sm' : 'rounded-full',
727
+ answer.customAnswer.trim()
728
+ ? 'bg-primary text-primary-foreground'
729
+ : 'border-[1.5px] border-muted-foreground/35'
730
+ )}
731
+ >
732
+ {answer.customAnswer.trim() ? <Check className="h-2 w-2" strokeWidth={3} /> : <Plus className="h-2 w-2" strokeWidth={3} />}
733
+ </span>
734
+ <input
735
+ value={answer.customAnswer}
736
+ onChange={(event) => updateCustomOption(event.target.value)}
737
+ placeholder="Other…"
738
+ className="goal-answer-focus goal-custom-input min-w-0 flex-1 bg-transparent text-[13px] leading-snug text-foreground outline-none placeholder:text-muted-foreground/50"
739
+ />
740
+ <kbd className="goal-shortcut-pill ml-1 flex h-4 w-4 shrink-0 items-center justify-center rounded border border-border/40 bg-muted/40 font-mono text-[10px] text-muted-foreground">/</kbd>
741
+ </label>
742
+ )}
743
+ </div>
744
+ )}
745
+
746
+ {showTextArea && (
747
+ <Textarea
748
+ ref={textareaRef}
749
+ value={textValue}
750
+ onChange={(event) => updateTextValue(event.target.value)}
751
+ placeholder={question.recommendedAnswer || 'Type your answer'}
752
+ className="goal-answer-focus"
753
+ />
754
+ )}
755
+
756
+ <div className="mt-3">
757
+ {noteOpen ? (
758
+ <div className="rounded-md bg-muted/25 p-2">
759
+ <div className="mb-1 flex items-center justify-between gap-2">
760
+ <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Note</span>
761
+ <button
762
+ type="button"
763
+ onClick={() => {
764
+ onChange({ note: '' });
765
+ onNoteOpenChange(false);
766
+ }}
767
+ className="inline-flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
768
+ title="Remove note"
769
+ aria-label="Remove note"
770
+ >
771
+ <X className="h-3.5 w-3.5" />
772
+ </button>
773
+ </div>
774
+ <Textarea
775
+ value={answer.note || ''}
776
+ onChange={(event) => onChange({ note: event.target.value })}
777
+ placeholder="Add context, constraints, or questions for the agent"
778
+ className="goal-note-textarea min-h-16 border-0 bg-transparent focus:ring-0"
779
+ />
780
+ <div className="mt-2 flex justify-end">
781
+ <Button type="button" variant="ghost" size="sm" onClick={onSkip}>
782
+ {answer.note?.trim() ? 'Skip with note' : 'Skip'}
783
+ <ChevronDown className="h-3 w-3 -rotate-90" />
784
+ </Button>
785
+ </div>
786
+ </div>
787
+ ) : (
788
+ <div className="flex items-center gap-1">
789
+ <Button type="button" variant="ghost" size="sm" onClick={() => onNoteOpenChange(true)}>
790
+ <Plus className="h-3.5 w-3.5" />
791
+ Add note
792
+ </Button>
793
+ <Button type="button" variant="ghost" size="sm" onClick={onSkip}>
794
+ Skip
795
+ <ChevronDown className="h-3 w-3 -rotate-90" />
796
+ </Button>
797
+ </div>
798
+ )}
799
+ </div>
800
+ </div>
801
+ );
802
+ };
803
+
804
+ const FACTS_HELP_ITEMS = [
805
+ { icon: Check, label: 'Accept', color: 'text-success', description: 'Mark a fact as accepted. Accepted facts become part of the final fact sheet.' },
806
+ { icon: Edit3, label: 'Edit', color: 'text-primary', description: 'Edit the fact text before accepting. Click again to finish editing.' },
807
+ { icon: MessageSquare, label: 'Comment', color: 'text-primary', description: 'Add a note or context to a fact. The agent sees your comments alongside the fact.' },
808
+ { icon: TestTube2, label: 'Auto-verify', color: 'text-primary', description: 'Flag this fact for automated verification. The agent will write concrete test checks for flagged facts in the plan.' },
809
+ { icon: Trash2, label: 'Remove', color: 'text-destructive', description: 'Remove a fact entirely. It won\'t appear in the final fact sheet or plan.' },
810
+ ];
811
+
812
+ const FactsSurface = React.forwardRef<GoalSetupSurfaceHandle, {
813
+ bundle: GoalSetupFactsBundle;
814
+ onSubmitted?: () => void;
815
+ onActionStateChange?: (state: GoalSetupActionState) => void;
816
+ }>(({ bundle, onSubmitted, onActionStateChange }, ref) => {
817
+ // Product choice: facts stay visible after acceptance so later review passes keep context.
818
+ // `showAccepted` is legacy model state and does not hide rows in this surface.
819
+ const [facts, setFacts] = useState<GoalSetupFactResult[]>(() =>
820
+ bundle.facts.map((fact) => ({
821
+ id: fact.id,
822
+ text: fact.text,
823
+ accepted: fact.accepted,
824
+ removed: fact.removed,
825
+ comment: fact.comment,
826
+ automatedVerification: fact.automatedVerification,
827
+ recommendedAutomatedVerification: fact.recommendedAutomatedVerification,
828
+ }))
829
+ );
830
+ const [editingId, setEditingId] = useState<string | null>(null);
831
+ const [commentingId, setCommentingId] = useState<string | null>(null);
832
+ const [showHelp, setShowHelp] = useState(false);
833
+ const [submitState, setSubmitState] = useState<SubmitState>('idle');
834
+ const [error, setError] = useState('');
835
+ const { copied, copy } = useGoalSetupCopy(setError);
836
+ const commentButtons = useRef(new Map<string, HTMLButtonElement>());
837
+ const commentDrafts = useRef(new Map<string, string>());
838
+
839
+ const liveFacts = facts.filter((fact) => !fact.removed);
840
+ const acceptedCount = liveFacts.filter((fact) => fact.accepted).length;
841
+ const commentingFact = commentingId
842
+ ? facts.find((fact) => fact.id === commentingId)
843
+ : undefined;
844
+ const commentingAnchor = commentingId
845
+ ? commentButtons.current.get(commentingId)
846
+ : undefined;
847
+
848
+ const updateFact = useCallback((id: string, patch: Partial<GoalSetupFactResult>) => {
849
+ setFacts((current) =>
850
+ current.map((fact) => (fact.id === id ? { ...fact, ...patch } : fact))
851
+ );
852
+ }, []);
853
+
854
+ const withOpenCommentDraft = useCallback((sourceFacts: GoalSetupFactResult[]) => {
855
+ if (!commentingId) return sourceFacts;
856
+ const draft = commentDrafts.current.get(commentingId);
857
+ if (draft === undefined) return sourceFacts;
858
+ return sourceFacts.map((fact) =>
859
+ fact.id === commentingId ? { ...fact, comment: draft.trim() ? draft : '' } : fact
860
+ );
861
+ }, [commentingId]);
862
+
863
+ const submitFacts = useCallback(async (nextFacts = facts) => {
864
+ if (submitState === 'submitting') return;
865
+ const factsToSubmit = withOpenCommentDraft(nextFacts);
866
+ const blankFact = factsToSubmit.find((fact) => !fact.removed && !fact.text.trim());
867
+ if (blankFact) {
868
+ setError('Fact text cannot be empty. Edit the blank fact or remove it before submitting.');
869
+ setEditingId(blankFact.id);
870
+ return;
871
+ }
872
+ setError('');
873
+ setSubmitState('submitting');
874
+ try {
875
+ await submitGoalSetup({
876
+ stage: 'facts',
877
+ title: bundle.title,
878
+ goalSlug: bundle.goalSlug,
879
+ facts: factsToSubmit,
880
+ });
881
+ setSubmitState('submitted');
882
+ onSubmitted?.();
883
+ } catch (err) {
884
+ setError(err instanceof Error ? err.message : 'Submission failed');
885
+ setSubmitState('error');
886
+ }
887
+ }, [bundle.goalSlug, bundle.title, facts, onSubmitted, submitState, withOpenCommentDraft]);
888
+
889
+ const handleSubmit = useCallback(() => {
890
+ submitFacts();
891
+ }, [submitFacts]);
892
+
893
+ const acceptAllFacts = useCallback(() => {
894
+ setFacts((current) =>
895
+ current.map((fact) => (fact.removed ? fact : { ...fact, accepted: true }))
896
+ );
897
+ }, []);
898
+
899
+ const copyFactsJson = useCallback(() => {
900
+ copy('facts-json', JSON.stringify(buildFactsCopyPayload(bundle, withOpenCommentDraft(facts)), null, 2));
901
+ }, [bundle, copy, facts, withOpenCommentDraft]);
902
+
903
+ const copyFactsMarkdown = useCallback(() => {
904
+ copy('facts-markdown', formatFactsMarkdown(bundle, withOpenCommentDraft(facts)));
905
+ }, [bundle, copy, facts, withOpenCommentDraft]);
906
+
907
+ React.useImperativeHandle(ref, () => ({ submit: handleSubmit }), [handleSubmit]);
908
+
909
+ useEffect(() => {
910
+ onActionStateChange?.({
911
+ canSubmit: true,
912
+ isSubmitting: submitState === 'submitting',
913
+ submitted: submitState === 'submitted',
914
+ submitLabel: 'Submit Facts',
915
+ });
916
+ }, [onActionStateChange, submitState]);
917
+
918
+ return (
919
+ <section className="w-full">
920
+ <div className="goal-shell">
921
+ <header className="mb-3 flex flex-wrap items-start justify-between gap-3">
922
+ <div className="min-w-0">
923
+ <div className="text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
924
+ Fact review
925
+ </div>
926
+ <div className="mt-0.5 flex items-baseline gap-2">
927
+ <h1 className="truncate text-lg font-medium leading-tight text-foreground">
928
+ {bundle.title || 'Review the facts'}
929
+ </h1>
930
+ <button
931
+ type="button"
932
+ onClick={() => setShowHelp(true)}
933
+ className="shrink-0 text-[11px] text-muted-foreground hover:text-foreground"
934
+ >
935
+ help
936
+ </button>
937
+ </div>
938
+ </div>
939
+ <div className="flex shrink-0 items-center gap-2">
940
+ <div className="flex items-center gap-1">
941
+ <GoalCopyButton
942
+ onClick={copyFactsJson}
943
+ copied={copied === 'facts-json'}
944
+ format="JSON"
945
+ title="Copy all facts as raw JSON"
946
+ />
947
+ <GoalCopyButton
948
+ onClick={copyFactsMarkdown}
949
+ copied={copied === 'facts-markdown'}
950
+ format="Markdown"
951
+ title="Copy all facts as markdown"
952
+ />
953
+ <GoalHeaderButton
954
+ onClick={acceptAllFacts}
955
+ disabled={liveFacts.length === 0 || submitState === 'submitting'}
956
+ title="Accept every visible fact"
957
+ className="gap-1.5 text-success hover:bg-success/10 hover:text-success"
958
+ >
959
+ <Check className="h-3.5 w-3.5" />
960
+ Accept all
961
+ </GoalHeaderButton>
962
+ </div>
963
+ <div className="font-mono text-[11px] text-muted-foreground">
964
+ {acceptedCount}/{liveFacts.length} accepted
965
+ </div>
966
+ </div>
967
+ </header>
968
+
969
+ <ConfirmDialog
970
+ isOpen={showHelp}
971
+ onClose={() => setShowHelp(false)}
972
+ title="Fact actions"
973
+ variant="info"
974
+ wide
975
+ confirmText="Got it"
976
+ message={
977
+ <div className="space-y-3">
978
+ {FACTS_HELP_ITEMS.map((item) => (
979
+ <div key={item.label} className="flex items-start gap-2.5">
980
+ <item.icon className={cx('mt-0.5 h-4 w-4 shrink-0', item.color)} />
981
+ <div>
982
+ <div className="text-sm font-medium text-foreground">{item.label}</div>
983
+ <div className="text-xs text-muted-foreground">{item.description}</div>
984
+ </div>
985
+ </div>
986
+ ))}
987
+ </div>
988
+ }
989
+ />
990
+
991
+ {error && (
992
+ <div className="mb-3 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
993
+ {error}
994
+ </div>
995
+ )}
996
+
997
+ {liveFacts.map((fact) => {
998
+ const editing = editingId === fact.id;
999
+ return (
1000
+ <div
1001
+ key={fact.id}
1002
+ className={cx(
1003
+ 'goal-row flex items-center gap-2.5 px-3 py-2.5',
1004
+ fact.accepted && 'answered'
1005
+ )}
1006
+ >
1007
+ <button
1008
+ type="button"
1009
+ onClick={() => updateFact(fact.id, { accepted: !fact.accepted })}
1010
+ title={fact.accepted ? 'Unaccept' : 'Accept'}
1011
+ className="shrink-0"
1012
+ >
1013
+ <StatusDot complete={fact.accepted} />
1014
+ </button>
1015
+
1016
+ <div className="min-w-0 flex-1">
1017
+ {editing ? (
1018
+ <Textarea
1019
+ value={fact.text}
1020
+ onChange={(event) => updateFact(fact.id, { text: event.target.value })}
1021
+ className="min-h-12"
1022
+ />
1023
+ ) : (
1024
+ <p className="text-[13.5px] leading-snug text-foreground">{fact.text}</p>
1025
+ )}
1026
+ {fact.comment && (
1027
+ <p className="mt-1.5 text-[12px] leading-snug text-muted-foreground">
1028
+ {fact.comment}
1029
+ </p>
1030
+ )}
1031
+ </div>
1032
+
1033
+ <div className="flex shrink-0 items-center gap-1">
1034
+ <button
1035
+ type="button"
1036
+ onClick={() => updateFact(fact.id, { accepted: !fact.accepted })}
1037
+ title={fact.accepted ? 'Unaccept' : 'Accept'}
1038
+ className={cx(
1039
+ 'inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors',
1040
+ fact.accepted ? 'bg-success/15 text-success' : 'text-muted-foreground hover:bg-success/10 hover:text-success'
1041
+ )}
1042
+ >
1043
+ <Check className="h-3.5 w-3.5" />
1044
+ </button>
1045
+ <button
1046
+ type="button"
1047
+ onClick={() => setEditingId(editing ? null : fact.id)}
1048
+ title={editing ? 'Done editing' : 'Edit'}
1049
+ className={cx(
1050
+ 'inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors',
1051
+ editing ? 'bg-primary/10 text-primary' : 'hover:bg-muted hover:text-foreground'
1052
+ )}
1053
+ >
1054
+ <Edit3 className="h-3.5 w-3.5" />
1055
+ </button>
1056
+ <button
1057
+ type="button"
1058
+ ref={(node) => {
1059
+ if (node) commentButtons.current.set(fact.id, node);
1060
+ else commentButtons.current.delete(fact.id);
1061
+ }}
1062
+ onClick={() => setCommentingId(fact.id)}
1063
+ title="Comment"
1064
+ className={cx(
1065
+ 'inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors',
1066
+ fact.comment ? 'bg-primary/10 text-primary' : 'hover:bg-muted hover:text-foreground'
1067
+ )}
1068
+ >
1069
+ <MessageSquare className="h-3.5 w-3.5" />
1070
+ </button>
1071
+ <button
1072
+ type="button"
1073
+ onClick={() => updateFact(fact.id, { automatedVerification: !fact.automatedVerification })}
1074
+ title={fact.automatedVerification ? 'Disable auto-verify' : 'Enable auto-verify'}
1075
+ className={cx(
1076
+ 'inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors',
1077
+ fact.automatedVerification ? 'bg-primary/10 text-primary' : 'hover:bg-muted hover:text-foreground'
1078
+ )}
1079
+ >
1080
+ <TestTube2 className="h-3.5 w-3.5" />
1081
+ </button>
1082
+ <button
1083
+ type="button"
1084
+ onClick={() => updateFact(fact.id, { removed: true, accepted: false })}
1085
+ title="Remove"
1086
+ className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
1087
+ >
1088
+ <Trash2 className="h-3.5 w-3.5" />
1089
+ </button>
1090
+ </div>
1091
+ </div>
1092
+ );
1093
+ })}
1094
+
1095
+ {liveFacts.length === 0 && (
1096
+ <div className="goal-row px-4 py-8 text-center text-sm text-muted-foreground">
1097
+ All facts have been removed.
1098
+ </div>
1099
+ )}
1100
+ </div>
1101
+
1102
+ {commentingFact && (
1103
+ <CommentPopover
1104
+ key={commentingFact.id}
1105
+ anchorEl={commentingAnchor}
1106
+ contextText={commentingFact.text}
1107
+ isGlobal={false}
1108
+ initialText={commentingFact.comment || ''}
1109
+ draftKey={goalFactCommentDraftKey(bundle, commentingFact.id)}
1110
+ allowImages={false}
1111
+ allowEmptySubmit
1112
+ onDraftChange={(text) => {
1113
+ commentDrafts.current.set(commentingFact.id, text);
1114
+ }}
1115
+ onSubmit={(text) => {
1116
+ commentDrafts.current.delete(commentingFact.id);
1117
+ updateFact(commentingFact.id, { comment: text.trim() ? text : '' });
1118
+ setCommentingId(null);
1119
+ }}
1120
+ onClose={() => {
1121
+ commentDrafts.current.delete(commentingFact.id);
1122
+ setCommentingId(null);
1123
+ }}
1124
+ />
1125
+ )}
1126
+ </section>
1127
+ );
1128
+ });
1129
+ FactsSurface.displayName = 'FactsSurface';
1130
+
1131
+ const GoalHeaderButton: React.FC<React.ButtonHTMLAttributes<HTMLButtonElement>> = ({
1132
+ className,
1133
+ type = 'button',
1134
+ ...props
1135
+ }) => (
1136
+ <button
1137
+ type={type}
1138
+ className={cx(
1139
+ 'inline-flex h-6 items-center rounded-md px-1.5 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-40',
1140
+ className
1141
+ )}
1142
+ {...props}
1143
+ />
1144
+ );
1145
+
1146
+ const GoalCopyButton: React.FC<{
1147
+ copied: boolean;
1148
+ format: 'JSON' | 'Markdown';
1149
+ } & React.ButtonHTMLAttributes<HTMLButtonElement>> = ({
1150
+ copied,
1151
+ format,
1152
+ className,
1153
+ title,
1154
+ ...props
1155
+ }) => (
1156
+ <GoalHeaderButton
1157
+ className={cx(
1158
+ 'gap-1.5 bg-muted/50 px-2.5',
1159
+ copied && 'bg-success/10 text-success hover:bg-success/10 hover:text-success',
1160
+ className
1161
+ )}
1162
+ title={copied ? `Copied ${format}` : title}
1163
+ aria-label={`Copy ${format}`}
1164
+ {...props}
1165
+ >
1166
+ {copied ? (
1167
+ <Check className="h-3.5 w-3.5" aria-hidden="true" />
1168
+ ) : (
1169
+ <Copy className="h-3.5 w-3.5" aria-hidden="true" />
1170
+ )}
1171
+ <span>Copy {format}</span>
1172
+ </GoalHeaderButton>
1173
+ );
1174
+
1175
+ const GoalShortcutPill: React.FC = () => (
1176
+ <div
1177
+ role="status"
1178
+ aria-label="Keyboard shortcuts"
1179
+ className="goal-shortcut-pill pointer-events-none fixed bottom-4 left-1/2 z-30 flex -translate-x-1/2 items-center gap-2 rounded-lg border border-border/50 bg-popover/85 px-3 py-1.5 text-[10px] text-muted-foreground shadow-xl backdrop-blur-md"
1180
+ >
1181
+ <kbd>tab</kbd>
1182
+ <span>next</span>
1183
+ <span className="text-muted-foreground/40">·</span>
1184
+ <span className="flex"><kbd>ctrl</kbd><kbd>u</kbd></span>
1185
+ <span>use rec</span>
1186
+ <span className="text-muted-foreground/40">·</span>
1187
+ <span className="flex"><kbd>ctrl</kbd><kbd>k</kbd></span>
1188
+ <span>skip</span>
1189
+ <span className="text-muted-foreground/40">·</span>
1190
+ <span className="flex"><kbd>ctrl</kbd><kbd>j</kbd></span>
1191
+ <span>note</span>
1192
+ <span className="text-muted-foreground/40">·</span>
1193
+ <span className="flex"><kbd>cmd</kbd><kbd>enter</kbd></span>
1194
+ <span>submit</span>
1195
+ </div>
1196
+ );
1197
+
1198
+
1199
+ const RecommendedBadge: React.FC<{ label: string }> = ({ label }) => (
1200
+ <span className="inline-flex self-start items-center rounded-full border border-primary/25 bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary">
1201
+ {label}
1202
+ </span>
1203
+ );
1204
+
1205
+ const StatusDot: React.FC<{ complete: boolean; skipped?: boolean }> = ({ complete, skipped }) => (
1206
+ <span
1207
+ aria-hidden="true"
1208
+ className={cx(
1209
+ 'flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-full transition-colors',
1210
+ complete
1211
+ ? 'bg-success text-success-foreground'
1212
+ : skipped
1213
+ ? 'bg-warning text-warning-foreground'
1214
+ : 'border-[1.5px] border-muted-foreground/30 bg-transparent'
1215
+ )}
1216
+ >
1217
+ {(complete || skipped) && <Check className="h-2.5 w-2.5" strokeWidth={3} />}
1218
+ </span>
1219
+ );
1220
+
1221
+
1222
+ function hasAnswer(question: GoalSetupQuestion, answer: GoalSetupQuestionAnswer): boolean {
1223
+ const text = buildAnswerText(question, answer);
1224
+ return text.trim().length > 0;
1225
+ }
1226
+
1227
+ function buildAnswerText(question: GoalSetupQuestion, answer: GoalSetupQuestionAnswer): string {
1228
+ const selectedLabels =
1229
+ question.options
1230
+ ?.filter((option) => answer.selectedOptionIds.includes(option.id))
1231
+ .map((option) => option.label) || [];
1232
+ const freeText =
1233
+ (question.answerMode || 'text') === 'text'
1234
+ ? answer.answer.trim()
1235
+ : answer.customAnswer.trim();
1236
+ return [...selectedLabels, freeText].filter(Boolean).join('\n');
1237
+ }
1238
+
1239
+ function buildInterviewCopyPayload(
1240
+ bundle: GoalSetupInterviewBundle,
1241
+ answers: GoalSetupQuestionAnswer[]
1242
+ ) {
1243
+ return {
1244
+ stage: 'interview',
1245
+ title: bundle.title,
1246
+ goalSlug: bundle.goalSlug,
1247
+ questions: bundle.questions,
1248
+ answers,
1249
+ };
1250
+ }
1251
+
1252
+ function buildFactsCopyPayload(
1253
+ bundle: GoalSetupFactsBundle,
1254
+ facts: GoalSetupFactResult[]
1255
+ ) {
1256
+ return {
1257
+ stage: 'facts',
1258
+ title: bundle.title,
1259
+ goalSlug: bundle.goalSlug,
1260
+ facts,
1261
+ };
1262
+ }
1263
+
1264
+ function formatInterviewMarkdown(
1265
+ bundle: GoalSetupInterviewBundle,
1266
+ answers: GoalSetupQuestionAnswer[]
1267
+ ): string {
1268
+ const answerById = new Map(answers.map((answer) => [answer.questionId, answer]));
1269
+ const lines = [`# ${bundle.title || 'Goal interview'}`, '', '## Questions'];
1270
+
1271
+ bundle.questions.forEach((question, index) => {
1272
+ const answer = answerById.get(question.id);
1273
+ const answerText = answer?.answer?.trim() || '';
1274
+ const note = answer?.note?.trim() || '';
1275
+ const status = answer?.skipped ? 'skipped' : answer?.completed ? 'answered' : 'unanswered';
1276
+
1277
+ lines.push('', `${index + 1}. ${question.prompt}`);
1278
+ lines.push(` - Status: ${status}`);
1279
+
1280
+ if (question.description) {
1281
+ lines.push(` - Context: ${question.description}`);
1282
+ }
1283
+
1284
+ if (question.recommendedAnswer) {
1285
+ lines.push(' - Recommended answer:');
1286
+ lines.push(...indentMarkdownBlock(question.recommendedAnswer, ' '));
1287
+ }
1288
+
1289
+ if (question.options?.length) {
1290
+ lines.push(' - Options:');
1291
+ for (const option of question.options) {
1292
+ const selected = answer?.selectedOptionIds.includes(option.id) ?? false;
1293
+ const recommended = question.recommendedOptionIds?.includes(option.id) ?? false;
1294
+ const description = option.description ? ` - ${option.description}` : '';
1295
+ lines.push(
1296
+ ` - [${selected ? 'x' : ' '}] ${option.label}${recommended ? ' (recommended)' : ''}${description}`
1297
+ );
1298
+ }
1299
+ }
1300
+
1301
+ lines.push(' - Answer:');
1302
+ lines.push(...indentMarkdownBlock(answerText || '_No answer yet_', ' '));
1303
+
1304
+ if (note) {
1305
+ lines.push(' - Note:');
1306
+ lines.push(...indentMarkdownBlock(note, ' '));
1307
+ }
1308
+ });
1309
+
1310
+ return `${lines.join('\n').trimEnd()}\n`;
1311
+ }
1312
+
1313
+ function formatFactsMarkdown(
1314
+ bundle: GoalSetupFactsBundle,
1315
+ facts: GoalSetupFactResult[]
1316
+ ): string {
1317
+ const lines = [`# ${bundle.title || 'Facts'}`, '', '## Facts'];
1318
+
1319
+ if (facts.length === 0) {
1320
+ lines.push('', 'No facts.');
1321
+ return `${lines.join('\n').trimEnd()}\n`;
1322
+ }
1323
+
1324
+ for (const fact of facts) {
1325
+ const status = fact.removed ? 'removed' : fact.accepted ? 'accepted' : 'pending';
1326
+ const checkbox = fact.accepted && !fact.removed ? 'x' : ' ';
1327
+ const factText = fact.removed ? `~~${fact.text}~~` : fact.text;
1328
+ const verification = `${fact.automatedVerification ? 'yes' : 'no'}${
1329
+ fact.recommendedAutomatedVerification ? ' (recommended)' : ''
1330
+ }`;
1331
+
1332
+ lines.push('', `- [${checkbox}] ${factText}`);
1333
+ lines.push(` - Status: ${status}`);
1334
+ lines.push(` - Automated verification: ${verification}`);
1335
+
1336
+ if (fact.comment?.trim()) {
1337
+ lines.push(' - Comment:');
1338
+ lines.push(...indentMarkdownBlock(fact.comment, ' '));
1339
+ }
1340
+ }
1341
+
1342
+ return `${lines.join('\n').trimEnd()}\n`;
1343
+ }
1344
+
1345
+ function indentMarkdownBlock(text: string, prefix: string): string[] {
1346
+ const trimmed = text.trim();
1347
+ if (!trimmed) return [`${prefix}_None_`];
1348
+ return trimmed.split(/\r?\n/).map((line) => `${prefix}${line}`);
1349
+ }
1350
+
1351
+ function goalFactCommentDraftKey(bundle: GoalSetupFactsBundle, factId: string): string {
1352
+ const scope = bundle.goalSlug || bundle.title || 'untitled-goal';
1353
+ return `goal-fact-${scope}-${factId}`;
1354
+ }