fix(web): queue preview comments while busy and pin deck markers

While a chat run is in flight, preview comments now queue instead of
showing a stuck Sending state. Deck slide changes no longer collapse
comment markers to 0,0 by filtering hidden targets and persisting slideIndex.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
chaoxiaoche 2026-05-30 18:22:13 +08:00
parent cfde84b038
commit 51aa968d75
10 changed files with 297 additions and 43 deletions

View file

@ -265,6 +265,9 @@ function migrate(db: SqliteDb): void {
if (!previewCommentCols.some((c: DbRow) => c.name === 'style_json')) { if (!previewCommentCols.some((c: DbRow) => c.name === 'style_json')) {
db.exec(`ALTER TABLE preview_comments ADD COLUMN style_json TEXT`); 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[]; const deploymentCols = db.prepare(`PRAGMA table_info(deployments)`).all() as DbRow[];
if (!deploymentCols.some((c: DbRow) => c.name === 'status')) { if (!deploymentCols.some((c: DbRow) => c.name === 'status')) {
db.exec(`ALTER TABLE deployments ADD COLUMN status TEXT NOT NULL DEFAULT 'ready'`); 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, text, position_json AS positionJson, html_hint AS htmlHint,
selection_kind AS selectionKind, member_count AS memberCount, selection_kind AS selectionKind, member_count AS memberCount,
pod_members_json AS podMembersJson, style_json AS styleJson, pod_members_json AS podMembersJson, style_json AS styleJson,
slide_index AS slideIndex,
note, status, created_at AS createdAt, updated_at AS updatedAt note, status, created_at AS createdAt, updated_at AS updatedAt
FROM preview_comments FROM preview_comments
WHERE project_id = ? AND conversation_id = ? 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)) ? Math.max(0, Math.round(target.memberCount))
: 0) : 0)
: 0; : 0;
const slideIndex = Number.isFinite(target.slideIndex) ? Math.max(0, Math.round(target.slideIndex)) : null;
const now = Date.now(); const now = Date.now();
const existing = db const existing = db
.prepare( .prepare(
@ -1116,8 +1121,8 @@ export function upsertPreviewComment(db: SqliteDb, projectId: string, conversati
`INSERT INTO preview_comments `INSERT INTO preview_comments
(id, project_id, conversation_id, file_path, element_id, selector, label, (id, project_id, conversation_id, file_path, element_id, selector, label,
text, position_json, html_hint, selection_kind, member_count, pod_members_json, text, position_json, html_hint, selection_kind, member_count, pod_members_json,
style_json, note, status, created_at, updated_at) style_json, slide_index, note, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(project_id, conversation_id, file_path, element_id) DO UPDATE SET ON CONFLICT(project_id, conversation_id, file_path, element_id) DO UPDATE SET
selector = excluded.selector, selector = excluded.selector,
label = excluded.label, label = excluded.label,
@ -1128,6 +1133,7 @@ export function upsertPreviewComment(db: SqliteDb, projectId: string, conversati
member_count = excluded.member_count, member_count = excluded.member_count,
pod_members_json = excluded.pod_members_json, pod_members_json = excluded.pod_members_json,
style_json = excluded.style_json, style_json = excluded.style_json,
slide_index = excluded.slide_index,
note = excluded.note, note = excluded.note,
status = 'open', status = 'open',
updated_at = excluded.updated_at`, updated_at = excluded.updated_at`,
@ -1146,6 +1152,7 @@ export function upsertPreviewComment(db: SqliteDb, projectId: string, conversati
selectionKind === 'pod' ? memberCount : null, selectionKind === 'pod' ? memberCount : null,
selectionKind === 'pod' ? JSON.stringify(podMembers) : null, selectionKind === 'pod' ? JSON.stringify(podMembers) : null,
style ? JSON.stringify(style) : null, style ? JSON.stringify(style) : null,
slideIndex,
note, note,
'open', 'open',
createdAt, createdAt,
@ -1183,6 +1190,7 @@ function getPreviewComment(db: SqliteDb, projectId: string, conversationId: stri
text, position_json AS positionJson, html_hint AS htmlHint, text, position_json AS positionJson, html_hint AS htmlHint,
selection_kind AS selectionKind, member_count AS memberCount, selection_kind AS selectionKind, member_count AS memberCount,
pod_members_json AS podMembersJson, style_json AS styleJson, pod_members_json AS podMembersJson, style_json AS styleJson,
slide_index AS slideIndex,
note, status, created_at AS createdAt, updated_at AS updatedAt note, status, created_at AS createdAt, updated_at AS updatedAt
FROM preview_comments FROM preview_comments
WHERE id = ? AND project_id = ? AND conversation_id = ?`, WHERE id = ? AND project_id = ? AND conversation_id = ?`,
@ -1214,6 +1222,7 @@ function normalizePreviewComment(row: DbRow) {
? row.memberCount ? row.memberCount
: undefined, : undefined,
podMembers: normalizedPodMembers, podMembers: normalizedPodMembers,
slideIndex: Number.isFinite(row.slideIndex) ? row.slideIndex : undefined,
note: row.note, note: row.note,
status: row.status, status: row.status,
createdAt: row.createdAt, createdAt: row.createdAt,

View file

@ -23,6 +23,7 @@ export interface PreviewCommentSnapshot {
selectionKind?: PreviewCommentSelectionKind; selectionKind?: PreviewCommentSelectionKind;
memberCount?: number; memberCount?: number;
podMembers?: PreviewCommentMember[]; podMembers?: PreviewCommentMember[];
slideIndex?: number;
} }
export interface CommentOverlayBounds { export interface CommentOverlayBounds {
@ -95,9 +96,34 @@ export function targetFromSnapshot(snapshot: PreviewCommentSnapshot): PreviewCom
: 0) : 0)
: undefined, : undefined,
podMembers: podMembers.length > 0 ? podMembers : 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<PreviewComment, 'slideIndex'>,
activeSlideIndex: number | null | undefined,
): boolean {
if (activeSlideIndex == null) return true;
if (typeof comment.slideIndex !== 'number') return true;
return comment.slideIndex === activeSlideIndex;
}
export function overlayBoundsFromSnapshot( export function overlayBoundsFromSnapshot(
snapshot: PreviewCommentSnapshot, snapshot: PreviewCommentSnapshot,
scale: number, scale: number,
@ -118,8 +144,11 @@ export function liveSnapshotForComment(
snapshots: Map<string, PreviewCommentSnapshot>, snapshots: Map<string, PreviewCommentSnapshot>,
): PreviewCommentSnapshot | null { ): PreviewCommentSnapshot | null {
const snapshot = snapshots.get(comment.elementId); 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 (!comment.elementId.startsWith('pin-')) return null;
if (!isValidCommentOverlayPosition(comment.position)) return null;
return { return {
filePath: comment.filePath, filePath: comment.filePath,
elementId: comment.elementId, elementId: comment.elementId,
@ -132,6 +161,7 @@ export function liveSnapshotForComment(
selectionKind: comment.selectionKind === 'pod' ? 'pod' : 'element', selectionKind: comment.selectionKind === 'pod' ? 'pod' : 'element',
memberCount: comment.memberCount, memberCount: comment.memberCount,
podMembers: normalizeMembers(comment.podMembers), podMembers: normalizeMembers(comment.podMembers),
slideIndex: comment.slideIndex,
}; };
} }

View file

@ -233,6 +233,8 @@ export function BoardComposerPopover({
onHoverMember, onHoverMember,
onDeleteComment, onDeleteComment,
sending, sending,
queueOnSend = false,
sendDisabled = false,
t, t,
scale = 1, scale = 1,
bounds, bounds,
@ -254,6 +256,8 @@ export function BoardComposerPopover({
onHoverMember?: (elementId: string | null) => void; onHoverMember?: (elementId: string | null) => void;
onDeleteComment?: (commentId: string) => void | Promise<void>; onDeleteComment?: (commentId: string) => void | Promise<void>;
sending: boolean; sending: boolean;
queueOnSend?: boolean;
sendDisabled?: boolean;
t: TranslateFn; t: TranslateFn;
scale?: number; scale?: number;
bounds?: PopoverBounds; bounds?: PopoverBounds;
@ -265,7 +269,12 @@ export function BoardComposerPopover({
const hasCommentChange = !existing || draft.trim() !== existing.note.trim(); const hasCommentChange = !existing || draft.trim() !== existing.note.trim();
const podMembers = target.podMembers ?? []; const podMembers = target.podMembers ?? [];
const composingRef = useRef(false); 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 ( return (
<div <div
className={`comment-popover${docked ? ' comment-popover-docked' : ''}`} className={`comment-popover${docked ? ' comment-popover-docked' : ''}`}
@ -354,7 +363,7 @@ export function BoardComposerPopover({
(event.metaKey || event.ctrlKey) (event.metaKey || event.ctrlKey)
) { ) {
event.preventDefault(); event.preventDefault();
if (sendDisabled) return; if (submitDisabled) return;
void onSendBatch(); void onSendBatch();
} }
}} }}
@ -409,10 +418,10 @@ export function BoardComposerPopover({
type="button" type="button"
className="primary" className="primary"
data-testid="comment-add-send" data-testid="comment-add-send"
disabled={sendDisabled} disabled={submitDisabled}
onClick={() => void onSendBatch()} onClick={() => void onSendBatch()}
> >
{sending ? t('chat.comments.sending') : t('chat.comments.sendToChat')} {primaryLabel}
</button> </button>
</div> </div>
</div> </div>

View file

@ -94,7 +94,9 @@ import { PreviewDrawOverlay } from './PreviewDrawOverlay';
import { import {
buildBoardCommentAttachments, buildBoardCommentAttachments,
commentTargetDisplayName, commentTargetDisplayName,
commentVisibleOnDeckSlide,
commentsToAttachments, commentsToAttachments,
isValidCommentOverlayPosition,
liveSnapshotForComment, liveSnapshotForComment,
overlayBoundsFromSnapshot, overlayBoundsFromSnapshot,
selectionKindLabel, selectionKindLabel,
@ -711,6 +713,8 @@ interface Props {
isDeck?: boolean; isDeck?: boolean;
onExportAsPptx?: ((fileName: string) => void) | undefined; onExportAsPptx?: ((fileName: string) => void) | undefined;
streaming?: boolean; streaming?: boolean;
commentQueueOnSend?: boolean;
commentSendDisabled?: boolean;
previewComments?: PreviewComment[]; previewComments?: PreviewComment[];
onSavePreviewComment?: (target: PreviewCommentTarget, note: string, attachAfterSave: boolean) => Promise<PreviewComment | null>; onSavePreviewComment?: (target: PreviewCommentTarget, note: string, attachAfterSave: boolean) => Promise<PreviewComment | null>;
onRemovePreviewComment?: (commentId: string) => Promise<void>; onRemovePreviewComment?: (commentId: string) => Promise<void>;
@ -733,6 +737,8 @@ export function FileViewer({
isDeck, isDeck,
onExportAsPptx, onExportAsPptx,
streaming, streaming,
commentQueueOnSend = false,
commentSendDisabled = false,
previewComments = [], previewComments = [],
onSavePreviewComment, onSavePreviewComment,
onRemovePreviewComment, onRemovePreviewComment,
@ -773,6 +779,8 @@ export function FileViewer({
isDeck={rendererMatch.renderer.id === 'deck-html'} isDeck={rendererMatch.renderer.id === 'deck-html'}
onExportAsPptx={onExportAsPptx} onExportAsPptx={onExportAsPptx}
streaming={Boolean(streaming)} streaming={Boolean(streaming)}
commentQueueOnSend={commentQueueOnSend}
commentSendDisabled={commentSendDisabled}
previewComments={previewComments} previewComments={previewComments}
onSavePreviewComment={onSavePreviewComment} onSavePreviewComment={onSavePreviewComment}
onRemovePreviewComment={onRemovePreviewComment} onRemovePreviewComment={onRemovePreviewComment}
@ -2094,6 +2102,8 @@ export function CommentSidePanel({
onSendSelected, onSendSelected,
onCreateComment, onCreateComment,
sending, sending,
queueOnSend = false,
sendDisabled = false,
t, t,
composer, composer,
}: { }: {
@ -2110,6 +2120,8 @@ export function CommentSidePanel({
onSendSelected: () => void | Promise<void>; onSendSelected: () => void | Promise<void>;
onCreateComment?: (note: string) => boolean | Promise<boolean>; onCreateComment?: (note: string) => boolean | Promise<boolean>;
sending: boolean; sending: boolean;
queueOnSend?: boolean;
sendDisabled?: boolean;
t: TranslateFn; t: TranslateFn;
composer?: ReactNode; composer?: ReactNode;
}) { }) {
@ -2119,7 +2131,7 @@ export function CommentSidePanel({
const selectedCount = visibleSelectedIds.size; const selectedCount = visibleSelectedIds.size;
const allSelected = comments.length > 0 && selectedCount === comments.length; const allSelected = comments.length > 0 && selectedCount === comments.length;
const commentsLabel = t('chat.tabComments'); 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 () => { const submitNewComment = async () => {
if (!onCreateComment || !newCommentDraft.trim()) return; if (!onCreateComment || !newCommentDraft.trim()) return;
const saved = await onCreateComment(newCommentDraft.trim()); const saved = await onCreateComment(newCommentDraft.trim());
@ -2228,10 +2240,14 @@ export function CommentSidePanel({
type="button" type="button"
className="primary" className="primary"
data-testid="comment-side-send-claude" data-testid="comment-side-send-claude"
disabled={sending} disabled={sending || sendDisabled}
onClick={() => void onSendSelected()} 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')}
</button> </button>
</div> </div>
) : null} ) : null}
@ -3015,6 +3031,7 @@ function CommentPreviewOverlays({
offsetX, offsetX,
offsetY, offsetY,
strokePoints, strokePoints,
activeSlideIndex = null,
onOpenComment, onOpenComment,
}: { }: {
comments: PreviewComment[]; comments: PreviewComment[];
@ -3028,10 +3045,12 @@ function CommentPreviewOverlays({
offsetX: number; offsetX: number;
offsetY: number; offsetY: number;
strokePoints: StrokePoint[]; strokePoints: StrokePoint[];
activeSlideIndex?: number | null;
onOpenComment: (comment: PreviewComment, snapshot: PreviewCommentSnapshot) => void; onOpenComment: (comment: PreviewComment, snapshot: PreviewCommentSnapshot) => void;
}) { }) {
const overlayOffset = { x: offsetX, y: offsetY }; const overlayOffset = { x: offsetX, y: offsetY };
const visibleComments = comments const visibleComments = comments
.filter((comment) => commentVisibleOnDeckSlide(comment, activeSlideIndex))
.map((comment, index) => ({ .map((comment, index) => ({
comment, comment,
index, index,
@ -3868,6 +3887,8 @@ function HtmlViewer({
isDeck, isDeck,
onExportAsPptx, onExportAsPptx,
streaming, streaming,
commentQueueOnSend = false,
commentSendDisabled = false,
previewComments = [], previewComments = [],
onSavePreviewComment, onSavePreviewComment,
onRemovePreviewComment, onRemovePreviewComment,
@ -3884,6 +3905,8 @@ function HtmlViewer({
isDeck: boolean; isDeck: boolean;
onExportAsPptx?: ((fileName: string) => void) | undefined; onExportAsPptx?: ((fileName: string) => void) | undefined;
streaming: boolean; streaming: boolean;
commentQueueOnSend?: boolean;
commentSendDisabled?: boolean;
previewComments?: PreviewComment[]; previewComments?: PreviewComment[];
onSavePreviewComment?: (target: PreviewCommentTarget, note: string, attachAfterSave: boolean) => Promise<PreviewComment | null>; onSavePreviewComment?: (target: PreviewCommentTarget, note: string, attachAfterSave: boolean) => Promise<PreviewComment | null>;
onRemovePreviewComment?: (commentId: string) => Promise<void>; onRemovePreviewComment?: (commentId: string) => Promise<void>;
@ -4928,22 +4951,25 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
data.targets.forEach((item) => { data.targets.forEach((item) => {
const elementId = String(item?.elementId || ''); const elementId = String(item?.elementId || '');
if (!elementId) return; 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, { next.set(elementId, {
filePath: file.name, filePath: file.name,
elementId, elementId,
selector: String(item?.selector || ''), selector: String(item?.selector || ''),
label: String(item?.label || ''), label: String(item?.label || ''),
text: String(item?.text || ''), text: String(item?.text || ''),
position: { position,
x: clampBridgeCoordinate(item?.position?.x),
y: clampBridgeCoordinate(item?.position?.y),
width: clampBridgeCoordinate(item?.position?.width),
height: clampBridgeCoordinate(item?.position?.height),
},
htmlHint: String(item?.htmlHint || ''), htmlHint: String(item?.htmlHint || ''),
style: normalizeAnnotationStyle(item?.style), style: normalizeAnnotationStyle(item?.style),
selectionKind: 'element', selectionKind: 'element',
memberCount: undefined, memberCount: undefined,
...(typeof item?.slideIndex === 'number' ? { slideIndex: item.slideIndex } : {}),
}); });
}); });
setLiveCommentTargets(next); setLiveCommentTargets(next);
@ -5053,6 +5079,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
selectionKind: data.selectionKind === 'pod' ? 'pod' : 'element', selectionKind: data.selectionKind === 'pod' ? 'pod' : 'element',
memberCount: finiteBridgeInteger(data.memberCount), memberCount: finiteBridgeInteger(data.memberCount),
podMembers: Array.isArray(data.podMembers) ? data.podMembers : undefined, podMembers: Array.isArray(data.podMembers) ? data.podMembers : undefined,
...(typeof data.slideIndex === 'number' ? { slideIndex: data.slideIndex } : {}),
}); });
function onMessage(ev: MessageEvent) { function onMessage(ev: MessageEvent) {
if (!isOurPreviewIframeSource(ev.source)) return; if (!isOurPreviewIframeSource(ev.source)) return;
@ -5066,28 +5093,29 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
const next = new Map<string, PreviewCommentSnapshot>(); const next = new Map<string, PreviewCommentSnapshot>();
data.targets.forEach((item) => { data.targets.forEach((item) => {
const snapshot = snapshotFromData(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); setLiveCommentTargets(next);
setActiveCommentTarget((current) => ( setActiveCommentTarget((current) => {
current if (!current) return null;
? current.selectionKind === 'pod' if (current.selectionKind === 'pod') return current;
? current const updated = next.get(current.elementId);
: next.get(current.elementId) ?? current if (updated && isValidCommentOverlayPosition(updated.position)) return updated;
: null return null;
)); });
setHoveredCommentTarget((current) => ( setHoveredCommentTarget((current) => {
current if (!current) return null;
? current.selectionKind === 'pod' if (current.selectionKind === 'pod') return current;
? current const updated = next.get(current.elementId);
: next.get(current.elementId) ?? null if (updated && isValidCommentOverlayPosition(updated.position)) return updated;
: null return null;
)); });
return; return;
} }
if (data.type === 'od:comment-active-target-update') { if (data.type === 'od:comment-active-target-update') {
const snapshot = snapshotFromData(data); const snapshot = snapshotFromData(data);
if (!snapshot.elementId) return; if (!snapshot.elementId || !isValidCommentOverlayPosition(snapshot.position)) return;
setLiveCommentTargets((current) => new Map(current).set(snapshot.elementId, snapshot)); setLiveCommentTargets((current) => new Map(current).set(snapshot.elementId, snapshot));
setActiveCommentTarget((current) => ( setActiveCommentTarget((current) => (
current && current.elementId === snapshot.elementId ? snapshot : current current && current.elementId === snapshot.elementId ? snapshot : current
@ -5103,14 +5131,14 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
} }
if (data.type === 'od:comment-hover') { if (data.type === 'od:comment-hover') {
const snapshot = snapshotFromData(data); const snapshot = snapshotFromData(data);
if (!snapshot.elementId) return; if (!snapshot.elementId || !isValidCommentOverlayPosition(snapshot.position)) return;
setHoveredCommentTarget(snapshot); setHoveredCommentTarget(snapshot);
setLiveCommentTargets((current) => new Map(current).set(snapshot.elementId, snapshot)); setLiveCommentTargets((current) => new Map(current).set(snapshot.elementId, snapshot));
return; return;
} }
if (data.type === 'od:comment-target') { if (data.type === 'od:comment-target') {
const snapshot = snapshotFromData(data); const snapshot = snapshotFromData(data);
if (!snapshot.elementId) return; if (!snapshot.elementId || !isValidCommentOverlayPosition(snapshot.position)) return;
const shouldOpenComposer = boardMode || commentCreateMode; const shouldOpenComposer = boardMode || commentCreateMode;
setActiveCommentTarget((current) => (shouldOpenComposer ? snapshot : current)); setActiveCommentTarget((current) => (shouldOpenComposer ? snapshot : current));
setHoveredCommentTarget(snapshot); setHoveredCommentTarget(snapshot);
@ -6167,6 +6195,12 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
setCommentDraft(''); 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() { async function sendBoardBatch() {
if (!activeCommentTarget || !onSendBoardCommentAttachments) return; if (!activeCommentTarget || !onSendBoardCommentAttachments) return;
const nextNotes = [...queuedBoardNotes]; const nextNotes = [...queuedBoardNotes];
@ -6176,7 +6210,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
try { try {
await onSendBoardCommentAttachments( await onSendBoardCommentAttachments(
buildBoardCommentAttachments({ buildBoardCommentAttachments({
target: targetFromSnapshot(activeCommentTarget), target: withDeckSlideIndex(targetFromSnapshot(activeCommentTarget)),
notes: nextNotes, notes: nextNotes,
}), }),
); );
@ -6191,8 +6225,9 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
const isFreePin = activeCommentTarget.elementId.startsWith('pin-'); const isFreePin = activeCommentTarget.elementId.startsWith('pin-');
setSendingBoardBatch(true); setSendingBoardBatch(true);
try { try {
const target = withDeckSlideIndex(targetFromSnapshot(activeCommentTarget));
const saved = await onSavePreviewComment( const saved = await onSavePreviewComment(
targetFromSnapshot(activeCommentTarget), target,
commentDraft.trim(), commentDraft.trim(),
false, false,
); );
@ -6383,6 +6418,14 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
const stillOpen = visibleSideComments.some((comment) => comment.id === activePreviewCommentId); const stillOpen = visibleSideComments.some((comment) => comment.id === activePreviewCommentId);
if (!stillOpen) clearBoardComposer(); if (!stillOpen) clearBoardComposer();
}, [activePreviewCommentId, boardMode, visibleSideComments]); }, [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 activeDeployment = deployResult || deployment;
const activeDeployedUrl = activeDeployment?.url?.trim() || ''; const activeDeployedUrl = activeDeployment?.url?.trim() || '';
const activeDeploymentDelayed = activeDeployment?.status === 'link-delayed'; const activeDeploymentDelayed = activeDeployment?.status === 'link-delayed';
@ -6578,7 +6621,9 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
}); });
setActivePreviewCommentId((current) => (current === commentId ? null : current)); setActivePreviewCommentId((current) => (current === commentId ? null : current));
} : undefined} } : undefined}
sending={sendingBoardBatch || streaming} sending={sendingBoardBatch}
queueOnSend={commentQueueOnSend}
sendDisabled={commentSendDisabled}
t={t} t={t}
scale={overlayPreviewScale} scale={overlayPreviewScale}
offset={{ x: overlayPreviewTransform.offsetX, y: overlayPreviewTransform.offsetY }} offset={{ x: overlayPreviewTransform.offsetX, y: overlayPreviewTransform.offsetY }}
@ -6656,7 +6701,9 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
} }
}} }}
onCreateComment={savePanelComment} onCreateComment={savePanelComment}
sending={sendingBoardBatch || streaming} sending={sendingBoardBatch}
queueOnSend={commentQueueOnSend}
sendDisabled={commentSendDisabled}
t={t} t={t}
composer={null} composer={null}
/> />
@ -7253,6 +7300,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
offsetX={overlayPreviewTransform.offsetX} offsetX={overlayPreviewTransform.offsetX}
offsetY={overlayPreviewTransform.offsetY} offsetY={overlayPreviewTransform.offsetY}
strokePoints={strokePoints} strokePoints={strokePoints}
activeSlideIndex={effectiveDeck ? slideState?.active ?? null : null}
onOpenComment={(comment, snapshot) => { onOpenComment={(comment, snapshot) => {
setCommentPanelOpen(true); setCommentPanelOpen(true);
setCommentSidePanelCollapsed(false); setCommentSidePanelCollapsed(false);

View file

@ -69,6 +69,8 @@ interface Props {
isDeck: boolean; isDeck: boolean;
onExportAsPptx?: ((fileName: string) => void) | undefined; onExportAsPptx?: ((fileName: string) => void) | undefined;
streaming?: boolean; streaming?: boolean;
commentQueueOnSend?: boolean;
commentSendDisabled?: boolean;
openRequest?: { name: string; nonce: number } | null; openRequest?: { name: string; nonce: number } | null;
liveArtifactEvents?: LiveArtifactEventItem[]; liveArtifactEvents?: LiveArtifactEventItem[];
designSystemActivityEvents?: AgentEvent[]; designSystemActivityEvents?: AgentEvent[];
@ -200,6 +202,8 @@ export function FileWorkspace({
isDeck, isDeck,
onExportAsPptx, onExportAsPptx,
streaming, streaming,
commentQueueOnSend = false,
commentSendDisabled = false,
openRequest, openRequest,
liveArtifactEvents = [], liveArtifactEvents = [],
designSystemActivityEvents = [], designSystemActivityEvents = [],
@ -1063,6 +1067,8 @@ export function FileWorkspace({
isDeck={isDeck} isDeck={isDeck}
onExportAsPptx={onExportAsPptx} onExportAsPptx={onExportAsPptx}
streaming={streaming} streaming={streaming}
commentQueueOnSend={commentQueueOnSend}
commentSendDisabled={commentSendDisabled}
previewComments={previewComments.filter((comment) => comment.filePath === activeFile.name)} previewComments={previewComments.filter((comment) => comment.filePath === activeFile.name)}
onSavePreviewComment={onSavePreviewComment} onSavePreviewComment={onSavePreviewComment}
onRemovePreviewComment={onRemovePreviewComment} onRemovePreviewComment={onRemovePreviewComment}

View file

@ -3064,13 +3064,15 @@ export function ProjectView({
const handleSendBoardCommentAttachments = useCallback( const handleSendBoardCommentAttachments = useCallback(
async (commentAttachments: ChatCommentAttachment[]) => { 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); setWorkspaceFocused(false);
setCommentInspectorActive(false); setCommentInspectorActive(false);
await handleSend('', [], commentAttachments); await handleSend('', [], commentAttachments);
}, },
[handleSend, currentConversationActionDisabled], [handleSend, currentConversationSendDisabled],
); );
const commentQueueOnSend = currentConversationBusy && !currentConversationSendDisabled;
const handleContinueRemainingTasks = useCallback( const handleContinueRemainingTasks = useCallback(
(_assistantMessage: ChatMessage, todos: TodoItem[]) => { (_assistantMessage: ChatMessage, todos: TodoItem[]) => {
@ -4556,6 +4558,8 @@ export function ProjectView({
isDeck={isDeck} isDeck={isDeck}
onExportAsPptx={handleExportAsPptx} onExportAsPptx={handleExportAsPptx}
streaming={currentConversationActionDisabled} streaming={currentConversationActionDisabled}
commentQueueOnSend={commentQueueOnSend}
commentSendDisabled={currentConversationSendDisabled}
openRequest={openRequest} openRequest={openRequest}
liveArtifactEvents={liveArtifactEvents} liveArtifactEvents={liveArtifactEvents}
designSystemActivityEvents={designSystemActivityEvents} designSystemActivityEvents={designSystemActivityEvents}

View file

@ -846,6 +846,21 @@ function injectSelectionBridge(
// brackets (close the <style> tag), and newlines (defense in depth). // brackets (close the <style> tag), and newlines (defense in depth).
var UNSAFE_VALUE = /[;{}<>\\n\\r]/; var UNSAFE_VALUE = /[;{}<>\\n\\r]/;
function active(){ return commentEnabled || inspectEnabled; } function active(){ return commentEnabled || inspectEnabled; }
function deckSlideIndexForPayload(){
try {
var state = window.__odDeckSlideState && window.__odDeckSlideState();
if (state && typeof state.active === 'number' && state.count > 1) return state.active;
} catch (_) {}
return null;
}
function elementVisibleForComment(el, rect){
if (!el || !rect || rect.width <= 0 || rect.height <= 0) return false;
try {
var cs = window.getComputedStyle(el);
if (cs.display === 'none' || cs.visibility === 'hidden' || Number(cs.opacity) === 0) return false;
} catch (_) {}
return true;
}
function esc(value){ try { return window.CSS && CSS.escape ? CSS.escape(value) : String(value).replace(/"/g, '\\\\"'); } catch (_) { return String(value); } } function esc(value){ try { return window.CSS && CSS.escape ? CSS.escape(value) : String(value).replace(/"/g, '\\\\"'); } catch (_) { return String(value); } }
// Recompute the selector from elementId rather than trusting the one in // Recompute the selector from elementId rather than trusting the one in
// the inbound message — a forged selector like // the inbound message — a forged selector like
@ -1077,6 +1092,7 @@ function meaningfulDomFallbackTarget(el) {
var html = ''; var html = '';
try { html = (el.outerHTML || '').replace(/\\s+/g, ' ').match(/^<[^>]+>/)?.[0] || ''; } catch (_) {} try { html = (el.outerHTML || '').replace(/\\s+/g, ' ').match(/^<[^>]+>/)?.[0] || ''; } catch (_) {}
var position = { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }; var position = { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) };
if (!elementVisibleForComment(el, position)) return null;
var payload = { var payload = {
type: 'od:comment-target', type: 'od:comment-target',
elementId: id, elementId: id,
@ -1087,6 +1103,8 @@ function meaningfulDomFallbackTarget(el) {
htmlHint: html.slice(0, 180), htmlHint: html.slice(0, 180),
style: styleSnapshot(el) style: styleSnapshot(el)
}; };
var slideIndex = deckSlideIndexForPayload();
if (typeof slideIndex === 'number') payload.slideIndex = slideIndex;
if (clickPoint) { if (clickPoint) {
payload.hoverPoint = { x: Math.round(clickPoint.x), y: Math.round(clickPoint.y) }; payload.hoverPoint = { x: Math.round(clickPoint.x), y: Math.round(clickPoint.y) };
} }
@ -1437,9 +1455,9 @@ function meaningfulDomFallbackTarget(el) {
var pinX = Math.round(ev.clientX); var pinX = Math.round(ev.clientX);
var pinY = Math.round(ev.clientY); var pinY = Math.round(ev.clientY);
var pinId = 'pin-' + Date.now().toString(36) + '-' + Math.floor(Math.random() * 1e6).toString(36); var pinId = 'pin-' + Date.now().toString(36) + '-' + Math.floor(Math.random() * 1e6).toString(36);
window.parent.postMessage({ var pinSlideIndex = deckSlideIndexForPayload();
var pinPayload = {
type: 'od:comment-target', type: 'od:comment-target',
elementId: pinId,
// Synthetic selector / label so daemon upsert validation (which // Synthetic selector / label so daemon upsert validation (which
// requires both to be non-empty) accepts the saved free-pin. // requires both to be non-empty) accepts the saved free-pin.
selector: '[data-od-pin="' + pinId + '"]', selector: '[data-od-pin="' + pinId + '"]',
@ -1450,7 +1468,10 @@ function meaningfulDomFallbackTarget(el) {
htmlHint: '', htmlHint: '',
style: null, style: null,
freePin: true freePin: true
}, '*'); };
pinPayload.elementId = pinId;
if (typeof pinSlideIndex === 'number') pinPayload.slideIndex = pinSlideIndex;
window.parent.postMessage(pinPayload, '*');
}, true); }, true);
// Pod drawing — only active in comment mode with the 'pod' tool. // Pod drawing — only active in comment mode with the 'pod' tool.
document.addEventListener('pointerdown', function(ev){ document.addEventListener('pointerdown', function(ev){
@ -1505,6 +1526,7 @@ function meaningfulDomFallbackTarget(el) {
setTimeout(requestPreviewScrollRestore, 0); setTimeout(requestPreviewScrollRestore, 0);
setTimeout(requestPreviewScrollRestore, 80); setTimeout(requestPreviewScrollRestore, 80);
setTimeout(requestPreviewScrollRestore, 240); setTimeout(requestPreviewScrollRestore, 240);
window.__odScheduleCommentTargets = schedulePostTargets;
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', postTargets); if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', postTargets);
else setTimeout(postTargets, 0); else setTimeout(postTargets, 0);
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', postPreviewScroll); if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', postPreviewScroll);
@ -1896,8 +1918,15 @@ function injectDeckBridge(doc: string, initialSlideIndex = 0): string {
if (el.querySelector('span,.bar')) return; if (el.querySelector('span,.bar')) return;
el.style.width=progressWidth; el.style.width=progressWidth;
}); });
try {
if (typeof window.__odScheduleCommentTargets === 'function') window.__odScheduleCommentTargets();
} catch (_) {}
} catch (e) {} } catch (e) {}
} }
window.__odDeckSlideState = function(){
var list = slides();
return { active: activeIndex(list), count: list.length };
};
function restoreInitialSlide(){ function restoreInitialSlide(){
if (didRestoreInitialSlide) { report(); return; } if (didRestoreInitialSlide) { report(); return; }
var list = slides(); var list = slides();

View file

@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import { import {
buildBoardCommentAttachments, buildBoardCommentAttachments,
buildVisualAnnotationAttachment, buildVisualAnnotationAttachment,
commentVisibleOnDeckSlide,
commentsToAttachments, commentsToAttachments,
historyWithCommentAttachmentContext, historyWithCommentAttachmentContext,
liveSnapshotForComment, liveSnapshotForComment,
@ -241,6 +242,29 @@ describe('preview comment attachment helpers', () => {
}); });
}); });
it('ignores collapsed live snapshots so deck slide changes do not jump markers to 0,0', () => {
const saved = comment({ filePath: 'index.html', elementId: 'hero-title' });
const snapshots = new Map([
['hero-title', {
filePath: 'index.html',
elementId: 'hero-title',
selector: '[data-od-id="hero-title"]',
label: 'h1.hero-title',
text: '',
htmlHint: '',
position: { x: 0, y: 0, width: 0, height: 0 },
}],
]);
expect(liveSnapshotForComment(saved, snapshots)).toBeNull();
});
it('shows deck comments only on their saved slide index', () => {
expect(commentVisibleOnDeckSlide({ slideIndex: 2 }, 2)).toBe(true);
expect(commentVisibleOnDeckSlide({ slideIndex: 2 }, 1)).toBe(false);
expect(commentVisibleOnDeckSlide({}, 1)).toBe(true);
});
it('serializes selected comments into API-mode prompt context without visible input', () => { it('serializes selected comments into API-mode prompt context without visible input', () => {
const attachments = commentsToAttachments([ const attachments = commentsToAttachments([
comment({ comment({

View file

@ -0,0 +1,91 @@
// @vitest-environment jsdom
// Regression: while a chat run is in flight the comment popover must queue
// instead of showing a stuck "Sending..." state with a disabled primary action.
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { BoardComposerPopover } from '../../src/components/BoardComposerPopover';
import type { PreviewCommentSnapshot } from '../../src/comments';
afterEach(() => {
cleanup();
});
const target: PreviewCommentSnapshot = {
filePath: 'index.html',
elementId: 'hero-title',
selector: '#hero-title',
label: 'Hero title',
text: '',
position: { x: 0, y: 0, width: 100, height: 24 },
htmlHint: '',
selectionKind: 'element',
};
const labels: Record<string, string> = {
'chat.comments.sendToChat': 'Send to chat',
'chat.comments.sending': 'Sending…',
'chat.annotationQueue': 'Queue',
'chat.comments.placeholder': 'Comment on this element…',
'chat.comments.comment': 'Comment',
};
describe('BoardComposerPopover queue on busy conversation', () => {
it('shows Queue and stays clickable while a run is in flight', () => {
const onSendBatch = vi.fn();
render(
<BoardComposerPopover
target={target}
existing={null}
draft="Make this text white"
notes={[]}
onDraft={() => {}}
onAddDraft={() => {}}
onRemoveQueuedNote={() => {}}
onClose={() => {}}
onSaveComment={() => {}}
onSendBatch={onSendBatch}
onRemoveMember={() => {}}
sending={false}
queueOnSend
sendDisabled={false}
t={((key: string) => labels[key] ?? key) as never}
/>,
);
const send = screen.getByTestId('comment-add-send') as HTMLButtonElement;
expect(send.textContent).toBe('Queue');
expect(send.disabled).toBe(false);
fireEvent.click(send);
expect(onSendBatch).toHaveBeenCalledTimes(1);
});
it('shows Sending… only while the batch submit is in flight', () => {
render(
<BoardComposerPopover
target={target}
existing={null}
draft="Make this text white"
notes={[]}
onDraft={() => {}}
onAddDraft={() => {}}
onRemoveQueuedNote={() => {}}
onClose={() => {}}
onSaveComment={() => {}}
onSendBatch={() => {}}
onRemoveMember={() => {}}
sending
queueOnSend
sendDisabled={false}
t={((key: string) => labels[key] ?? key) as never}
/>,
);
const send = screen.getByTestId('comment-add-send') as HTMLButtonElement;
expect(send.textContent).toBe('Sending…');
expect(send.disabled).toBe(true);
});
});

View file

@ -55,6 +55,8 @@ export interface PreviewCommentTarget {
selectionKind?: PreviewCommentSelectionKind; selectionKind?: PreviewCommentSelectionKind;
memberCount?: number; memberCount?: number;
podMembers?: PreviewCommentMember[]; podMembers?: PreviewCommentMember[];
/** Zero-based deck slide index when the comment was placed. */
slideIndex?: number;
} }
export interface PreviewComment { export interface PreviewComment {
@ -72,6 +74,8 @@ export interface PreviewComment {
selectionKind?: PreviewCommentSelectionKind; selectionKind?: PreviewCommentSelectionKind;
memberCount?: number; memberCount?: number;
podMembers?: PreviewCommentMember[]; podMembers?: PreviewCommentMember[];
/** Zero-based deck slide index when the comment was placed. */
slideIndex?: number;
note: string; note: string;
status: PreviewCommentStatus; status: PreviewCommentStatus;
createdAt: number; createdAt: number;