Skip to content

Commit 14f8bdb

Browse files
authored
feat: bot callback URL params with componentized toolbar buttons (#416)
Adds ?cb=<encoded_url>&ct=<token> URL params for bot-initiated plan review callbacks. When a bot posts a Plannotator share link with these params, Approve/Feedback buttons appear and POST the user's decision (with annotated plan URL) back to the bot. Changes: - callback.ts: parse callback config from URL search/hash params, execute POST - ToolbarButtons.tsx: extract shared FeedbackButton/ApproveButton components (6 instances → 2 components) - sharing.ts: strip ?cb=&ct= from hash before decompressing share payload - App.tsx: callback buttons gated on isSharedSession + shareUrl readiness - 19 tests covering config parsing, URL validation, presigned URL preservation, and POST execution Co-authored-by: aviadshiber <aviadshiber@users.noreply.github.com> For provenance purposes, this commit was AI assisted.
1 parent df76f9e commit 14f8bdb

6 files changed

Lines changed: 409 additions & 62 deletions

File tree

packages/editor/App.tsx

Lines changed: 53 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ import { AnnotationToolstrip } from '@plannotator/ui/components/AnnotationToolst
1313
import { TaterSpriteRunning } from '@plannotator/ui/components/TaterSpriteRunning';
1414
import { TaterSpritePullup } from '@plannotator/ui/components/TaterSpritePullup';
1515
import { Settings } from '@plannotator/ui/components/Settings';
16+
import { FeedbackButton, ApproveButton } from '@plannotator/ui/components/ToolbarButtons';
1617
import { useSharing } from '@plannotator/ui/hooks/useSharing';
18+
import { getCallbackConfig, CallbackAction, executeCallback, type ToastPayload } from '@plannotator/ui/utils/callback';
1719
import { useAgents } from '@plannotator/ui/hooks/useAgents';
1820
import { useActiveSection } from '@plannotator/ui/hooks/useActiveSection';
1921
import { storage } from '@plannotator/ui/utils/storage';
@@ -115,7 +117,7 @@ const App: React.FC = () => {
115117

116118
const [showExportDropdown, setShowExportDropdown] = useState(false);
117119
const [initialExportTab, setInitialExportTab] = useState<'share' | 'annotations' | 'notes'>();
118-
const [noteSaveToast, setNoteSaveToast] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
120+
const [noteSaveToast, setNoteSaveToast] = useState<ToastPayload>(null);
119121
const [isPlanDiffActive, setIsPlanDiffActive] = useState(false);
120122
const [planDiffMode, setPlanDiffMode] = useState<PlanDiffMode>('clean');
121123
const [previousPlan, setPreviousPlan] = useState<string | null>(null);
@@ -924,6 +926,29 @@ const App: React.FC = () => {
924926
return output;
925927
}, [blocks, allAnnotations, globalAttachments, linkedDocHook.getDocAnnotations, editorAnnotations]);
926928

929+
// Bot callback config — read once from URL search params (?cb=&ct=)
930+
const callbackConfig = React.useMemo(() => getCallbackConfig(), []);
931+
932+
const callCallback = React.useCallback(async (action: CallbackAction) => {
933+
if (!callbackConfig || isSubmitting || !shareUrl) return;
934+
setIsSubmitting(true);
935+
try {
936+
const toast = await executeCallback(action, callbackConfig, shareUrl);
937+
if (toast) {
938+
setNoteSaveToast(toast);
939+
setTimeout(() => setNoteSaveToast(null), 4000);
940+
if (toast.type === 'success') {
941+
setSubmitted(action === CallbackAction.Approve ? 'approved' : 'denied');
942+
}
943+
}
944+
} finally {
945+
setIsSubmitting(false);
946+
}
947+
}, [callbackConfig, isSubmitting, shareUrl]);
948+
949+
const handleCallbackApprove = React.useCallback(() => callCallback(CallbackAction.Approve), [callCallback]);
950+
const handleCallbackFeedback = React.useCallback(() => callCallback(CallbackAction.Feedback), [callCallback]);
951+
927952
// Quick-save handlers for export dropdown and keyboard shortcut
928953
const handleDownloadAnnotations = () => {
929954
setShowExportDropdown(false);
@@ -1109,6 +1134,25 @@ const App: React.FC = () => {
11091134
</div>
11101135

11111136
<div className="flex items-center gap-1 md:gap-2">
1137+
{/* Bot callback buttons — only shown when ?cb=&ct= params are present */}
1138+
{callbackConfig && !isApiMode && isSharedSession && (
1139+
<>
1140+
<div className="w-px h-5 bg-border/50 mx-1 hidden md:block" />
1141+
<FeedbackButton
1142+
onClick={handleCallbackFeedback}
1143+
disabled={isSubmitting || !shareUrl}
1144+
isLoading={isSubmitting}
1145+
title="Send feedback to bot"
1146+
/>
1147+
<ApproveButton
1148+
onClick={handleCallbackApprove}
1149+
disabled={isSubmitting || !shareUrl}
1150+
isLoading={isSubmitting}
1151+
title="Approve design and notify bot"
1152+
/>
1153+
</>
1154+
)}
1155+
11121156
{isApiMode && !linkedDocHook.isActive && archive.archiveMode && (
11131157
<>
11141158
<button
@@ -1133,7 +1177,7 @@ const App: React.FC = () => {
11331177

11341178
{isApiMode && !linkedDocHook.isActive && !archive.archiveMode && (
11351179
<>
1136-
<button
1180+
<FeedbackButton
11371181
onClick={() => {
11381182
if (annotateMode) {
11391183
handleAnnotateFeedback();
@@ -1150,29 +1194,18 @@ const App: React.FC = () => {
11501194
}
11511195
}}
11521196
disabled={isSubmitting}
1153-
className={`p-1.5 md:px-2.5 md:py-1 rounded-md text-xs font-medium transition-all ${
1154-
isSubmitting
1155-
? 'opacity-50 cursor-not-allowed bg-muted text-muted-foreground'
1156-
: 'bg-accent/15 text-accent hover:bg-accent/25 border border-accent/30'
1157-
}`}
1197+
isLoading={isSubmitting}
1198+
label={annotateMode ? (allAnnotations.length > 0 || editorAnnotations.length > 0 || linkedDocHook.docAnnotationCount > 0 ? 'Send Annotations' : 'Done') : 'Send Feedback'}
11581199
title={annotateMode ? (allAnnotations.length > 0 || editorAnnotations.length > 0 || linkedDocHook.docAnnotationCount > 0 ? 'Send Annotations' : 'Done') : 'Send Feedback'}
1159-
>
1160-
<svg className="w-4 h-4 md:hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
1161-
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
1162-
</svg>
1163-
<span className="hidden md:inline">{isSubmitting ? 'Sending...' : annotateMode ? (allAnnotations.length > 0 || editorAnnotations.length > 0 || linkedDocHook.docAnnotationCount > 0 ? 'Send Annotations' : 'Done') : 'Send Feedback'}</span>
1164-
</button>
1200+
/>
11651201

11661202
{!annotateMode && <div className="relative group/approve">
1167-
<button
1203+
<ApproveButton
11681204
onClick={() => {
1169-
// Show warning for Claude Code users with annotations
11701205
if (origin === 'claude-code' && allAnnotations.length > 0) {
11711206
setShowClaudeCodeWarning(true);
11721207
return;
11731208
}
1174-
1175-
// Check if agent exists for OpenCode users
11761209
if (origin === 'opencode') {
11771210
const warning = getAgentWarning();
11781211
if (warning) {
@@ -1181,21 +1214,12 @@ const App: React.FC = () => {
11811214
return;
11821215
}
11831216
}
1184-
11851217
handleApprove();
11861218
}}
11871219
disabled={isSubmitting}
1188-
className={`px-2 py-1 md:px-2.5 rounded-md text-xs font-medium transition-all ${
1189-
isSubmitting
1190-
? 'opacity-50 cursor-not-allowed bg-muted text-muted-foreground'
1191-
: origin === 'claude-code' && allAnnotations.length > 0
1192-
? 'bg-success/50 text-success-foreground/70 hover:bg-success hover:text-success-foreground'
1193-
: 'bg-success text-success-foreground hover:opacity-90'
1194-
}`}
1195-
>
1196-
<span className="md:hidden">{isSubmitting ? '...' : 'OK'}</span>
1197-
<span className="hidden md:inline">{isSubmitting ? 'Approving...' : 'Approve'}</span>
1198-
</button>
1220+
isLoading={isSubmitting}
1221+
dimmed={origin === 'claude-code' && allAnnotations.length > 0}
1222+
/>
11991223
{origin === 'claude-code' && allAnnotations.length > 0 && (
12001224
<div className="absolute top-full right-0 mt-2 px-3 py-2 bg-popover border border-border rounded-lg shadow-xl text-xs text-foreground w-56 text-center opacity-0 invisible group-hover/approve:opacity-100 group-hover/approve:visible transition-all pointer-events-none z-50">
12011225
<div className="absolute bottom-full right-4 border-4 border-transparent border-b-border" />

packages/review-editor/App.tsx

Lines changed: 12 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ThemeProvider, useTheme } from '@plannotator/ui/components/ThemeProvide
44
import { ModeToggle } from '@plannotator/ui/components/ModeToggle';
55
import { ConfirmDialog } from '@plannotator/ui/components/ConfirmDialog';
66
import { Settings } from '@plannotator/ui/components/Settings';
7+
import { FeedbackButton, ApproveButton } from '@plannotator/ui/components/ToolbarButtons';
78
import { UpdateBanner } from '@plannotator/ui/components/UpdateBanner';
89
import { storage } from '@plannotator/ui/utils/storage';
910
import { CompletionOverlay } from '@plannotator/ui/components/CompletionOverlay';
@@ -1242,7 +1243,7 @@ const ReviewApp: React.FC = () => {
12421243
)}
12431244

12441245
{/* Send Feedback button — always the same label */}
1245-
<button
1246+
<FeedbackButton
12461247
onClick={() => {
12471248
if (platformMode) {
12481249
setPlatformGeneralComment('');
@@ -1255,28 +1256,16 @@ const ReviewApp: React.FC = () => {
12551256
isSendingFeedback || isApproving || isPlatformActioning ||
12561257
(!platformMode && totalAnnotationCount === 0)
12571258
}
1258-
className={`p-1.5 md:px-2.5 md:py-1 rounded-md text-xs font-medium transition-all ${
1259-
isSendingFeedback || isApproving || isPlatformActioning
1260-
? 'opacity-50 cursor-not-allowed bg-muted text-muted-foreground'
1261-
: !platformMode && totalAnnotationCount === 0
1262-
? 'opacity-50 cursor-not-allowed bg-accent/10 text-accent/50'
1263-
: 'bg-accent/15 text-accent hover:bg-accent/25 border border-accent/30'
1264-
}`}
1259+
isLoading={isSendingFeedback || isPlatformActioning}
1260+
muted={!platformMode && totalAnnotationCount === 0 && !isSendingFeedback && !isApproving && !isPlatformActioning}
1261+
label={platformMode ? 'Post Comments' : 'Send Feedback'}
1262+
loadingLabel={platformMode ? 'Posting...' : 'Sending...'}
12651263
title={!platformMode && totalAnnotationCount === 0 ? "Add annotations to send feedback" : "Send feedback"}
1266-
>
1267-
<svg className="w-4 h-4 md:hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
1268-
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
1269-
</svg>
1270-
<span className="hidden md:inline">{
1271-
isSendingFeedback || isPlatformActioning
1272-
? (platformMode ? 'Posting...' : 'Sending...')
1273-
: (platformMode ? 'Post Comments' : 'Send Feedback')
1274-
}</span>
1275-
</button>
1264+
/>
12761265

12771266
{/* Approve button — always the same label */}
12781267
<div className="relative group/approve">
1279-
<button
1268+
<ApproveButton
12801269
onClick={() => {
12811270
if (platformMode) {
12821271
if (platformUser && prMetadata?.author === platformUser) return;
@@ -1294,24 +1283,15 @@ const ReviewApp: React.FC = () => {
12941283
isSendingFeedback || isApproving || isPlatformActioning ||
12951284
(platformMode && !!platformUser && prMetadata?.author === platformUser)
12961285
}
1297-
className={`px-2 py-1 md:px-2.5 rounded-md text-xs font-medium transition-all ${
1298-
isSendingFeedback || isApproving || isPlatformActioning
1299-
? 'opacity-50 cursor-not-allowed bg-muted text-muted-foreground'
1300-
: platformMode && platformUser && prMetadata?.author === platformUser
1301-
? 'opacity-40 cursor-not-allowed bg-muted text-muted-foreground'
1302-
: !platformMode && totalAnnotationCount > 0
1303-
? 'bg-success/50 text-success-foreground/70 hover:bg-success hover:text-success-foreground'
1304-
: 'bg-success text-success-foreground hover:opacity-90'
1305-
}`}
1286+
isLoading={isApproving}
1287+
dimmed={!platformMode && totalAnnotationCount > 0}
1288+
muted={platformMode && !!platformUser && prMetadata?.author === platformUser && !isSendingFeedback && !isApproving && !isPlatformActioning}
13061289
title={
13071290
platformMode && platformUser && prMetadata?.author === platformUser
13081291
? `You can't approve your own ${mrLabel}`
13091292
: "Approve - no changes needed"
13101293
}
1311-
>
1312-
<span className="md:hidden">{isApproving ? '...' : 'OK'}</span>
1313-
<span className="hidden md:inline">{isApproving ? 'Approving...' : 'Approve'}</span>
1314-
</button>
1294+
/>
13151295
{/* Tooltip: own PR warning OR annotations-lost warning */}
13161296
{platformMode && platformUser && prMetadata?.author === platformUser ? (
13171297
<div className="absolute top-full right-0 mt-2 px-3 py-2 bg-popover border border-border rounded-lg shadow-xl text-xs text-foreground w-48 text-center opacity-0 invisible group-hover/approve:opacity-100 group-hover/approve:visible transition-all pointer-events-none z-50">
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import React from 'react';
2+
3+
interface FeedbackButtonProps {
4+
onClick: () => void;
5+
disabled?: boolean;
6+
isLoading?: boolean;
7+
label?: string;
8+
loadingLabel?: string;
9+
title?: string;
10+
muted?: boolean;
11+
}
12+
13+
export const FeedbackButton: React.FC<FeedbackButtonProps> = ({
14+
onClick,
15+
disabled = false,
16+
isLoading = false,
17+
label = 'Send Feedback',
18+
loadingLabel = 'Sending...',
19+
title = 'Send Feedback',
20+
muted = false,
21+
}) => (
22+
<button
23+
onClick={onClick}
24+
disabled={disabled}
25+
className={`p-1.5 md:px-2.5 md:py-1 rounded-md text-xs font-medium transition-all ${
26+
muted
27+
? 'opacity-50 cursor-not-allowed bg-accent/10 text-accent/50'
28+
: disabled
29+
? 'opacity-50 cursor-not-allowed bg-muted text-muted-foreground'
30+
: 'bg-accent/15 text-accent hover:bg-accent/25 border border-accent/30'
31+
}`}
32+
title={title}
33+
>
34+
<svg className="w-4 h-4 md:hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
35+
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
36+
</svg>
37+
<span className="hidden md:inline">{isLoading ? loadingLabel : label}</span>
38+
</button>
39+
);
40+
41+
interface ApproveButtonProps {
42+
onClick: () => void;
43+
disabled?: boolean;
44+
isLoading?: boolean;
45+
label?: string;
46+
loadingLabel?: string;
47+
mobileLabel?: string;
48+
mobileLoadingLabel?: string;
49+
title?: string;
50+
dimmed?: boolean;
51+
muted?: boolean;
52+
}
53+
54+
export const ApproveButton: React.FC<ApproveButtonProps> = ({
55+
onClick,
56+
disabled = false,
57+
isLoading = false,
58+
label = 'Approve',
59+
loadingLabel = 'Approving...',
60+
mobileLabel = 'OK',
61+
mobileLoadingLabel = '...',
62+
title,
63+
dimmed = false,
64+
muted = false,
65+
}) => (
66+
<button
67+
onClick={onClick}
68+
disabled={disabled}
69+
className={`px-2 py-1 md:px-2.5 rounded-md text-xs font-medium transition-all ${
70+
muted
71+
? 'opacity-40 cursor-not-allowed bg-muted text-muted-foreground'
72+
: disabled
73+
? 'opacity-50 cursor-not-allowed bg-muted text-muted-foreground'
74+
: dimmed
75+
? 'bg-success/50 text-success-foreground/70 hover:bg-success hover:text-success-foreground'
76+
: 'bg-success text-success-foreground hover:opacity-90'
77+
}`}
78+
title={title}
79+
>
80+
<span className="md:hidden">{isLoading ? mobileLoadingLabel : mobileLabel}</span>
81+
<span className="hidden md:inline">{isLoading ? loadingLabel : label}</span>
82+
</button>
83+
);

0 commit comments

Comments
 (0)