diff --git a/apps/web/src/components/FileViewer.tsx b/apps/web/src/components/FileViewer.tsx index a61eff560..32e0bb2f7 100644 --- a/apps/web/src/components/FileViewer.tsx +++ b/apps/web/src/components/FileViewer.tsx @@ -3685,6 +3685,7 @@ function HtmlViewer({ ); const [activeCommentTarget, setActiveCommentTarget] = useState(null); const [hoveredCommentTarget, setHoveredCommentTarget] = useState(null); + const [activePreviewCommentId, setActivePreviewCommentId] = useState(null); const [liveCommentTargets, setLiveCommentTargets] = useState>(() => new Map()); const liveCommentTargetsRef = useRef(liveCommentTargets); const [commentDraft, setCommentDraft] = useState(''); @@ -4178,6 +4179,7 @@ function HtmlViewer({ if (!selectionMode) { setActiveCommentTarget((current) => (current ? null : current)); setHoveredCommentTarget((current) => (current ? null : current)); + setActivePreviewCommentId((current) => (current ? null : current)); setLiveCommentTargets((current) => (current.size > 0 ? new Map() : current)); setQueuedBoardNotes((current) => (current.length > 0 ? [] : current)); setStrokePoints((current) => (current.length > 0 ? [] : current)); @@ -4245,11 +4247,16 @@ function HtmlViewer({ if (data.type === 'od:comment-target') { const snapshot = snapshotFromData(data); if (!snapshot.elementId) return; - const existing = previewComments.find((comment) => comment.elementId === snapshot.elementId); + const existing = previewComments.find((comment) => + comment.filePath === file.name && + comment.status === 'open' && + comment.elementId === snapshot.elementId, + ); setActiveCommentTarget(snapshot); setHoveredCommentTarget(snapshot); setLiveCommentTargets((current) => new Map(current).set(snapshot.elementId, snapshot)); if (boardMode) { + setActivePreviewCommentId(existing?.id ?? null); setCommentDraft(existing?.note ?? ''); setQueuedBoardNotes([]); } @@ -4285,6 +4292,7 @@ function HtmlViewer({ } setActiveCommentTarget(nextTarget); setHoveredCommentTarget(nextTarget); + setActivePreviewCommentId(null); setQueuedBoardNotes([]); setCommentDraft(''); setStrokePoints([]); @@ -5034,6 +5042,7 @@ function HtmlViewer({ function clearBoardComposer() { setActiveCommentTarget(null); setHoveredCommentTarget(null); + setActivePreviewCommentId(null); setCommentDraft(''); setQueuedBoardNotes([]); setStrokePoints([]); @@ -5083,9 +5092,15 @@ function HtmlViewer({ const canShare = source !== null; const exportTitle = file.name.replace(/\.html?$/i, '') || file.name; const canPptx = canShare && Boolean(onExportAsPptx) && !streaming; - const visibleSideComments = previewComments.filter( - (comment) => comment.filePath === file.name && comment.status === 'open', + const visibleSideComments = useMemo( + () => previewComments.filter((comment) => comment.filePath === file.name && comment.status === 'open'), + [file.name, previewComments], ); + useEffect(() => { + if (!boardMode || !activePreviewCommentId) return; + const stillOpen = visibleSideComments.some((comment) => comment.id === activePreviewCommentId); + if (!stillOpen) clearBoardComposer(); + }, [activePreviewCommentId, boardMode, visibleSideComments]); const activeDeployment = deployResult || deployment; const activeDeployedUrl = activeDeployment?.url?.trim() || ''; const activeDeploymentDelayed = activeDeployment?.status === 'link-delayed'; @@ -5798,7 +5813,7 @@ function HtmlViewer({ {(boardMode || drawClickSelectionMode) ? ( { setActiveCommentTarget(snapshot); setHoveredCommentTarget(snapshot); + setActivePreviewCommentId(comment.id); setCommentDraft(comment.note); setQueuedBoardNotes([]); }} @@ -5834,7 +5850,7 @@ function HtmlViewer({ {boardMode && activeCommentTarget ? ( comment.elementId === activeCommentTarget.elementId) ?? null} + existing={visibleSideComments.find((comment) => comment.elementId === activeCommentTarget.elementId) ?? null} draft={commentDraft} notes={queuedBoardNotes} onDraft={setCommentDraft} @@ -5889,6 +5905,7 @@ function HtmlViewer({ }; setActiveCommentTarget(snapshot); setHoveredCommentTarget(snapshot); + setActivePreviewCommentId(comment.id); setCommentDraft(comment.note); setQueuedBoardNotes([]); }} diff --git a/apps/web/tests/components/FileViewer.test.tsx b/apps/web/tests/components/FileViewer.test.tsx index ad12bee29..92569c413 100644 --- a/apps/web/tests/components/FileViewer.test.tsx +++ b/apps/web/tests/components/FileViewer.test.tsx @@ -34,7 +34,7 @@ import { updateInspectOverride, } from '../../src/components/FileViewer'; import type { InspectOverrideMap } from '../../src/components/FileViewer'; -import type { LiveArtifact, LiveArtifactWorkspaceEntry, ProjectFile } from '../../src/types'; +import type { LiveArtifact, LiveArtifactWorkspaceEntry, PreviewComment, ProjectFile } from '../../src/types'; import { I18nProvider } from '../../src/i18n'; import type { Dict } from '../../src/i18n/types'; @@ -1141,6 +1141,142 @@ describe('FileViewer tweaks toolbar', () => { expect(screen.queryByText('Queues while working')).toBeNull(); }); + it('hides non-open saved comments from preview markers when the side panel is empty', () => { + const resolvedComment: PreviewComment = { + id: 'comment-applying', + projectId: 'project-1', + conversationId: 'conversation-1', + filePath: 'preview.html', + elementId: 'pin-applying', + selector: '[data-od-pin="pin-applying"]', + label: 'pin-applying', + text: '', + htmlHint: '', + position: { x: 24, y: 32, width: 18, height: 18 }, + note: 'Already sent to Claude', + status: 'applying', + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + render( + , + ); + + fireEvent.click(screen.getByTestId('board-mode-toggle')); + + expect(screen.getByTestId('comment-side-panel')).toBeTruthy(); + expect(screen.queryByTestId('comment-saved-marker-pin-applying')).toBeNull(); + expect(screen.queryByText('Already sent to Claude')).toBeNull(); + }); + + it('does not preload non-open element comments into the picker composer', async () => { + const applyingElementComment: PreviewComment = { + id: 'comment-element-applying', + projectId: 'project-1', + conversationId: 'conversation-1', + filePath: 'preview.html', + elementId: 'hero', + selector: '[data-od-id="hero"]', + label: 'Hero', + text: 'Hero', + htmlHint: '
Hero
', + position: { x: 8, y: 12, width: 120, height: 48 }, + note: 'Do not resurrect this note', + status: 'applying', + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + render( + , + ); + + const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement; + fireEvent.click(screen.getByTestId('board-mode-toggle')); + + window.dispatchEvent(new MessageEvent('message', { + source: frame.contentWindow, + data: { + type: 'od:comment-target', + elementId: 'hero', + selector: '[data-od-id="hero"]', + label: 'Hero', + text: 'Hero', + position: { x: 8, y: 12, width: 120, height: 48 }, + htmlHint: '
Hero
', + }, + })); + + const input = await screen.findByTestId('comment-popover-input') as HTMLTextAreaElement; + expect(input.value).toBe(''); + expect(screen.queryByText('Remove')).toBeNull(); + expect(screen.queryByText('Do not resurrect this note')).toBeNull(); + }); + + it('closes an open saved-comment composer when that comment leaves the open state', async () => { + const openComment: PreviewComment = { + id: 'comment-status-transition', + projectId: 'project-1', + conversationId: 'conversation-1', + filePath: 'preview.html', + elementId: 'pin-transition', + selector: '[data-od-pin="pin-transition"]', + label: 'pin-transition', + text: '', + htmlHint: '', + position: { x: 40, y: 52, width: 18, height: 18 }, + note: 'Do not recreate this stale comment', + status: 'open', + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByTestId('board-mode-toggle')); + fireEvent.click(screen.getByRole('button', { name: 'Open comment for pin-transition' })); + + expect((await screen.findByTestId('comment-popover-input') as HTMLTextAreaElement).value) + .toBe('Do not recreate this stale comment'); + + rerender( + , + ); + + await waitFor(() => { + expect(screen.queryByTestId('comment-popover-input')).toBeNull(); + }); + expect(screen.queryByTestId('comment-saved-marker-pin-transition')).toBeNull(); + expect(screen.queryByText('Do not recreate this stale comment')).toBeNull(); + }); + it('collapses the comment side panel into a narrow reopen rail', () => { const onCollapseChange = vi.fn();