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')) {
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,

View file

@ -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<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(
snapshot: PreviewCommentSnapshot,
scale: number,
@ -118,8 +144,11 @@ export function liveSnapshotForComment(
snapshots: Map<string, PreviewCommentSnapshot>,
): 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,
};
}

View file

@ -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<void>;
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 (
<div
className={`comment-popover${docked ? ' comment-popover-docked' : ''}`}
@ -354,7 +363,7 @@ export function BoardComposerPopover({
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
if (sendDisabled) return;
if (submitDisabled) return;
void onSendBatch();
}
}}
@ -409,10 +418,10 @@ export function BoardComposerPopover({
type="button"
className="primary"
data-testid="comment-add-send"
disabled={sendDisabled}
disabled={submitDisabled}
onClick={() => void onSendBatch()}
>
{sending ? t('chat.comments.sending') : t('chat.comments.sendToChat')}
{primaryLabel}
</button>
</div>
</div>

View file

@ -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<PreviewComment | null>;
onRemovePreviewComment?: (commentId: string) => Promise<void>;
@ -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<void>;
onCreateComment?: (note: string) => boolean | Promise<boolean>;
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')}
</button>
</div>
) : 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<PreviewComment | null>;
onRemovePreviewComment?: (commentId: string) => Promise<void>;
@ -4928,22 +4951,25 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
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<ManualEditTarget[]>([
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<ManualEditTarget[]>([
const next = new Map<string, PreviewCommentSnapshot>();
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<ManualEditTarget[]>([
}
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<ManualEditTarget[]>([
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<ManualEditTarget[]>([
try {
await onSendBoardCommentAttachments(
buildBoardCommentAttachments({
target: targetFromSnapshot(activeCommentTarget),
target: withDeckSlideIndex(targetFromSnapshot(activeCommentTarget)),
notes: nextNotes,
}),
);
@ -6191,8 +6225,9 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
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<ManualEditTarget[]>([
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<ManualEditTarget[]>([
});
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<ManualEditTarget[]>([
}
}}
onCreateComment={savePanelComment}
sending={sendingBoardBatch || streaming}
sending={sendingBoardBatch}
queueOnSend={commentQueueOnSend}
sendDisabled={commentSendDisabled}
t={t}
composer={null}
/>
@ -7253,6 +7300,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
offsetX={overlayPreviewTransform.offsetX}
offsetY={overlayPreviewTransform.offsetY}
strokePoints={strokePoints}
activeSlideIndex={effectiveDeck ? slideState?.active ?? null : null}
onOpenComment={(comment, snapshot) => {
setCommentPanelOpen(true);
setCommentSidePanelCollapsed(false);

View file

@ -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}

View file

@ -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}

View file

@ -846,6 +846,21 @@ function injectSelectionBridge(
// brackets (close the <style> tag), and newlines (defense in depth).
var UNSAFE_VALUE = /[;{}<>\\n\\r]/;
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); } }
// Recompute the selector from elementId rather than trusting the one in
// the inbound message — a forged selector like
@ -1077,6 +1092,7 @@ function meaningfulDomFallbackTarget(el) {
var html = '';
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) };
if (!elementVisibleForComment(el, position)) return null;
var payload = {
type: 'od:comment-target',
elementId: id,
@ -1087,6 +1103,8 @@ function meaningfulDomFallbackTarget(el) {
htmlHint: html.slice(0, 180),
style: styleSnapshot(el)
};
var slideIndex = deckSlideIndexForPayload();
if (typeof slideIndex === 'number') payload.slideIndex = slideIndex;
if (clickPoint) {
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 pinY = Math.round(ev.clientY);
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',
elementId: pinId,
// Synthetic selector / label so daemon upsert validation (which
// requires both to be non-empty) accepts the saved free-pin.
selector: '[data-od-pin="' + pinId + '"]',
@ -1450,7 +1468,10 @@ function meaningfulDomFallbackTarget(el) {
htmlHint: '',
style: null,
freePin: true
}, '*');
};
pinPayload.elementId = pinId;
if (typeof pinSlideIndex === 'number') pinPayload.slideIndex = pinSlideIndex;
window.parent.postMessage(pinPayload, '*');
}, true);
// Pod drawing — only active in comment mode with the 'pod' tool.
document.addEventListener('pointerdown', function(ev){
@ -1505,6 +1526,7 @@ function meaningfulDomFallbackTarget(el) {
setTimeout(requestPreviewScrollRestore, 0);
setTimeout(requestPreviewScrollRestore, 80);
setTimeout(requestPreviewScrollRestore, 240);
window.__odScheduleCommentTargets = schedulePostTargets;
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', postTargets);
else setTimeout(postTargets, 0);
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;
el.style.width=progressWidth;
});
try {
if (typeof window.__odScheduleCommentTargets === 'function') window.__odScheduleCommentTargets();
} catch (_) {}
} catch (e) {}
}
window.__odDeckSlideState = function(){
var list = slides();
return { active: activeIndex(list), count: list.length };
};
function restoreInitialSlide(){
if (didRestoreInitialSlide) { report(); return; }
var list = slides();

View file

@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
buildBoardCommentAttachments,
buildVisualAnnotationAttachment,
commentVisibleOnDeckSlide,
commentsToAttachments,
historyWithCommentAttachmentContext,
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', () => {
const attachments = commentsToAttachments([
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;
memberCount?: number;
podMembers?: PreviewCommentMember[];
/** Zero-based deck slide index when the comment was placed. */
slideIndex?: number;
}
export interface PreviewComment {
@ -72,6 +74,8 @@ export interface PreviewComment {
selectionKind?: PreviewCommentSelectionKind;
memberCount?: number;
podMembers?: PreviewCommentMember[];
/** Zero-based deck slide index when the comment was placed. */
slideIndex?: number;
note: string;
status: PreviewCommentStatus;
createdAt: number;