@plannotator/ui 0.50.0 → 0.51.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/HANDOFF.md +144 -11
- package/components/AISettingsTab.tsx +32 -0
- package/components/AgentToolAnnouncementDialog.tsx +347 -0
- package/components/AgentsTab.tsx +9 -9
- package/components/AskSessionAnnouncementDialog.tsx +329 -0
- package/components/CommentPopover.tsx +32 -18
- package/components/DecisionControl.tsx +2 -7
- package/components/DiagramBlock.tsx +46 -4
- package/components/DocBadges.tsx +12 -8
- package/components/PlanHeaderMenu.tsx +1 -1
- package/components/Settings.tsx +73 -1
- package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
- package/components/Viewer.tsx +4 -0
- package/components/ai/AIProviderBar.tsx +18 -1
- package/components/ai/DocumentAIChatPanel.tsx +4 -9
- package/components/ai/SessionAskNotice.tsx +33 -21
- package/components/blocks/QuestionBlock.tsx +24 -19
- package/components/blocks/QuestionContext.tsx +81 -0
- package/components/diagram/DiagramComposer.tsx +54 -3
- package/components/diagram/DiagramViewer.tsx +31 -2
- package/components/diagram/index.ts +1 -1
- package/components/html-viewer/HtmlViewer.tsx +13 -3
- package/components/html-viewer/bridge-script.asset.js +124 -8
- package/components/html-viewer/bridge-script.ts +124 -8
- package/components/html-viewer/useHtmlAnnotation.ts +36 -55
- package/hooks/useAIChat.ts +26 -5
- package/hooks/useAIProviderActivation.ts +13 -0
- package/hooks/useAIProviderConfig.ts +3 -3
- package/hooks/useActiveSection.ts +3 -2
- package/hooks/useAgentToolSetting.ts +33 -0
- package/hooks/useAnnotationDraft.ts +53 -7
- package/hooks/useAnnotationHighlighter.ts +101 -5
- package/hooks/useExternalAnnotations.ts +21 -1
- package/hooks/useFirstRunAnnouncementWindow.ts +114 -0
- package/hooks/useLatchedTrue.ts +15 -0
- package/hooks/useLinkedDoc.ts +64 -4
- package/hooks/useModelCatalogs.ts +9 -2
- package/package.json +2 -2
- package/styles.css +1 -1
- package/themes/gruvbox.css +6 -0
- package/types.ts +13 -0
- package/utils/agentClosed.ts +33 -0
- package/utils/agentToolAnnouncement.ts +109 -0
- package/utils/agentToolSetting.ts +108 -0
- package/utils/aiProvider.ts +13 -40
- package/utils/annotationOwnsHighlight.ts +27 -0
- package/utils/annotationScope.ts +15 -0
- package/utils/askSessionAnnouncement.ts +107 -0
- package/utils/blockTargeting.ts +61 -2
- package/utils/diagramAskAI.ts +161 -0
- package/utils/parser.ts +236 -110
- package/utils/questionAnswers.ts +3 -1
- package/utils/quickLabels.ts +2 -2
package/components/Settings.tsx
CHANGED
|
@@ -7,6 +7,12 @@ import type { TokenHoverDelay } from '@plannotator/core/token-hover';
|
|
|
7
7
|
import { configStore, useConfigValue, setReviewPanelView, setReviewDefaultDiffType, setReviewAutoViewed } from '../config';
|
|
8
8
|
import { setWebMcpToolsEnabled, useWebMcpToolsEnabled } from '../webmcp/preference';
|
|
9
9
|
import { DIAGRAM_SHADOW_OPTIONS } from '../utils/diagramShadow';
|
|
10
|
+
import {
|
|
11
|
+
agentToolAppliesWhen,
|
|
12
|
+
agentToolCostNote,
|
|
13
|
+
agentToolHostName,
|
|
14
|
+
type AgentToolSetting,
|
|
15
|
+
} from '../utils/agentToolSetting';
|
|
10
16
|
import { loadDiffFont } from '../utils/diffFonts';
|
|
11
17
|
import { TaterSpritePullup } from './TaterSpritePullup';
|
|
12
18
|
import { getIdentity, regenerateIdentity, setCustomIdentity, isIdentityEditable } from '../utils/identity';
|
|
@@ -106,6 +112,14 @@ interface SettingsProps {
|
|
|
106
112
|
* `env` is PLANNOTATOR_AUTO_UPDATE when it overrides the config file.
|
|
107
113
|
* Hosts that omit it get no toggle. */
|
|
108
114
|
autoUpdateSetting?: { env?: boolean };
|
|
115
|
+
/** The `plannotator` agent tool switch, as the server reports it
|
|
116
|
+
* (parseAgentToolSetting of serverConfig: present only when the session's
|
|
117
|
+
* host registers the tool). Shown only together with onAgentToolChange;
|
|
118
|
+
* hosts that omit them get no row. */
|
|
119
|
+
agentToolSetting?: AgentToolSetting;
|
|
120
|
+
/** Saves the agent tool switch (saveAgentToolSetting); rejects with a
|
|
121
|
+
* readable Error, which the row shows. */
|
|
122
|
+
onAgentToolChange?: (enabled: boolean) => Promise<void>;
|
|
109
123
|
/** Current session is a local git review where since-base ISN'T offered
|
|
110
124
|
* (base ref unresolvable) — the Git tab shows a note that the Git-status
|
|
111
125
|
* preference can't take effect in THIS repo. */
|
|
@@ -269,6 +283,53 @@ function ToggleSwitch({ checked, onChange, label, description, disabled = false
|
|
|
269
283
|
);
|
|
270
284
|
}
|
|
271
285
|
|
|
286
|
+
/**
|
|
287
|
+
* The `plannotator` agent tool switch. The value is the one the NEXT session
|
|
288
|
+
* uses (the tool list is part of the model's prompt, so a running session
|
|
289
|
+
* never changes it). Writes go straight to POST /api/config and wait for the
|
|
290
|
+
* answer, so a failed save is reported and the switch stays where it was.
|
|
291
|
+
*/
|
|
292
|
+
function AgentToolSettingRow({ setting, onChange }: {
|
|
293
|
+
readonly setting: AgentToolSetting;
|
|
294
|
+
readonly onChange: (enabled: boolean) => Promise<void>;
|
|
295
|
+
}) {
|
|
296
|
+
const [saving, setSaving] = useState(false);
|
|
297
|
+
const [error, setError] = useState<string | null>(null);
|
|
298
|
+
const locked = setting.env !== undefined;
|
|
299
|
+
const change = async (next: boolean) => {
|
|
300
|
+
if (saving || locked) return;
|
|
301
|
+
setSaving(true);
|
|
302
|
+
setError(null);
|
|
303
|
+
try {
|
|
304
|
+
await onChange(next);
|
|
305
|
+
} catch (e) {
|
|
306
|
+
setError(e instanceof Error && e.message ? e.message : 'Could not save the setting.');
|
|
307
|
+
} finally {
|
|
308
|
+
setSaving(false);
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
return (
|
|
312
|
+
<div data-agent-tool-setting={setting.host} aria-busy={saving}>
|
|
313
|
+
<ToggleSwitch
|
|
314
|
+
checked={setting.enabled}
|
|
315
|
+
onChange={(next) => void change(next)}
|
|
316
|
+
disabled={locked}
|
|
317
|
+
label="Let the agent open reviews (plannotator tool)"
|
|
318
|
+
description={
|
|
319
|
+
locked
|
|
320
|
+
? `Set by PLANNOTATOR_AGENT_TOOL in your environment, so it is ${setting.enabled ? 'on' : 'off'} here.`
|
|
321
|
+
: `${agentToolHostName(setting.host)} can open reviews itself and keep working. ${agentToolCostNote(setting.host)} Applies from ${agentToolAppliesWhen(setting.host)}.`
|
|
322
|
+
}
|
|
323
|
+
/>
|
|
324
|
+
{error && (
|
|
325
|
+
<p role="alert" data-agent-tool-setting-error className="mt-1 text-xs text-destructive">
|
|
326
|
+
{error}
|
|
327
|
+
</p>
|
|
328
|
+
)}
|
|
329
|
+
</div>
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
272
333
|
interface VimSettingsTabProps {
|
|
273
334
|
readonly vimModeEnabled: boolean;
|
|
274
335
|
readonly vimHudEnabled: boolean;
|
|
@@ -941,7 +1002,7 @@ const CommentsTab: React.FC = () => {
|
|
|
941
1002
|
);
|
|
942
1003
|
};
|
|
943
1004
|
|
|
944
|
-
export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', annotateParity = false, onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser, autoUpdateSetting, sinceBaseUnavailable, isCompactTouchLayout = false, onDetectObsidianVaults, agentTerminalAvailable = false, webmcpAvailable = false }) => {
|
|
1005
|
+
export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', annotateParity = false, onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser, autoUpdateSetting, agentToolSetting, onAgentToolChange, sinceBaseUnavailable, isCompactTouchLayout = false, onDetectObsidianVaults, agentTerminalAvailable = false, webmcpAvailable = false }) => {
|
|
945
1006
|
const webmcpTools = useWebMcpToolsEnabled();
|
|
946
1007
|
const autoUpdate = useConfigValue('autoUpdate');
|
|
947
1008
|
const [showDialog, setShowDialog] = useState(false);
|
|
@@ -1403,6 +1464,17 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
|
|
|
1403
1464
|
</>
|
|
1404
1465
|
)}
|
|
1405
1466
|
|
|
1467
|
+
{/* The `plannotator` agent tool switch: only where the
|
|
1468
|
+
server reports a tool host (Claude Code mod, Pi,
|
|
1469
|
+
OpenCode 2). Writes agentTool to config.json; applies
|
|
1470
|
+
to the next session. */}
|
|
1471
|
+
{agentToolSetting && onAgentToolChange && (
|
|
1472
|
+
<>
|
|
1473
|
+
<div className="border-t border-border" />
|
|
1474
|
+
<AgentToolSettingRow setting={agentToolSetting} onChange={onAgentToolChange} />
|
|
1475
|
+
</>
|
|
1476
|
+
)}
|
|
1477
|
+
|
|
1406
1478
|
{/* Agent tools (WebMCP). Shown only when the browser
|
|
1407
1479
|
exposes document.modelContext; off unregisters the
|
|
1408
1480
|
tools for this browser. Cookie-only and idle at its
|
|
@@ -107,7 +107,7 @@ function GitHubMark({ className }: { readonly className?: string }) {
|
|
|
107
107
|
);
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
function XMark({ className }: { readonly className?: string }) {
|
|
110
|
+
export function XMark({ className }: { readonly className?: string }) {
|
|
111
111
|
return (
|
|
112
112
|
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" focusable="false">
|
|
113
113
|
<path
|
package/components/Viewer.tsx
CHANGED
|
@@ -1434,6 +1434,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1434
1434
|
onAddAnnotation={readOnly ? undefined : onAddAnnotation}
|
|
1435
1435
|
readOnly={readOnly}
|
|
1436
1436
|
onRestoreReport={onRestoreReport}
|
|
1437
|
+
onAskAI={readOnly ? undefined : onAskAI}
|
|
1438
|
+
askAISourcePath={linkedDocInfo?.filepath ?? sourceInfo}
|
|
1437
1439
|
/>
|
|
1438
1440
|
</Suspense>
|
|
1439
1441
|
) : group.block.type === 'code' && isGraphvizLanguage(group.block.language) ? (
|
|
@@ -1446,6 +1448,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1446
1448
|
onAddAnnotation={readOnly ? undefined : onAddAnnotation}
|
|
1447
1449
|
readOnly={readOnly}
|
|
1448
1450
|
onRestoreReport={onRestoreReport}
|
|
1451
|
+
onAskAI={readOnly ? undefined : onAskAI}
|
|
1452
|
+
askAISourcePath={linkedDocInfo?.filepath ?? sourceInfo}
|
|
1449
1453
|
/>
|
|
1450
1454
|
</Suspense>
|
|
1451
1455
|
) : group.block.type === 'table' ? (
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { getProviderMeta } from '../ProviderIcons';
|
|
3
|
-
import { type AIProviderOption } from '../../utils/aiProvider';
|
|
3
|
+
import { findSessionBridge, type AIProviderOption } from '../../utils/aiProvider';
|
|
4
4
|
import { ModelSourceHint, modelSourceToolForProvider } from '../ModelSourceHint';
|
|
5
5
|
|
|
6
6
|
interface AIProviderBarProps {
|
|
@@ -30,6 +30,23 @@ export const AIProviderBar: React.FC<AIProviderBarProps> = ({
|
|
|
30
30
|
);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
// "Ask this session": the session that opened Plannotator is the only thing
|
|
34
|
+
// Ask AI talks to, so there is no provider or model to choose. Show its label.
|
|
35
|
+
const sessionBridge = findSessionBridge(providers);
|
|
36
|
+
if (sessionBridge) {
|
|
37
|
+
const bridgeMeta = getProviderMeta(sessionBridge.name, sessionBridge.label);
|
|
38
|
+
const BridgeIcon = bridgeMeta.icon;
|
|
39
|
+
return (
|
|
40
|
+
<div
|
|
41
|
+
className="border-t border-border/50 px-2 py-1.5 flex items-center gap-1.5 text-[11px] text-muted-foreground"
|
|
42
|
+
data-ai-provider-session-bridge
|
|
43
|
+
>
|
|
44
|
+
<BridgeIcon className="w-3.5 h-3.5 flex-shrink-0" />
|
|
45
|
+
<span className="min-w-0 truncate text-foreground">{bridgeMeta.label}</span>
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
33
50
|
const currentProvider = providers.find(p => p.id === selectedProviderId) ?? providers[0];
|
|
34
51
|
const effectiveProviderId = currentProvider?.id ?? '';
|
|
35
52
|
const models = currentProvider?.models ?? [];
|
|
@@ -5,7 +5,7 @@ import { formatRelativeTime, renderChatMarkdown } from '../../utils/aiChatFormat
|
|
|
5
5
|
import { OverlayScrollArea } from '../OverlayScrollArea';
|
|
6
6
|
import { SparklesIcon } from '../SparklesIcon';
|
|
7
7
|
import { AIProviderBar } from './AIProviderBar';
|
|
8
|
-
import { SessionAskActions, SessionAskStatus, sessionAskErrorTone, type SessionAskAction } from './SessionAskNotice';
|
|
8
|
+
import { SessionAskActions, SessionAskNote, SessionAskStatus, sessionAskErrorTone, type SessionAskAction } from './SessionAskNotice';
|
|
9
9
|
import { submitHint } from '../../utils/platform';
|
|
10
10
|
|
|
11
11
|
interface DocumentAIChatPanelProps {
|
|
@@ -20,10 +20,8 @@ interface DocumentAIChatPanelProps {
|
|
|
20
20
|
aiProviders?: AIProviderOption[];
|
|
21
21
|
aiConfig?: { providerId: string | null; model: string | null; reasoningEffort?: string | null };
|
|
22
22
|
onAIConfigChange?: (config: { providerId?: string | null; model?: string | null; reasoningEffort?: string | null }) => void;
|
|
23
|
-
/** "Ask this session": the reviewer's choice on a busy
|
|
23
|
+
/** "Ask this session": the reviewer's choice on a busy answer. */
|
|
24
24
|
onSessionAskAction?: (questionId: string, action: SessionAskAction) => void;
|
|
25
|
-
/** Label of the provider "Ask a separate AI instead" would use; null hides it. */
|
|
26
|
-
sessionAskFallbackLabel?: string | null;
|
|
27
25
|
}
|
|
28
26
|
|
|
29
27
|
function truncate(text: string, max = 180): string {
|
|
@@ -70,7 +68,6 @@ export const DocumentAIChatPanel: React.FC<DocumentAIChatPanelProps> = ({
|
|
|
70
68
|
aiConfig,
|
|
71
69
|
onAIConfigChange,
|
|
72
70
|
onSessionAskAction,
|
|
73
|
-
sessionAskFallbackLabel,
|
|
74
71
|
}) => {
|
|
75
72
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
76
73
|
const [generalInput, setGeneralInput] = useState('');
|
|
@@ -120,7 +117,6 @@ export const DocumentAIChatPanel: React.FC<DocumentAIChatPanelProps> = ({
|
|
|
120
117
|
key={entry.question.id}
|
|
121
118
|
entry={entry}
|
|
122
119
|
onSessionAskAction={onSessionAskAction}
|
|
123
|
-
sessionAskFallbackLabel={sessionAskFallbackLabel}
|
|
124
120
|
/>
|
|
125
121
|
))}
|
|
126
122
|
</div>
|
|
@@ -167,8 +163,7 @@ export const DocumentAIChatPanel: React.FC<DocumentAIChatPanelProps> = ({
|
|
|
167
163
|
const DocumentQAPair = memo<{
|
|
168
164
|
entry: AIChatEntry;
|
|
169
165
|
onSessionAskAction?: (questionId: string, action: SessionAskAction) => void;
|
|
170
|
-
|
|
171
|
-
}>(({ entry, onSessionAskAction, sessionAskFallbackLabel }) => {
|
|
166
|
+
}>(({ entry, onSessionAskAction }) => {
|
|
172
167
|
const { question, response } = entry;
|
|
173
168
|
const renderedResponse = useMemo(
|
|
174
169
|
() => response.text ? renderChatMarkdown(response.text) : null,
|
|
@@ -210,7 +205,6 @@ const DocumentQAPair = memo<{
|
|
|
210
205
|
<p className={`text-xs ${sessionAskErrorTone(response)}`}>{response.error}</p>
|
|
211
206
|
<SessionAskActions
|
|
212
207
|
response={response}
|
|
213
|
-
fallbackLabel={sessionAskFallbackLabel}
|
|
214
208
|
onAction={onSessionAskAction ? (action) => onSessionAskAction(question.id, action) : undefined}
|
|
215
209
|
/>
|
|
216
210
|
</>
|
|
@@ -226,6 +220,7 @@ const DocumentQAPair = memo<{
|
|
|
226
220
|
<span className="ai-streaming-cursor" /> Thinking...
|
|
227
221
|
</span>
|
|
228
222
|
) : null}
|
|
223
|
+
{!response.error && <SessionAskNote response={response} />}
|
|
229
224
|
</div>
|
|
230
225
|
</div>
|
|
231
226
|
);
|
|
@@ -3,7 +3,7 @@ import type { AIResponse } from '../../types';
|
|
|
3
3
|
import { SESSION_ASK_ERROR_CODES } from '../../utils/aiProvider';
|
|
4
4
|
|
|
5
5
|
/** What the reviewer chose on an "Ask this session" answer that could not run. */
|
|
6
|
-
export type SessionAskAction = 'wait' | 'interrupt'
|
|
6
|
+
export type SessionAskAction = 'wait' | 'interrupt';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* In-progress line for a question waiting on a busy session. Renders nothing
|
|
@@ -33,20 +33,20 @@ const buttonClass =
|
|
|
33
33
|
'px-2 py-1 rounded-md text-[10px] font-medium transition-colors';
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* Follow-up
|
|
36
|
+
* Follow-up under an "Ask this session" error:
|
|
37
37
|
* - the session is busy: ask when it finishes, or interrupt it and ask now;
|
|
38
|
-
* - the session is gone or blocked:
|
|
39
|
-
*
|
|
38
|
+
* - the session is gone or blocked: a plain note. Ask AI has no other provider
|
|
39
|
+
* while a session is attached, so there is nothing to switch to.
|
|
40
|
+
* Busy actions render only when the host passes a handler.
|
|
40
41
|
*/
|
|
41
42
|
export const SessionAskActions: React.FC<{
|
|
42
43
|
response: AIResponse;
|
|
43
44
|
onAction?: (action: SessionAskAction) => void;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}> = ({ response, onAction, fallbackLabel }) => {
|
|
47
|
-
if (!onAction || !response.errorCode) return null;
|
|
45
|
+
}> = ({ response, onAction }) => {
|
|
46
|
+
if (!response.errorCode) return null;
|
|
48
47
|
|
|
49
48
|
if (response.errorCode === SESSION_ASK_ERROR_CODES.agentBusy) {
|
|
49
|
+
if (!onAction) return null;
|
|
50
50
|
return (
|
|
51
51
|
<div className="flex flex-wrap gap-1.5 mt-2" data-session-ask-actions="busy">
|
|
52
52
|
<button
|
|
@@ -68,22 +68,34 @@ export const SessionAskActions: React.FC<{
|
|
|
68
68
|
);
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
if (
|
|
72
|
-
|
|
73
|
-
fallbackLabel
|
|
74
|
-
) {
|
|
71
|
+
if (response.errorCode === SESSION_ASK_ERROR_CODES.gone || response.errorCode === SESSION_ASK_ERROR_CODES.blocked) {
|
|
72
|
+
const gone = response.errorCode === SESSION_ASK_ERROR_CODES.gone;
|
|
75
73
|
return (
|
|
76
|
-
<
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
Ask
|
|
83
|
-
|
|
84
|
-
</div>
|
|
74
|
+
<p
|
|
75
|
+
className="mt-1.5 text-[11px] text-muted-foreground"
|
|
76
|
+
data-session-ask-unreachable={gone ? 'gone' : 'blocked'}
|
|
77
|
+
>
|
|
78
|
+
{gone
|
|
79
|
+
? 'Ask AI answers only from the session that opened Plannotator, and that session is gone, so Ask AI can\'t reach it.'
|
|
80
|
+
: 'Ask AI answers only from the session that opened Plannotator, and that session is waiting on this decision, so Ask AI can\'t reach it until you decide.'}
|
|
81
|
+
</p>
|
|
85
82
|
);
|
|
86
83
|
}
|
|
87
84
|
|
|
88
85
|
return null;
|
|
89
86
|
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A muted note under an "Ask this session" answer that stopped early because
|
|
90
|
+
* the person typed into the session while it answered (the rest of that turn
|
|
91
|
+
* went to their prompt). The partial answer above it stands. Renders nothing
|
|
92
|
+
* for any other response.
|
|
93
|
+
*/
|
|
94
|
+
export const SessionAskNote: React.FC<{ response: Pick<AIResponse, 'notice'> }> = ({ response }) => {
|
|
95
|
+
if (!response.notice) return null;
|
|
96
|
+
return (
|
|
97
|
+
<p className="mt-1.5 text-[11px] text-muted-foreground" data-session-ask-note="taken-over">
|
|
98
|
+
{response.notice}
|
|
99
|
+
</p>
|
|
100
|
+
);
|
|
101
|
+
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import React, { useId, useRef, useState } from 'react';
|
|
1
|
+
import React, { useId, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import {
|
|
3
|
+
canonicalQuestionAnswer,
|
|
3
4
|
emptyQuestionAnswer,
|
|
4
5
|
isQuestionAnswerEmpty,
|
|
5
6
|
questionStatus,
|
|
@@ -9,7 +10,7 @@ import {
|
|
|
9
10
|
type QuestionAnswer,
|
|
10
11
|
} from '@plannotator/core/question-block';
|
|
11
12
|
import { InlineMarkdown } from '../InlineMarkdown';
|
|
12
|
-
import {
|
|
13
|
+
import { QuestionContext } from './QuestionContext';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* A `:::question` block rendered as an answer card (the approved "Card"
|
|
@@ -122,7 +123,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
122
123
|
blockId,
|
|
123
124
|
indexed,
|
|
124
125
|
total,
|
|
125
|
-
answer:
|
|
126
|
+
answer: storedAnswer,
|
|
126
127
|
onAnswer: onLiveAnswer,
|
|
127
128
|
onSaveAnswer,
|
|
128
129
|
saveLabel,
|
|
@@ -136,6 +137,13 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
136
137
|
onNavigateAnchor,
|
|
137
138
|
}) => {
|
|
138
139
|
const { question, number } = indexed;
|
|
140
|
+
// An answer saved under an older label of a choice (0.28.1 read a wrapped
|
|
141
|
+
// plain bullet's first line as its label) shows that choice picked; the
|
|
142
|
+
// next edit stores the current label.
|
|
143
|
+
const savedAnswer = useMemo(
|
|
144
|
+
() => (storedAnswer ? canonicalQuestionAnswer(question, storedAnswer) : storedAnswer),
|
|
145
|
+
[question, storedAnswer],
|
|
146
|
+
);
|
|
139
147
|
const saveMode = !!onSaveAnswer;
|
|
140
148
|
// Save mode: the unsaved draft (null = the reviewer emptied it). Absent
|
|
141
149
|
// means the card shows the host's answer.
|
|
@@ -349,7 +357,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
349
357
|
</span>
|
|
350
358
|
</div>
|
|
351
359
|
|
|
352
|
-
<p id={promptId} className="m-0 text-[15px] font-semibold leading-[1.45] text-foreground">
|
|
360
|
+
<p id={promptId} className="m-0 text-[15px] font-semibold leading-[1.45] text-foreground" data-question-part="prompt">
|
|
353
361
|
{inline(question.prompt)}
|
|
354
362
|
</p>
|
|
355
363
|
{(question.decision || question.decisionOnAnswer) && (
|
|
@@ -378,20 +386,17 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
378
386
|
</div>
|
|
379
387
|
)}
|
|
380
388
|
{question.context && (
|
|
381
|
-
<
|
|
382
|
-
{
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
repoHost,
|
|
393
|
-
})}
|
|
394
|
-
</div>
|
|
389
|
+
<QuestionContext
|
|
390
|
+
id={contextId}
|
|
391
|
+
markdown={question.context}
|
|
392
|
+
imageBaseDir={imageBaseDir}
|
|
393
|
+
onImageClick={onImageClick}
|
|
394
|
+
onOpenLinkedDoc={onOpenLinkedDoc}
|
|
395
|
+
onOpenCodeFile={onOpenCodeFile}
|
|
396
|
+
onNavigateAnchor={onNavigateAnchor}
|
|
397
|
+
githubRepo={githubRepo}
|
|
398
|
+
repoHost={repoHost}
|
|
399
|
+
/>
|
|
395
400
|
)}
|
|
396
401
|
|
|
397
402
|
{question.kind === 'text' ? (
|
|
@@ -454,7 +459,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
454
459
|
className="mt-[3px] h-[15px] w-[15px] shrink-0 cursor-pointer accent-primary disabled:cursor-default"
|
|
455
460
|
disabled={readOnly}
|
|
456
461
|
/>
|
|
457
|
-
<span className="min-w-0 flex-1 select-text text-[13.5px] leading-[1.45] text-foreground">
|
|
462
|
+
<span className="min-w-0 flex-1 select-text text-[13.5px] leading-[1.45] text-foreground" data-question-part="choice">
|
|
458
463
|
<span className="font-[550]">{inline(choice.label)}</span>
|
|
459
464
|
{choice.description && (
|
|
460
465
|
<span className="text-muted-foreground"> — {inline(choice.description)}</span>
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import React, { useMemo } from 'react';
|
|
2
|
+
import type { Block } from '../../types';
|
|
3
|
+
import { computeListIndices, groupBlocks, parseMarkdownToBlocks } from '../../utils/parser';
|
|
4
|
+
// BlockRenderer renders QuestionBlock, which renders this: a cycle that only
|
|
5
|
+
// meets at render time, after both modules have loaded.
|
|
6
|
+
import { BlockRenderer } from '../BlockRenderer';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The document renderers at the card's scale and tone. A stylesheet rather
|
|
10
|
+
* than Tailwind arbitrary variants on purpose: those would add utilities to
|
|
11
|
+
* every bundle whose Tailwind scans `packages/ui/components` (the guides.show
|
|
12
|
+
* viewer among them, which never renders a question card), and a host gets
|
|
13
|
+
* these rules without any Tailwind setup. Unlayered, so it outranks the
|
|
14
|
+
* renderers' utility classes.
|
|
15
|
+
*/
|
|
16
|
+
const QUESTION_CONTEXT_CSS = `
|
|
17
|
+
.question-context > * { margin-top: 0.5rem; margin-bottom: 0.5rem; }
|
|
18
|
+
.question-context > :first-child { margin-top: 0; }
|
|
19
|
+
.question-context > :last-child { margin-bottom: 0; }
|
|
20
|
+
.question-context p, .question-context .text-sm { font-size: 13px; line-height: 1.5; }
|
|
21
|
+
.question-context p, .question-context [data-question-context-list] span { color: inherit; }
|
|
22
|
+
.question-context h1, .question-context h2, .question-context h3 { margin: 0.75rem 0 0.25rem; font-size: 14px; }
|
|
23
|
+
.question-context h3 { font-size: 13.5px; }
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A question card's context, rendered as the document renders markdown: the
|
|
28
|
+
* same block parser and the same block renderers, so a table, a list, a code
|
|
29
|
+
* fence, a quote or an image in the context looks the way it does in the
|
|
30
|
+
* document instead of collapsing into one paragraph.
|
|
31
|
+
*
|
|
32
|
+
* The blocks belong to the card: they carry no `data-block-id` of their own,
|
|
33
|
+
* so a selection or pinpoint inside them resolves to the card (and its
|
|
34
|
+
* `data-question-part="context"` part), never to a block id that also names a
|
|
35
|
+
* real document block. The card sets the scale: muted 13px prose, tight
|
|
36
|
+
* spacing.
|
|
37
|
+
*/
|
|
38
|
+
export const QuestionContext: React.FC<{
|
|
39
|
+
id: string;
|
|
40
|
+
markdown: string;
|
|
41
|
+
imageBaseDir?: string;
|
|
42
|
+
onImageClick?: (src: string, alt: string) => void;
|
|
43
|
+
onOpenLinkedDoc?: (path: string) => void;
|
|
44
|
+
onOpenCodeFile?: (path: string) => void;
|
|
45
|
+
onNavigateAnchor?: (hash: string) => void;
|
|
46
|
+
githubRepo?: string;
|
|
47
|
+
repoHost?: string;
|
|
48
|
+
}> = ({ id, markdown, ...inlineProps }) => {
|
|
49
|
+
const groups = useMemo((): Array<{ list: Block[]; indices: (number | null)[] } | { block: Block }> => {
|
|
50
|
+
// Ids leave after grouping (grouping keys on them); undefined makes the
|
|
51
|
+
// renderers omit `data-block-id`.
|
|
52
|
+
const anonymous = (block: Block): Block => ({ ...block, id: undefined as unknown as string });
|
|
53
|
+
return groupBlocks(parseMarkdownToBlocks(markdown, { frontmatter: false })).map((group) =>
|
|
54
|
+
group.type === 'list-group'
|
|
55
|
+
? { list: group.blocks.map(anonymous), indices: computeListIndices(group.blocks) }
|
|
56
|
+
: { block: anonymous(group.block) },
|
|
57
|
+
);
|
|
58
|
+
}, [markdown]);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div
|
|
62
|
+
id={id}
|
|
63
|
+
className="question-context mt-1 text-[13px] leading-normal text-muted-foreground"
|
|
64
|
+
data-question-part="context"
|
|
65
|
+
>
|
|
66
|
+
{/* React hoists this into <head> once for every card. */}
|
|
67
|
+
<style href="plannotator-question-context" precedence="default">{QUESTION_CONTEXT_CSS}</style>
|
|
68
|
+
{groups.map((group, i) =>
|
|
69
|
+
'list' in group ? (
|
|
70
|
+
<div key={`l-${i}`} data-question-context-list="">
|
|
71
|
+
{group.list.map((block, j) => (
|
|
72
|
+
<BlockRenderer key={j} block={block} orderedIndex={group.indices[j]} {...inlineProps} />
|
|
73
|
+
))}
|
|
74
|
+
</div>
|
|
75
|
+
) : (
|
|
76
|
+
<BlockRenderer key={`b-${i}`} block={group.block} {...inlineProps} />
|
|
77
|
+
),
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
};
|
|
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type KeyboardEvent } from 'react';
|
|
|
2
2
|
import { diagramTargetName, diagramTargetText } from '@plannotator/core/diagram-anchor';
|
|
3
3
|
import { cn } from '../../lib/utils';
|
|
4
4
|
import type { ScreenRect } from '../../utils/diagram-projection';
|
|
5
|
+
import { SparklesIcon } from '../SparklesIcon';
|
|
5
6
|
import { Button } from '../ui/button';
|
|
6
7
|
import type { DiagramComposerDraft } from './useDiagramComments';
|
|
7
8
|
|
|
@@ -12,6 +13,12 @@ import type { DiagramComposerDraft } from './useDiagramComments';
|
|
|
12
13
|
* ruling; the markdown composer shows no hint either). It lives here
|
|
13
14
|
* because the markdown composer is bound to web-highlighter's selection and
|
|
14
15
|
* the html composer to the sandbox bridge; neither reaches an app-owned svg.
|
|
16
|
+
*
|
|
17
|
+
* With `onAskAI` it also offers the markdown composer's "Ask AI": the typed
|
|
18
|
+
* text is asked as a question about the part instead of saved as a comment.
|
|
19
|
+
* Disabled until there is text, never bound to a key (Enter stays Comment,
|
|
20
|
+
* as Mod+Enter stays Save in the markdown composer), and the composer closes
|
|
21
|
+
* once the host accepts the question.
|
|
15
22
|
*/
|
|
16
23
|
|
|
17
24
|
/** The composer's width; it sits to the right of the ring and flips left
|
|
@@ -28,6 +35,7 @@ export function DiagramComposer({
|
|
|
28
35
|
disabledReason,
|
|
29
36
|
onSubmit,
|
|
30
37
|
onCancel,
|
|
38
|
+
onAskAI,
|
|
31
39
|
}: {
|
|
32
40
|
draft: DiagramComposerDraft;
|
|
33
41
|
anchorRect: ScreenRect;
|
|
@@ -41,12 +49,23 @@ export function DiagramComposer({
|
|
|
41
49
|
disabledReason?: string;
|
|
42
50
|
onSubmit: (body: string) => void;
|
|
43
51
|
onCancel: () => void;
|
|
52
|
+
/** Ask the typed text as a question about this part. Resolves true when
|
|
53
|
+
* the host took it (the composer then closes), false to keep the draft.
|
|
54
|
+
* Absent: no Ask AI button. */
|
|
55
|
+
onAskAI?: (question: string) => Promise<boolean>;
|
|
44
56
|
}) {
|
|
45
57
|
const [text, setText] = useState('');
|
|
58
|
+
const [asking, setAsking] = useState(false);
|
|
46
59
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
|
47
60
|
useEffect(() => {
|
|
48
61
|
textareaRef.current?.focus();
|
|
49
62
|
}, []);
|
|
63
|
+
// A refused question hands the keyboard back to the textarea, once it is
|
|
64
|
+
// enabled again (it is disabled while the question is pending).
|
|
65
|
+
const [refocus, setRefocus] = useState(0);
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (refocus > 0) textareaRef.current?.focus();
|
|
68
|
+
}, [refocus]);
|
|
50
69
|
|
|
51
70
|
const target = draft.primary.target;
|
|
52
71
|
const unsavedPart = draft.sourceLine === null && sourceDirty;
|
|
@@ -54,6 +73,25 @@ export function DiagramComposer({
|
|
|
54
73
|
const rightFits = anchorRect.left + anchorRect.width + 12 + COMPOSER_WIDTH_PX <= hostWidth;
|
|
55
74
|
const left = rightFits ? anchorRect.left + anchorRect.width + 12 : Math.max(0, anchorRect.left - COMPOSER_WIDTH_PX - 12);
|
|
56
75
|
|
|
76
|
+
const canAskAI = onAskAI !== undefined && !asking && !submitting && text.trim() !== '';
|
|
77
|
+
const askAI = async () => {
|
|
78
|
+
const question = text.trim();
|
|
79
|
+
if (onAskAI === undefined || question === '' || asking) {
|
|
80
|
+
textareaRef.current?.focus();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
setAsking(true);
|
|
84
|
+
let accepted = false;
|
|
85
|
+
try {
|
|
86
|
+
accepted = await onAskAI(question);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
console.error('Ask AI action failed:', error);
|
|
89
|
+
} finally {
|
|
90
|
+
setAsking(false);
|
|
91
|
+
}
|
|
92
|
+
if (!accepted) setRefocus((n) => n + 1);
|
|
93
|
+
};
|
|
94
|
+
|
|
57
95
|
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
58
96
|
if (event.key === 'Escape') {
|
|
59
97
|
// Ours: the canvas and whatever holds it (a popout) must not also
|
|
@@ -65,7 +103,7 @@ export function DiagramComposer({
|
|
|
65
103
|
}
|
|
66
104
|
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
|
|
67
105
|
event.preventDefault();
|
|
68
|
-
if (canWrite && text.trim() !== '') onSubmit(text);
|
|
106
|
+
if (canWrite && !asking && text.trim() !== '') onSubmit(text);
|
|
69
107
|
}
|
|
70
108
|
};
|
|
71
109
|
|
|
@@ -100,7 +138,7 @@ export function DiagramComposer({
|
|
|
100
138
|
onKeyDown={onKeyDown}
|
|
101
139
|
placeholder="Add a comment..."
|
|
102
140
|
rows={3}
|
|
103
|
-
disabled={submitting}
|
|
141
|
+
disabled={submitting || asking}
|
|
104
142
|
className={cn(
|
|
105
143
|
'w-full resize-none rounded-md border border-input bg-background px-2 py-1.5 text-xs text-foreground placeholder:text-muted-foreground',
|
|
106
144
|
'outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
|
@@ -112,10 +150,23 @@ export function DiagramComposer({
|
|
|
112
150
|
</p>
|
|
113
151
|
)}
|
|
114
152
|
<div className="flex items-center justify-end gap-1">
|
|
153
|
+
{onAskAI !== undefined && (
|
|
154
|
+
<button
|
|
155
|
+
type="button"
|
|
156
|
+
data-diagram-ask-ai=""
|
|
157
|
+
onClick={() => void askAI()}
|
|
158
|
+
disabled={!canAskAI}
|
|
159
|
+
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-primary disabled:cursor-not-allowed disabled:opacity-50"
|
|
160
|
+
title={canAskAI ? 'Ask AI this question' : 'Type a question to ask AI'}
|
|
161
|
+
>
|
|
162
|
+
<SparklesIcon className="h-3 w-3" />
|
|
163
|
+
Ask AI
|
|
164
|
+
</button>
|
|
165
|
+
)}
|
|
115
166
|
<Button type="button" variant="ghost" size="xs" onClick={onCancel}>
|
|
116
167
|
Cancel
|
|
117
168
|
</Button>
|
|
118
|
-
<Button type="button" size="xs" disabled={!canWrite || submitting || text.trim() === ''} onClick={() => onSubmit(text)}>
|
|
169
|
+
<Button type="button" size="xs" disabled={!canWrite || submitting || asking || text.trim() === ''} onClick={() => onSubmit(text)}>
|
|
119
170
|
Comment
|
|
120
171
|
</Button>
|
|
121
172
|
</div>
|