diff --git a/.changeset/comment-mode-copy.md b/.changeset/comment-mode-copy.md new file mode 100644 index 00000000..5f25bf5f --- /dev/null +++ b/.changeset/comment-mode-copy.md @@ -0,0 +1,6 @@ +--- +"@open-codesign/desktop": patch +"@open-codesign/i18n": patch +--- + +Update comment mode copy for saving comments and adding saved comments to chat. diff --git a/apps/desktop/src/main/workspace-watcher.test.ts b/apps/desktop/src/main/workspace-watcher.test.ts index 7a4055ec..76fba933 100644 --- a/apps/desktop/src/main/workspace-watcher.test.ts +++ b/apps/desktop/src/main/workspace-watcher.test.ts @@ -142,15 +142,37 @@ describe('files-watcher subscribe / unsubscribe', () => { expect(watchMock).toHaveBeenCalledTimes(1); }); - it('falls back to polling when native watch is denied by permissions', () => { + it('does not reject when the bound workspace folder is missing', () => { reset(); - const err = Object.assign(new Error('operation not permitted'), { code: 'EPERM' }); + const err = Object.assign(new Error('no such file or directory'), { code: 'ENOENT' }); watchMock.mockImplementation(() => { throw err; }); getDesignMock.mockReturnValue({ id: 'd1', - workspacePath: tempWorkspace('codesign-watch-eperm'), + workspacePath: tempWorkspace('codesign-watch-missing'), + }); + registerFilesWatcherIpc({} as never, () => null); + const sub = getHandler('codesign:files:v1:subscribe'); + + expect(sub(null, { schemaVersion: 1, designId: 'd1' })).toEqual({ ok: true }); + + expect(watchMock).toHaveBeenCalledTimes(1); + expect(__test.watchers.has('d1')).toBe(false); + }); + + it.each([ + ['permission denial', 'EPERM'], + ['unsupported directory watch', 'EISDIR'], + ])('falls back to polling when native watch fails from %s', (_reason, code) => { + reset(); + const err = Object.assign(new Error('watch unavailable'), { code }); + watchMock.mockImplementation(() => { + throw err; + }); + getDesignMock.mockReturnValue({ + id: 'd1', + workspacePath: tempWorkspace(`codesign-watch-${code.toLowerCase()}`), }); registerFilesWatcherIpc({} as never, () => null); const sub = getHandler('codesign:files:v1:subscribe'); diff --git a/apps/desktop/src/main/workspace-watcher.ts b/apps/desktop/src/main/workspace-watcher.ts index 4c773987..77993189 100644 --- a/apps/desktop/src/main/workspace-watcher.ts +++ b/apps/desktop/src/main/workspace-watcher.ts @@ -50,6 +50,10 @@ interface ActiveWatcher { pollBusy: boolean; } +type WatcherStartResult = + | { ok: true; entry: ActiveWatcher } + | { ok: false; reason: 'workspace-unavailable' | 'watch-failed' }; + const watchers = new Map(); function isIgnored(rel: string): boolean { @@ -61,9 +65,14 @@ function toForwardSlashes(path: string): string { return sep === '/' ? path : path.split(sep).join('/'); } -function isPermissionWatchError(err: unknown): boolean { +function shouldFallbackToPolling(err: unknown): boolean { + const code = (err as NodeJS.ErrnoException).code; + return code === 'EPERM' || code === 'EACCES' || code === 'EISDIR'; +} + +function isWorkspaceUnavailableWatchError(err: unknown): boolean { const code = (err as NodeJS.ErrnoException).code; - return code === 'EPERM' || code === 'EACCES'; + return code === 'ENOENT' || code === 'ENOTDIR'; } async function pollWorkspaceSignature(root: string): Promise { @@ -151,7 +160,7 @@ function startWatcher( designId: string, workspacePath: string, getWin: () => BrowserWindow | null, -): ActiveWatcher | null { +): WatcherStartResult { const entry: ActiveWatcher = { watcher: null, workspacePath, @@ -174,17 +183,20 @@ function startWatcher( workspacePath, error: err instanceof Error ? err.message : String(err), }); - if (isPermissionWatchError(err)) { + if (shouldFallbackToPolling(err)) { watchers.set(designId, entry); startPolling(designId, entry, getWin); - return entry; + return { ok: true, entry }; } - return null; + if (isWorkspaceUnavailableWatchError(err)) { + return { ok: false, reason: 'workspace-unavailable' }; + } + return { ok: false, reason: 'watch-failed' }; } entry.watcher = watcher; watcher.on('error', (err) => { log.warn('files.watch.error', { designId, error: String(err) }); - if (!isPermissionWatchError(err)) return; + if (!shouldFallbackToPolling(err)) return; const active = watchers.get(designId); if (!active || active.watcher !== watcher) return; try { @@ -196,7 +208,7 @@ function startWatcher( startPolling(designId, active, getWin); }); watchers.set(designId, entry); - return entry; + return { ok: true, entry }; } function stopWatcher(designId: string): void { @@ -242,10 +254,14 @@ export function registerFilesWatcherIpc(db: Database, getWin: () => BrowserWindo return { ok: true }; } } - const entry = startWatcher(designId, workspacePath, getWin); - if (!entry) { + const result = startWatcher(designId, workspacePath, getWin); + if (!result.ok) { + if (result.reason === 'workspace-unavailable') { + return { ok: true }; + } throw new CodesignError('Failed to watch workspace files', 'IPC_DB_ERROR'); } + const { entry } = result; entry.refCount = 1; return { ok: true }; }); diff --git a/apps/desktop/src/renderer/src/components/FilesTabView.test.ts b/apps/desktop/src/renderer/src/components/FilesTabView.test.ts index 30eeb460..e880c615 100644 --- a/apps/desktop/src/renderer/src/components/FilesTabView.test.ts +++ b/apps/desktop/src/renderer/src/components/FilesTabView.test.ts @@ -1,3 +1,4 @@ +import type { CommentRow } from '@open-codesign/shared'; import { describe, expect, it, vi } from 'vitest'; import { openFileTab } from '../store/slices/tabs'; import { @@ -8,12 +9,14 @@ import { detectedPreviewTarget, effectivePreviewModeForDesign, externalAppManagedFallbackPath, + findReusableWorkspaceFileCommentForSelector, htmlRequiresWorkspaceDevServer, isMarkdownPreviewFile, isPreviewSourceUsableForSelectedPath, isRenderableDesignFileKind, previewKindForFile, resolveReferencedWorkspacePreviewPath, + shouldEnableWorkspaceFilePreviewInteractions, shouldShowTweakPanelForFile, shouldUseDesignPreviewResolverForFile, splitMarkdownFrontmatter, @@ -23,6 +26,24 @@ import { } from './FilesTabView'; describe('FilesTabView preview helpers', () => { + const commentRow = (overrides: Partial = {}): CommentRow => ({ + schemaVersion: 1, + id: overrides.id ?? 'comment-1', + designId: overrides.designId ?? 'design-1', + snapshotId: overrides.snapshotId ?? 'snapshot-1', + kind: overrides.kind ?? 'edit', + selector: overrides.selector ?? '#hero', + tag: overrides.tag ?? 'section', + outerHTML: overrides.outerHTML ?? '
Hello
', + rect: overrides.rect ?? { top: 10, left: 20, width: 100, height: 50 }, + text: overrides.text ?? 'Saved note', + status: overrides.status ?? 'pending', + createdAt: overrides.createdAt ?? '2026-05-13T00:00:00.000Z', + appliedInSnapshotId: overrides.appliedInSnapshotId ?? null, + ...(overrides.scope ? { scope: overrides.scope } : {}), + ...(overrides.parentOuterHTML ? { parentOuterHTML: overrides.parentOuterHTML } : {}), + }); + it('clamps the file browser splitter width to usable bounds', () => { expect(clampFileBrowserWidth(120, 1280)).toBe(260); expect(clampFileBrowserWidth(480.4, 1280)).toBe(480); @@ -145,6 +166,52 @@ describe('FilesTabView preview helpers', () => { }); }); + it('prefills the existing pending comment when reselecting the same file preview element', () => { + const existing = commentRow({ id: 'comment-existing', text: 'Keep the saved note visible' }); + const selectCanvasElement = vi.fn(); + const openCommentBubble = vi.fn(); + const applyLiveRects = vi.fn(); + const pushIframeError = vi.fn(); + const handlers = createWorkspaceFilePreviewMessageHandlers({ + previewZoom: 100, + comments: [existing], + currentSnapshotId: existing.snapshotId, + selectCanvasElement, + openCommentBubble, + applyLiveRects, + pushIframeError, + }); + + handlers.onElementSelected({ + __codesign: true, + type: 'ELEMENT_SELECTED', + selector: existing.selector, + tag: existing.tag, + outerHTML: existing.outerHTML, + rect: existing.rect, + }); + + expect(openCommentBubble).toHaveBeenCalledWith( + expect.objectContaining({ + selector: existing.selector, + existingCommentId: existing.id, + initialText: existing.text, + }), + ); + }); + + it('falls back to a pending comment for the same selector when the file preview has no current snapshot', () => { + const existing = commentRow({ id: 'comment-existing', snapshotId: 'snapshot-stale' }); + + expect( + findReusableWorkspaceFileCommentForSelector({ + comments: [existing], + currentSnapshotId: null, + selector: existing.selector, + }), + ).toBe(existing); + }); + it('marks html/jsx/tsx files as renderable', () => { expect(isRenderableDesignFileKind('html')).toBe(true); expect(isRenderableDesignFileKind('jsx')).toBe(true); @@ -219,6 +286,12 @@ describe('FilesTabView preview helpers', () => { ).toBe(false); }); + it('keeps local workspace runtime previews interactive outside dedicated file tabs', () => { + expect(shouldEnableWorkspaceFilePreviewInteractions({ previewKind: 'runtime' })).toBe(true); + expect(shouldEnableWorkspaceFilePreviewInteractions({ previewKind: 'markdown' })).toBe(false); + expect(shouldEnableWorkspaceFilePreviewInteractions({ previewKind: null })).toBe(false); + }); + it('uses the design-level resolver only for generated preview fallbacks', () => { expect( shouldUseDesignPreviewResolverForFile({ diff --git a/apps/desktop/src/renderer/src/components/FilesTabView.tsx b/apps/desktop/src/renderer/src/components/FilesTabView.tsx index 316bb134..df396a41 100644 --- a/apps/desktop/src/renderer/src/components/FilesTabView.tsx +++ b/apps/desktop/src/renderer/src/components/FilesTabView.tsx @@ -1,6 +1,11 @@ import { useT } from '@open-codesign/i18n'; import { buildPreviewDocument, isRenderablePath } from '@open-codesign/runtime'; -import { DEFAULT_SOURCE_ENTRY, LEGACY_SOURCE_ENTRY, type PreviewMode } from '@open-codesign/shared'; +import { + type CommentRow, + DEFAULT_SOURCE_ENTRY, + LEGACY_SOURCE_ENTRY, + type PreviewMode, +} from '@open-codesign/shared'; import { ChevronRight, ExternalLink, @@ -41,7 +46,9 @@ import { handlePreviewMessage, isTrustedPreviewMessageSource, type PreviewMessageHandlers, + postClearPinToPreviewWindow, postModeToPreviewWindow, + postPinSelectorToPreviewWindow, scaleRectForZoom, stablePreviewSourceKey, } from '../preview/helpers'; @@ -783,6 +790,12 @@ export function shouldShowTweakPanelForFile(input: { ); } +export function shouldEnableWorkspaceFilePreviewInteractions(input: { + previewKind: FilePreviewKind | null; +}): boolean { + return input.previewKind === 'runtime'; +} + export function shouldUseDesignPreviewResolverForFile(input: { path: string; previewKind: FilePreviewKind; @@ -924,14 +937,40 @@ interface WorkspaceFilePreviewProps { interface WorkspaceFilePreviewMessageHandlerInput { previewZoom: number; + comments?: CommentRow[] | undefined; + currentSnapshotId?: string | null | undefined; selectCanvasElement: ReturnType['selectCanvasElement']; openCommentBubble: ReturnType['openCommentBubble']; applyLiveRects: ReturnType['applyLiveRects']; pushIframeError: ReturnType['pushIframeError']; } +export function findReusableWorkspaceFileCommentForSelector(input: { + comments: CommentRow[]; + currentSnapshotId: string | null; + selector: string; +}): CommentRow | null { + let fallback: CommentRow | null = null; + for (let index = input.comments.length - 1; index >= 0; index--) { + const comment = input.comments[index]; + if ( + comment?.kind === 'edit' && + comment.status === 'pending' && + comment.selector === input.selector + ) { + if (input.currentSnapshotId !== null && comment.snapshotId === input.currentSnapshotId) { + return comment; + } + fallback ??= comment; + } + } + return fallback; +} + export function createWorkspaceFilePreviewMessageHandlers({ previewZoom, + comments = [], + currentSnapshotId = null, selectCanvasElement, openCommentBubble, applyLiveRects, @@ -946,11 +985,19 @@ export function createWorkspaceFilePreviewMessageHandlers({ outerHTML: msg.outerHTML, rect: scaled, }); + const existingComment = findReusableWorkspaceFileCommentForSelector({ + comments, + currentSnapshotId, + selector: msg.selector, + }); openCommentBubble({ selector: msg.selector, tag: msg.tag, outerHTML: msg.outerHTML, rect: scaled, + ...(existingComment + ? { existingCommentId: existingComment.id, initialText: existingComment.text } + : {}), ...(typeof msg.parentOuterHTML === 'string' && msg.parentOuterHTML.length > 0 ? { parentOuterHTML: msg.parentOuterHTML } : {}), @@ -1431,6 +1478,9 @@ export function WorkspaceFilePreview({ const selectCanvasElement = useCodesignStore((s) => s.selectCanvasElement); const openCommentBubble = useCodesignStore((s) => s.openCommentBubble); const applyLiveRects = useCodesignStore((s) => s.applyLiveRects); + const comments = useCodesignStore((s) => s.comments); + const currentSnapshotId = useCodesignStore((s) => s.currentSnapshotId); + const commentBubble = useCodesignStore((s) => s.commentBubble); const { files: observedFiles } = useDesignFiles(files ? null : currentDesignId); const workspaceFiles = files ?? observedFiles; const currentDesign = designs.find((d) => d.id === currentDesignId); @@ -1481,6 +1531,8 @@ export function WorkspaceFilePreview({ event.data, createWorkspaceFilePreviewMessageHandlers({ previewZoom, + comments, + currentSnapshotId, selectCanvasElement: (selection) => { if (interactive) selectCanvasElement(selection); }, @@ -1500,6 +1552,8 @@ export function WorkspaceFilePreview({ }, [ pushIframeError, previewZoom, + comments, + currentSnapshotId, selectCanvasElement, openCommentBubble, applyLiveRects, @@ -1514,6 +1568,19 @@ export function WorkspaceFilePreview({ ); }, [interactionMode, pushIframeError, interactive]); + useEffect(() => { + if (!interactive) return; + if (commentBubble && interactionMode === 'comment') { + postPinSelectorToPreviewWindow( + iframeRef.current?.contentWindow, + commentBubble.selector, + pushIframeError, + ); + return; + } + postClearPinToPreviewWindow(iframeRef.current?.contentWindow, pushIframeError); + }, [commentBubble, interactionMode, interactive, pushIframeError]); + useEffect(() => { // Re-read when the file watcher reports changed metadata for either the // selected file or an HTML placeholder's resolved JSX/TSX source. @@ -1728,6 +1795,9 @@ export function FilesTabView({ activePath = null }: { activePath?: string | null const externalFallbackFile = externalFallbackPath ? (files.find((f) => f.path === externalFallbackPath) ?? null) : null; + const externalFallbackPreviewKind = externalFallbackPath + ? previewKindForFile(externalFallbackPath, externalFallbackFile?.kind) + : null; const usesExternalPreview = effectivePreviewMode === 'connected-url' || effectivePreviewMode === 'external-app'; const showPreviewHeaderAction = @@ -1812,7 +1882,9 @@ export function FilesTabView({ activePath = null }: { activePath?: string | null path={externalFallbackPath} file={externalFallbackFile} files={files} - interactive={isDedicatedFileTab} + interactive={shouldEnableWorkspaceFilePreviewInteractions({ + previewKind: externalFallbackPreviewKind, + })} /> ); } @@ -1829,7 +1901,9 @@ export function FilesTabView({ activePath = null }: { activePath?: string | null path={selectedPath} file={selectedFile} files={files} - interactive={isDedicatedFileTab} + interactive={shouldEnableWorkspaceFilePreviewInteractions({ + previewKind: previewKindForFile(selectedPath, selectedFile.kind), + })} /> ); } @@ -1841,7 +1915,9 @@ export function FilesTabView({ activePath = null }: { activePath?: string | null path={selectedPath} file={selectedFile} files={files} - interactive={isDedicatedFileTab} + interactive={shouldEnableWorkspaceFilePreviewInteractions({ + previewKind: previewKindForFile(selectedPath, selectedFile?.kind), + })} /> ); } diff --git a/apps/desktop/src/renderer/src/components/PreviewPane.test.ts b/apps/desktop/src/renderer/src/components/PreviewPane.test.ts index 06b059ac..b433f7b1 100644 --- a/apps/desktop/src/renderer/src/components/PreviewPane.test.ts +++ b/apps/desktop/src/renderer/src/components/PreviewPane.test.ts @@ -2,10 +2,13 @@ import { describe, expect, it, vi } from 'vitest'; import { useCodesignStore } from '../store'; import { computeFitPreviewZoom, + findReusablePendingCommentForSelector, handlePreviewMessage, isPreviewPaneWelcomeState, isTrustedPreviewMessageSource, + postClearPinToPreviewWindow, postModeToPreviewWindow, + postPinSelectorToPreviewWindow, previewArtboardFrameClass, previewArtboardStyle, previewPaneLayoutClasses, @@ -14,6 +17,21 @@ import { stablePreviewSourceKey, } from './PreviewPane'; +const COMMENT_BASE = { + schemaVersion: 1 as const, + designId: 'design-1', + snapshotId: 'snapshot-1', + kind: 'edit' as const, + selector: '#hero', + tag: 'section', + outerHTML: '
Hero
', + rect: { top: 0, left: 0, width: 100, height: 80 }, + text: 'Make it softer', + status: 'pending' as const, + createdAt: '2026-05-13T00:00:00.000Z', + appliedInSnapshotId: null, +}; + describe('isTrustedPreviewMessageSource', () => { it('accepts only messages from the active preview iframe window', () => { const previewWindow = {} as Window; @@ -126,6 +144,45 @@ describe('preview pane welcome state', () => { }); }); +describe('findReusablePendingCommentForSelector', () => { + it('reuses the pending comment already attached to the same selector', () => { + const comment = { ...COMMENT_BASE, id: 'comment-1' }; + + expect( + findReusablePendingCommentForSelector({ + comments: [comment], + currentSnapshotId: 'snapshot-1', + selector: '#hero', + }), + ).toBe(comment); + }); + + it('ignores applied comments and comments from another selector', () => { + expect( + findReusablePendingCommentForSelector({ + comments: [ + { ...COMMENT_BASE, id: 'other-selector', selector: '#other' }, + { ...COMMENT_BASE, id: 'applied', status: 'applied' as const }, + ], + currentSnapshotId: 'snapshot-1', + selector: '#hero', + }), + ).toBeNull(); + }); + + it('falls back to the latest pending comment for the same selector when snapshots drift', () => { + const stale = { ...COMMENT_BASE, id: 'stale', snapshotId: 'snapshot-old' }; + + expect( + findReusablePendingCommentForSelector({ + comments: [stale], + currentSnapshotId: 'snapshot-1', + selector: '#hero', + }), + ).toBe(stale); + }); +}); + describe('stablePreviewSourceKey', () => { it('masks EDITMODE and TWEAK_SCHEMA spans for JSX artifacts', () => { const source = `const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{"accent":"#000"}/*EDITMODE-END*/; @@ -303,3 +360,30 @@ describe('postModeToPreviewWindow', () => { expect(onError).not.toHaveBeenCalled(); }); }); + +describe('preview pin postMessage helpers', () => { + it('posts PIN_SELECTOR for saved-comment selections', () => { + const onError = vi.fn(); + const post = vi.fn(); + const win = { postMessage: post } as unknown as Window; + + expect(postPinSelectorToPreviewWindow(win, '#hero', onError)).toBe(true); + + expect(post).toHaveBeenCalledWith( + { __codesign: true, type: 'PIN_SELECTOR', selector: '#hero' }, + '*', + ); + expect(onError).not.toHaveBeenCalled(); + }); + + it('posts CLEAR_PIN for closed comment bubbles', () => { + const onError = vi.fn(); + const post = vi.fn(); + const win = { postMessage: post } as unknown as Window; + + expect(postClearPinToPreviewWindow(win, onError)).toBe(true); + + expect(post).toHaveBeenCalledWith({ __codesign: true, type: 'CLEAR_PIN' }, '*'); + expect(onError).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/renderer/src/components/PreviewPane.tsx b/apps/desktop/src/renderer/src/components/PreviewPane.tsx index 5eef1d86..a7125b39 100644 --- a/apps/desktop/src/renderer/src/components/PreviewPane.tsx +++ b/apps/desktop/src/renderer/src/components/PreviewPane.tsx @@ -1,5 +1,6 @@ import { useT } from '@open-codesign/i18n'; import { buildPreviewDocument } from '@open-codesign/runtime'; +import type { CommentRow } from '@open-codesign/shared'; import { type CSSProperties, type DragEvent, @@ -19,7 +20,9 @@ import { formatIframeError, handlePreviewMessage, isTrustedPreviewMessageSource, + postClearPinToPreviewWindow, postModeToPreviewWindow, + postPinSelectorToPreviewWindow, scaleRectForZoom, stablePreviewSourceKey, } from '../preview/helpers'; @@ -44,7 +47,9 @@ export { formatIframeError, handlePreviewMessage, isTrustedPreviewMessageSource, + postClearPinToPreviewWindow, postModeToPreviewWindow, + postPinSelectorToPreviewWindow, scaleRectForZoom, stablePreviewSourceKey, } from '../preview/helpers'; @@ -124,6 +129,28 @@ export function computeFitPreviewZoom(input: { return Math.min(100, Math.max(25, Math.floor(fit))); } +export function findReusablePendingCommentForSelector(input: { + comments: CommentRow[]; + currentSnapshotId: string | null; + selector: string; +}): CommentRow | null { + let fallback: CommentRow | null = null; + for (let index = input.comments.length - 1; index >= 0; index--) { + const comment = input.comments[index]; + if ( + comment?.kind === 'edit' && + comment.status === 'pending' && + comment.selector === input.selector + ) { + if (input.currentSnapshotId !== null && comment.snapshotId === input.currentSnapshotId) { + return comment; + } + fallback ??= comment; + } + } + return fallback; +} + export function previewArtboardStyle(viewport: FramedPreviewViewport): CSSProperties { return viewport === 'tablet' ? { @@ -287,6 +314,7 @@ export function PreviewPane({ onPickStarter }: PreviewPaneProps) { const previewSource = useCodesignStore((s) => s.previewSource); const previewSourceByDesign = useCodesignStore((s) => s.previewSourceByDesign); const recentDesignIds = useCodesignStore((s) => s.recentDesignIds); + const view = useCodesignStore((s) => s.view); const currentDesignId = useCodesignStore((s) => s.currentDesignId); const designs = useCodesignStore((s) => s.designs); const chatMessages = useCodesignStore((s) => s.chatMessages); @@ -309,6 +337,7 @@ export function PreviewPane({ onPickStarter }: PreviewPaneProps) { const openCommentBubble = useCodesignStore((s) => s.openCommentBubble); const closeCommentBubble = useCodesignStore((s) => s.closeCommentBubble); const submitComment = useCodesignStore((s) => s.submitComment); + const queueCommentForPrompt = useCodesignStore((s) => s.queueCommentForPrompt); const applyLiveRects = useCodesignStore((s) => s.applyLiveRects); const clearLiveRects = useCodesignStore((s) => s.clearLiveRects); const liveRects = useCodesignStore((s) => s.liveRects); @@ -451,11 +480,19 @@ export function PreviewPane({ onPickStarter }: PreviewPaneProps) { outerHTML: msg.outerHTML, rect: scaled, }); + const existingComment = findReusablePendingCommentForSelector({ + comments, + currentSnapshotId, + selector: msg.selector, + }); openCommentBubble({ selector: msg.selector, tag: msg.tag, outerHTML: msg.outerHTML, rect: scaled, + ...(existingComment + ? { existingCommentId: existingComment.id, initialText: existingComment.text } + : {}), ...(typeof msg.parentOuterHTML === 'string' && msg.parentOuterHTML.length > 0 ? { parentOuterHTML: msg.parentOuterHTML } : {}), @@ -475,7 +512,15 @@ export function PreviewPane({ onPickStarter }: PreviewPaneProps) { window.addEventListener('message', onMessage); return () => window.removeEventListener('message', onMessage); - }, [pushIframeError, selectCanvasElement, openCommentBubble, previewZoom, applyLiveRects]); + }, [ + pushIframeError, + selectCanvasElement, + openCommentBubble, + previewZoom, + comments, + currentSnapshotId, + applyLiveRects, + ]); // Pool entries: active design first (using the freshest in-memory // previewSource), then any other recently-visited designs that still have a @@ -503,6 +548,20 @@ export function PreviewPane({ onPickStarter }: PreviewPaneProps) { }, [currentDesignId, previewSource, previewSourceByDesign, recentDesignIds]); const activeTab = canvasTabs[activeCanvasTab]; + + useEffect(() => { + if (activeTab?.kind === 'files' || activeTab?.kind === 'file') return; + if (commentBubble && interactionMode === 'comment') { + postPinSelectorToPreviewWindow( + iframeRef.current?.contentWindow, + commentBubble.selector, + pushIframeError, + ); + return; + } + postClearPinToPreviewWindow(iframeRef.current?.contentWindow, pushIframeError); + }, [activeTab?.kind, commentBubble, interactionMode, pushIframeError]); + const showCommentUi = interactionMode === 'comment'; const snapshotComments = currentSnapshotId ? comments.filter((c) => c.snapshotId === currentSnapshotId) @@ -639,7 +698,7 @@ export function PreviewPane({ onPickStarter }: PreviewPaneProps) { > {body} - {commentBubble && interactionMode === 'comment' + {commentBubble && interactionMode === 'comment' && view === 'workspace' ? (() => { const liveForBubble = liveRects[commentBubble.selector]; const scaled = liveForBubble @@ -655,6 +714,33 @@ export function PreviewPane({ onPickStarter }: PreviewPaneProps) { // when the user clicks another chip and comes back. const stashed = bubbleDraftsRef.current.get(bubbleKey); const initialText = stashed ?? commentBubble.initialText; + const clearPinAndClose = () => { + postClearPinToPreviewWindow(iframeRef.current?.contentWindow, pushIframeError); + closeCommentBubble(); + }; + const persistComment = async (text: string) => { + const trimmed = text.trim(); + if (!trimmed && !existingId) { + bubbleDraftsRef.current.delete(bubbleKey); + return { row: null }; + } + const row = await submitComment({ + kind: 'edit', + selector: commentBubble.selector, + tag: commentBubble.tag, + outerHTML: commentBubble.outerHTML, + rect: commentBubble.rect, + text: trimmed, + scope: 'element', + ...(existingId ? { existingCommentId: existingId } : {}), + ...(commentBubble.parentOuterHTML + ? { parentOuterHTML: commentBubble.parentOuterHTML } + : {}), + }); + if (!row) return null; + bubbleDraftsRef.current.delete(bubbleKey); + return { row }; + }; return ( { - const win = iframeRef.current?.contentWindow; - if (win) { - try { - win.postMessage({ __codesign: true, type: 'CLEAR_PIN' }, '*'); - } catch { - /* noop */ - } - } - closeCommentBubble(); + onSaveAndClose={async (text: string) => { + const result = await persistComment(text); + if (result === null) return; + clearPinAndClose(); }} - onSendToClaude={async (text: string) => { - const row = await submitComment({ - kind: 'edit', - selector: commentBubble.selector, - tag: commentBubble.tag, - outerHTML: commentBubble.outerHTML, - rect: commentBubble.rect, - text, - scope: 'element', - ...(existingId ? { existingCommentId: existingId } : {}), - ...(commentBubble.parentOuterHTML - ? { parentOuterHTML: commentBubble.parentOuterHTML } - : {}), - }); - // On failure (no snapshot, IPC error, duplicate) keep the - // bubble open so the user's draft survives. A toast has - // already been surfaced by the store layer. - if (!row) return; - // Persisted — wipe the stashed draft so the next open - // starts clean (a reopened chip re-reads from DB). - bubbleDraftsRef.current.delete(bubbleKey); - const win = iframeRef.current?.contentWindow; - if (win) { - try { - win.postMessage({ __codesign: true, type: 'CLEAR_PIN' }, '*'); - } catch { - /* noop */ - } + onSaveAndSend={async (text: string) => { + const result = await persistComment(text); + if (result === null) return; + clearPinAndClose(); + if (result.row) { + queueCommentForPrompt(result.row.id); } - closeCommentBubble(); - // Stage only — user clicks the "Apply" button on the chip bar - // to send all accumulated edits in one go. }} /> ); diff --git a/apps/desktop/src/renderer/src/components/chat/CommentChipBar.tsx b/apps/desktop/src/renderer/src/components/chat/CommentChipBar.tsx index b83e9fd8..da30b827 100644 --- a/apps/desktop/src/renderer/src/components/chat/CommentChipBar.tsx +++ b/apps/desktop/src/renderer/src/components/chat/CommentChipBar.tsx @@ -9,12 +9,13 @@ import { useCodesignStore } from '../../store'; * * Click a chip body → reopen the bubble for that comment. * Click the × → delete the comment (pin disappears too). - * Click Apply → fire sendPrompt with empty prompt so staged edits get + * Click Apply → fire sendPrompt with empty prompt so queued edits get * flushed via buildEnrichedPrompt in one batch. */ export function CommentChipBar() { const t = useT(); const comments = useCodesignStore((s) => s.comments); + const queuedCommentIds = useCodesignStore((s) => s.queuedCommentIds); const openCommentBubble = useCodesignStore((s) => s.openCommentBubble); const removeComment = useCodesignStore((s) => s.removeComment); const previewZoom = useCodesignStore((s) => s.previewZoom); @@ -25,7 +26,10 @@ export function CommentChipBar() { (s) => s.isGenerating && s.generatingDesignId === s.currentDesignId, ); - const pending = comments.filter((c) => c != null && c.kind === 'edit' && c.status === 'pending'); + const queued = new Set(queuedCommentIds); + const pending = comments.filter( + (c) => c != null && c.kind === 'edit' && c.status === 'pending' && queued.has(c.id), + ); if (pending.length === 0) return null; const isReady = diff --git a/apps/desktop/src/renderer/src/components/comment/CommentBubble.test.tsx b/apps/desktop/src/renderer/src/components/comment/CommentBubble.test.tsx index 6a500214..2192f903 100644 --- a/apps/desktop/src/renderer/src/components/comment/CommentBubble.test.tsx +++ b/apps/desktop/src/renderer/src/components/comment/CommentBubble.test.tsx @@ -12,8 +12,8 @@ describe('CommentBubble module', () => { tag: 'div', outerHTML: '
', rect: { top: 0, left: 0, width: 1, height: 1 }, - onClose: () => {}, - onSendToClaude: () => {}, + onSaveAndClose: () => {}, + onSaveAndSend: () => {}, }; expect(props.rect.top).toBe(0); }); @@ -25,8 +25,8 @@ describe('CommentBubble module', () => { outerHTML: '
', rect: { top: 0, left: 0, width: 1, height: 1 }, initialText: 'make it bigger', - onClose: () => {}, - onSendToClaude: (_text: string) => {}, + onSaveAndClose: () => {}, + onSaveAndSend: (_text: string) => {}, }; expect(props.initialText).toBe('make it bigger'); }); diff --git a/apps/desktop/src/renderer/src/components/comment/CommentBubble.tsx b/apps/desktop/src/renderer/src/components/comment/CommentBubble.tsx index 61c29ab8..88755471 100644 --- a/apps/desktop/src/renderer/src/components/comment/CommentBubble.tsx +++ b/apps/desktop/src/renderer/src/components/comment/CommentBubble.tsx @@ -1,6 +1,6 @@ import { useT } from '@open-codesign/i18n'; -import { Send, X } from 'lucide-react'; -import { useEffect, useId, useRef, useState } from 'react'; +import { Check, Send, X } from 'lucide-react'; +import { useCallback, useEffect, useId, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; export interface CommentBubbleProps { @@ -13,8 +13,8 @@ export interface CommentBubbleProps { * unsent draft keyed by anchor id. Without this, switching to a different * chip / element silently discarded the current text. */ onDraftChange?: (text: string) => void; - onClose: () => void; - onSendToClaude: (text: string) => Promise | void; + onSaveAndClose: (text: string) => Promise | void; + onSaveAndSend: (text: string) => Promise | void; } /** English fallback text for each quick action id — sent to the LLM. */ @@ -35,15 +35,38 @@ export function CommentBubble({ rect, initialText, onDraftChange, - onClose, - onSendToClaude, + onSaveAndClose, + onSaveAndSend, }: CommentBubbleProps) { const t = useT(); const [draft, setDraft] = useState(initialText ?? ''); - const [pending, setPending] = useState(false); + const [pendingAction, setPendingAction] = useState<'save' | 'send' | null>(null); const rootRef = useRef(null); const textareaRef = useRef(null); const titleId = useId(); + const pending = pendingAction !== null; + + const runAction = useCallback( + async (action: 'save' | 'send', handler: (text: string) => Promise | void) => { + const text = draft.trim(); + if ((action === 'send' && !text) || pendingAction) return; + setPendingAction(action); + try { + await handler(text); + } finally { + setPendingAction(null); + } + }, + [draft, pendingAction], + ); + + const handleSaveAndClose = useCallback(async () => { + await runAction('save', onSaveAndClose); + }, [onSaveAndClose, runAction]); + + const handleSaveAndSend = useCallback(async () => { + await runAction('send', onSaveAndSend); + }, [onSaveAndSend, runAction]); useEffect(() => { textareaRef.current?.focus(); @@ -56,24 +79,13 @@ export function CommentBubble({ // frustrating failure mode. Explicit close mirrors how chat / dialog UIs // treat in-progress text. function onKey(e: KeyboardEvent) { - if (e.key === 'Escape') onClose(); + if (e.key === 'Escape') void handleSaveAndClose(); } document.addEventListener('keydown', onKey); return () => { document.removeEventListener('keydown', onKey); }; - }, [onClose]); - - async function handleSubmit() { - const text = draft.trim(); - if (!text || pending) return; - setPending(true); - try { - await onSendToClaude(text); - } finally { - setPending(false); - } - } + }, [handleSaveAndClose]); // Truncated element preview — just the tag + key attributes const tagPreview = (() => { @@ -106,7 +118,8 @@ export function CommentBubble({ + diff --git a/apps/desktop/src/renderer/src/components/comment/CommentsPanel.tsx b/apps/desktop/src/renderer/src/components/comment/CommentsPanel.tsx index 6adc4d3f..f642485c 100644 --- a/apps/desktop/src/renderer/src/components/comment/CommentsPanel.tsx +++ b/apps/desktop/src/renderer/src/components/comment/CommentsPanel.tsx @@ -1,12 +1,13 @@ import { useT } from '@open-codesign/i18n'; import type { CommentRow } from '@open-codesign/shared'; -import { Trash2, X } from 'lucide-react'; +import { Send, Trash2, X } from 'lucide-react'; import { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import { useCodesignStore } from '../../store'; export function CommentsPanel() { const t = useT(); + const view = useCodesignStore((s) => s.view); const interactionMode = useCodesignStore((s) => s.interactionMode); const currentDesignId = useCodesignStore((s) => s.currentDesignId); const comments = useCodesignStore((s) => s.comments); @@ -14,9 +15,16 @@ export function CommentsPanel() { const previewZoom = useCodesignStore((s) => s.previewZoom); const setInteractionMode = useCodesignStore((s) => s.setInteractionMode); const openCommentBubble = useCodesignStore((s) => s.openCommentBubble); + const selectCanvasElement = useCodesignStore((s) => s.selectCanvasElement); const removeComment = useCodesignStore((s) => s.removeComment); + const queueCommentForPrompt = useCodesignStore((s) => s.queueCommentForPrompt); + const queuedCommentIds = useCodesignStore((s) => s.queuedCommentIds); + const liveRects = useCodesignStore((s) => s.liveRects); + const isGenerating = useCodesignStore( + (s) => s.isGenerating && s.generatingDesignId === s.currentDesignId, + ); - const active = interactionMode === 'comment' && currentDesignId !== null; + const active = view === 'workspace' && interactionMode === 'comment' && currentDesignId !== null; const [mounted, setMounted] = useState(active); const [visible, setVisible] = useState(false); @@ -43,16 +51,24 @@ export function CommentsPanel() { function handleOpen(c: CommentRow): void { const scale = previewZoom / 100; + const rawRect = liveRects[c.selector] ?? c.rect; + const rect = { + top: rawRect.top * scale, + left: rawRect.left * scale, + width: rawRect.width * scale, + height: rawRect.height * scale, + }; + selectCanvasElement({ + selector: c.selector, + tag: c.tag, + outerHTML: c.outerHTML, + rect, + }); openCommentBubble({ selector: c.selector, tag: c.tag, outerHTML: c.outerHTML, - rect: { - top: c.rect.top * scale, - left: c.rect.left * scale, - width: c.rect.width * scale, - height: c.rect.height * scale, - }, + rect, existingCommentId: c.id, initialText: c.text, ...(c.scope ? { initialScope: c.scope } : {}), @@ -60,6 +76,11 @@ export function CommentsPanel() { }); } + function handleSend(c: CommentRow): void { + if (c.kind !== 'edit' || c.status !== 'pending') return; + queueCommentForPrompt(c.id); + } + return createPortal(
+ {canSend ? ( + + ) : null} + {/* Delete — only on hover */}