diff --git a/e2e-tests/helpers/test_helper.ts b/e2e-tests/helpers/test_helper.ts index b5aa2de..c15d050 100644 --- a/e2e-tests/helpers/test_helper.ts +++ b/e2e-tests/helpers/test_helper.ts @@ -522,8 +522,15 @@ export class PageObject { .click({ timeout: Timeout.EXTRA_LONG }); } - async clickDeselectComponent() { - await this.page.getByRole("button", { name: "Deselect component" }).click(); + async clickDeselectComponent(options?: { index?: number }) { + const buttons = this.page.getByRole("button", { + name: "Deselect component", + }); + if (options?.index !== undefined) { + await buttons.nth(options.index).click(); + } else { + await buttons.first().click(); + } } async clickPreviewMoreOptions() { @@ -582,12 +589,12 @@ export class PageObject { await expect(this.getChatInputContainer()).toMatchAriaSnapshot(); } - getSelectedComponentDisplay() { + getSelectedComponentsDisplay() { return this.page.getByTestId("selected-component-display"); } - async snapshotSelectedComponentDisplay() { - await expect(this.getSelectedComponentDisplay()).toMatchAriaSnapshot(); + async snapshotSelectedComponentsDisplay() { + await expect(this.getSelectedComponentsDisplay()).toMatchAriaSnapshot(); } async snapshotPreview({ name }: { name?: string } = {}) { diff --git a/e2e-tests/select_component.spec.ts b/e2e-tests/select_component.spec.ts index 3e43ac8..111f249 100644 --- a/e2e-tests/select_component.spec.ts +++ b/e2e-tests/select_component.spec.ts @@ -14,11 +14,11 @@ testSkipIfWindows("select component", async ({ po }) => { .click(); await po.snapshotPreview(); - await po.snapshotSelectedComponentDisplay(); + await po.snapshotSelectedComponentsDisplay(); await po.sendPrompt("[dump] make it smaller"); await po.snapshotPreview(); - await expect(po.getSelectedComponentDisplay()).not.toBeVisible(); + await expect(po.getSelectedComponentsDisplay()).not.toBeVisible(); await po.snapshotServerDump("all-messages"); @@ -27,6 +27,34 @@ testSkipIfWindows("select component", async ({ po }) => { await po.snapshotServerDump("last-message"); }); +testSkipIfWindows("select multiple components", async ({ po }) => { + await po.setUp(); + await po.sendPrompt("tc=basic"); + await po.clickTogglePreviewPanel(); + await po.clickPreviewPickElement(); + + await po + .getPreviewIframeElement() + .contentFrame() + .getByRole("heading", { name: "Welcome to Your Blank App" }) + .click(); + + await po + .getPreviewIframeElement() + .contentFrame() + .getByText("Made with Dyad") + .click(); + + await po.snapshotPreview(); + await po.snapshotSelectedComponentsDisplay(); + + await po.sendPrompt("[dump] make both smaller"); + await po.snapshotPreview(); + await expect(po.getSelectedComponentsDisplay()).not.toBeVisible(); + + await po.snapshotServerDump("last-message"); +}); + testSkipIfWindows("deselect component", async ({ po }) => { await po.setUp(); await po.sendPrompt("tc=basic"); @@ -40,19 +68,50 @@ testSkipIfWindows("deselect component", async ({ po }) => { .click(); await po.snapshotPreview(); - await po.snapshotSelectedComponentDisplay(); + await po.snapshotSelectedComponentsDisplay(); // Deselect the component and make sure the state has reverted await po.clickDeselectComponent(); await po.snapshotPreview(); - await expect(po.getSelectedComponentDisplay()).not.toBeVisible(); + await expect(po.getSelectedComponentsDisplay()).not.toBeVisible(); // Send one more prompt to make sure it's a normal message. await po.sendPrompt("[dump] tc=basic"); await po.snapshotServerDump("last-message"); }); +testSkipIfWindows( + "deselect individual component from multiple", + async ({ po }) => { + await po.setUp(); + await po.sendPrompt("tc=basic"); + await po.clickTogglePreviewPanel(); + await po.clickPreviewPickElement(); + + await po + .getPreviewIframeElement() + .contentFrame() + .getByRole("heading", { name: "Welcome to Your Blank App" }) + .click(); + + await po + .getPreviewIframeElement() + .contentFrame() + .getByText("Made with Dyad") + .click(); + + await po.snapshotSelectedComponentsDisplay(); + + await po.clickDeselectComponent({ index: 0 }); + + await po.snapshotPreview(); + await po.snapshotSelectedComponentsDisplay(); + + await expect(po.getSelectedComponentsDisplay()).toBeVisible(); + }, +); + testSkipIfWindows("upgrade app to select component", async ({ po }) => { await po.setUp(); await po.importApp("select-component"); @@ -94,7 +153,7 @@ testSkipIfWindows("select component next.js", async ({ po }) => { .click(); await po.snapshotPreview(); - await po.snapshotSelectedComponentDisplay(); + await po.snapshotSelectedComponentsDisplay(); await po.sendPrompt("[dump] make it smaller"); await po.snapshotPreview(); diff --git a/e2e-tests/snapshots/select_component.spec.ts_deselect-component-1.aria.yml b/e2e-tests/snapshots/select_component.spec.ts_deselect-component-1.aria.yml index 556cb43..eb03981 100644 --- a/e2e-tests/snapshots/select_component.spec.ts_deselect-component-1.aria.yml +++ b/e2e-tests/snapshots/select_component.spec.ts_deselect-component-1.aria.yml @@ -5,5 +5,4 @@ - paragraph: Start building your amazing project here! - link "Made with Dyad": - /url: https://www.dyad.sh/ -- img -- text: Edit with AI h1 src/pages/Index.tsx \ No newline at end of file +- text: h1 src/pages/Index.tsx \ No newline at end of file diff --git a/e2e-tests/snapshots/select_component.spec.ts_deselect-component-2.aria.yml b/e2e-tests/snapshots/select_component.spec.ts_deselect-component-2.aria.yml index 724e29c..bdb7727 100644 --- a/e2e-tests/snapshots/select_component.spec.ts_deselect-component-2.aria.yml +++ b/e2e-tests/snapshots/select_component.spec.ts_deselect-component-2.aria.yml @@ -1,3 +1,5 @@ +- text: Selected Components (1) +- button "Clear all" - img - text: h1 src/pages/Index.tsx:9 - button "Deselect component": diff --git a/e2e-tests/snapshots/select_component.spec.ts_deselect-individual-component-from-multiple-1.aria.yml b/e2e-tests/snapshots/select_component.spec.ts_deselect-individual-component-from-multiple-1.aria.yml new file mode 100644 index 0000000..8b730f2 --- /dev/null +++ b/e2e-tests/snapshots/select_component.spec.ts_deselect-individual-component-from-multiple-1.aria.yml @@ -0,0 +1,10 @@ +- text: Selected Components (2) +- button "Clear all" +- img +- text: h1 src/pages/Index.tsx:9 +- button "Deselect component": + - img +- img +- text: a src/components/made-with-dyad.tsx:4 +- button "Deselect component": + - img \ No newline at end of file diff --git a/e2e-tests/snapshots/select_component.spec.ts_deselect-individual-component-from-multiple-2.aria.yml b/e2e-tests/snapshots/select_component.spec.ts_deselect-individual-component-from-multiple-2.aria.yml new file mode 100644 index 0000000..62a62f4 --- /dev/null +++ b/e2e-tests/snapshots/select_component.spec.ts_deselect-individual-component-from-multiple-2.aria.yml @@ -0,0 +1,8 @@ +- region "Notifications (F8)": + - list +- region "Notifications alt+T" +- heading "Welcome to Your Blank App" [level=1] +- paragraph: Start building your amazing project here! +- link "Made with Dyad": + - /url: https://www.dyad.sh/ +- text: a src/components/made-with-dyad.tsx \ No newline at end of file diff --git a/e2e-tests/snapshots/select_component.spec.ts_deselect-individual-component-from-multiple-3.aria.yml b/e2e-tests/snapshots/select_component.spec.ts_deselect-individual-component-from-multiple-3.aria.yml new file mode 100644 index 0000000..5d9dd38 --- /dev/null +++ b/e2e-tests/snapshots/select_component.spec.ts_deselect-individual-component-from-multiple-3.aria.yml @@ -0,0 +1,6 @@ +- text: Selected Components (1) +- button "Clear all" +- img +- text: a src/components/made-with-dyad.tsx:4 +- button "Deselect component": + - img \ No newline at end of file diff --git a/e2e-tests/snapshots/select_component.spec.ts_select-component-1.aria.yml b/e2e-tests/snapshots/select_component.spec.ts_select-component-1.aria.yml index 556cb43..eb03981 100644 --- a/e2e-tests/snapshots/select_component.spec.ts_select-component-1.aria.yml +++ b/e2e-tests/snapshots/select_component.spec.ts_select-component-1.aria.yml @@ -5,5 +5,4 @@ - paragraph: Start building your amazing project here! - link "Made with Dyad": - /url: https://www.dyad.sh/ -- img -- text: Edit with AI h1 src/pages/Index.tsx \ No newline at end of file +- text: h1 src/pages/Index.tsx \ No newline at end of file diff --git a/e2e-tests/snapshots/select_component.spec.ts_select-component-1.txt b/e2e-tests/snapshots/select_component.spec.ts_select-component-1.txt index 2c3c7f0..be24fc2 100644 --- a/e2e-tests/snapshots/select_component.spec.ts_select-component-1.txt +++ b/e2e-tests/snapshots/select_component.spec.ts_select-component-1.txt @@ -104,7 +104,9 @@ message: This is a simple basic response role: user message: [dump] make it smaller -Selected component: h1 (file: src/pages/Index.tsx) +Selected components: + +Component: h1 (file: src/pages/Index.tsx) Snippet: ``` diff --git a/e2e-tests/snapshots/select_component.spec.ts_select-component-2.aria.yml b/e2e-tests/snapshots/select_component.spec.ts_select-component-2.aria.yml index 724e29c..bdb7727 100644 --- a/e2e-tests/snapshots/select_component.spec.ts_select-component-2.aria.yml +++ b/e2e-tests/snapshots/select_component.spec.ts_select-component-2.aria.yml @@ -1,3 +1,5 @@ +- text: Selected Components (1) +- button "Clear all" - img - text: h1 src/pages/Index.tsx:9 - button "Deselect component": diff --git a/e2e-tests/snapshots/select_component.spec.ts_select-component-next-js-1.aria.yml b/e2e-tests/snapshots/select_component.spec.ts_select-component-next-js-1.aria.yml index 9f7113e..7fc7554 100644 --- a/e2e-tests/snapshots/select_component.spec.ts_select-component-next-js-1.aria.yml +++ b/e2e-tests/snapshots/select_component.spec.ts_select-component-next-js-1.aria.yml @@ -1 +1,8 @@ -- text: Edit with AI h1 src/app/page.tsx \ No newline at end of file +- main: + - heading "Blank page" [level=1] +- link "Made with Dyad": + - /url: https://www.dyad.sh/ +- text: h1 src/app/page.tsx +- alert +- button "Open Next.js Dev Tools": + - img \ No newline at end of file diff --git a/e2e-tests/snapshots/select_component.spec.ts_select-component-next-js-1.txt b/e2e-tests/snapshots/select_component.spec.ts_select-component-next-js-1.txt index 8f16d31..9e6300f 100644 --- a/e2e-tests/snapshots/select_component.spec.ts_select-component-next-js-1.txt +++ b/e2e-tests/snapshots/select_component.spec.ts_select-component-next-js-1.txt @@ -151,7 +151,9 @@ message: This is a simple basic response role: user message: [dump] make it smaller -Selected component: h1 (file: src/app/page.tsx) +Selected components: + +Component: h1 (file: src/app/page.tsx) Snippet: ``` diff --git a/e2e-tests/snapshots/select_component.spec.ts_select-component-next-js-2.aria.yml b/e2e-tests/snapshots/select_component.spec.ts_select-component-next-js-2.aria.yml index aaa0b3a..4f2e4e7 100644 --- a/e2e-tests/snapshots/select_component.spec.ts_select-component-next-js-2.aria.yml +++ b/e2e-tests/snapshots/select_component.spec.ts_select-component-next-js-2.aria.yml @@ -1,3 +1,5 @@ +- text: Selected Components (1) +- button "Clear all" - img - text: h1 src/app/page.tsx:7 - button "Deselect component": diff --git a/e2e-tests/snapshots/select_component.spec.ts_select-component-next-js-3.aria.yml b/e2e-tests/snapshots/select_component.spec.ts_select-component-next-js-3.aria.yml index 176906a..20aee84 100644 --- a/e2e-tests/snapshots/select_component.spec.ts_select-component-next-js-3.aria.yml +++ b/e2e-tests/snapshots/select_component.spec.ts_select-component-next-js-3.aria.yml @@ -2,3 +2,6 @@ - heading "Blank page" [level=1] - link "Made with Dyad": - /url: https://www.dyad.sh/ +- alert +- button "Open Next.js Dev Tools": + - img \ No newline at end of file diff --git a/e2e-tests/snapshots/select_component.spec.ts_select-multiple-components-1.aria.yml b/e2e-tests/snapshots/select_component.spec.ts_select-multiple-components-1.aria.yml new file mode 100644 index 0000000..62a62f4 --- /dev/null +++ b/e2e-tests/snapshots/select_component.spec.ts_select-multiple-components-1.aria.yml @@ -0,0 +1,8 @@ +- region "Notifications (F8)": + - list +- region "Notifications alt+T" +- heading "Welcome to Your Blank App" [level=1] +- paragraph: Start building your amazing project here! +- link "Made with Dyad": + - /url: https://www.dyad.sh/ +- text: a src/components/made-with-dyad.tsx \ No newline at end of file diff --git a/e2e-tests/snapshots/select_component.spec.ts_select-multiple-components-1.txt b/e2e-tests/snapshots/select_component.spec.ts_select-multiple-components-1.txt new file mode 100644 index 0000000..d78a9e1 --- /dev/null +++ b/e2e-tests/snapshots/select_component.spec.ts_select-multiple-components-1.txt @@ -0,0 +1,27 @@ +=== +role: user +message: [dump] make both smaller + +Selected components: + +1. Component: h1 (file: src/pages/Index.tsx) + +Snippet: +``` +
+

Welcome to Your Blank App

// <-- EDIT HERE +

+ Start building your amazing project here! +

+``` + +2. Component: a (file: src/components/made-with-dyad.tsx) + +Snippet: +``` +
+ ( - null, -); +export const selectedComponentsPreviewAtom = atom([]); + +export const previewIframeRefAtom = atom(null); diff --git a/src/components/chat/ChatInput.tsx b/src/components/chat/ChatInput.tsx index 394393c..8254aa0 100644 --- a/src/components/chat/ChatInput.tsx +++ b/src/components/chat/ChatInput.tsx @@ -61,8 +61,11 @@ import { FileAttachmentDropdown } from "./FileAttachmentDropdown"; import { showError, showExtraFilesToast } from "@/lib/toast"; import { ChatInputControls } from "../ChatInputControls"; import { ChatErrorBox } from "./ChatErrorBox"; -import { selectedComponentPreviewAtom } from "@/atoms/previewAtoms"; -import { SelectedComponentDisplay } from "./SelectedComponentDisplay"; +import { + selectedComponentsPreviewAtom, + previewIframeRefAtom, +} from "@/atoms/previewAtoms"; +import { SelectedComponentsDisplay } from "./SelectedComponentDisplay"; import { useCheckProblems } from "@/hooks/useCheckProblems"; import { LexicalChatInput } from "./LexicalChatInput"; import { useChatModeToggle } from "@/hooks/useChatModeToggle"; @@ -84,9 +87,10 @@ export function ChatInput({ chatId }: { chatId?: number }) { const setMessagesById = useSetAtom(chatMessagesByIdAtom); const setIsPreviewOpen = useSetAtom(isPreviewOpenAtom); const [showTokenBar, setShowTokenBar] = useAtom(showTokenBarAtom); - const [selectedComponent, setSelectedComponent] = useAtom( - selectedComponentPreviewAtom, + const [selectedComponents, setSelectedComponents] = useAtom( + selectedComponentsPreviewAtom, ); + const previewIframeRef = useAtomValue(previewIframeRefAtom); const { checkProblems } = useCheckProblems(appId); // Use the attachments hook const { @@ -148,7 +152,21 @@ export function ChatInput({ chatId }: { chatId?: number }) { const currentInput = inputValue; setInputValue(""); - setSelectedComponent(null); + + // Use all selected components for multi-component editing + const componentsToSend = + selectedComponents && selectedComponents.length > 0 + ? selectedComponents + : []; + setSelectedComponents([]); + + // Clear overlays in the preview iframe + if (previewIframeRef?.contentWindow) { + previewIframeRef.contentWindow.postMessage( + { type: "clear-dyad-component-overlays" }, + "*", + ); + } // Send message with attachments and clear them after sending await streamMessage({ @@ -156,7 +174,7 @@ export function ChatInput({ chatId }: { chatId?: number }) { chatId, attachments, redo: false, - selectedComponent, + selectedComponents: componentsToSend, }); clearAttachments(); posthog.capture("chat:submit"); @@ -288,7 +306,7 @@ export function ChatInput({ chatId }: { chatId?: number }) { /> )} - + {/* Use the AttachmentsList component */} { + const componentToRemove = selectedComponents[index]; + const newComponents = selectedComponents.filter((_, i) => i !== index); + setSelectedComponents(newComponents); + + // Remove the specific overlay from the iframe + if (previewIframeRef?.contentWindow) { + previewIframeRef.contentWindow.postMessage( + { + type: "remove-dyad-component-overlay", + componentId: componentToRemove.id, + }, + "*", + ); + } + }; + + const handleClearAll = () => { + setSelectedComponents([]); + + if (previewIframeRef?.contentWindow) { + previewIframeRef.contentWindow.postMessage( + { type: "clear-dyad-component-overlays" }, + "*", + ); + } + }; + + if (!selectedComponents || selectedComponents.length === 0) { return null; } return ( -
-
-
- -
- - {selectedComponent.name} - - - {selectedComponent.relativePath}:{selectedComponent.lineNumber} - -
-
+
+
+ + Selected Components ({selectedComponents.length}) +
+ {selectedComponents.map((selectedComponent, index) => ( +
+
+
+ +
+ + {selectedComponent.name} + + + {selectedComponent.relativePath}: + {selectedComponent.lineNumber} + +
+
+ +
+
+ ))}
); } diff --git a/src/components/preview_panel/PreviewIframe.tsx b/src/components/preview_panel/PreviewIframe.tsx index 6d019f8..593668e 100644 --- a/src/components/preview_panel/PreviewIframe.tsx +++ b/src/components/preview_panel/PreviewIframe.tsx @@ -35,7 +35,10 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useStreamChat } from "@/hooks/useStreamChat"; -import { selectedComponentPreviewAtom } from "@/atoms/previewAtoms"; +import { + selectedComponentsPreviewAtom, + previewIframeRefAtom, +} from "@/atoms/previewAtoms"; import { ComponentSelection } from "@/ipc/ipc_types"; import { Tooltip, @@ -52,6 +55,7 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { useRunApp } from "@/hooks/useRunApp"; import { useShortcut } from "@/hooks/useShortcut"; import { cn } from "@/lib/utils"; +import { normalizePath } from "../../../shared/normalizePath"; interface ErrorBannerProps { error: { message: string; source: "preview-app" | "dyad-app" } | undefined; @@ -169,9 +173,10 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => { const [canGoForward, setCanGoForward] = useState(false); const [navigationHistory, setNavigationHistory] = useState([]); const [currentHistoryPosition, setCurrentHistoryPosition] = useState(0); - const [selectedComponentPreview, setSelectedComponentPreview] = useAtom( - selectedComponentPreviewAtom, + const [selectedComponentsPreview, setSelectedComponentsPreview] = useAtom( + selectedComponentsPreviewAtom, ); + const setPreviewIframeRef = useSetAtom(previewIframeRefAtom); const iframeRef = useRef(null); const [isPicking, setIsPicking] = useState(false); @@ -189,9 +194,14 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => { //detect if the user is using Mac const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0; + // Update iframe ref atom + useEffect(() => { + setPreviewIframeRef(iframeRef.current); + }, [iframeRef.current, setPreviewIframeRef]); + // Deactivate component selector when selection is cleared useEffect(() => { - if (!selectedComponentPreview) { + if (!selectedComponentsPreview || selectedComponentsPreview.length === 0) { if (iframeRef.current?.contentWindow) { iframeRef.current.contentWindow.postMessage( { type: "deactivate-dyad-component-selector" }, @@ -200,7 +210,7 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => { } setIsPicking(false); } - }, [selectedComponentPreview]); + }, [selectedComponentsPreview]); // Add message listener for iframe errors and navigation events useEffect(() => { @@ -217,8 +227,37 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => { if (event.data?.type === "dyad-component-selected") { console.log("Component picked:", event.data); - setSelectedComponentPreview(parseComponentSelection(event.data)); - setIsPicking(false); + + // Parse the single selected component + const component = event.data.component + ? parseComponentSelection({ + type: "dyad-component-selected", + id: event.data.component.id, + name: event.data.component.name, + }) + : null; + + if (!component) return; + + // Add to existing components, avoiding duplicates by id + setSelectedComponentsPreview((prev) => { + // Check if this component is already selected + if (prev.some((c) => c.id === component.id)) { + return prev; + } + return [...prev, component]; + }); + + return; + } + + if (event.data?.type === "dyad-component-deselected") { + const componentId = event.data.componentId; + if (componentId) { + setSelectedComponentsPreview((prev) => + prev.filter((c) => c.id !== componentId), + ); + } return; } @@ -306,7 +345,7 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => { errorMessage, setErrorMessage, setIsComponentSelectorInitialized, - setSelectedComponentPreview, + setSelectedComponentsPreview, ]); useEffect(() => { @@ -742,7 +781,7 @@ function parseComponentSelection(data: any): ComponentSelection | null { return { id, name, - relativePath, + relativePath: normalizePath(relativePath), lineNumber, columnNumber, }; diff --git a/src/hooks/useStreamChat.ts b/src/hooks/useStreamChat.ts index e8fc2e3..2dcc131 100644 --- a/src/hooks/useStreamChat.ts +++ b/src/hooks/useStreamChat.ts @@ -69,14 +69,14 @@ export function useStreamChat({ chatId, redo, attachments, - selectedComponent, + selectedComponents, onSettled, }: { prompt: string; chatId: number; redo?: boolean; attachments?: FileAttachment[]; - selectedComponent?: ComponentSelection | null; + selectedComponents?: ComponentSelection[]; onSettled?: () => void; }) => { if ( @@ -106,7 +106,7 @@ export function useStreamChat({ let hasIncrementedStreamCount = false; try { IpcClient.getInstance().streamMessage(prompt, { - selectedComponent: selectedComponent ?? null, + selectedComponents: selectedComponents ?? [], chatId, redo, attachments, diff --git a/src/ipc/handlers/chat_stream_handlers.ts b/src/ipc/handlers/chat_stream_handlers.ts index 7850606..21ecd29 100644 --- a/src/ipc/handlers/chat_stream_handlers.ts +++ b/src/ipc/handlers/chat_stream_handlers.ts @@ -351,44 +351,51 @@ export function registerChatStreamHandlers() { } catch (e) { logger.error("Failed to inline referenced prompts:", e); } - if (req.selectedComponent) { - let componentSnippet = "[component snippet not available]"; - try { - const componentFileContent = await readFile( - path.join( - getDyadAppPath(chat.app.path), - req.selectedComponent.relativePath, - ), - "utf8", - ); - const lines = componentFileContent.split("\n"); - const selectedIndex = req.selectedComponent.lineNumber - 1; - // Let's get one line before and three after for context. - const startIndex = Math.max(0, selectedIndex - 1); - const endIndex = Math.min(lines.length, selectedIndex + 4); + const componentsToProcess = req.selectedComponents || []; - const snippetLines = lines.slice(startIndex, endIndex); - const selectedLineInSnippetIndex = selectedIndex - startIndex; + if (componentsToProcess.length > 0) { + userPrompt += "\n\nSelected components:\n"; - if (snippetLines[selectedLineInSnippetIndex]) { - snippetLines[selectedLineInSnippetIndex] = - `${snippetLines[selectedLineInSnippetIndex]} // <-- EDIT HERE`; + for (const component of componentsToProcess) { + let componentSnippet = "[component snippet not available]"; + try { + const componentFileContent = await readFile( + path.join(getDyadAppPath(chat.app.path), component.relativePath), + "utf8", + ); + const lines = componentFileContent.split(/\r?\n/); + const selectedIndex = component.lineNumber - 1; + + // Let's get one line before and three after for context. + const startIndex = Math.max(0, selectedIndex - 1); + const endIndex = Math.min(lines.length, selectedIndex + 4); + + const snippetLines = lines.slice(startIndex, endIndex); + const selectedLineInSnippetIndex = selectedIndex - startIndex; + + if (snippetLines[selectedLineInSnippetIndex]) { + snippetLines[selectedLineInSnippetIndex] = + `${snippetLines[selectedLineInSnippetIndex]} // <-- EDIT HERE`; + } + + componentSnippet = snippetLines.join("\n"); + } catch (err) { + logger.error( + `Error reading selected component file content: ${err}`, + ); } - componentSnippet = snippetLines.join("\n"); - } catch (err) { - logger.error(`Error reading selected component file content: ${err}`); - } - - userPrompt += `\n\nSelected component: ${req.selectedComponent.name} (file: ${req.selectedComponent.relativePath}) + userPrompt += `\n${componentsToProcess.length > 1 ? `${componentsToProcess.indexOf(component) + 1}. ` : ""}Component: ${component.name} (file: ${component.relativePath}) Snippet: \`\`\` ${componentSnippet} \`\`\` `; + } } + await db .insert(messages) .values({ @@ -460,18 +467,18 @@ ${componentSnippet} const appPath = getDyadAppPath(updatedChat.app.path); // When we don't have smart context enabled, we - // only include the selected component's file for codebase context. + // only include the selected components' files for codebase context. // - // If we have selected component and smart context is enabled, + // If we have selected components and smart context is enabled, // we handle this specially below. const chatContext = - req.selectedComponent && !isSmartContextEnabled + req.selectedComponents && + req.selectedComponents.length > 0 && + !isSmartContextEnabled ? { - contextPaths: [ - { - globPath: req.selectedComponent.relativePath, - }, - ], + contextPaths: req.selectedComponents.map((component) => ({ + globPath: component.relativePath, + })), smartContextAutoIncludes: [], } : validateChatContext(updatedChat.app.chatContext); @@ -482,12 +489,19 @@ ${componentSnippet} chatContext, }); - // For smart context and selected component, we will mark the selected component's file as focused. + // For smart context and selected components, we will mark the selected components' files as focused. // This means that we don't do the regular smart context handling, but we'll allow fetching // additional files through as needed. - if (isSmartContextEnabled && req.selectedComponent) { + if ( + isSmartContextEnabled && + req.selectedComponents && + req.selectedComponents.length > 0 + ) { + const selectedPaths = new Set( + req.selectedComponents.map((component) => component.relativePath), + ); for (const file of files) { - if (file.path === req.selectedComponent.relativePath) { + if (selectedPaths.has(file.path)) { file.focused = true; } } diff --git a/src/ipc/ipc_client.ts b/src/ipc/ipc_client.ts index 958f060..7ae20dd 100644 --- a/src/ipc/ipc_client.ts +++ b/src/ipc/ipc_client.ts @@ -387,7 +387,7 @@ export class IpcClient { public streamMessage( prompt: string, options: { - selectedComponent: ComponentSelection | null; + selectedComponents?: ComponentSelection[]; chatId: number; redo?: boolean; attachments?: FileAttachment[]; @@ -401,7 +401,7 @@ export class IpcClient { chatId, redo, attachments, - selectedComponent, + selectedComponents, onUpdate, onEnd, onError, @@ -441,7 +441,7 @@ export class IpcClient { prompt, chatId, redo, - selectedComponent, + selectedComponents, attachments: fileDataArray, }) .catch((err) => { @@ -464,7 +464,7 @@ export class IpcClient { prompt, chatId, redo, - selectedComponent, + selectedComponents, }) .catch((err) => { console.error("Error streaming message:", err); diff --git a/src/ipc/ipc_types.ts b/src/ipc/ipc_types.ts index 5f21fd8..16f2f33 100644 --- a/src/ipc/ipc_types.ts +++ b/src/ipc/ipc_types.ts @@ -41,7 +41,7 @@ export interface ChatStreamParams { data: string; // Base64 encoded file data attachmentType: "upload-to-codebase" | "chat-context"; // FileAttachment type }>; - selectedComponent: ComponentSelection | null; + selectedComponents?: ComponentSelection[]; } export interface ChatResponseEnd { diff --git a/worker/dyad-component-selector-client.js b/worker/dyad-component-selector-client.js index b5ddf56..c6fb56d 100644 --- a/worker/dyad-component-selector-client.js +++ b/worker/dyad-component-selector-client.js @@ -1,7 +1,9 @@ (() => { - const OVERLAY_ID = "__dyad_overlay__"; - let overlay, label; - + const OVERLAY_CLASS = "__dyad_overlay__"; + let overlays = []; + let hoverOverlay = null; + let hoverLabel = null; + let currentHoveredElement = null; //detect if the user is using Mac const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0; @@ -15,8 +17,8 @@ const css = (el, obj) => Object.assign(el.style, obj); function makeOverlay() { - overlay = document.createElement("div"); - overlay.id = OVERLAY_ID; + const overlay = document.createElement("div"); + overlay.className = OVERLAY_CLASS; css(overlay, { position: "absolute", border: "2px solid #7f22fe", @@ -27,7 +29,7 @@ boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)", }); - label = document.createElement("div"); + const label = document.createElement("div"); css(label, { position: "absolute", left: "0", @@ -45,89 +47,231 @@ }); overlay.appendChild(label); document.body.appendChild(overlay); + + return { overlay, label }; } function updateOverlay(el, isSelected = false) { - if (!overlay) makeOverlay(); + // If no element, hide hover overlay + if (!el) { + if (hoverOverlay) hoverOverlay.style.display = "none"; + return; + } + + if (isSelected) { + if (overlays.some((item) => item.el === el)) { + return; + } + + const { overlay, label } = makeOverlay(); + overlays.push({ overlay, label, el }); + + const rect = el.getBoundingClientRect(); + css(overlay, { + top: `${rect.top + window.scrollY}px`, + left: `${rect.left + window.scrollX}px`, + width: `${rect.width}px`, + height: `${rect.height}px`, + display: "block", + border: "3px solid #7f22fe", + background: "rgba(127, 34, 254, 0.05)", + }); + + css(label, { display: "none" }); + + return; + } + + // Otherwise, this is a hover overlay: reuse the hover overlay node + if (!hoverOverlay || !hoverLabel) { + const o = makeOverlay(); + hoverOverlay = o.overlay; + hoverLabel = o.label; + } const rect = el.getBoundingClientRect(); - css(overlay, { + css(hoverOverlay, { top: `${rect.top + window.scrollY}px`, left: `${rect.left + window.scrollX}px`, width: `${rect.width}px`, height: `${rect.height}px`, display: "block", - border: isSelected ? "3px solid #7f22fe" : "2px solid #7f22fe", - background: isSelected - ? "rgba(127, 34, 254, 0.05)" - : "rgba(0,170,255,.05)", + border: "2px solid #7f22fe", + background: "rgba(0,170,255,.05)", }); - - css(label, { - background: "#7f22fe", - }); - - // Clear previous contents - while (label.firstChild) { - label.removeChild(label.firstChild); - } - - if (isSelected) { - const editLine = document.createElement("div"); - - const svgNS = "http://www.w3.org/2000/svg"; - const svg = document.createElementNS(svgNS, "svg"); - svg.setAttribute("width", "12"); - svg.setAttribute("height", "12"); - svg.setAttribute("viewBox", "0 0 16 16"); - svg.setAttribute("fill", "none"); - Object.assign(svg.style, { - display: "inline-block", - verticalAlign: "-2px", - marginRight: "4px", - }); - const path = document.createElementNS(svgNS, "path"); - path.setAttribute( - "d", - "M8 0L9.48528 6.51472L16 8L9.48528 9.48528L8 16L6.51472 9.48528L0 8L6.51472 6.51472L8 0Z", - ); - path.setAttribute("fill", "white"); - svg.appendChild(path); - - editLine.appendChild(svg); - editLine.appendChild(document.createTextNode("Edit with AI")); - label.appendChild(editLine); - } - + css(hoverLabel, { background: "#7f22fe" }); + while (hoverLabel.firstChild) hoverLabel.removeChild(hoverLabel.firstChild); const name = el.dataset.dyadName || ""; const file = (el.dataset.dyadId || "").split(":")[0]; - const nameEl = document.createElement("div"); nameEl.textContent = name; - label.appendChild(nameEl); - + hoverLabel.appendChild(nameEl); if (file) { const fileEl = document.createElement("span"); css(fileEl, { fontSize: "10px", opacity: ".8" }); - fileEl.textContent = file; + fileEl.textContent = file.replace(/\\/g, "/"); + hoverLabel.appendChild(fileEl); + } + + // Update positions after showing hover label in case it caused layout shift + requestAnimationFrame(updateAllOverlayPositions); + } + + function updateAllOverlayPositions() { + // Update all selected overlays + overlays.forEach(({ overlay, el }) => { + const rect = el.getBoundingClientRect(); + css(overlay, { + top: `${rect.top + window.scrollY}px`, + left: `${rect.left + window.scrollX}px`, + width: `${rect.width}px`, + height: `${rect.height}px`, + }); + }); + + // Update hover overlay if visible + if ( + hoverOverlay && + hoverOverlay.style.display !== "none" && + state.element + ) { + const rect = state.element.getBoundingClientRect(); + css(hoverOverlay, { + top: `${rect.top + window.scrollY}px`, + left: `${rect.left + window.scrollX}px`, + width: `${rect.width}px`, + height: `${rect.height}px`, + }); + } + } + + function clearOverlays() { + overlays.forEach(({ overlay }) => overlay.remove()); + overlays = []; + + if (hoverOverlay) { + hoverOverlay.remove(); + hoverOverlay = null; + hoverLabel = null; + } + + currentHoveredElement = null; + } + + function removeOverlayById(componentId) { + const index = overlays.findIndex( + ({ el }) => el.dataset.dyadId === componentId, + ); + if (index !== -1) { + const { overlay } = overlays[index]; + overlay.remove(); + overlays.splice(index, 1); + } + } + + // Helper function to show/hide and populate label for a selected overlay + function updateSelectedOverlayLabel(item, show) { + const { label, el } = item; + + if (!show) { + css(label, { display: "none" }); + // Update positions after hiding label in case it caused layout shift + requestAnimationFrame(updateAllOverlayPositions); + return; + } + + // Clear and populate label + css(label, { display: "block", background: "#7f22fe" }); + while (label.firstChild) label.removeChild(label.firstChild); + + // Add "Edit with AI" line + const editLine = document.createElement("div"); + const svgNS = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(svgNS, "svg"); + svg.setAttribute("width", "12"); + svg.setAttribute("height", "12"); + svg.setAttribute("viewBox", "0 0 16 16"); + svg.setAttribute("fill", "none"); + Object.assign(svg.style, { + display: "inline-block", + verticalAlign: "-2px", + marginRight: "4px", + }); + const path = document.createElementNS(svgNS, "path"); + path.setAttribute( + "d", + "M8 0L9.48528 6.51472L16 8L9.48528 9.48528L8 16L6.51472 9.48528L0 8L6.51472 6.51472L8 0Z", + ); + path.setAttribute("fill", "white"); + svg.appendChild(path); + editLine.appendChild(svg); + editLine.appendChild(document.createTextNode("Edit with AI")); + label.appendChild(editLine); + + // Add component name and file + const name = el.dataset.dyadName || ""; + const file = (el.dataset.dyadId || "").split(":")[0]; + const nameEl = document.createElement("div"); + nameEl.textContent = name; + label.appendChild(nameEl); + if (file) { + const fileEl = document.createElement("span"); + css(fileEl, { fontSize: "10px", opacity: ".8" }); + fileEl.textContent = file.replace(/\\/g, "/"); label.appendChild(fileEl); } + + // Update positions after showing label in case it caused layout shift + requestAnimationFrame(updateAllOverlayPositions); } /* ---------- event handlers -------------------------------------------- */ function onMouseMove(e) { - if (state.type !== "inspecting") return; - let el = e.target; while (el && !el.dataset.dyadId) el = el.parentElement; - if (state.element === el) return; - state.element = el; + const hoveredItem = overlays.find((item) => item.el === el); - if (el) { - updateOverlay(el, false); - } else { - if (overlay) overlay.style.display = "none"; + if (currentHoveredElement && currentHoveredElement !== el) { + const previousItem = overlays.find( + (item) => item.el === currentHoveredElement, + ); + if (previousItem) { + updateSelectedOverlayLabel(previousItem, false); + } + } + + currentHoveredElement = el; + + // If hovering over a selected component, show its label + if (hoveredItem) { + updateSelectedOverlayLabel(hoveredItem, true); + if (hoverOverlay) hoverOverlay.style.display = "none"; + } + + // Handle inspecting state (component selector is active) + if (state.type === "inspecting") { + if (state.element === el) return; + state.element = el; + + if (!hoveredItem && el) { + updateOverlay(el, false); + } else if (!el) { + if (hoverOverlay) hoverOverlay.style.display = "none"; + } + } + } + + function onMouseLeave(e) { + if (!e.relatedTarget) { + if (hoverOverlay) { + hoverOverlay.style.display = "none"; + requestAnimationFrame(updateAllOverlayPositions); + } + currentHoveredElement = null; + if (state.type === "inspecting") { + state.element = null; + } } } @@ -136,14 +280,30 @@ e.preventDefault(); e.stopPropagation(); - state = { type: "selected", element: state.element }; + const selectedItem = overlays.find((item) => item.el === e.target); + if (selectedItem) { + removeOverlayById(state.element.dataset.dyadId); + window.parent.postMessage( + { + type: "dyad-component-deselected", + componentId: state.element.dataset.dyadId, + }, + "*", + ); + return; + } + updateOverlay(state.element, true); + requestAnimationFrame(updateAllOverlayPositions); + window.parent.postMessage( { type: "dyad-component-selected", - id: state.element.dataset.dyadId, - name: state.element.dataset.dyadName, + component: { + id: state.element.dataset.dyadId, + name: state.element.dataset.dyadName, + }, }, "*", ); @@ -177,25 +337,25 @@ /* ---------- activation / deactivation --------------------------------- */ function activate() { if (state.type === "inactive") { - window.addEventListener("mousemove", onMouseMove, true); window.addEventListener("click", onClick, true); } state = { type: "inspecting", element: null }; - if (overlay) { - overlay.style.display = "none"; - } } function deactivate() { if (state.type === "inactive") return; - window.removeEventListener("mousemove", onMouseMove, true); window.removeEventListener("click", onClick, true); - if (overlay) { - overlay.remove(); - overlay = null; - label = null; + // Don't clear overlays on deactivate - keep selected components visible + // Hide only the hover overlay and all labels + if (hoverOverlay) { + hoverOverlay.style.display = "none"; } + + // Hide all labels when deactivating + overlays.forEach((item) => updateSelectedOverlayLabel(item, false)); + currentHoveredElement = null; + state = { type: "inactive" }; } @@ -204,11 +364,25 @@ if (e.source !== window.parent) return; if (e.data.type === "activate-dyad-component-selector") activate(); if (e.data.type === "deactivate-dyad-component-selector") deactivate(); + if (e.data.type === "clear-dyad-component-overlays") clearOverlays(); + if (e.data.type === "remove-dyad-component-overlay") { + if (e.data.componentId) { + removeOverlayById(e.data.componentId); + } + } }); // Always listen for keyboard shortcuts window.addEventListener("keydown", onKeyDown, true); + // Always listen for mouse move to show/hide labels on selected overlays + window.addEventListener("mousemove", onMouseMove, true); + + document.addEventListener("mouseleave", onMouseLeave, true); + + // Update overlay positions on window resize + window.addEventListener("resize", updateAllOverlayPositions); + function initializeComponentSelector() { if (!document.body) { console.error(