Unverified 提交 9d11aec6 authored 作者: Mohamed Aziz Mejri's avatar Mohamed Aziz Mejri 提交者: GitHub

Planning mode (#2370)

<!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds a new Plan chat mode for guided requirements gathering and implementation planning, with a preview panel to review and accept plans, and an automatic handoff to Agent v2 when accepted. Plans can be saved as markdown when the user opts to persist and are retrievable per chat/app. - **New Features** - Plan chat mode with a focused system prompt and plan-only agent tools (planning_questionnaire, write_plan, exit_plan). - Persistent questionnaire UI above the chat input (text, radio, checkbox) and a Plan panel to view and accept the drafted plan. - IPC events (plan:update, plan:questionnaire, plan:exit) to stream plan updates, show questionnaires, and switch to implementation in a new chat on acceptance. - Optional persist toggle with file-based storage in .dyad/plans and CRUD IPC (create/get/update/delete, per app/chat). - Shortcut to implement a saved plan: /implement-plan=<plan-id>, with plan ID validation for safety. - **Migration** - Install dependency: @base-ui/react/radio-group. - Adds a Drizzle migration creating a plans table (0025_romantic_mantis.sql). - .dyad/ is automatically added to .gitignore. <sup>Written for commit f464e9ad242bf731068ce7b86efb48ae734ea8c4. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2370"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: 's avatarClaude Opus 4.5 <noreply@anthropic.com>
上级 cc99f729
......@@ -240,3 +240,13 @@ When running GitHub Actions with `pull_request_target` on cross-repo PRs (from f
- Wrapping `ToggleGroupItem` in `TooltipTrigger` without `render` also breaks `:first-child`/`:last-child` CSS selectors for rounded corners on the group.
- For drag handles and resize rails, prefer the native `title` attribute over `Tooltip` — tooltips appear immediately on hover and interfere with drag interactions, while `title` has a built-in delay.
### Drizzle migration conflicts during rebase
When rebasing a branch that has drizzle migrations conflicting with upstream (e.g., both have `0023_*.sql`):
1. Keep upstream's migration files (they're already deployed to production)
2. Rename the PR's conflicting migration to the next available index (e.g., `0023_romantic_mantis.sql``0025_romantic_mantis.sql`)
3. Update `drizzle/meta/_journal.json` to include all migrations with correct indices
4. Create/update the snapshot file (`drizzle/meta/00XX_snapshot.json`) with the new index, updating `prevId` to reference the previous snapshot's `id`
5. If the PR had subsequent commits that deleted/modified its migration files, those changes become no-ops after renaming — just accept the deletion conflicts by staging the renamed files
AGENTS.md
\ No newline at end of file
AGENTS.md
CREATE TABLE `plans` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`app_id` integer NOT NULL,
`chat_id` integer,
`title` text NOT NULL,
`summary` text,
`content` text NOT NULL,
`status` text DEFAULT 'draft' NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
FOREIGN KEY (`app_id`) REFERENCES `apps`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`chat_id`) REFERENCES `chats`(`id`) ON UPDATE no action ON DELETE set null
);
{
"version": "6",
"dialect": "sqlite",
"id": "58bbbbba-abef-41e9-b0f8-000fbb4f59ac",
"prevId": "ce28ff48-ebcb-4c8e-90aa-623ebe456839",
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"prevId": "39604cc7-898f-4dde-a4dd-a46a8cc7680a",
"tables": {
"apps": {
"name": "apps",
......@@ -682,6 +682,13 @@
"notNull": false,
"autoincrement": false
},
"using_free_agent_mode_quota": {
"name": "using_free_agent_mode_quota",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
......@@ -711,6 +718,109 @@
"uniqueConstraints": {},
"checkConstraints": {}
},
"plans": {
"name": "plans",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"app_id": {
"name": "app_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"chat_id": {
"name": "chat_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"summary": {
"name": "summary",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'draft'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"plans_app_id_apps_id_fk": {
"name": "plans_app_id_apps_id_fk",
"tableFrom": "plans",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"plans_chat_id_chats_id_fk": {
"name": "plans_chat_id_chats_id_fk",
"tableFrom": "plans",
"tableTo": "chats",
"columnsFrom": [
"chat_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"prompts": {
"name": "prompts",
"columns": {
......@@ -853,4 +963,4 @@
"internal": {
"indexes": {}
}
}
\ No newline at end of file
}
......@@ -176,6 +176,13 @@
"when": 1769582904159,
"tag": "0024_useful_skin",
"breakpoints": true
},
{
"idx": 25,
"version": "6",
"when": 1769600000000,
"tag": "0025_romantic_mantis",
"breakpoints": true
}
]
}
\ No newline at end of file
}
import type { LocalAgentFixture } from "../../../../testing/fake-llm-server/localAgentTypes";
export const fixture: LocalAgentFixture = {
description: "Present an implementation plan to the user",
turns: [
{
text: "I'll create a plan for you.",
toolCalls: [
{
name: "write_plan",
args: {
title: "Test Plan",
summary: "A test implementation plan for E2E testing.",
plan: "## Overview\n\nThis is a test plan.\n\n## Steps\n\n1. Step one\n2. Step two",
},
},
],
},
{
text: "I've presented the implementation plan. You can review it in the preview panel and accept it when ready.",
},
],
};
import type { LocalAgentFixture } from "../../../../testing/fake-llm-server/localAgentTypes";
export const fixture: LocalAgentFixture = {
description: "Exit plan mode after user accepts the plan",
turns: [
{
text: "Great, let's proceed with the implementation.",
toolCalls: [
{
name: "exit_plan",
args: {
confirmation: true,
},
},
],
},
{
text: "Plan accepted. Switching to implementation mode.",
},
],
};
import type { LocalAgentFixture } from "../../../../testing/fake-llm-server/localAgentTypes";
export const fixture: LocalAgentFixture = {
description: "Present a planning questionnaire to the user",
turns: [
{
text: "Let me ask you a few questions to understand your requirements.",
toolCalls: [
{
name: "planning_questionnaire",
args: {
title: "Project Requirements",
description: "Help me understand your project needs",
questions: [
{
id: "framework",
type: "radio",
question: "Which framework do you prefer?",
options: ["React", "Vue", "Svelte"],
required: true,
},
],
},
},
],
},
{
text: "Thanks, I'll wait for your responses before proceeding.",
},
],
};
......@@ -477,7 +477,7 @@ export class PageObject {
}
async selectChatMode(
mode: "build" | "ask" | "agent" | "local-agent" | "basic-agent",
mode: "build" | "ask" | "agent" | "local-agent" | "basic-agent" | "plan",
) {
await this.page.getByTestId("chat-mode-selector").click();
const mapping: Record<string, string> = {
......@@ -486,6 +486,7 @@ export class PageObject {
agent: "Build with MCP",
"local-agent": "Agent v2",
"basic-agent": "Basic Agent", // For free users
plan: "Plan.*Design before you build",
};
const optionName = mapping[mode];
await this.page
......
import fs from "node:fs";
import path from "node:path";
import { expect } from "@playwright/test";
import { Timeout, test } from "./helpers/test_helper";
test("plan mode - accept plan redirects to new chat and saves to disk", async ({
po,
}) => {
test.setTimeout(180000);
await po.setUpDyadPro({ localAgent: true });
await po.importApp("minimal");
await po.selectChatMode("plan");
// Get app path before accepting (needed to check saved plan)
const appPath = await po.getCurrentAppPath();
// Trigger write_plan fixture
await po.sendPrompt("tc=local-agent/accept-plan");
// Capture current chat ID from URL
const initialUrl = po.page.url();
const initialChatIdMatch = initialUrl.match(/[?&]id=(\d+)/);
expect(initialChatIdMatch).not.toBeNull();
const initialChatId = initialChatIdMatch![1];
// Wait for plan panel to appear
const acceptButton = po.page.getByRole("button", { name: "Accept Plan" });
await expect(acceptButton).toBeVisible({ timeout: Timeout.MEDIUM });
// Accept the plan (plans are now always saved to .dyad/plans/)
await acceptButton.click();
// Wait for navigation to a different chat
await expect(async () => {
const currentUrl = po.page.url();
const match = currentUrl.match(/[?&]id=(\d+)/);
expect(match).not.toBeNull();
expect(match![1]).not.toEqual(initialChatId);
}).toPass({ timeout: Timeout.MEDIUM });
// Verify plan was saved to .dyad/plans/
const planDir = path.join(appPath!, ".dyad", "plans");
let mdFiles: string[] = [];
await expect(async () => {
const files = fs.readdirSync(planDir);
mdFiles = files.filter((f) => f.endsWith(".md"));
expect(mdFiles.length).toBeGreaterThan(0);
}).toPass({ timeout: Timeout.MEDIUM });
// Verify plan content
const planContent = fs.readFileSync(path.join(planDir, mdFiles[0]), "utf-8");
expect(planContent).toContain("Test Plan");
});
test("plan mode - questionnaire flow", async ({ po }) => {
test.setTimeout(180000);
await po.setUpDyadPro({ localAgent: true });
await po.importApp("minimal");
await po.selectChatMode("plan");
// Trigger questionnaire fixture
await po.sendPrompt("tc=local-agent/questionnaire");
// Wait for questionnaire UI to appear
await expect(po.page.getByText("Project Requirements")).toBeVisible({
timeout: Timeout.MEDIUM,
});
// Select "React" radio option
await po.page.getByLabel("React").click();
// Click Submit (single question → Submit button shown)
await po.page.getByRole("button", { name: /Submit/ }).click();
// Wait for the LLM response to the submitted answers
await po.waitForChatCompletion();
// Snapshot the messages
await po.snapshotMessages();
});
- paragraph: tc=local-agent/questionnaire
- paragraph: Let me ask you a few questions to understand your requirements.
- button:
- img
- img
- text: Approved
- img
- text: claude-opus-4-5
- img
- text: less than a minute ago
- button "Request ID":
- img
- paragraph: "Here are my responses to the questionnaire:"
- paragraph:
- strong: Which framework do you prefer?
- text: React
- img
- text: file1.txt
- button "Edit":
- img
- img
- text: file1.txt
- paragraph: More EOM
- button:
- img
- img
- text: claude-opus-4-5
- img
- text: less than a minute ago
- button "Request ID":
- img
- button "Undo":
- img
- button "Retry":
- img
\ No newline at end of file
差异被折叠。
......@@ -10,8 +10,9 @@
* Escapes special characters in XML attribute values.
* Handles: & " < >
*/
export function escapeXmlAttr(str: string): string {
return str
export function escapeXmlAttr(str: string | null | undefined): string {
if (str == null) return "";
return String(str)
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
......@@ -34,8 +35,12 @@ export function unescapeXmlAttr(str: string): string {
* Escapes special characters in XML content (text between tags).
* Handles: & < >
*/
export function escapeXmlContent(str: string): string {
return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
export function escapeXmlContent(str: string | null | undefined): string {
if (str == null) return "";
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
/**
......
......@@ -16,6 +16,7 @@ import { useSettings } from "@/hooks/useSettings";
import type { ZoomLevel } from "@/lib/schemas";
import { selectedComponentsPreviewAtom } from "@/atoms/previewAtoms";
import { chatInputValueAtom } from "@/atoms/chatAtoms";
import { usePlanEvents } from "@/hooks/usePlanEvents";
const DEFAULT_ZOOM_LEVEL: ZoomLevel = "100";
......@@ -32,6 +33,9 @@ export default function RootLayout({ children }: { children: ReactNode }) {
const selectedAppId = useAtomValue(selectedAppIdAtom);
const setConsoleEntries = useSetAtom(appConsoleEntriesAtom);
// Initialize plan events listener
usePlanEvents();
useEffect(() => {
const zoomLevel = settings?.zoomLevel ?? DEFAULT_ZOOM_LEVEL;
const zoomFactor = Number(zoomLevel) / 100;
......
......@@ -8,7 +8,13 @@ export const selectedAppIdAtom = atom<number | null>(null);
export const appsListAtom = atom<ListedApp[]>([]);
export const versionsListAtom = atom<Version[]>([]);
export const previewModeAtom = atom<
"preview" | "code" | "problems" | "configure" | "publish" | "security"
| "preview"
| "code"
| "problems"
| "configure"
| "publish"
| "security"
| "plan"
>("preview");
export const selectedVersionIdAtom = atom<string | null>(null);
......
......@@ -31,3 +31,6 @@ export const pendingAgentConsentsAtom = atom<PendingAgentConsent[]>([]);
// Agent todos per chat
export const agentTodosByChatIdAtom = atom<Map<number, AgentTodo[]>>(new Map());
// Flag: set when user switches to plan mode from another mode in a chat with messages
export const needsFreshPlanChatAtom = atom<boolean>(false);
import { atom } from "jotai";
import type { PlanQuestionnairePayload } from "@/ipc/types/plan";
export interface PlanData {
content: string;
title: string;
summary?: string;
}
export interface PlanState {
plansByChatId: Map<number, PlanData>;
acceptedChatIds: Set<number>;
transitioningChatIds: Set<number>;
}
export const planStateAtom = atom<PlanState>({
plansByChatId: new Map(),
acceptedChatIds: new Set<number>(),
transitioningChatIds: new Set<number>(),
});
export interface PendingPlanImplementation {
chatId: number;
title: string;
planSlug: string;
}
export const pendingPlanImplementationAtom =
atom<PendingPlanImplementation | null>(null);
export const pendingQuestionnaireAtom = atom<PlanQuestionnairePayload | null>(
null,
);
......@@ -85,6 +85,8 @@ export function ChatModeSelector() {
case "local-agent":
// Show "Basic Agent" for non-Pro users, "Agent" for Pro users
return isProEnabled ? "Agent" : "Basic Agent";
case "plan":
return "Plan";
default:
return "Build";
}
......@@ -103,7 +105,9 @@ export function ChatModeSelector() {
data-testid="chat-mode-selector"
className={cn(
"h-6 w-fit px-1.5 py-0 text-xs-sm font-medium shadow-none gap-0.5",
selectedMode === "build" || selectedMode === "local-agent"
selectedMode === "build" ||
selectedMode === "local-agent" ||
selectedMode === "plan"
? "bg-background hover:bg-muted/50 focus:bg-muted/50"
: "bg-primary/10 hover:bg-primary/20 focus:bg-primary/20 text-primary border-primary/20 dark:bg-primary/20 dark:hover:bg-primary/30 dark:focus:bg-primary/30",
)}
......@@ -119,17 +123,30 @@ export function ChatModeSelector() {
</Tooltip>
<SelectContent align="start">
{isProEnabled && (
<SelectItem value="local-agent">
<div className="flex flex-col items-start">
<div className="flex items-center gap-1.5">
<span className="font-medium">Agent v2</span>
<NewBadge />
<>
<SelectItem value="local-agent">
<div className="flex flex-col items-start">
<div className="flex items-center gap-1.5">
<span className="font-medium">Agent v2</span>
<NewBadge />
</div>
<span className="text-xs text-muted-foreground">
Better at bigger tasks and debugging
</span>
</div>
<span className="text-xs text-muted-foreground">
Better at bigger tasks and debugging
</span>
</div>
</SelectItem>
</SelectItem>
<SelectItem value="plan">
<div className="flex flex-col items-start">
<div className="flex items-center gap-1.5">
<span className="font-medium">Plan</span>
<NewBadge />
</div>
<span className="text-xs text-muted-foreground">
Design before you build
</span>
</div>
</SelectItem>
</>
)}
{!isProEnabled && (
<SelectItem value="local-agent" disabled={isQuotaExceeded}>
......
......@@ -18,7 +18,7 @@ import {
Lock,
} from "lucide-react";
import type React from "react";
import { useCallback, useEffect, useState, useMemo } from "react";
import { useCallback, useEffect, useRef, useState, useMemo } from "react";
import { useSettings } from "@/hooks/useSettings";
import { ipc } from "@/ipc/types";
......@@ -28,6 +28,7 @@ import {
selectedChatIdAtom,
pendingAgentConsentsAtom,
agentTodosByChatIdAtom,
needsFreshPlanChatAtom,
} from "@/atoms/chatAtoms";
import { atom, useAtom, useSetAtom, useAtomValue } from "jotai";
import { useStreamChat } from "@/hooks/useStreamChat";
......@@ -53,12 +54,13 @@ import { useVersions } from "@/hooks/useVersions";
import { useAttachments } from "@/hooks/useAttachments";
import { AttachmentsList } from "./AttachmentsList";
import { DragDropOverlay } from "./DragDropOverlay";
import { showExtraFilesToast } from "@/lib/toast";
import { showExtraFilesToast, showInfo } from "@/lib/toast";
import { useSummarizeInNewChat } from "./SummarizeInNewChatButton";
import { ChatInputControls } from "../ChatInputControls";
import { ChatErrorBox } from "./ChatErrorBox";
import { AgentConsentBanner } from "./AgentConsentBanner";
import { TodoList } from "./TodoList";
import { QuestionnaireInput } from "./QuestionnaireInput";
import {
selectedComponentsPreviewAtom,
previewIframeRefAtom,
......@@ -182,6 +184,24 @@ export function ChatInput({ chatId }: { chatId?: number }) {
}, [chatId, messagesById]);
const { userBudget } = useUserBudgetInfo();
const [needsFreshPlanChat, setNeedsFreshPlanChat] = useAtom(
needsFreshPlanChatAtom,
);
// Detect transition to plan mode from another mode in a chat with messages
const prevModeRef = useRef(settings?.selectedChatMode);
useEffect(() => {
const prevMode = prevModeRef.current;
const currentMode = settings?.selectedChatMode;
prevModeRef.current = currentMode;
if (prevMode && prevMode !== "plan" && currentMode === "plan") {
const messages = chatId ? (messagesById.get(chatId) ?? []) : [];
if (messages.length > 0) {
setNeedsFreshPlanChat(true);
}
}
}, [settings?.selectedChatMode, chatId, messagesById, setNeedsFreshPlanChat]);
// Token counting for context limit banner
const { result: tokenCountResult } = useCountTokens(
......@@ -224,6 +244,30 @@ export function ChatInput({ chatId }: { chatId?: number }) {
return;
}
// If switching to plan mode from another mode in a chat with messages,
// create a new chat for a clean context.
if (needsFreshPlanChat && settings?.selectedChatMode === "plan" && appId) {
const currentInput = inputValue;
setInputValue("");
setNeedsFreshPlanChat(false);
const newChatId = await ipc.chat.createChat(appId);
setSelectedChatId(newChatId);
navigate({ to: "/chat", search: { id: newChatId } });
queryClient.invalidateQueries({ queryKey: queryKeys.chats.all });
showInfo("We've switched you to a new chat for a clean context");
await streamMessage({
prompt: currentInput,
chatId: newChatId,
attachments,
redo: false,
});
clearAttachments();
posthog.capture("chat:submit", { chatMode: settings?.selectedChatMode });
return;
}
const currentInput = inputValue;
setInputValue("");
......@@ -389,6 +433,9 @@ export function ChatInput({ chatId }: { chatId?: number }) {
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Show active questionnaire if exists */}
<QuestionnaireInput />
{/* Show todo list if there are todos for this chat */}
{chatTodos.length > 0 && <TodoList todos={chatTodos} />}
{/* Show agent consent banner if there's a pending consent request */}
......
import React, { useState, useEffect } from "react";
import { useAtomValue } from "jotai";
import { CheckCircle, ArrowRight } from "lucide-react";
import { planStateAtom } from "@/atoms/planAtoms";
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
interface DyadExitPlanProps {
node: {
properties: {
notes?: string;
};
};
}
export const DyadExitPlan: React.FC<DyadExitPlanProps> = ({ node }) => {
const { notes } = node.properties;
const chatId = useAtomValue(selectedChatIdAtom);
const planState = useAtomValue(planStateAtom);
const isTransitioning = chatId
? planState.transitioningChatIds.has(chatId)
: false;
const [dotCount, setDotCount] = useState(0);
useEffect(() => {
if (!isTransitioning) return;
const interval = setInterval(() => {
setDotCount((prev) => (prev + 1) % 4);
}, 400);
return () => clearInterval(interval);
}, [isTransitioning]);
return (
<div className="my-4 flex items-center gap-3 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<CheckCircle className="text-green-500 flex-shrink-0" size={24} />
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold text-green-800 dark:text-green-200">
Plan Accepted
</span>
<ArrowRight className="text-green-500" size={16} />
<span className="text-green-700 dark:text-green-300">
{isTransitioning
? `Preparing a new chat${".".repeat(dotCount + 1)}`
: "Opening new chat for implementation"}
</span>
</div>
{notes && (
<p className="text-sm text-green-600 dark:text-green-400 mt-1">
{notes}
</p>
)}
</div>
</div>
);
};
......@@ -34,6 +34,8 @@ import { DyadDatabaseSchema } from "./DyadDatabaseSchema";
import { DyadSupabaseTableSchema } from "./DyadSupabaseTableSchema";
import { DyadSupabaseProjectInfo } from "./DyadSupabaseProjectInfo";
import { DyadStatus } from "./DyadStatus";
import { DyadWritePlan } from "./DyadWritePlan";
import { DyadExitPlan } from "./DyadExitPlan";
import { mapActionToButton } from "./ChatInput";
import { SuggestedAction } from "@/lib/schemas";
import { FixAllErrorsButton } from "./FixAllErrorsButton";
......@@ -69,6 +71,9 @@ const DYAD_CUSTOM_TAGS = [
"dyad-supabase-table-schema",
"dyad-supabase-project-info",
"dyad-status",
// Plan mode tags
"dyad-write-plan",
"dyad-exit-plan",
];
interface DyadMarkdownParserProps {
......@@ -709,6 +714,33 @@ function renderCustomTag(
</DyadStatus>
);
case "dyad-write-plan":
return (
<DyadWritePlan
node={{
properties: {
title: attributes.title || "Implementation Plan",
summary: attributes.summary,
complete: attributes.complete,
state: getState({ isStreaming, inProgress }),
},
}}
>
{content}
</DyadWritePlan>
);
case "dyad-exit-plan":
return (
<DyadExitPlan
node={{
properties: {
notes: attributes.notes,
},
}}
/>
);
default:
return null;
}
......
import React, { useState } from "react";
import { FileText, Eye, ChevronDown, ChevronUp, Loader2 } from "lucide-react";
import { useSetAtom } from "jotai";
import { previewModeAtom } from "@/atoms/appAtoms";
import { CustomTagState } from "./stateTypes";
import { usePlan } from "@/hooks/usePlan";
interface DyadWritePlanProps {
node: {
properties: {
title: string;
summary?: string;
complete?: string;
state?: CustomTagState;
};
};
children?: React.ReactNode;
}
export const DyadWritePlan: React.FC<DyadWritePlanProps> = ({ node }) => {
const { title, summary, complete, state } = node.properties;
const [showSummary, setShowSummary] = useState(false);
const setPreviewMode = useSetAtom(previewModeAtom);
// Consider in progress if state is pending OR complete is explicitly "false"
const isInProgress = state === "pending" || complete === "false";
const { savedPlan, hasPlanInMemory } = usePlan({ enabled: !isInProgress });
const hasPlan = hasPlanInMemory || !!savedPlan;
return (
<div
className={`my-4 border rounded-lg overflow-hidden ${
isInProgress ? "border-primary/60" : "border-primary/20"
} bg-primary/5`}
>
<div className="px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<FileText
className={`text-primary ${isInProgress ? "animate-pulse" : ""}`}
size={20}
/>
<div className="flex items-center gap-2">
<span className="font-semibold text-foreground">{title}</span>
{summary && (
<button
type="button"
onClick={() => setShowSummary(!showSummary)}
className="text-primary hover:text-primary/80 transition-colors"
aria-label={showSummary ? "Hide summary" : "Show summary"}
>
{showSummary ? (
<ChevronUp size={16} />
) : (
<ChevronDown size={16} />
)}
</button>
)}
</div>
</div>
<div className="flex items-center">
{!isInProgress && hasPlan && (
<button
type="button"
onClick={() => setPreviewMode("plan")}
className="flex items-center gap-1.5 text-xs font-medium text-primary-foreground px-4 py-1.5 bg-primary rounded-md hover:bg-primary/90 transition-colors"
>
<Eye size={14} />
View Plan
</button>
)}
{isInProgress && (
<span className="flex items-center gap-1.5 text-xs text-primary px-3 py-1 bg-primary/20 rounded-md font-medium">
<Loader2 size={12} className="animate-spin" />
Generating plan...
</span>
)}
</div>
</div>
{isInProgress && (
<div className="px-4 pb-3">
<div
className="h-1.5 w-full rounded-full overflow-hidden"
style={{
background:
"linear-gradient(90deg, transparent 0%, hsl(var(--primary) / 0.3) 50%, transparent 100%)",
backgroundSize: "200% 100%",
animation: "shimmer 1.5s ease-in-out infinite",
}}
/>
</div>
)}
{summary && showSummary && (
<div className="px-4 pb-3 pt-0">
<p className="text-sm text-muted-foreground pl-7">{summary}</p>
</div>
)}
</div>
);
};
差异被折叠。
import React, { useEffect, useState } from "react";
import { useAtomValue, useSetAtom } from "jotai";
import { Button } from "@/components/ui/button";
import { Check, FileText } from "lucide-react";
import { VanillaMarkdownParser } from "@/components/chat/DyadMarkdownParser";
import { planStateAtom } from "@/atoms/planAtoms";
import { previewModeAtom } from "@/atoms/appAtoms";
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { useStreamChat } from "@/hooks/useStreamChat";
import { usePlan } from "@/hooks/usePlan";
import { useSettings } from "@/hooks/useSettings";
export const PlanPanel: React.FC = () => {
const chatId = useAtomValue(selectedChatIdAtom);
const planState = useAtomValue(planStateAtom);
const previewMode = useAtomValue(previewModeAtom);
const setPreviewMode = useSetAtom(previewModeAtom);
const { streamMessage, isStreaming } = useStreamChat();
const { savedPlan } = usePlan();
const { settings } = useSettings();
const planData = chatId ? planState.plansByChatId.get(chatId) : null;
const currentPlan = planData?.content ?? null;
const currentTitle = planData?.title ?? null;
const currentSummary = planData?.summary ?? null;
const isAccepted = chatId ? planState.acceptedChatIds.has(chatId) : false;
// Plan was already saved if we found it in the filesystem
const isSavedPlan = !!savedPlan;
// If there's no plan content, switch back to preview mode
useEffect(() => {
if (!currentPlan && previewMode === "plan") {
setPreviewMode("preview");
}
}, [currentPlan, previewMode, setPreviewMode]);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleAccept = () => {
if (!chatId) return;
if (settings?.selectedChatMode !== "plan") return;
if (isSubmitting) return;
setIsSubmitting(true);
streamMessage({
chatId,
prompt:
"I accept this plan. Call the exit_plan tool now with confirmation: true to begin implementation.",
});
};
// Don't render anything if there's no plan - effect will switch to preview mode
if (!currentPlan) {
return null;
}
return (
<div className="h-full flex flex-col">
<div className="flex-1 overflow-y-auto p-4">
<div className="border rounded-lg bg-card">
<div className="px-4 py-3 border-b">
<div className="flex items-center gap-2">
<FileText className="text-blue-500" size={20} />
<h2 className="text-lg font-semibold">
{currentTitle || "Implementation Plan"}
</h2>
</div>
{currentSummary && (
<p className="text-sm text-muted-foreground mt-1">
{currentSummary}
</p>
)}
</div>
<div className="p-4">
<div className="prose dark:prose-invert prose-sm max-w-none">
<VanillaMarkdownParser content={currentPlan} />
</div>
</div>
</div>
</div>
<div className="border-t p-4 space-y-4 bg-background">
{isAccepted || isSavedPlan ? (
<div className="flex items-center gap-2 text-green-700 dark:text-green-300">
<Check size={16} />
<span className="text-sm font-medium">
Plan accepted — implementation started in a new chat
</span>
</div>
) : (
<div className="flex gap-2">
<Button
onClick={handleAccept}
disabled={isStreaming || isSubmitting}
className="flex-1"
>
<Check size={16} className="mr-2" />
Accept Plan
</Button>
</div>
)}
</div>
</div>
);
};
......@@ -17,6 +17,7 @@ import { Console } from "./Console";
import { useRunApp } from "@/hooks/useRunApp";
import { PublishPanel } from "./PublishPanel";
import { SecurityPanel } from "./SecurityPanel";
import { PlanPanel } from "./PlanPanel";
import { useSupabase } from "@/hooks/useSupabase";
interface ConsoleHeaderProps {
......@@ -147,6 +148,8 @@ export function PreviewPanel() {
<PublishPanel />
) : previewMode === "security" ? (
<SecurityPanel />
) : previewMode === "plan" ? (
<PlanPanel />
) : (
<Problems />
)}
......
"use client";
import * as React from "react";
import { RadioGroup as BaseRadioGroup } from "@base-ui/react/radio-group";
import { Radio } from "@base-ui/react/radio";
import { Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const RadioGroup = React.forwardRef<
HTMLDivElement,
React.ComponentPropsWithoutRef<typeof BaseRadioGroup> & {
onValueChange?: (value: string) => void;
}
>(({ className, onValueChange, ...props }, ref) => {
return (
<BaseRadioGroup
className={cn("grid gap-2", className)}
onValueChange={(value) => onValueChange?.(value as string)}
{...props}
ref={ref}
/>
);
});
RadioGroup.displayName = "RadioGroup";
const RadioGroupItem = React.forwardRef<
HTMLButtonElement,
React.ComponentPropsWithoutRef<typeof Radio.Root>
>(({ className, ...props }, ref) => {
return (
<Radio.Root
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 flex items-center justify-center",
className,
)}
{...props}
>
<Radio.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</Radio.Indicator>
</Radio.Root>
);
});
RadioGroupItem.displayName = "RadioGroupItem";
export { RadioGroup, RadioGroupItem };
import { useEffect } from "react";
import { useSetAtom, useAtomValue } from "jotai";
import { useQuery } from "@tanstack/react-query";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { planStateAtom } from "@/atoms/planAtoms";
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { planClient } from "@/ipc/types/plan";
import { queryKeys } from "@/lib/queryKeys";
/**
* Loads a saved plan from disk and syncs it into memory state for the current chat.
*
* @param options.enabled - Extra condition to suppress the query (e.g. while plan is streaming). Defaults to true.
*/
export function usePlan({ enabled = true }: { enabled?: boolean } = {}) {
const chatId = useAtomValue(selectedChatIdAtom);
const appId = useAtomValue(selectedAppIdAtom);
const planState = useAtomValue(planStateAtom);
const setPlanState = useSetAtom(planStateAtom);
const hasPlanInMemory = chatId ? planState.plansByChatId.has(chatId) : false;
const { data: savedPlan, isLoading } = useQuery({
queryKey: queryKeys.plans.forChat({
appId: appId ?? null,
chatId: chatId ?? null,
}),
queryFn: async () => {
if (!appId || !chatId) return null;
return planClient.getPlanForChat({ appId, chatId });
},
enabled: !!appId && !!chatId && !hasPlanInMemory && enabled,
staleTime: 1000 * 60 * 5, // 5 minutes
});
// Sync saved plan into memory state
useEffect(() => {
if (savedPlan && chatId && !hasPlanInMemory) {
setPlanState((prev) => {
const nextPlans = new Map(prev.plansByChatId);
nextPlans.set(chatId, {
content: savedPlan.content,
title: savedPlan.title,
summary: savedPlan.summary ?? undefined,
});
return {
...prev,
plansByChatId: nextPlans,
};
});
}
}, [savedPlan, chatId, hasPlanInMemory, setPlanState]);
return {
savedPlan,
hasPlanInMemory,
isLoading,
};
}
import { useEffect, useRef } from "react";
import { useAtomValue, useSetAtom } from "jotai";
import { useNavigate } from "@tanstack/react-router";
import { useQueryClient } from "@tanstack/react-query";
import { useSettings } from "./useSettings";
import { queryKeys } from "@/lib/queryKeys";
import {
planStateAtom,
pendingPlanImplementationAtom,
pendingQuestionnaireAtom,
} from "@/atoms/planAtoms";
import { previewModeAtom, selectedAppIdAtom } from "@/atoms/appAtoms";
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import {
planEventClient,
planClient,
type PlanUpdatePayload,
type PlanExitPayload,
type PlanQuestionnairePayload,
} from "@/ipc/types/plan";
import { ipc } from "@/ipc/types";
import { showError } from "@/lib/toast";
/**
* Hook to handle plan mode IPC events.
* Should be called at the app root level to listen for plan events.
*/
export function usePlanEvents() {
const setPlanState = useSetAtom(planStateAtom);
const planState = useAtomValue(planStateAtom);
const setPreviewMode = useSetAtom(previewModeAtom);
const selectedAppId = useAtomValue(selectedAppIdAtom);
const setPendingPlanImplementation = useSetAtom(
pendingPlanImplementationAtom,
);
const setPendingQuestionnaire = useSetAtom(pendingQuestionnaireAtom);
const setSelectedChatId = useSetAtom(selectedChatIdAtom);
const navigate = useNavigate();
const queryClient = useQueryClient();
const { settings, updateSettings } = useSettings();
// Use refs for values accessed in event handlers to avoid stale closures
const planStateRef = useRef(planState);
const selectedAppIdRef = useRef(selectedAppId);
const settingsRef = useRef(settings);
// Keep refs up to date
planStateRef.current = planState;
selectedAppIdRef.current = selectedAppId;
settingsRef.current = settings;
useEffect(() => {
// Handle plan updates
const unsubscribeUpdate = planEventClient.onUpdate(
(payload: PlanUpdatePayload) => {
// Update plan state
setPlanState((prev) => {
const nextPlans = new Map(prev.plansByChatId);
nextPlans.set(payload.chatId, {
content: payload.plan,
title: payload.title,
summary: payload.summary,
});
return {
...prev,
plansByChatId: nextPlans,
};
});
// Switch to plan preview mode
setPreviewMode("plan");
},
);
// Handle plan exit (transition to implementation)
const unsubscribeExit = planEventClient.onExit(
async (payload: PlanExitPayload) => {
// Mark this chat's plan as accepted
setPlanState((prev) => {
const nextAccepted = new Set(prev.acceptedChatIds);
nextAccepted.add(payload.chatId);
return {
...prev,
acceptedChatIds: nextAccepted,
};
});
// Immediately cancel the current stream so we can start the plan implementation
try {
await ipc.chat.cancelStream(payload.chatId);
} catch (error) {
console.error("Failed to cancel stream:", error);
}
// Show transitioning state while we prepare the implementation
setPlanState((prev) => {
const nextTransitioning = new Set(prev.transitioningChatIds);
nextTransitioning.add(payload.chatId);
return { ...prev, transitioningChatIds: nextTransitioning };
});
// Pause so the user can see the "Plan accepted" confirmation
await new Promise((resolve) => setTimeout(resolve, 2500));
// Clear transitioning state
setPlanState((prev) => {
const nextTransitioning = new Set(prev.transitioningChatIds);
nextTransitioning.delete(payload.chatId);
return { ...prev, transitioningChatIds: nextTransitioning };
});
// Read latest values from refs to avoid stale closure
const currentState = planStateRef.current;
const planData = currentState.plansByChatId.get(payload.chatId);
// Switch chat mode to local-agent for implementation (only if currently in plan mode)
if (settingsRef.current?.selectedChatMode === "plan") {
updateSettings({ selectedChatMode: "local-agent" });
}
// Switch preview back to preview mode
setPreviewMode("preview");
// Create a new chat for implementation and navigate to it
if (!planData || !selectedAppIdRef.current) {
console.error("Failed to start implementation: missing plan data", {
hasContent: !!planData,
hasAppId: !!selectedAppIdRef.current,
});
return;
}
// Always persist the plan to .dyad/plans/
let planSlug: string;
try {
planSlug = await planClient.createPlan({
appId: selectedAppIdRef.current,
chatId: payload.chatId,
title: planData.title,
summary: planData.summary,
content: planData.content,
});
} catch {
showError("Failed to save plan. Please try again.");
return;
}
try {
const newChatId = await ipc.chat.createChat(selectedAppIdRef.current);
// Navigate to the new chat
setSelectedChatId(newChatId);
navigate({ to: "/chat", search: { id: newChatId } });
// Refresh the chat list so the new chat appears in the sidebar
queryClient.invalidateQueries({
queryKey: queryKeys.chats.all,
});
// Queue the plan for implementation in the new chat
setPendingPlanImplementation({
chatId: newChatId,
title: planData.title,
planSlug,
});
} catch (error) {
console.error("Failed to create new chat for implementation:", error);
}
},
);
// Handle questionnaire events
const unsubscribeQuestionnaire = planEventClient.onQuestionnaire(
(payload: PlanQuestionnairePayload) => {
setPendingQuestionnaire(payload);
},
);
return () => {
unsubscribeUpdate();
unsubscribeExit();
unsubscribeQuestionnaire();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
setPlanState,
setPreviewMode,
updateSettings,
setPendingPlanImplementation,
setPendingQuestionnaire,
setSelectedChatId,
navigate,
queryClient,
]);
}
import { useEffect, useRef } from "react";
import { useAtomValue, useSetAtom } from "jotai";
import { pendingPlanImplementationAtom } from "@/atoms/planAtoms";
import {
isStreamingByIdAtom,
chatMessagesByIdAtom,
chatErrorByIdAtom,
} from "@/atoms/chatAtoms";
import { ipc } from "@/ipc/types";
/**
* Hook to handle starting plan implementation when a plan is accepted.
* Watches for pending plan implementations and sends the plan to the agent
* AFTER the current stream completes.
*/
export function usePlanImplementation() {
const pendingPlan = useAtomValue(pendingPlanImplementationAtom);
const setPendingPlan = useSetAtom(pendingPlanImplementationAtom);
const isStreamingById = useAtomValue(isStreamingByIdAtom);
const setIsStreamingById = useSetAtom(isStreamingByIdAtom);
const setMessagesById = useSetAtom(chatMessagesByIdAtom);
const setErrorById = useSetAtom(chatErrorByIdAtom);
// Track if we've already triggered implementation for this pending plan
const hasTriggeredRef = useRef(false);
// Track the previous streaming state for the pending chat
const wasStreamingRef = useRef(false);
// Track mounted state to prevent state updates after unmount
const isMountedRef = useRef(true);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
useEffect(() => {
// Reset trigger flag when pending plan changes
if (!pendingPlan) {
hasTriggeredRef.current = false;
wasStreamingRef.current = false;
return;
}
// Check current streaming state for the pending chat
const isNowStreaming = isStreamingById.get(pendingPlan.chatId) ?? false;
const wasStreaming = wasStreamingRef.current;
// Update the ref for next render
wasStreamingRef.current = isNowStreaming;
// Only trigger when:
// 1. We haven't triggered yet
// 2. Streaming just completed (was true, now false) OR was never streaming
const streamJustCompleted = wasStreaming && !isNowStreaming;
const neverWasStreaming = !wasStreaming && !isNowStreaming;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
if (
!hasTriggeredRef.current &&
(streamJustCompleted || neverWasStreaming)
) {
// Set immediately to prevent duplicate scheduling on rapid re-renders
hasTriggeredRef.current = true;
// Capture pending plan value before the timeout to avoid stale closure
const planToImplement = pendingPlan;
// Add a small delay to ensure React state has settled after mode switch
timeoutId = setTimeout(() => {
const chatId = planToImplement.chatId;
// Send /implement-plan= command — expanded server-side in chat_stream_handlers
const prompt = `/implement-plan=${planToImplement.planSlug}`;
// Set streaming state to true
setIsStreamingById((prev) => {
const next = new Map(prev);
next.set(chatId, true);
return next;
});
// Clear any previous errors
setErrorById((prev) => {
const next = new Map(prev);
next.set(chatId, null);
return next;
});
// Send the message to start implementation using IPC directly
// (We can't use useStreamChat here because it has conditional hooks)
ipc.chatStream.start(
{
chatId,
prompt,
selectedComponents: [],
},
{
onChunk: ({ messages: updatedMessages }) => {
if (!isMountedRef.current) return;
// Update the messages so the UI shows the streaming response
setMessagesById((prev) => {
const next = new Map(prev);
next.set(chatId, updatedMessages);
return next;
});
},
onEnd: () => {
if (!isMountedRef.current) return;
// Stream completed - update streaming state
setIsStreamingById((prev) => {
const next = new Map(prev);
next.set(chatId, false);
return next;
});
},
onError: ({ error }) => {
if (!isMountedRef.current) return;
console.error("Plan implementation stream error:", error);
// Update error state
setErrorById((prev) => {
const next = new Map(prev);
next.set(chatId, error);
return next;
});
// Also set streaming to false on error
setIsStreamingById((prev) => {
const next = new Map(prev);
next.set(chatId, false);
return next;
});
},
},
);
// Clear the pending plan after triggering
setPendingPlan(null);
}, 100); // Small delay to let state settle
}
return () => {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
};
}, [
pendingPlan,
isStreamingById,
setPendingPlan,
setIsStreamingById,
setMessagesById,
setErrorById,
]);
}
......@@ -83,6 +83,7 @@ import { parseAppMentions } from "@/shared/parse_mention_apps";
import { prompts as promptsTable } from "../../db/schema";
import { inArray } from "drizzle-orm";
import { replacePromptReference } from "../utils/replacePromptReference";
import { parsePlanFile, validatePlanId } from "./planUtils";
import { mcpManager } from "../utils/mcp_manager";
import z from "zod";
import {
......@@ -367,6 +368,42 @@ export function registerChatStreamHandlers() {
logger.error("Failed to inline referenced prompts:", e);
}
// Expand /implement-plan= into full implementation prompt
// Keep the original short form for display in the UI; the expanded
// content is only injected into the AI message history.
let implementPlanDisplayPrompt: string | undefined;
const implementPlanMatch = userPrompt.match(/^\/implement-plan=(.+)$/);
if (implementPlanMatch) {
try {
implementPlanDisplayPrompt = userPrompt;
const planSlug = implementPlanMatch[1];
validatePlanId(planSlug);
const appPath = getDyadAppPath(chat.app.path);
const planFilePath = path.join(
appPath,
".dyad",
"plans",
`${planSlug}.md`,
);
const raw = await fs.promises.readFile(planFilePath, "utf-8");
const { meta, content } = parsePlanFile(raw);
const planPath = `.dyad/plans/${planSlug}.md`;
userPrompt = `Please implement the following plan:
## ${meta.title || "Implementation Plan"}
${content}
Start implementing this plan now. Follow the steps outlined and create/modify the necessary files.
You may update the plan at \`${planPath}\` to mark your progress.`;
} catch (e) {
implementPlanDisplayPrompt = undefined;
logger.error("Failed to expand /implement-plan= prompt:", e);
}
}
const componentsToProcess = req.selectedComponents || [];
if (componentsToProcess.length > 0) {
......@@ -416,7 +453,7 @@ ${componentSnippet}
.values({
chatId: req.chatId,
role: "user",
content: userPrompt,
content: implementPlanDisplayPrompt ?? userPrompt,
})
.returning({ id: messages.id });
const userMessageId = insertedUserMessage.id;
......@@ -579,6 +616,21 @@ ${componentSnippet}
commitHash: message.commitHash,
}));
// The DB stores the short /implement-plan= display form; inject the
// expanded plan content into the AI message history so the model
// receives the full plan.
if (implementPlanDisplayPrompt) {
for (let i = messageHistory.length - 1; i >= 0; i--) {
if (messageHistory[i].role === "user") {
messageHistory[i] = {
...messageHistory[i],
content: userPrompt,
};
break;
}
}
}
// For Dyad Pro + Deep Context, we set to 200 chat turns (+1)
// this is to enable more cache hits. Practically, users should
// rarely go over this limit because they will hit the model's
......@@ -1073,6 +1125,30 @@ This conversation includes one or more image attachments. When the user uploads
return;
}
// Handle plan mode: use local-agent with plan tools only
// Plan mode is for requirements gathering and creating implementation plans
if (
settings.selectedChatMode === "plan" &&
!mentionedAppsCodebases.length
) {
// Reconstruct system prompt for plan mode
const planModeSystemPrompt = constructSystemPrompt({
aiRules,
chatMode: "plan",
enableTurboEditsV2: false,
themePrompt,
});
await handleLocalAgentStream(event, req, abortController, {
placeholderMessageId: placeholderAssistantMessage.id,
systemPrompt: planModeSystemPrompt,
dyadRequestId: dyadRequestId ?? "[no-request-id]",
planModeOnly: true,
messageOverride: isSummarizeIntent ? chatMessages : undefined,
});
return;
}
// Handle local-agent mode (Agent v2)
// Mentioned apps can't be handled by the local agent (defer to balanced smart context
// in build mode)
......
import fs from "node:fs";
import path from "node:path";
/**
* Ensures `.dyad/` is listed in the project's `.gitignore`.
* Creates `.gitignore` if it doesn't exist.
*/
export async function ensureDyadGitignored(appPath: string): Promise<void> {
const gitignorePath = path.join(appPath, ".gitignore");
let content = "";
try {
content = await fs.promises.readFile(gitignorePath, "utf-8");
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
// .gitignore doesn't exist yet — will be created below
}
// Check if .dyad or .dyad/ is already ignored
const lines = content.split(/\r?\n/);
const alreadyIgnored = lines.some(
(line) => line.trim() === ".dyad" || line.trim() === ".dyad/",
);
if (alreadyIgnored) return;
// Append .dyad/ to the end, ensuring a leading newline if file has content
const suffix = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
await fs.promises.writeFile(
gitignorePath,
content + suffix + ".dyad/\n",
"utf-8",
);
}
export function slugify(text: string): string {
const result = text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.substring(0, 60);
return result || "untitled";
}
export function buildFrontmatter(meta: Record<string, string>): string {
const lines = Object.entries(meta).map(
([k, v]) =>
`${k}: "${v.replace(/\\/g, "\\\\").replace(/\n/g, " ").replace(/"/g, '\\"')}"`,
);
return `---\n${lines.join("\n")}\n---\n\n`;
}
export function validatePlanId(planId: string): void {
if (!/^[a-z0-9-]+$/.test(planId)) {
throw new Error("Invalid plan ID");
}
}
export function parsePlanFile(raw: string): {
meta: Record<string, string>;
content: string;
} {
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n*([\s\S]*)$/);
if (!match) return { meta: {}, content: raw };
const meta: Record<string, string> = {};
for (const line of match[1].split(/\r?\n/)) {
const idx = line.indexOf(":");
if (idx > 0) {
const key = line.slice(0, idx).trim();
let val = line.slice(idx + 1).trim();
if (val.startsWith('"') && val.endsWith('"')) {
val = val.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
}
meta[key] = val;
}
}
return { meta, content: match[2].trim() };
}
import fs from "node:fs";
import path from "node:path";
import { db } from "../../db";
import { apps } from "../../db/schema";
import { eq } from "drizzle-orm";
import { getDyadAppPath } from "../../paths/paths";
import log from "electron-log";
import { createTypedHandler } from "./base";
import { planContracts } from "../types/plan";
import {
slugify,
buildFrontmatter,
validatePlanId,
parsePlanFile,
ensureDyadGitignored,
} from "./planUtils";
const logger = log.scope("plan_handlers");
async function getPlanDir(appId: number): Promise<string> {
const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
if (!app) throw new Error("App not found");
const appPath = getDyadAppPath(app.path);
const planDir = path.join(appPath, ".dyad", "plans");
await fs.promises.mkdir(planDir, { recursive: true });
await ensureDyadGitignored(appPath);
return planDir;
}
export function registerPlanHandlers() {
createTypedHandler(planContracts.createPlan, async (_, params) => {
const { appId, chatId, title, summary, content } = params;
const planDir = await getPlanDir(appId);
const now = new Date().toISOString();
const slug = `chat-${chatId}-${slugify(title)}-${Date.now()}`;
validatePlanId(slug);
const meta: Record<string, string> = {
title,
summary: summary ?? "",
chatId: String(chatId),
createdAt: now,
updatedAt: now,
};
const frontmatter = buildFrontmatter(meta);
const filePath = path.join(planDir, `${slug}.md`);
await fs.promises.writeFile(filePath, frontmatter + content, "utf-8");
logger.info("Created plan:", slug, "for app:", appId, "with title:", title);
return slug;
});
createTypedHandler(planContracts.getPlan, async (_, { appId, planId }) => {
validatePlanId(planId);
const planDir = await getPlanDir(appId);
const filePath = path.join(planDir, `${planId}.md`);
let raw: string;
try {
raw = await fs.promises.readFile(filePath, "utf-8");
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
throw new Error(`Plan not found: ${planId}`);
}
throw err;
}
const { meta, content } = parsePlanFile(raw);
return {
id: planId,
appId,
chatId: meta.chatId ? Number(meta.chatId) : null,
title: meta.title ?? "",
summary: meta.summary || null,
content,
createdAt: meta.createdAt ?? new Date().toISOString(),
updatedAt: meta.updatedAt ?? new Date().toISOString(),
};
});
createTypedHandler(
planContracts.getPlanForChat,
async (_, { appId, chatId }) => {
const planDir = await getPlanDir(appId);
let files: string[];
try {
files = await fs.promises.readdir(planDir);
} catch {
return null;
}
const mdFiles = files.filter((f) => f.endsWith(".md"));
const prefix = `chat-${chatId}-`;
const matches = mdFiles.filter((f) => f.startsWith(prefix));
if (matches.length === 0) return null;
// Sort to get the latest plan (filenames contain timestamps)
matches.sort();
const match = matches[matches.length - 1];
const filePath = path.join(planDir, match);
const raw = await fs.promises.readFile(filePath, "utf-8");
const { meta, content } = parsePlanFile(raw);
const slug = match.replace(/\.md$/, "");
return {
id: slug,
appId,
chatId: meta.chatId ? Number(meta.chatId) : chatId,
title: meta.title ?? "",
summary: meta.summary || null,
content,
createdAt: meta.createdAt ?? new Date().toISOString(),
updatedAt: meta.updatedAt ?? new Date().toISOString(),
};
},
);
createTypedHandler(planContracts.updatePlan, async (_, params) => {
const { appId, id, ...updates } = params;
validatePlanId(id);
const planDir = await getPlanDir(appId);
const filePath = path.join(planDir, `${id}.md`);
const raw = await fs.promises.readFile(filePath, "utf-8");
const { meta, content } = parsePlanFile(raw);
if (updates.title !== undefined) meta.title = updates.title;
if (updates.summary !== undefined) meta.summary = updates.summary;
meta.updatedAt = new Date().toISOString();
const newContent =
updates.content !== undefined ? updates.content : content;
const frontmatter = buildFrontmatter(meta);
await fs.promises.writeFile(filePath, frontmatter + newContent, "utf-8");
logger.info("Updated plan:", id);
});
createTypedHandler(planContracts.deletePlan, async (_, { appId, planId }) => {
validatePlanId(planId);
const planDir = await getPlanDir(appId);
const filePath = path.join(planDir, `${planId}.md`);
try {
await fs.promises.unlink(filePath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
throw new Error(`Plan not found: ${planId}`);
}
throw err;
}
logger.info("Deleted plan:", planId);
});
}
......@@ -37,6 +37,7 @@ import { registerSecurityHandlers } from "./handlers/security_handlers";
import { registerVisualEditingHandlers } from "../pro/main/ipc/handlers/visual_editing_handlers";
import { registerAgentToolHandlers } from "../pro/main/ipc/handlers/local_agent/agent_tool_handlers";
import { registerFreeAgentQuotaHandlers } from "./handlers/free_agent_quota_handlers";
import { registerPlanHandlers } from "./handlers/plan_handlers";
export function registerIpcHandlers() {
// Register all IPC handlers by category
......@@ -79,4 +80,5 @@ export function registerIpcHandlers() {
registerVisualEditingHandlers();
registerAgentToolHandlers();
registerFreeAgentQuotaHandlers();
registerPlanHandlers();
}
......@@ -38,6 +38,7 @@ import { visualEditingContracts } from "../types/visual-editing";
import { securityContracts } from "../types/security";
import { miscContracts, miscEvents } from "../types/misc";
import { freeAgentQuotaContracts } from "../types/free_agent_quota";
import { planEvents, planContracts } from "../types/plan";
// =============================================================================
// Invoke Channels (derived from all contracts)
......@@ -91,6 +92,7 @@ export const VALID_INVOKE_CHANNELS = [
...getInvokeChannels(securityContracts),
...getInvokeChannels(miscContracts),
...getInvokeChannels(freeAgentQuotaContracts),
...getInvokeChannels(planContracts),
// Test-only channels
...TEST_INVOKE_CHANNELS,
......@@ -115,6 +117,7 @@ export const VALID_RECEIVE_CHANNELS = [
...getReceiveChannels(mcpEvents),
...getReceiveChannels(systemEvents),
...getReceiveChannels(miscEvents),
...getReceiveChannels(planEvents),
] as const;
// =============================================================================
......
import { z } from "zod";
import {
defineEvent,
createEventClient,
defineContract,
createClient,
} from "../contracts/core";
// Plan Schemas
export const PlanUpdateSchema = z.object({
chatId: z.number(),
title: z.string(),
summary: z.string().optional(),
plan: z.string(),
});
export type PlanUpdatePayload = z.infer<typeof PlanUpdateSchema>;
export const PlanExitSchema = z.object({
chatId: z.number(),
});
export type PlanExitPayload = z.infer<typeof PlanExitSchema>;
const TextQuestionSchema = z.object({
id: z.string(),
type: z.literal("text"),
question: z.string(),
required: z.boolean().optional(),
placeholder: z.string().optional(),
});
const MultipleChoiceQuestionSchema = z.object({
id: z.string(),
type: z.enum(["radio", "checkbox"]),
question: z.string(),
options: z.array(z.string()).min(1),
required: z.boolean().optional(),
placeholder: z.string().optional(),
});
export const QuestionSchema = z.union([
TextQuestionSchema,
MultipleChoiceQuestionSchema,
]);
export type Question = z.infer<typeof QuestionSchema>;
export const PlanQuestionnaireSchema = z.object({
chatId: z.number(),
title: z.string(),
description: z.string().optional(),
questions: z.array(QuestionSchema),
});
export type PlanQuestionnairePayload = z.infer<typeof PlanQuestionnaireSchema>;
export const PlanSchema = z.object({
id: z.string(),
appId: z.number(),
chatId: z.number().nullable(),
title: z.string(),
summary: z.string().nullable(),
content: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
});
export type Plan = z.infer<typeof PlanSchema>;
export const CreatePlanParamsSchema = z.object({
appId: z.number(),
chatId: z.number(),
title: z.string(),
summary: z.string().optional(),
content: z.string(),
});
export type CreatePlanParams = z.infer<typeof CreatePlanParamsSchema>;
export const UpdatePlanParamsSchema = z.object({
appId: z.number(),
id: z.string(),
title: z.string().optional(),
summary: z.string().optional(),
content: z.string().optional(),
});
export type UpdatePlanParams = z.infer<typeof UpdatePlanParamsSchema>;
// Plan Event Contracts (Main -> Renderer)
export const planEvents = {
update: defineEvent({
channel: "plan:update",
payload: PlanUpdateSchema,
}),
exit: defineEvent({
channel: "plan:exit",
payload: PlanExitSchema,
}),
questionnaire: defineEvent({
channel: "plan:questionnaire",
payload: PlanQuestionnaireSchema,
}),
} as const;
// Plan CRUD Contracts (Invoke/Response)
export const planContracts = {
createPlan: defineContract({
channel: "plan:create",
input: CreatePlanParamsSchema,
output: z.string(),
}),
getPlan: defineContract({
channel: "plan:get",
input: z.object({ appId: z.number(), planId: z.string() }),
output: PlanSchema,
}),
getPlanForChat: defineContract({
channel: "plan:get-for-chat",
input: z.object({ appId: z.number(), chatId: z.number() }),
output: PlanSchema.nullable(),
}),
updatePlan: defineContract({
channel: "plan:update-plan",
input: UpdatePlanParamsSchema,
output: z.void(),
}),
deletePlan: defineContract({
channel: "plan:delete",
input: z.object({ appId: z.number(), planId: z.string() }),
output: z.void(),
}),
} as const;
// Plan Clients
export const planEventClient = createEventClient(planEvents);
export const planClient = createClient(planContracts);
......@@ -36,6 +36,20 @@ export const queryKeys = {
["chats", "search", appId, query] as const,
},
// ─────────────────────────────────────────────────────────────────────────────
// Plans
// ─────────────────────────────────────────────────────────────────────────────
plans: {
all: ["plans"] as const,
forChat: ({
appId,
chatId,
}: {
appId: number | null;
chatId: number | null;
}) => ["plans", "forChat", appId, chatId] as const,
},
// ─────────────────────────────────────────────────────────────────────────────
// Proposals
// ─────────────────────────────────────────────────────────────────────────────
......@@ -264,6 +278,7 @@ export type QueryKeyOf<T> = T extends readonly unknown[]
export type AppQueryKey =
| QueryKeyOf<(typeof queryKeys.apps)[keyof typeof queryKeys.apps]>
| QueryKeyOf<(typeof queryKeys.chats)[keyof typeof queryKeys.chats]>
| QueryKeyOf<(typeof queryKeys.plans)[keyof typeof queryKeys.plans]>
| QueryKeyOf<(typeof queryKeys.proposals)[keyof typeof queryKeys.proposals]>
| QueryKeyOf<(typeof queryKeys.versions)[keyof typeof queryKeys.versions]>
| QueryKeyOf<(typeof queryKeys.branches)[keyof typeof queryKeys.branches]>
......
......@@ -143,7 +143,13 @@ export type RuntimeMode = z.infer<typeof RuntimeModeSchema>;
export const RuntimeMode2Schema = z.enum(["host", "docker"]);
export type RuntimeMode2 = z.infer<typeof RuntimeMode2Schema>;
export const ChatModeSchema = z.enum(["build", "ask", "agent", "local-agent"]);
export const ChatModeSchema = z.enum([
"build",
"ask",
"agent",
"local-agent",
"plan",
]);
export type ChatMode = z.infer<typeof ChatModeSchema>;
export const GitHubSecretsSchema = z.object({
......
......@@ -13,6 +13,7 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { isPreviewOpenAtom, isChatPanelHiddenAtom } from "@/atoms/viewAtoms";
import { useChats } from "@/hooks/useChats";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { usePlanImplementation } from "@/hooks/usePlanImplementation";
const DEFAULT_CHAT_PANEL_SIZE = 50;
......@@ -30,6 +31,9 @@ export default function ChatPage() {
const previousSizeRef = useRef<number>(DEFAULT_CHAT_PANEL_SIZE);
const isInitialMountRef = useRef(true);
// Handle plan implementation when a plan is accepted
usePlanImplementation();
useEffect(() => {
if (!chatId && chats.length && !loading) {
// Not a real navigation, just a redirect, when the user navigates to /chat
......
......@@ -59,6 +59,9 @@ import { TOOL_DEFINITIONS } from "./tool_definitions";
import { parseAiMessagesJson } from "@/ipc/utils/ai_messages_utils";
import { parseMcpToolKey, sanitizeMcpName } from "@/ipc/utils/mcp_tool_utils";
import { addIntegrationTool } from "./tools/add_integration";
import { planningQuestionnaireTool } from "./tools/planning_questionnaire";
import { writePlanTool } from "./tools/write_plan";
import { exitPlanTool } from "./tools/exit_plan";
const logger = log.scope("local_agent_handler");
......@@ -110,6 +113,7 @@ export async function handleLocalAgentStream(
systemPrompt,
dyadRequestId,
readOnly = false,
planModeOnly = false,
messageOverride,
}: {
placeholderMessageId: number;
......@@ -120,6 +124,11 @@ export async function handleLocalAgentStream(
* State-modifying tools are disabled, and no commits/deploys are made.
*/
readOnly?: boolean;
/**
* If true, only include tools allowed in plan mode.
* This includes read-only exploration tools and planning-specific tools.
*/
planModeOnly?: boolean;
/**
* If provided, use these messages instead of fetching from the database.
* Used for summarization where messages need to be transformed.
......@@ -237,8 +246,10 @@ export async function handleLocalAgentStream(
// Build tool set (agent tools + MCP tools)
// In read-only mode, only include read-only tools and skip MCP tools
// (since we can't determine if MCP tools modify state)
const agentTools = buildAgentToolSet(ctx, { readOnly });
const mcpTools = readOnly ? {} : await getMcpTools(event, ctx);
// In plan mode, only include planning tools (read + questionnaire/plan tools)
const agentTools = buildAgentToolSet(ctx, { readOnly, planModeOnly });
const mcpTools =
readOnly || planModeOnly ? {} : await getMcpTools(event, ctx);
const allTools: ToolSet = { ...agentTools, ...mcpTools };
// Prepare message history with graceful fallback
......@@ -270,7 +281,22 @@ export async function handleLocalAgentStream(
system: systemPrompt,
messages: messageHistory,
tools: allTools,
stopWhen: [stepCountIs(25), hasToolCall(addIntegrationTool.name)], // Allow multiple tool call rounds, stop on add_integration
stopWhen: [
stepCountIs(25),
hasToolCall(addIntegrationTool.name),
// In plan mode, stop immediately after presenting a questionnaire,
// writing a plan, or exiting plan mode so the agent yields control
// back to the user. Without this, some models (e.g. Gemini Pro 3)
// ignore the prompt-level "STOP" instruction and keep calling tools
// in a loop.
...(planModeOnly
? [
hasToolCall(planningQuestionnaireTool.name),
hasToolCall(writePlanTool.name),
hasToolCall(exitPlanTool.name),
]
: []),
],
abortSignal: abortController.signal,
// Inject pending user messages (e.g., images from web_crawl) between steps
// We must re-inject all accumulated messages each step because the AI SDK
......@@ -437,8 +463,8 @@ export async function handleLocalAgentStream(
logger.warn("Failed to save AI messages JSON:", err);
}
// In read-only mode, skip deploys and commits
if (!readOnly) {
// In read-only and plan mode, skip deploys and commits
if (!readOnly && !planModeOnly) {
// Deploy all Supabase functions if shared modules changed
await deployAllFunctionsIfNeeded(ctx);
......
......@@ -27,6 +27,9 @@ import { updateTodosTool } from "./tools/update_todos";
import { runTypeChecksTool } from "./tools/run_type_checks";
import { grepTool } from "./tools/grep";
import { codeSearchTool } from "./tools/code_search";
import { planningQuestionnaireTool } from "./tools/planning_questionnaire";
import { writePlanTool } from "./tools/write_plan";
import { exitPlanTool } from "./tools/exit_plan";
import type { LanguageModelV3ToolResultOutput } from "@ai-sdk/provider";
import {
escapeXmlAttr,
......@@ -61,6 +64,10 @@ export const TOOL_DEFINITIONS: readonly ToolDefinition[] = [
webCrawlTool,
updateTodosTool,
runTypeChecksTool,
// Plan mode tools
planningQuestionnaireTool,
writePlanTool,
exitPlanTool,
];
// ============================================================================
// Agent Tool Name Type (derived from TOOL_DEFINITIONS)
......@@ -263,6 +270,11 @@ export interface BuildAgentToolSetOptions {
* Used for read-only modes like "ask" mode.
*/
readOnly?: boolean;
/**
* If true, only include tools that are allowed in plan mode.
* Plan mode has access to read-only tools plus planning-specific tools.
*/
planModeOnly?: boolean;
}
const FILE_EDIT_TOOLS: Set<FileEditToolName> = new Set(FILE_EDIT_TOOL_NAMES);
......@@ -292,6 +304,16 @@ function trackFileEditTool(
ctx.fileEditTracker[filePath][toolName as FileEditToolName]++;
}
/**
* Planning-specific tools that are only available in plan mode.
* In plan mode, all non-state-modifying tools are also included automatically.
*/
const PLANNING_SPECIFIC_TOOLS = new Set([
"planning_questionnaire",
"write_plan",
"exit_plan",
]);
/**
* Build ToolSet for AI SDK from tool definitions
*/
......@@ -307,6 +329,15 @@ export function buildAgentToolSet(
continue;
}
// In plan mode, skip state-modifying tools unless they're planning-specific
if (
options.planModeOnly &&
tool.modifiesState &&
!PLANNING_SPECIFIC_TOOLS.has(tool.name)
) {
continue;
}
// In read-only mode, skip tools that modify state
if (options.readOnly && tool.modifiesState) {
continue;
......
import { z } from "zod";
import log from "electron-log";
import { ToolDefinition, AgentContext } from "./types";
import { safeSend } from "@/ipc/utils/safe_sender";
const logger = log.scope("exit_plan");
const exitPlanSchema = z.object({
confirmation: z
.literal(true)
.describe("Must be true to confirm the user has accepted the plan"),
});
const DESCRIPTION = `
Exit planning mode after the user has accepted the implementation plan.
IMPORTANT: Only use this tool when:
1. A plan has been presented using the write_plan tool
2. The user has EXPLICITLY accepted the plan (said "yes", "accept", "looks good", etc.)
3. You are ready to begin implementation
This will:
- Switch to Agent mode for implementation
- Change the preview panel back to app preview
- Begin the implementation phase
Do NOT use this tool if:
- The user has requested changes to the plan
- The user has asked questions about the plan
- No plan has been presented yet
Example usage after user says "Looks good, let's build it!":
{
"confirmation": true
}
`;
export const exitPlanTool: ToolDefinition<z.infer<typeof exitPlanSchema>> = {
name: "exit_plan",
description: DESCRIPTION,
inputSchema: exitPlanSchema,
defaultConsent: "always",
modifiesState: true,
getConsentPreview: () => "Exit plan mode and start implementation",
buildXml: (args) => {
if (!args.confirmation) return undefined;
return `<dyad-exit-plan></dyad-exit-plan>`;
},
execute: async (_args, ctx: AgentContext) => {
logger.log("Exiting plan mode, transitioning to implementation");
safeSend(ctx.event.sender, "plan:exit", {
chatId: ctx.chatId,
});
return "Plan accepted. Switching to Agent mode to begin implementation. The agreed plan will guide the implementation process.";
},
};
import { z } from "zod";
import log from "electron-log";
import { ToolDefinition, AgentContext } from "./types";
import { safeSend } from "@/ipc/utils/safe_sender";
const logger = log.scope("planning_questionnaire");
const BaseQuestionFields = {
id: z.string().describe("Unique identifier for this question"),
question: z.string().describe("The question text to display to the user"),
required: z
.boolean()
.optional()
.describe("Whether this question requires an answer (defaults to true)"),
placeholder: z
.string()
.optional()
.describe("Placeholder text for text inputs"),
};
const TextQuestionSchema = z.object({
...BaseQuestionFields,
type: z.literal("text"),
});
const MultipleChoiceQuestionSchema = z.object({
...BaseQuestionFields,
type: z
.enum(["radio", "checkbox"])
.describe("radio for single choice, checkbox for multiple choice"),
options: z
.array(z.string())
.min(1)
.max(3)
.describe(
"Options for the question. Keep to max 3 — users can always provide a custom answer via the free-form text input.",
),
});
const QuestionSchema = z.union([
TextQuestionSchema,
MultipleChoiceQuestionSchema,
]);
const planningQuestionnaireSchema = z.object({
title: z.string().describe("Title of this questionnaire section"),
description: z
.string()
.optional()
.describe(
"Brief description or context for why these questions are being asked",
),
questions: z
.array(QuestionSchema)
.min(1)
.max(3)
.describe("Array of 1-3 questions to present to the user"),
});
const DESCRIPTION = `
Present a structured questionnaire to gather requirements from the user during the planning phase.
**CRITICAL**: After calling this tool, you MUST STOP and wait for the user's responses before proceeding. Do NOT create a plan or take further action until the user has answered all questions. The user's responses will be sent as a follow-up message.
Use this tool to collect specific information about:
- Feature requirements and expected behavior
- Technology preferences or constraints
- Design and UX choices
- Priority decisions
- Edge cases and error handling expectations
Question Types:
- \`text\`: Free-form text input for open-ended questions
- \`radio\`: Single choice from multiple options (with additional free-form text input)
- \`checkbox\`: Multiple choice (with additional free-form text input)
**NOTE**: All question types (except pure text) include a free-form text input where users can provide custom answers or additional details. This ensures users are never limited to just the predefined options.
Best Practices:
- Ask 1-3 focused questions at a time
- Keep options to a maximum of 3 per question — users can always type a custom answer
- Users can always type a custom answer, so you don't need to cover every possible option
- Group related questions together
- Provide clear options when using radio/checkbox
- Explain why you're asking if it's not obvious
Example:
{
"title": "Authentication Preferences",
"description": "Help me understand your authentication requirements",
"questions": [
{
"id": "auth_method",
"type": "radio",
"question": "Which authentication method would you prefer?",
"options": ["Email/Password", "OAuth (Google, GitHub)", "Magic Link"],
"required": true
}
]
}
`;
export const planningQuestionnaireTool: ToolDefinition<
z.infer<typeof planningQuestionnaireSchema>
> = {
name: "planning_questionnaire",
description: DESCRIPTION,
inputSchema: planningQuestionnaireSchema,
defaultConsent: "always",
modifiesState: true,
getConsentPreview: (args) =>
`Questionnaire: ${args.title} (${args.questions.length} questions)`,
execute: async (args, ctx: AgentContext) => {
logger.log(
`Presenting questionnaire: ${args.title} (${args.questions.length} questions)`,
);
safeSend(ctx.event.sender, "plan:questionnaire", {
chatId: ctx.chatId,
title: args.title,
description: args.description,
questions: args.questions,
});
return `Questionnaire "${args.title}" presented to the user. STOP HERE and wait for the user to respond. Do NOT create a plan or continue until you receive the user's answers in a follow-up message.`;
},
};
import { z } from "zod";
import log from "electron-log";
import { ToolDefinition, AgentContext, escapeXmlAttr } from "./types";
import { safeSend } from "@/ipc/utils/safe_sender";
const logger = log.scope("write_plan");
const writePlanSchema = z.object({
title: z.string().describe("Title of the implementation plan"),
summary: z
.string()
.describe("Brief summary (1-2 sentences) of what will be built"),
plan: z
.string()
.describe(
"Full implementation plan in markdown format. Include sections for: feature overview, UI/UX design, considerations, technical approach, implementation steps, code changes, and testing strategy. Put product/UX sections first, technical sections last.",
),
});
const DESCRIPTION = `
Present an implementation plan to the user in the preview panel.
The plan should be comprehensive and include (in this order — product/UX first, technical last):
- **Overview**: Clear description of what will be built or changed
- **UI/UX Design**: User flows, layout, component placement, interactions
- **Considerations**: Potential challenges, trade-offs, edge cases, or alternatives
- **Technical Approach**: Architecture decisions, patterns to use, libraries needed
- **Implementation Steps**: Ordered, granular tasks with file-level specificity
- **Code Changes**: Specific files to modify/create and what changes are needed
- **Testing Strategy**: How the feature should be validated
Format the plan in markdown for clear readability. Use headers, bullet points, and code blocks for file paths.
After presenting the plan, the user can:
- Accept the plan (use exit_plan tool to proceed to implementation)
- Request changes (update the plan based on their feedback)
Example:
{
"title": "User Authentication System",
"summary": "Implement a complete authentication system with email/password login, session management, and protected routes.",
"plan": "## Overview\\n\\nImplement a secure authentication system...\\n\\n## Technical Approach\\n\\n- Use JWT for session management...\\n\\n## Implementation Steps\\n\\n1. Create auth context...\\n2. Build login form...\\n\\n## Testing Strategy\\n\\n- Unit tests for auth hooks..."
}
`;
export const writePlanTool: ToolDefinition<z.infer<typeof writePlanSchema>> = {
name: "write_plan",
description: DESCRIPTION,
inputSchema: writePlanSchema,
defaultConsent: "always",
modifiesState: true,
getConsentPreview: (args) => `Plan: ${args.title}`,
buildXml: (args, isComplete) => {
if (!args.title) return undefined;
const title = escapeXmlAttr(args.title);
const summary = args.summary ? escapeXmlAttr(args.summary) : "";
return `<dyad-write-plan title="${title}" summary="${summary}" complete="${isComplete}"></dyad-write-plan>`;
},
execute: async (args, ctx: AgentContext) => {
logger.log(`Writing plan: ${args.title}`);
safeSend(ctx.event.sender, "plan:update", {
chatId: ctx.chatId,
title: args.title,
summary: args.summary,
plan: args.plan,
});
return `Implementation plan "${args.title}" has been presented to the user. They can review it in the preview panel and either accept it or request changes.`;
},
};
export const PLAN_MODE_SYSTEM_PROMPT = `
<role>
You are Dyad Plan Mode, an AI planning assistant specialized in gathering requirements and creating detailed implementation plans for software changes. You operate in a collaborative, exploratory mode focused on understanding before building.
</role>
# Core Mission
Your goal is to have a thoughtful brainstorming session with the user to fully understand their request, then create a comprehensive implementation plan. Think of yourself as a technical product manager who asks insightful questions and creates detailed specifications.
# Planning Process Workflow
## Phase 1: Discovery & Requirements Gathering
1. **Initial Understanding**: When a user describes what they want, first acknowledge their request and identify what you already understand about it.
2. **Explore the Codebase**: Use read-only tools (read_file, list_files, grep, code_search) to examine the existing codebase structure, patterns, and relevant files.
3. **Ask Clarifying Questions**: Use the \`planning_questionnaire\` tool to ask targeted questions about:
- Specific functionality and behavior
- Edge cases and error handling
- UI/UX expectations
- Integration points with existing code
- Performance or security considerations
- User workflows and interactions
**IMPORTANT**: After calling \`planning_questionnaire\`, you MUST STOP and wait for the user's responses. Do NOT proceed to create a plan until the user has answered all questions. The user's responses will appear in a follow-up message.
4. **Iterative Clarification**: Based on user responses, continue exploring the codebase and asking follow-up questions until you have a clear picture. After receiving the first round of answers, consider whether follow-up questions are needed before moving to plan creation.
## Phase 2: Plan Creation
Once you have sufficient context, create a detailed implementation plan using the \`write_plan\` tool. The plan should include (in this order — product/UX first, technical last):
- **Overview**: Clear description of what will be built or changed
- **UI/UX Design**: User flows, layout, component placement, interactions
- **Considerations**: Potential challenges, trade-offs, edge cases, or alternatives
- **Technical Approach**: Architecture decisions, patterns to use, libraries needed
- **Implementation Steps**: Ordered, granular tasks with file-level specificity
- **Code Changes**: Specific files to modify/create and what changes are needed
- **Testing Strategy**: How the feature should be validated
## Phase 3: Plan Refinement & Approval
After presenting the plan:
- If user suggests changes: Acknowledge their feedback, investigate how to incorporate suggestions (explore codebase if needed), and update the plan using \`write_plan\` tool again
- **If user accepts**: You MUST immediately call the \`exit_plan\` tool with \`confirmation: true\`. Do NOT respond with any text — your entire response must be the \`exit_plan\` tool call and nothing else. This is critical for the system to transition correctly.
# Communication Guidelines
## Tone & Style
- Be collaborative and conversational, like a thoughtful colleague brainstorming together
- Show genuine curiosity about the user's vision
- Think out loud about trade-offs and options
- Be concise but thorough - avoid over-explaining obvious points
- Use natural language, not overly formal or robotic phrasing
## Question Strategy
- Ask 1-3 focused questions at a time (don't overwhelm)
- Prioritize questions that unblock multiple decisions
- Frame questions as options when possible ("Would you prefer A or B?")
- Explain why you're asking if it's not obvious
- Group related questions together
## Exploration Approach
- Proactively examine the codebase to understand context
- Share relevant findings: "I noticed you're using [X pattern] in [Y file]..."
- Identify existing patterns to follow for consistency
- Call out potential integration challenges early
# Available Tools
## Read-Only Tools (for exploration)
- \`read_file\` - Read file contents
- \`list_files\` - List directory contents
- \`grep\` - Search for patterns in files
- \`code_search\` - Semantic code search
## Planning Tools (for interaction)
- \`planning_questionnaire\` - Present structured questions to the user (supports text, radio, and checkbox question types)
- \`write_plan\` - Present or update the implementation plan as a markdown document
- \`exit_plan\` - Transition to implementation mode after plan approval
# Important Constraints
- **NEVER write code or make file changes in plan mode**
- **NEVER use <dyad-write>, <dyad-edit>, <dyad-delete>, <dyad-add-dependency> or any code-producing tags**
- Focus entirely on requirements gathering and planning
- Keep plans clear, actionable, and well-structured
- Ask clarifying questions proactively
- Break complex changes into discrete implementation steps
- Only use \`exit_plan\` when the user explicitly accepts the plan
- **CRITICAL**: When the user accepts the plan, you MUST call \`exit_plan\` immediately as your only action. Do not output any text before or after the tool call. Failure to call \`exit_plan\` will block the user from proceeding to implementation.
[[AI_RULES]]
# Remember
Your job is to:
1. Understand what the user wants to accomplish
2. Explore the existing codebase to inform the plan
3. Ask questions to clarify requirements
4. Create a comprehensive implementation plan
5. Refine the plan based on user feedback
6. Transition to implementation only after explicit approval — by calling \`exit_plan\` (not by generating text)
You are NOT building anything yet - you are planning what will be built.
`;
const DEFAULT_PLAN_AI_RULES = `# Tech Stack Context
When exploring the codebase, identify:
- Frontend framework (React, Vue, etc.)
- Styling approach (Tailwind, CSS modules, etc.)
- State management patterns
- Component architecture
- Routing approach
- API patterns
Use this context to inform your implementation plan and ensure consistency with existing patterns.
`;
export function constructPlanModePrompt(
aiRules: string | undefined,
themePrompt?: string,
): string {
let prompt = PLAN_MODE_SYSTEM_PROMPT.replace(
"[[AI_RULES]]",
aiRules ?? DEFAULT_PLAN_AI_RULES,
);
if (themePrompt) {
prompt += "\n\n" + themePrompt;
}
return prompt;
}
......@@ -3,6 +3,7 @@ import fs from "node:fs";
import log from "electron-log";
import { TURBO_EDITS_V2_SYSTEM_PROMPT } from "../pro/main/prompts/turbo_edits_v2_prompt";
import { constructLocalAgentPrompt } from "./local_agent_prompt";
import { constructPlanModePrompt } from "./plan_mode_prompt";
const logger = log.scope("system_prompt");
......@@ -513,7 +514,7 @@ export const constructSystemPrompt = ({
basicAgentMode,
}: {
aiRules: string | undefined;
chatMode?: "build" | "ask" | "agent" | "local-agent";
chatMode?: "build" | "ask" | "agent" | "local-agent" | "plan";
enableTurboEditsV2: boolean;
themePrompt?: string;
/** If true, use read-only mode for local-agent (ask mode with tools) */
......@@ -521,6 +522,10 @@ export const constructSystemPrompt = ({
/** If true, use basic agent mode (free tier with limited tools) */
basicAgentMode?: boolean;
}) => {
if (chatMode === "plan") {
return constructPlanModePrompt(aiRules, themePrompt);
}
if (chatMode === "local-agent") {
return constructLocalAgentPrompt(aiRules, themePrompt, {
readOnly,
......
......@@ -54,6 +54,11 @@ export const createChatCompletionHandler =
if (localAgentFixture) {
return handleLocalAgentFixture(req, res, localAgentFixture);
}
// Route plan acceptance message to exit-plan fixture
if (textContent.includes("I accept this plan")) {
return handleLocalAgentFixture(req, res, "exit-plan");
}
}
let messageContent = CANNED_MESSAGE;
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论