diff --git a/apps/daemon/src/db.ts b/apps/daemon/src/db.ts index c7d54ff62..885b6c1d9 100644 --- a/apps/daemon/src/db.ts +++ b/apps/daemon/src/db.ts @@ -265,6 +265,9 @@ function migrate(db: SqliteDb): void { if (!previewCommentCols.some((c: DbRow) => c.name === 'style_json')) { db.exec(`ALTER TABLE preview_comments ADD COLUMN style_json TEXT`); } + if (!previewCommentCols.some((c: DbRow) => c.name === 'slide_index')) { + db.exec(`ALTER TABLE preview_comments ADD COLUMN slide_index INTEGER`); + } const deploymentCols = db.prepare(`PRAGMA table_info(deployments)`).all() as DbRow[]; if (!deploymentCols.some((c: DbRow) => c.name === 'status')) { db.exec(`ALTER TABLE deployments ADD COLUMN status TEXT NOT NULL DEFAULT 'ready'`); @@ -1072,6 +1075,7 @@ export function listPreviewComments(db: SqliteDb, projectId: string, conversatio text, position_json AS positionJson, html_hint AS htmlHint, selection_kind AS selectionKind, member_count AS memberCount, pod_members_json AS podMembersJson, style_json AS styleJson, + slide_index AS slideIndex, note, status, created_at AS createdAt, updated_at AS updatedAt FROM preview_comments WHERE project_id = ? AND conversation_id = ? @@ -1102,6 +1106,7 @@ export function upsertPreviewComment(db: SqliteDb, projectId: string, conversati ? Math.max(0, Math.round(target.memberCount)) : 0) : 0; + const slideIndex = Number.isFinite(target.slideIndex) ? Math.max(0, Math.round(target.slideIndex)) : null; const now = Date.now(); const existing = db .prepare( @@ -1116,8 +1121,8 @@ export function upsertPreviewComment(db: SqliteDb, projectId: string, conversati `INSERT INTO preview_comments (id, project_id, conversation_id, file_path, element_id, selector, label, text, position_json, html_hint, selection_kind, member_count, pod_members_json, - style_json, note, status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + style_json, slide_index, note, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(project_id, conversation_id, file_path, element_id) DO UPDATE SET selector = excluded.selector, label = excluded.label, @@ -1128,6 +1133,7 @@ export function upsertPreviewComment(db: SqliteDb, projectId: string, conversati member_count = excluded.member_count, pod_members_json = excluded.pod_members_json, style_json = excluded.style_json, + slide_index = excluded.slide_index, note = excluded.note, status = 'open', updated_at = excluded.updated_at`, @@ -1146,6 +1152,7 @@ export function upsertPreviewComment(db: SqliteDb, projectId: string, conversati selectionKind === 'pod' ? memberCount : null, selectionKind === 'pod' ? JSON.stringify(podMembers) : null, style ? JSON.stringify(style) : null, + slideIndex, note, 'open', createdAt, @@ -1183,6 +1190,7 @@ function getPreviewComment(db: SqliteDb, projectId: string, conversationId: stri text, position_json AS positionJson, html_hint AS htmlHint, selection_kind AS selectionKind, member_count AS memberCount, pod_members_json AS podMembersJson, style_json AS styleJson, + slide_index AS slideIndex, note, status, created_at AS createdAt, updated_at AS updatedAt FROM preview_comments WHERE id = ? AND project_id = ? AND conversation_id = ?`, @@ -1214,6 +1222,7 @@ function normalizePreviewComment(row: DbRow) { ? row.memberCount : undefined, podMembers: normalizedPodMembers, + slideIndex: Number.isFinite(row.slideIndex) ? row.slideIndex : undefined, note: row.note, status: row.status, createdAt: row.createdAt, diff --git a/apps/web/src/comments.ts b/apps/web/src/comments.ts index ba2904ec4..059e0144a 100644 --- a/apps/web/src/comments.ts +++ b/apps/web/src/comments.ts @@ -23,6 +23,7 @@ export interface PreviewCommentSnapshot { selectionKind?: PreviewCommentSelectionKind; memberCount?: number; podMembers?: PreviewCommentMember[]; + slideIndex?: number; } export interface CommentOverlayBounds { @@ -95,9 +96,34 @@ export function targetFromSnapshot(snapshot: PreviewCommentSnapshot): PreviewCom : 0) : undefined, podMembers: podMembers.length > 0 ? podMembers : undefined, + ...(snapshot.slideIndex === undefined ? {} : { slideIndex: snapshot.slideIndex }), }; } +export function isValidCommentOverlayPosition( + position: { x: number; y: number; width: number; height: number } | undefined | null, +): boolean { + if (!position) return false; + const normalized = normalizePosition(position); + return ( + Number.isFinite(normalized.x) + && Number.isFinite(normalized.y) + && Number.isFinite(normalized.width) + && Number.isFinite(normalized.height) + && normalized.width > 0 + && normalized.height > 0 + ); +} + +export function commentVisibleOnDeckSlide( + comment: Pick, + activeSlideIndex: number | null | undefined, +): boolean { + if (activeSlideIndex == null) return true; + if (typeof comment.slideIndex !== 'number') return true; + return comment.slideIndex === activeSlideIndex; +} + export function overlayBoundsFromSnapshot( snapshot: PreviewCommentSnapshot, scale: number, @@ -118,8 +144,11 @@ export function liveSnapshotForComment( snapshots: Map, ): PreviewCommentSnapshot | null { const snapshot = snapshots.get(comment.elementId); - if (snapshot && snapshot.filePath === comment.filePath) return snapshot; + if (snapshot && snapshot.filePath === comment.filePath && isValidCommentOverlayPosition(snapshot.position)) { + return snapshot; + } if (!comment.elementId.startsWith('pin-')) return null; + if (!isValidCommentOverlayPosition(comment.position)) return null; return { filePath: comment.filePath, elementId: comment.elementId, @@ -132,6 +161,7 @@ export function liveSnapshotForComment( selectionKind: comment.selectionKind === 'pod' ? 'pod' : 'element', memberCount: comment.memberCount, podMembers: normalizeMembers(comment.podMembers), + slideIndex: comment.slideIndex, }; } diff --git a/apps/web/src/components/BoardComposerPopover.tsx b/apps/web/src/components/BoardComposerPopover.tsx index 29ff80e57..abba2b375 100644 --- a/apps/web/src/components/BoardComposerPopover.tsx +++ b/apps/web/src/components/BoardComposerPopover.tsx @@ -233,6 +233,8 @@ export function BoardComposerPopover({ onHoverMember, onDeleteComment, sending, + queueOnSend = false, + sendDisabled = false, t, scale = 1, bounds, @@ -254,6 +256,8 @@ export function BoardComposerPopover({ onHoverMember?: (elementId: string | null) => void; onDeleteComment?: (commentId: string) => void | Promise; sending: boolean; + queueOnSend?: boolean; + sendDisabled?: boolean; t: TranslateFn; scale?: number; bounds?: PopoverBounds; @@ -265,7 +269,12 @@ export function BoardComposerPopover({ const hasCommentChange = !existing || draft.trim() !== existing.note.trim(); const podMembers = target.podMembers ?? []; const composingRef = useRef(false); - const sendDisabled = pendingCount === 0 || sending; + const submitDisabled = pendingCount === 0 || sending || sendDisabled; + const primaryLabel = sending + ? t('chat.comments.sending') + : queueOnSend + ? t('chat.annotationQueue') + : t('chat.comments.sendToChat'); return (
void onSendBatch()} > - {sending ? t('chat.comments.sending') : t('chat.comments.sendToChat')} + {primaryLabel}
diff --git a/apps/web/src/components/FileViewer.tsx b/apps/web/src/components/FileViewer.tsx index 47c8647b6..d9eac82ab 100644 --- a/apps/web/src/components/FileViewer.tsx +++ b/apps/web/src/components/FileViewer.tsx @@ -94,7 +94,9 @@ import { PreviewDrawOverlay } from './PreviewDrawOverlay'; import { buildBoardCommentAttachments, commentTargetDisplayName, + commentVisibleOnDeckSlide, commentsToAttachments, + isValidCommentOverlayPosition, liveSnapshotForComment, overlayBoundsFromSnapshot, selectionKindLabel, @@ -711,6 +713,8 @@ interface Props { isDeck?: boolean; onExportAsPptx?: ((fileName: string) => void) | undefined; streaming?: boolean; + commentQueueOnSend?: boolean; + commentSendDisabled?: boolean; previewComments?: PreviewComment[]; onSavePreviewComment?: (target: PreviewCommentTarget, note: string, attachAfterSave: boolean) => Promise; onRemovePreviewComment?: (commentId: string) => Promise; @@ -733,6 +737,8 @@ export function FileViewer({ isDeck, onExportAsPptx, streaming, + commentQueueOnSend = false, + commentSendDisabled = false, previewComments = [], onSavePreviewComment, onRemovePreviewComment, @@ -773,6 +779,8 @@ export function FileViewer({ isDeck={rendererMatch.renderer.id === 'deck-html'} onExportAsPptx={onExportAsPptx} streaming={Boolean(streaming)} + commentQueueOnSend={commentQueueOnSend} + commentSendDisabled={commentSendDisabled} previewComments={previewComments} onSavePreviewComment={onSavePreviewComment} onRemovePreviewComment={onRemovePreviewComment} @@ -2094,6 +2102,8 @@ export function CommentSidePanel({ onSendSelected, onCreateComment, sending, + queueOnSend = false, + sendDisabled = false, t, composer, }: { @@ -2110,6 +2120,8 @@ export function CommentSidePanel({ onSendSelected: () => void | Promise; onCreateComment?: (note: string) => boolean | Promise; sending: boolean; + queueOnSend?: boolean; + sendDisabled?: boolean; t: TranslateFn; composer?: ReactNode; }) { @@ -2119,7 +2131,7 @@ export function CommentSidePanel({ const selectedCount = visibleSelectedIds.size; const allSelected = comments.length > 0 && selectedCount === comments.length; const commentsLabel = t('chat.tabComments'); - const canCreateComment = Boolean(onCreateComment) && newCommentDraft.trim().length > 0 && !sending; + const canCreateComment = Boolean(onCreateComment) && newCommentDraft.trim().length > 0 && !sending && !sendDisabled; const submitNewComment = async () => { if (!onCreateComment || !newCommentDraft.trim()) return; const saved = await onCreateComment(newCommentDraft.trim()); @@ -2228,10 +2240,14 @@ export function CommentSidePanel({ type="button" className="primary" data-testid="comment-side-send-claude" - disabled={sending} + disabled={sending || sendDisabled} onClick={() => void onSendSelected()} > - {sending ? t('chat.comments.sending') : t('chat.comments.sendToChat')} + {sending + ? t('chat.comments.sending') + : queueOnSend + ? t('chat.annotationQueue') + : t('chat.comments.sendToChat')} ) : null} @@ -3015,6 +3031,7 @@ function CommentPreviewOverlays({ offsetX, offsetY, strokePoints, + activeSlideIndex = null, onOpenComment, }: { comments: PreviewComment[]; @@ -3028,10 +3045,12 @@ function CommentPreviewOverlays({ offsetX: number; offsetY: number; strokePoints: StrokePoint[]; + activeSlideIndex?: number | null; onOpenComment: (comment: PreviewComment, snapshot: PreviewCommentSnapshot) => void; }) { const overlayOffset = { x: offsetX, y: offsetY }; const visibleComments = comments + .filter((comment) => commentVisibleOnDeckSlide(comment, activeSlideIndex)) .map((comment, index) => ({ comment, index, @@ -3868,6 +3887,8 @@ function HtmlViewer({ isDeck, onExportAsPptx, streaming, + commentQueueOnSend = false, + commentSendDisabled = false, previewComments = [], onSavePreviewComment, onRemovePreviewComment, @@ -3884,6 +3905,8 @@ function HtmlViewer({ isDeck: boolean; onExportAsPptx?: ((fileName: string) => void) | undefined; streaming: boolean; + commentQueueOnSend?: boolean; + commentSendDisabled?: boolean; previewComments?: PreviewComment[]; onSavePreviewComment?: (target: PreviewCommentTarget, note: string, attachAfterSave: boolean) => Promise; onRemovePreviewComment?: (commentId: string) => Promise; @@ -4928,22 +4951,25 @@ const [manualEditTargets, setManualEditTargets] = useState([ data.targets.forEach((item) => { const elementId = String(item?.elementId || ''); if (!elementId) return; + const position = { + x: clampBridgeCoordinate(item?.position?.x), + y: clampBridgeCoordinate(item?.position?.y), + width: clampBridgeCoordinate(item?.position?.width), + height: clampBridgeCoordinate(item?.position?.height), + }; + if (!isValidCommentOverlayPosition(position)) return; next.set(elementId, { filePath: file.name, elementId, selector: String(item?.selector || ''), label: String(item?.label || ''), text: String(item?.text || ''), - position: { - x: clampBridgeCoordinate(item?.position?.x), - y: clampBridgeCoordinate(item?.position?.y), - width: clampBridgeCoordinate(item?.position?.width), - height: clampBridgeCoordinate(item?.position?.height), - }, + position, htmlHint: String(item?.htmlHint || ''), style: normalizeAnnotationStyle(item?.style), selectionKind: 'element', memberCount: undefined, + ...(typeof item?.slideIndex === 'number' ? { slideIndex: item.slideIndex } : {}), }); }); setLiveCommentTargets(next); @@ -5053,6 +5079,7 @@ const [manualEditTargets, setManualEditTargets] = useState([ selectionKind: data.selectionKind === 'pod' ? 'pod' : 'element', memberCount: finiteBridgeInteger(data.memberCount), podMembers: Array.isArray(data.podMembers) ? data.podMembers : undefined, + ...(typeof data.slideIndex === 'number' ? { slideIndex: data.slideIndex } : {}), }); function onMessage(ev: MessageEvent) { if (!isOurPreviewIframeSource(ev.source)) return; @@ -5066,28 +5093,29 @@ const [manualEditTargets, setManualEditTargets] = useState([ const next = new Map(); data.targets.forEach((item) => { const snapshot = snapshotFromData(item); - if (snapshot.elementId) next.set(snapshot.elementId, snapshot); + if (!snapshot.elementId || !isValidCommentOverlayPosition(snapshot.position)) return; + next.set(snapshot.elementId, snapshot); }); setLiveCommentTargets(next); - setActiveCommentTarget((current) => ( - current - ? current.selectionKind === 'pod' - ? current - : next.get(current.elementId) ?? current - : null - )); - setHoveredCommentTarget((current) => ( - current - ? current.selectionKind === 'pod' - ? current - : next.get(current.elementId) ?? null - : null - )); + setActiveCommentTarget((current) => { + if (!current) return null; + if (current.selectionKind === 'pod') return current; + const updated = next.get(current.elementId); + if (updated && isValidCommentOverlayPosition(updated.position)) return updated; + return null; + }); + setHoveredCommentTarget((current) => { + if (!current) return null; + if (current.selectionKind === 'pod') return current; + const updated = next.get(current.elementId); + if (updated && isValidCommentOverlayPosition(updated.position)) return updated; + return null; + }); return; } if (data.type === 'od:comment-active-target-update') { const snapshot = snapshotFromData(data); - if (!snapshot.elementId) return; + if (!snapshot.elementId || !isValidCommentOverlayPosition(snapshot.position)) return; setLiveCommentTargets((current) => new Map(current).set(snapshot.elementId, snapshot)); setActiveCommentTarget((current) => ( current && current.elementId === snapshot.elementId ? snapshot : current @@ -5103,14 +5131,14 @@ const [manualEditTargets, setManualEditTargets] = useState([ } if (data.type === 'od:comment-hover') { const snapshot = snapshotFromData(data); - if (!snapshot.elementId) return; + if (!snapshot.elementId || !isValidCommentOverlayPosition(snapshot.position)) return; setHoveredCommentTarget(snapshot); setLiveCommentTargets((current) => new Map(current).set(snapshot.elementId, snapshot)); return; } if (data.type === 'od:comment-target') { const snapshot = snapshotFromData(data); - if (!snapshot.elementId) return; + if (!snapshot.elementId || !isValidCommentOverlayPosition(snapshot.position)) return; const shouldOpenComposer = boardMode || commentCreateMode; setActiveCommentTarget((current) => (shouldOpenComposer ? snapshot : current)); setHoveredCommentTarget(snapshot); @@ -6167,6 +6195,12 @@ const [manualEditTargets, setManualEditTargets] = useState([ setCommentDraft(''); } + function withDeckSlideIndex(target: PreviewCommentTarget): PreviewCommentTarget { + if (!effectiveDeck || typeof slideState?.active !== 'number') return target; + if (typeof target.slideIndex === 'number') return target; + return { ...target, slideIndex: slideState.active }; + } + async function sendBoardBatch() { if (!activeCommentTarget || !onSendBoardCommentAttachments) return; const nextNotes = [...queuedBoardNotes]; @@ -6176,7 +6210,7 @@ const [manualEditTargets, setManualEditTargets] = useState([ try { await onSendBoardCommentAttachments( buildBoardCommentAttachments({ - target: targetFromSnapshot(activeCommentTarget), + target: withDeckSlideIndex(targetFromSnapshot(activeCommentTarget)), notes: nextNotes, }), ); @@ -6191,8 +6225,9 @@ const [manualEditTargets, setManualEditTargets] = useState([ const isFreePin = activeCommentTarget.elementId.startsWith('pin-'); setSendingBoardBatch(true); try { + const target = withDeckSlideIndex(targetFromSnapshot(activeCommentTarget)); const saved = await onSavePreviewComment( - targetFromSnapshot(activeCommentTarget), + target, commentDraft.trim(), false, ); @@ -6383,6 +6418,14 @@ const [manualEditTargets, setManualEditTargets] = useState([ const stillOpen = visibleSideComments.some((comment) => comment.id === activePreviewCommentId); if (!stillOpen) clearBoardComposer(); }, [activePreviewCommentId, boardMode, visibleSideComments]); + useEffect(() => { + if (!effectiveDeck || slideState == null || !boardMode) return; + if (!activePreviewCommentId) return; + const activeComment = visibleSideComments.find((comment) => comment.id === activePreviewCommentId); + if (activeComment && !commentVisibleOnDeckSlide(activeComment, slideState.active)) { + clearBoardComposer(); + } + }, [activePreviewCommentId, boardMode, effectiveDeck, slideState?.active, visibleSideComments]); const activeDeployment = deployResult || deployment; const activeDeployedUrl = activeDeployment?.url?.trim() || ''; const activeDeploymentDelayed = activeDeployment?.status === 'link-delayed'; @@ -6578,7 +6621,9 @@ const [manualEditTargets, setManualEditTargets] = useState([ }); setActivePreviewCommentId((current) => (current === commentId ? null : current)); } : undefined} - sending={sendingBoardBatch || streaming} + sending={sendingBoardBatch} + queueOnSend={commentQueueOnSend} + sendDisabled={commentSendDisabled} t={t} scale={overlayPreviewScale} offset={{ x: overlayPreviewTransform.offsetX, y: overlayPreviewTransform.offsetY }} @@ -6656,7 +6701,9 @@ const [manualEditTargets, setManualEditTargets] = useState([ } }} onCreateComment={savePanelComment} - sending={sendingBoardBatch || streaming} + sending={sendingBoardBatch} + queueOnSend={commentQueueOnSend} + sendDisabled={commentSendDisabled} t={t} composer={null} /> @@ -7253,6 +7300,7 @@ const [manualEditTargets, setManualEditTargets] = useState([ offsetX={overlayPreviewTransform.offsetX} offsetY={overlayPreviewTransform.offsetY} strokePoints={strokePoints} + activeSlideIndex={effectiveDeck ? slideState?.active ?? null : null} onOpenComment={(comment, snapshot) => { setCommentPanelOpen(true); setCommentSidePanelCollapsed(false); diff --git a/apps/web/src/components/FileWorkspace.tsx b/apps/web/src/components/FileWorkspace.tsx index 5dfdd6b1f..be30a6cdd 100644 --- a/apps/web/src/components/FileWorkspace.tsx +++ b/apps/web/src/components/FileWorkspace.tsx @@ -69,6 +69,8 @@ interface Props { isDeck: boolean; onExportAsPptx?: ((fileName: string) => void) | undefined; streaming?: boolean; + commentQueueOnSend?: boolean; + commentSendDisabled?: boolean; openRequest?: { name: string; nonce: number } | null; liveArtifactEvents?: LiveArtifactEventItem[]; designSystemActivityEvents?: AgentEvent[]; @@ -200,6 +202,8 @@ export function FileWorkspace({ isDeck, onExportAsPptx, streaming, + commentQueueOnSend = false, + commentSendDisabled = false, openRequest, liveArtifactEvents = [], designSystemActivityEvents = [], @@ -1063,6 +1067,8 @@ export function FileWorkspace({ isDeck={isDeck} onExportAsPptx={onExportAsPptx} streaming={streaming} + commentQueueOnSend={commentQueueOnSend} + commentSendDisabled={commentSendDisabled} previewComments={previewComments.filter((comment) => comment.filePath === activeFile.name)} onSavePreviewComment={onSavePreviewComment} onRemovePreviewComment={onRemovePreviewComment} diff --git a/apps/web/src/components/ProjectView.tsx b/apps/web/src/components/ProjectView.tsx index 0946629d1..c1449d6e6 100644 --- a/apps/web/src/components/ProjectView.tsx +++ b/apps/web/src/components/ProjectView.tsx @@ -3064,13 +3064,15 @@ export function ProjectView({ const handleSendBoardCommentAttachments = useCallback( async (commentAttachments: ChatCommentAttachment[]) => { - if (currentConversationActionDisabled || commentAttachments.length === 0) return; + // Match ChatComposer: while a run is in flight we queue instead of blocking. + if (currentConversationSendDisabled || commentAttachments.length === 0) return; setWorkspaceFocused(false); setCommentInspectorActive(false); await handleSend('', [], commentAttachments); }, - [handleSend, currentConversationActionDisabled], + [handleSend, currentConversationSendDisabled], ); + const commentQueueOnSend = currentConversationBusy && !currentConversationSendDisabled; const handleContinueRemainingTasks = useCallback( (_assistantMessage: ChatMessage, todos: TodoItem[]) => { @@ -4556,6 +4558,8 @@ export function ProjectView({ isDeck={isDeck} onExportAsPptx={handleExportAsPptx} streaming={currentConversationActionDisabled} + commentQueueOnSend={commentQueueOnSend} + commentSendDisabled={currentConversationSendDisabled} openRequest={openRequest} liveArtifactEvents={liveArtifactEvents} designSystemActivityEvents={designSystemActivityEvents} diff --git a/apps/web/src/runtime/srcdoc.ts b/apps/web/src/runtime/srcdoc.ts index b1cfccde1..9c32fa3b9 100644 --- a/apps/web/src/runtime/srcdoc.ts +++ b/apps/web/src/runtime/srcdoc.ts @@ -846,6 +846,21 @@ function injectSelectionBridge( // brackets (close the