@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.
- package/README.md +60 -0
- package/assets/diff-options.png +0 -0
- package/assets/icon-codex.png +0 -0
- package/assets/look-flat.png +0 -0
- package/assets/look-grid.png +0 -0
- package/assets/review-sections.png +0 -0
- package/assets/review-tree.png +0 -0
- package/assets/workspaces.webp +0 -0
- package/components/AISettingsTab.tsx +145 -0
- package/components/ActionMenu.tsx +101 -0
- package/components/AgentControls.tsx +216 -0
- package/components/AgentsTab.tsx +1294 -0
- package/components/AnnotationPanel.tsx +731 -0
- package/components/AnnotationSidebar.tsx +86 -0
- package/components/AnnotationToolbar.tsx +323 -0
- package/components/AnnotationToolstrip.tsx +355 -0
- package/components/ApproveDropdown.tsx +170 -0
- package/components/AttachmentsButton.tsx +410 -0
- package/components/BlockRenderer.tsx +163 -0
- package/components/BorderTrail.tsx +38 -0
- package/components/CodeFilePicker.tsx +64 -0
- package/components/CodeFilePopout.tsx +602 -0
- package/components/CodePathValidationContext.tsx +14 -0
- package/components/CommentPopover.tsx +512 -0
- package/components/CompletionOverlay.tsx +99 -0
- package/components/ConfirmDialog.tsx +128 -0
- package/components/DocBadges.tsx +231 -0
- package/components/EditorAnnotationCard.tsx +65 -0
- package/components/ExportModal.tsx +565 -0
- package/components/FloatingQuickLabelPicker.tsx +144 -0
- package/components/GitHubIcon.tsx +16 -0
- package/components/GitLabIcon.tsx +30 -0
- package/components/GraphvizBlock.tsx +511 -0
- package/components/ImageAnnotator/Canvas.tsx +118 -0
- package/components/ImageAnnotator/Toolbar.tsx +218 -0
- package/components/ImageAnnotator/index.tsx +272 -0
- package/components/ImageAnnotator/types.ts +39 -0
- package/components/ImageAnnotator/utils.ts +147 -0
- package/components/ImageThumbnail.tsx +126 -0
- package/components/ImportModal.tsx +146 -0
- package/components/InlineMarkdown.tsx +1094 -0
- package/components/KeyboardShortcuts.tsx +208 -0
- package/components/Landing.tsx +496 -0
- package/components/ListItemBody.tsx +62 -0
- package/components/ListMarker.tsx +73 -0
- package/components/LookAndFeelAnnouncementDialog.tsx +230 -0
- package/components/MarkdownEditor.tsx +44 -0
- package/components/MenuVersionSection.tsx +91 -0
- package/components/MermaidBlock.tsx +579 -0
- package/components/ModeToggle.tsx +68 -0
- package/components/OpenInAppButton.tsx +299 -0
- package/components/OverlayScrollArea.tsx +82 -0
- package/components/PermissionModeSetup.tsx +92 -0
- package/components/PinpointOverlay.tsx +99 -0
- package/components/PlanAIAnnouncementDialog.tsx +187 -0
- package/components/PlanHeaderMenu.tsx +299 -0
- package/components/PopoutDialog.tsx +91 -0
- package/components/Popover.tsx +27 -0
- package/components/ProviderIcons.tsx +51 -0
- package/components/PullRequestIcon.tsx +12 -0
- package/components/QuickLabelDropdown.tsx +63 -0
- package/components/RenderedMarkdown.tsx +54 -0
- package/components/RepoIcon.tsx +12 -0
- package/components/ResizeHandle.tsx +86 -0
- package/components/ReviewAgentsIcon.tsx +18 -0
- package/components/SearchableSelect.tsx +157 -0
- package/components/Settings.tsx +2220 -0
- package/components/SparklesIcon.tsx +45 -0
- package/components/StickyHeaderLane.tsx +260 -0
- package/components/TableOfContents.tsx +157 -0
- package/components/TaterSpritePullup.tsx +34 -0
- package/components/TaterSpriteRunning.tsx +54 -0
- package/components/TaterSpriteSitting.tsx +37 -0
- package/components/TextShimmer.tsx +57 -0
- package/components/ThemeProvider.tsx +162 -0
- package/components/ThemeTab.tsx +139 -0
- package/components/ToolbarButtons.tsx +116 -0
- package/components/Tooltip.tsx +45 -0
- package/components/Viewer.tsx +977 -0
- package/components/ai/AIProviderBar.tsx +95 -0
- package/components/ai/DocumentAIChatPanel.tsx +313 -0
- package/components/blocks/AlertBlock.tsx +58 -0
- package/components/blocks/Callout.tsx +60 -0
- package/components/blocks/CodeBlock.tsx +75 -0
- package/components/blocks/HtmlBlock.tsx +122 -0
- package/components/blocks/MathBlock.tsx +36 -0
- package/components/blocks/TableBlock.tsx +146 -0
- package/components/blocks/TablePopout.tsx +279 -0
- package/components/blocks/TableToolbar.tsx +153 -0
- package/components/blocks/proseBody.tsx +102 -0
- package/components/core/button.tsx +44 -0
- package/components/core/textarea.tsx +26 -0
- package/components/diagramLanguages.ts +14 -0
- package/components/goal-setup/GoalSetupSurface.tsx +1354 -0
- package/components/html-viewer/HtmlViewer.tsx +386 -0
- package/components/html-viewer/bridge-script.ts +505 -0
- package/components/html-viewer/index.ts +1 -0
- package/components/html-viewer/useHtmlAnnotation.ts +400 -0
- package/components/icons/AgentIcons.tsx +66 -0
- package/components/icons/AppIcon.tsx +56 -0
- package/components/icons/MessagesIcon.tsx +11 -0
- package/components/icons/ObsidianIcons.tsx +208 -0
- package/components/icons/app/android-studio.svg +369 -0
- package/components/icons/app/antigravity.svg +97 -0
- package/components/icons/app/cursor.svg +16 -0
- package/components/icons/app/file-explorer.svg +20 -0
- package/components/icons/app/finder.png +0 -0
- package/components/icons/app/ghostty.svg +13 -0
- package/components/icons/app/iterm2.svg +13 -0
- package/components/icons/app/powershell.svg +14 -0
- package/components/icons/app/sublime-text.svg +17 -0
- package/components/icons/app/terminal.png +0 -0
- package/components/icons/app/textmate.png +0 -0
- package/components/icons/app/vscode.svg +39 -0
- package/components/icons/app/warp.png +0 -0
- package/components/icons/app/xcode.png +0 -0
- package/components/icons/app/zed-dark.svg +15 -0
- package/components/icons/app/zed.svg +15 -0
- package/components/icons/themeIcons.tsx +47 -0
- package/components/mermaidSvg.ts +33 -0
- package/components/plan-diff/PlanCleanDiffView.tsx +884 -0
- package/components/plan-diff/PlanDiffBadge.tsx +48 -0
- package/components/plan-diff/PlanDiffModeSwitcher.tsx +103 -0
- package/components/plan-diff/PlanDiffViewer.tsx +214 -0
- package/components/plan-diff/PlanRawDiffView.tsx +102 -0
- package/components/plan-diff/VSCodeIcon.tsx +132 -0
- package/components/settings/HooksTab.tsx +208 -0
- package/components/sidebar/ArchiveBrowser.tsx +98 -0
- package/components/sidebar/CountBadge.tsx +12 -0
- package/components/sidebar/FileBrowser.tsx +498 -0
- package/components/sidebar/MessagesBrowser.tsx +109 -0
- package/components/sidebar/SidebarContainer.tsx +353 -0
- package/components/sidebar/SidebarTabs.tsx +155 -0
- package/components/sidebar/VersionBrowser.tsx +141 -0
- package/components/types.d.ts +7 -0
- package/components/ui/badge.tsx +44 -0
- package/components/ui/button.tsx +83 -0
- package/components/ui/card.tsx +58 -0
- package/components/ui/dialog.tsx +106 -0
- package/components/ui/dropdown-menu.tsx +240 -0
- package/components/ui/state-pill.tsx +45 -0
- package/components/ui/tabs.tsx +46 -0
- package/components/ui/textarea.tsx +25 -0
- package/config/configStore.ts +214 -0
- package/config/index.ts +4 -0
- package/config/reviewView.ts +42 -0
- package/config/settings.ts +310 -0
- package/config/useConfig.ts +20 -0
- package/configure.ts +69 -0
- package/globals.d.ts +14 -0
- package/hooks/pfm/useCodeFilePopout.ts +111 -0
- package/hooks/useAIChat.ts +532 -0
- package/hooks/useAIProviderConfig.ts +115 -0
- package/hooks/useActiveSection.ts +78 -0
- package/hooks/useAgentJobs.ts +306 -0
- package/hooks/useAgentSettings.ts +579 -0
- package/hooks/useAgents.ts +90 -0
- package/hooks/useAnnotationDraft.ts +523 -0
- package/hooks/useAnnotationHighlighter.ts +1170 -0
- package/hooks/useArchive.ts +171 -0
- package/hooks/useAutoClose.ts +104 -0
- package/hooks/useCodeAnnotationDraft.ts +194 -0
- package/hooks/useCodeFilePopout.ts +1 -0
- package/hooks/useDismissOnOutsideAndEscape.ts +50 -0
- package/hooks/useDraggable.ts +108 -0
- package/hooks/useEditorAnnotations.ts +64 -0
- package/hooks/useExternalAnnotationHighlights.ts +105 -0
- package/hooks/useExternalAnnotations.ts +268 -0
- package/hooks/useFileBrowser.ts +402 -0
- package/hooks/useInputMethodSwitch.ts +89 -0
- package/hooks/useIsMobile.ts +17 -0
- package/hooks/useLinkedDoc.ts +494 -0
- package/hooks/useOverlayViewport.ts +36 -0
- package/hooks/usePinpoint.ts +182 -0
- package/hooks/usePlanDiff.ts +176 -0
- package/hooks/usePrintMode.ts +27 -0
- package/hooks/useResizablePanel.ts +173 -0
- package/hooks/useScrollViewport.ts +38 -0
- package/hooks/useSharing.ts +441 -0
- package/hooks/useSidebar.ts +55 -0
- package/hooks/useUpdateCheck.ts +132 -0
- package/hooks/useValidatedCodePaths.ts +94 -0
- package/icons/GitUser.tsx +17 -0
- package/lib/utils.ts +11 -0
- package/package.json +117 -0
- package/plannotator.webp +0 -0
- package/print.css +427 -0
- package/shortcuts/code-review/ai.shortcuts.ts +25 -0
- package/shortcuts/code-review/allFilesDiff.shortcuts.ts +46 -0
- package/shortcuts/code-review/annotationToolbar.shortcuts.ts +30 -0
- package/shortcuts/code-review/fileTree.shortcuts.ts +35 -0
- package/shortcuts/code-review/prComments.shortcuts.ts +18 -0
- package/shortcuts/code-review/suggestionModal.shortcuts.ts +25 -0
- package/shortcuts/code-review/tourDialog.shortcuts.ts +18 -0
- package/shortcuts/core.ts +399 -0
- package/shortcuts/index.ts +21 -0
- package/shortcuts/plan-review/annotationPanel.shortcuts.ts +25 -0
- package/shortcuts/plan-review/annotationToolbar.shortcuts.ts +39 -0
- package/shortcuts/plan-review/commentPopover.shortcuts.ts +21 -0
- package/shortcuts/plan-review/goalSetup.shortcuts.ts +10 -0
- package/shortcuts/plan-review/imageAnnotator.shortcuts.ts +42 -0
- package/shortcuts/plan-review/inputMethod.shortcuts.ts +30 -0
- package/shortcuts/plan-review/sidebar.shortcuts.ts +34 -0
- package/shortcuts/plan-review/viewer.shortcuts.ts +24 -0
- package/shortcuts/runtime.ts +259 -0
- package/sprite_package_additional/index.html +34 -0
- package/sprite_package_additional/sprite.png +0 -0
- package/sprite_package_new/index.html +34 -0
- package/sprite_package_new/sprite.png +0 -0
- package/sprite_package_pulluphang/index.html +34 -0
- package/sprite_package_pulluphang/sprite.png +0 -0
- package/styles.css +1 -0
- package/theme.css +893 -0
- package/themes/adwaita.css +62 -0
- package/themes/andromeeda.css +66 -0
- package/themes/aurora-x.css +66 -0
- package/themes/ayu-dark.css +66 -0
- package/themes/caffeine.css +113 -0
- package/themes/catppuccin.css +62 -0
- package/themes/claude-plus.css +60 -0
- package/themes/cursor-hc.css +34 -0
- package/themes/cursor-midnight.css +34 -0
- package/themes/cursor.css +62 -0
- package/themes/dark-plus.css +66 -0
- package/themes/doom-64.css +109 -0
- package/themes/dracula.css +33 -0
- package/themes/everforest-hard.css +62 -0
- package/themes/everforest-soft.css +62 -0
- package/themes/everforest.css +62 -0
- package/themes/github.css +66 -0
- package/themes/gruvbox.css +62 -0
- package/themes/houston.css +66 -0
- package/themes/kanagawa-dragon.css +34 -0
- package/themes/kanagawa-lotus.css +34 -0
- package/themes/kanagawa-wave.css +34 -0
- package/themes/laserwave.css +66 -0
- package/themes/material.css +62 -0
- package/themes/min.css +66 -0
- package/themes/monokai-pro.css +34 -0
- package/themes/neutral.css +59 -0
- package/themes/night-owl.css +66 -0
- package/themes/nord.css +66 -0
- package/themes/one-dark-pro.css +66 -0
- package/themes/one-light.css +66 -0
- package/themes/paulmillr.css +34 -0
- package/themes/plannotator.css +60 -0
- package/themes/plastic.css +66 -0
- package/themes/poimandres.css +66 -0
- package/themes/quantum-rose.css +109 -0
- package/themes/red.css +66 -0
- package/themes/rose-pine.css +62 -0
- package/themes/simple.css +124 -0
- package/themes/slack.css +66 -0
- package/themes/snazzy-light.css +66 -0
- package/themes/soft-pop.css +60 -0
- package/themes/solar-dusk.css +109 -0
- package/themes/solarized.css +66 -0
- package/themes/synthwave-84.css +34 -0
- package/themes/terminal.css +62 -0
- package/themes/tinacious.css +57 -0
- package/themes/tokyo-night.css +62 -0
- package/themes/vesper.css +62 -0
- package/themes/vitesse-black.css +66 -0
- package/themes/vitesse.css +62 -0
- package/types.ts +264 -0
- package/utils/agentSwitch.ts +71 -0
- package/utils/aiChatFormat.ts +33 -0
- package/utils/aiPrompt.ts +29 -0
- package/utils/aiProvider.ts +217 -0
- package/utils/anchors.ts +11 -0
- package/utils/annotateAgentTerminal.ts +29 -0
- package/utils/annotationHelpers.ts +101 -0
- package/utils/bear.ts +59 -0
- package/utils/blockTargeting.ts +240 -0
- package/utils/callback.ts +99 -0
- package/utils/commentContent.ts +8 -0
- package/utils/defaultNotesApp.ts +20 -0
- package/utils/diffFonts.ts +33 -0
- package/utils/editorMode.ts +31 -0
- package/utils/fileBrowser.ts +40 -0
- package/utils/generateId.ts +7 -0
- package/utils/generateIdentity.ts +29 -0
- package/utils/identity.ts +112 -0
- package/utils/inlineTransforms.ts +41 -0
- package/utils/inputMethod.ts +17 -0
- package/utils/lookAndFeelAnnouncement.ts +18 -0
- package/utils/obsidian.ts +201 -0
- package/utils/octarine.ts +53 -0
- package/utils/parser.ts +991 -0
- package/utils/permissionMode.ts +77 -0
- package/utils/planAIAnnouncement.ts +17 -0
- package/utils/planAgentInstructions.ts +137 -0
- package/utils/planDiffEngine.ts +590 -0
- package/utils/planSave.ts +49 -0
- package/utils/platform.ts +11 -0
- package/utils/quickLabels.ts +77 -0
- package/utils/reviewAgentInstructions.ts +182 -0
- package/utils/sanitizeHtml.ts +45 -0
- package/utils/sharing.ts +350 -0
- package/utils/slugify.ts +37 -0
- package/utils/storage.ts +141 -0
- package/utils/themeRegistry.ts +567 -0
- package/utils/uiPreferences.ts +34 -0
- package/utils/upload.ts +56 -0
- 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
|
+
}
|