From 04b1a36f4acc07d07932decfae8a9b6e3de28559 Mon Sep 17 00:00:00 2001 From: Will Chen Date: Wed, 29 Oct 2025 21:26:01 -0700 Subject: [PATCH] Deep link: add prompt (#1669) Example: open "dyad://add-prompt?data=eyJ0aXRsZSI6IlRlc3QgUHJvbXB0IiwiZGVzY3JpcHRpb24iOiJBIHRlc3QgcHJvbXB0IGZyb20gZGVlcCBsaW5rIiwiY29udGVudCI6IlRoaXMgaXMgdGhlIGNvbnRlbnQgb2YgdGhlIHByb21wdC4ifQ%3D%3D" --- > [!NOTE] > Adds dyad://add-prompt deep link that navigates to Library and opens a prefilled Create Prompt dialog from base64 JSON. > > - **Deep Link Handling** > - Parse `dyad://add-prompt?data=` in `src/main.ts`; validate with `AddPromptDataSchema` and send `deep-link-received` with payload. > - Extend `DeepLinkContext` to navigate to `/library` on `add-prompt`. > - **Library/Dialogs** > - Add controlled open state and `prefillData` support to `CreateOrEditPromptDialog` and `CreatePromptDialog` (`src/components/CreatePromptDialog.tsx`). > - In `src/pages/library.tsx`, listen for `add-prompt` deep link, prefill form, open dialog, and clear deep-link state. > - **Schemas** > - Define `AddPromptDataSchema`, `AddPromptPayload`, and `AddPromptDeepLinkData` in `src/ipc/deep_link_data.ts` and include in `DeepLinkData` union. > - **E2E Tests** > - Add Playwright test `e2e-tests/add_prompt_deep_link.spec.ts` and ARIA snapshot to verify deep link opens prefilled dialog and saves prompt. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 1ddb12306cfca195682c8a1b719f60093b858d54. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- e2e-tests/add_prompt_deep_link.spec.ts | 52 +++++++++++++++++++ ...p-link-with-base64-encoded-data-1.aria.yml | 7 +++ src/components/CreatePromptDialog.tsx | 49 +++++++++++++++-- src/contexts/DeepLinkContext.tsx | 7 ++- src/ipc/deep_link_data.ts | 15 ++++++ src/main.ts | 28 ++++++++++ src/pages/library.tsx | 48 ++++++++++++++++- 7 files changed, 199 insertions(+), 7 deletions(-) create mode 100644 e2e-tests/add_prompt_deep_link.spec.ts create mode 100644 e2e-tests/snapshots/add_prompt_deep_link.spec.ts_add-prompt-via-deep-link-with-base64-encoded-data-1.aria.yml diff --git a/e2e-tests/add_prompt_deep_link.spec.ts b/e2e-tests/add_prompt_deep_link.spec.ts new file mode 100644 index 0000000..e228dcc --- /dev/null +++ b/e2e-tests/add_prompt_deep_link.spec.ts @@ -0,0 +1,52 @@ +import { test } from "./helpers/test_helper"; +import { expect } from "@playwright/test"; + +test("add prompt via deep link with base64-encoded data", async ({ + po, + electronApp, +}) => { + await po.setUp(); + await po.goToLibraryTab(); + + // Verify library is empty initially + await expect(po.page.getByTestId("prompt-card")).not.toBeVisible(); + + // Create the prompt data to be encoded + const promptData = { + title: "Deep Link Test Prompt", + description: "A prompt created via deep link", + content: "You are a helpful assistant. Please help with:\n\n[task here]", + }; + + // Encode the data as base64 (matching the pattern in main.ts) + const base64Data = Buffer.from(JSON.stringify(promptData)).toString("base64"); + const deepLinkUrl = `dyad://add-prompt?data=${encodeURIComponent(base64Data)}`; + + console.log("Triggering deep link:", deepLinkUrl); + + // Trigger the deep link by emitting the 'open-url' event in the main process + await electronApp.evaluate(({ app }, url) => { + app.emit("open-url", { preventDefault: () => {} }, url); + }, deepLinkUrl); + + // Wait for the dialog to open and verify prefilled data + await expect( + po.page.getByRole("dialog").getByText("Create New Prompt"), + ).toBeVisible(); + + // Verify the form is prefilled with the correct data + await expect(po.page.getByRole("textbox", { name: "Title" })).toHaveValue( + promptData.title, + ); + await expect( + po.page.getByRole("textbox", { name: "Description (optional)" }), + ).toHaveValue(promptData.description); + await expect(po.page.getByRole("textbox", { name: "Content" })).toHaveValue( + promptData.content, + ); + + // Save the prompt + await po.page.getByRole("button", { name: "Save" }).click(); + + await expect(po.page.getByTestId("prompt-card")).toMatchAriaSnapshot(); +}); diff --git a/e2e-tests/snapshots/add_prompt_deep_link.spec.ts_add-prompt-via-deep-link-with-base64-encoded-data-1.aria.yml b/e2e-tests/snapshots/add_prompt_deep_link.spec.ts_add-prompt-via-deep-link-with-base64-encoded-data-1.aria.yml new file mode 100644 index 0000000..b93f2ac --- /dev/null +++ b/e2e-tests/snapshots/add_prompt_deep_link.spec.ts_add-prompt-via-deep-link-with-base64-encoded-data-1.aria.yml @@ -0,0 +1,7 @@ +- heading "Deep Link Test Prompt" [level=3] +- paragraph: A prompt created via deep link +- button: + - img +- button: + - img +- text: "You are a helpful assistant. Please help with: [task here]" \ No newline at end of file diff --git a/src/components/CreatePromptDialog.tsx b/src/components/CreatePromptDialog.tsx index 3a23f03..96f8a39 100644 --- a/src/components/CreatePromptDialog.tsx +++ b/src/components/CreatePromptDialog.tsx @@ -38,6 +38,13 @@ interface CreateOrEditPromptDialogProps { content: string; }) => Promise; trigger?: React.ReactNode; + prefillData?: { + title: string; + description: string; + content: string; + }; + isOpen?: boolean; + onOpenChange?: (open: boolean) => void; } export function CreateOrEditPromptDialog({ @@ -46,8 +53,14 @@ export function CreateOrEditPromptDialog({ onCreatePrompt, onUpdatePrompt, trigger, + prefillData, + isOpen, + onOpenChange, }: CreateOrEditPromptDialogProps) { - const [open, setOpen] = useState(false); + const [internalOpen, setInternalOpen] = useState(false); + const open = isOpen !== undefined ? isOpen : internalOpen; + const setOpen = onOpenChange || setInternalOpen; + const [draft, setDraft] = useState({ title: "", description: "", @@ -74,7 +87,7 @@ export function CreateOrEditPromptDialog({ } }; - // Initialize draft with prompt data when editing + // Initialize draft with prompt data when editing or prefill data useEffect(() => { if (mode === "edit" && prompt) { setDraft({ @@ -82,10 +95,16 @@ export function CreateOrEditPromptDialog({ description: prompt.description || "", content: prompt.content, }); + } else if (prefillData) { + setDraft({ + title: prefillData.title, + description: prefillData.description, + content: prefillData.content, + }); } else { setDraft({ title: "", description: "", content: "" }); } - }, [mode, prompt, open]); + }, [mode, prompt, prefillData, open]); // Auto-resize textarea when content changes useEffect(() => { @@ -107,6 +126,12 @@ export function CreateOrEditPromptDialog({ description: prompt.description || "", content: prompt.content, }); + } else if (prefillData) { + setDraft({ + title: prefillData.title, + description: prefillData.description, + content: prefillData.content, + }); } else { setDraft({ title: "", description: "", content: "" }); } @@ -222,14 +247,30 @@ export function CreateOrEditPromptDialog({ // Backward compatibility wrapper for create mode export function CreatePromptDialog({ onCreatePrompt, + prefillData, + isOpen, + onOpenChange, }: { onCreatePrompt: (prompt: { title: string; description?: string; content: string; }) => Promise; + prefillData?: { + title: string; + description: string; + content: string; + }; + isOpen?: boolean; + onOpenChange?: (open: boolean) => void; }) { return ( - + ); } diff --git a/src/contexts/DeepLinkContext.tsx b/src/contexts/DeepLinkContext.tsx index 9ed8895..fa34dbb 100644 --- a/src/contexts/DeepLinkContext.tsx +++ b/src/contexts/DeepLinkContext.tsx @@ -1,4 +1,5 @@ import React, { createContext, useContext, useEffect, useState } from "react"; +import { useNavigate } from "@tanstack/react-router"; import { IpcClient } from "../ipc/ipc_client"; import { DeepLinkData } from "../ipc/deep_link_data"; import { useScrollAndNavigateTo } from "@/hooks/useScrollAndNavigateTo"; @@ -17,6 +18,7 @@ export function DeepLinkProvider({ children }: { children: React.ReactNode }) { const [lastDeepLink, setLastDeepLink] = useState< (DeepLinkData & { timestamp: number }) | null >(null); + const navigate = useNavigate(); const scrollAndNavigateTo = useScrollAndNavigateTo("/settings", { behavior: "smooth", block: "start", @@ -29,11 +31,14 @@ export function DeepLinkProvider({ children }: { children: React.ReactNode }) { if (data.type === "add-mcp-server") { // Navigate to tools-mcp section scrollAndNavigateTo("tools-mcp"); + } else if (data.type === "add-prompt") { + // Navigate to library page + navigate({ to: "/library" }); } }); return unsubscribe; - }, []); + }, [navigate, scrollAndNavigateTo]); return ( ; + +export type AddPromptDeepLinkData = { + type: "add-prompt"; + payload: AddPromptPayload; +}; + export type DeepLinkData = | AddMcpServerDeepLinkData + | AddPromptDeepLinkData | { type: string; }; diff --git a/src/main.ts b/src/main.ts index bb5db55..6d2a4f7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -21,6 +21,8 @@ import { handleNeonOAuthReturn } from "./neon_admin/neon_return_handler"; import { AddMcpServerConfigSchema, AddMcpServerPayload, + AddPromptDataSchema, + AddPromptPayload, } from "./ipc/deep_link_data"; log.errorHandler.startCatching(); @@ -357,6 +359,32 @@ function handleDeepLinkReturn(url: string) { } return; } + // dyad://add-prompt?data= + if (parsed.hostname === "add-prompt") { + const data = parsed.searchParams.get("data"); + if (!data) { + dialog.showErrorBox("Invalid URL", "Expected data parameter"); + return; + } + + try { + const decodedJson = atob(data); + const decoded = JSON.parse(decodedJson); + const parsedData = AddPromptDataSchema.parse(decoded); + + mainWindow?.webContents.send("deep-link-received", { + type: parsed.hostname, + payload: parsedData as AddPromptPayload, + }); + } catch (error) { + logger.error("Failed to parse add-prompt deep link:", error); + dialog.showErrorBox( + "Invalid Prompt Data", + "The deep link contains malformed data. Please check the URL and try again.", + ); + } + return; + } dialog.showErrorBox("Invalid deep link URL", url); } diff --git a/src/pages/library.tsx b/src/pages/library.tsx index be59c99..db1f878 100644 --- a/src/pages/library.tsx +++ b/src/pages/library.tsx @@ -1,21 +1,65 @@ -import React from "react"; +import React, { useState, useEffect } from "react"; import { usePrompts } from "@/hooks/usePrompts"; import { CreatePromptDialog, CreateOrEditPromptDialog, } from "@/components/CreatePromptDialog"; import { DeleteConfirmationDialog } from "@/components/DeleteConfirmationDialog"; +import { useDeepLink } from "@/contexts/DeepLinkContext"; +import { AddPromptDeepLinkData } from "@/ipc/deep_link_data"; +import { showInfo } from "@/lib/toast"; export default function LibraryPage() { const { prompts, isLoading, createPrompt, updatePrompt, deletePrompt } = usePrompts(); + const { lastDeepLink, clearLastDeepLink } = useDeepLink(); + const [dialogOpen, setDialogOpen] = useState(false); + const [prefillData, setPrefillData] = useState< + | { + title: string; + description: string; + content: string; + } + | undefined + >(undefined); + + useEffect(() => { + const handleDeepLink = async () => { + if (lastDeepLink?.type === "add-prompt") { + const deepLink = lastDeepLink as AddPromptDeepLinkData; + const payload = deepLink.payload; + showInfo(`Prefilled prompt: ${payload.title}`); + setPrefillData({ + title: payload.title, + description: payload.description, + content: payload.content, + }); + setDialogOpen(true); + clearLastDeepLink(); + } + }; + handleDeepLink(); + }, [lastDeepLink?.timestamp, clearLastDeepLink]); + + const handleDialogClose = (open: boolean) => { + setDialogOpen(open); + if (!open) { + // Clear prefill data when dialog closes + setPrefillData(undefined); + } + }; return (

Library: Prompts

- +
{isLoading ? (