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

Add planning_questionnaire to the agent mode (#2566)

<!-- This is an auto-generated description by cubic. --> ## Summary by cubic Enable planning_questionnaire in normal agent mode and enforce a clarify‑before‑plan workflow with explicit examples. Also adds a one‑pass todo follow‑up so the agent completes remaining tasks before stopping. - **New Features** - planning_questionnaire is available in both plan and normal modes. - Clarify step: call planning_questionnaire to ask 1–3 focused questions for vague requests; skip when specific; STOP until the user replies. Always use it for new app/major feature requests. - One-pass todo follow-up: if a turn ends with incomplete todos, inject a short reminder and run another pass to finish them. - **Bug Fixes** - Always stop after planning_questionnaire in both modes to prevent repeated tool calls. - Gate plan-only tools: write_plan and exit_plan are plan‑mode only; questionnaire remains available in normal mode. <sup>Written for commit c6a67ea7e6981e86418dddeaa245b918a22261e9. 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/2566" target="_blank"> <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 avatarMohamed Aziz Mejri <mohamedazizmejri@Mohameds-Mac-mini.local> Co-authored-by: 's avatarClaude Opus 4.5 <noreply@anthropic.com>
上级 d5a7b8b1
import { testSkipIfWindows } from "./helpers/test_helper"; import { expect } from "@playwright/test";
import { Timeout, testSkipIfWindows } from "./helpers/test_helper";
/** /**
* E2E tests for local-agent mode (Agent v2) * E2E tests for local-agent mode (Agent v2)
...@@ -42,3 +43,43 @@ testSkipIfWindows("local-agent - parallel tool calls", async ({ po }) => { ...@@ -42,3 +43,43 @@ testSkipIfWindows("local-agent - parallel tool calls", async ({ po }) => {
files: ["src/utils/math.ts", "src/utils/string.ts"], files: ["src/utils/math.ts", "src/utils/string.ts"],
}); });
}); });
testSkipIfWindows("local-agent - questionnaire flow", async ({ po }) => {
await po.setUpDyadPro({ localAgent: true });
await po.importApp("minimal");
await po.chatActions.selectLocalAgentMode();
// Wait for the auto-generated AI_RULES response to fully complete,
// then start a new chat to avoid the chat:stream:end event from the
// AI_RULES stream clearing the questionnaire state.
await po.chatActions.waitForChatCompletion();
await po.chatActions.clickNewChat();
// Trigger questionnaire fixture
await po.sendPrompt("tc=local-agent/questionnaire", {
skipWaitForCompletion: true,
});
// Wait for questionnaire UI to appear
await expect(po.page.getByText("Which framework do you prefer?")).toBeVisible(
{
timeout: Timeout.MEDIUM,
},
);
await expect(po.page.getByRole("button", { name: "Submit" })).toBeVisible({
timeout: Timeout.MEDIUM,
});
// Select "Vue" radio option
await po.page.getByText("Vue", { exact: true }).click();
// Submit the questionnaire
await po.page.getByRole("button", { name: /Submit/ }).click();
// Wait for the LLM response after submitting answers
await po.chatActions.waitForChatCompletion();
// Snapshot the messages
await po.snapshotMessages();
});
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { expect } from "@playwright/test"; import { expect } from "@playwright/test";
import { Timeout, test } from "./helpers/test_helper"; import { Timeout, testSkipIfWindows } from "./helpers/test_helper";
test("plan mode - accept plan redirects to new chat and saves to disk", async ({ testSkipIfWindows(
po, "plan mode - accept plan redirects to new chat and saves to disk",
}) => { async ({ po }) => {
test.setTimeout(180000); await po.setUpDyadPro({ localAgent: true });
await po.setUpDyadPro({ localAgent: true }); await po.importApp("minimal");
await po.importApp("minimal"); await po.chatActions.selectChatMode("plan");
await po.chatActions.selectChatMode("plan");
// Get app path before accepting (needed to check saved plan)
// Get app path before accepting (needed to check saved plan) const appPath = await po.appManagement.getCurrentAppPath();
const appPath = await po.appManagement.getCurrentAppPath();
// Trigger write_plan fixture
// Trigger write_plan fixture await po.sendPrompt("tc=local-agent/accept-plan");
await po.sendPrompt("tc=local-agent/accept-plan");
// Capture current chat ID from URL
// Capture current chat ID from URL const initialUrl = po.page.url();
const initialUrl = po.page.url(); const initialChatIdMatch = initialUrl.match(/[?&]id=(\d+)/);
const initialChatIdMatch = initialUrl.match(/[?&]id=(\d+)/); expect(initialChatIdMatch).not.toBeNull();
expect(initialChatIdMatch).not.toBeNull(); const initialChatId = initialChatIdMatch![1];
const initialChatId = initialChatIdMatch![1];
// Wait for plan panel to appear
// Wait for plan panel to appear const acceptButton = po.page.getByRole("button", { name: "Accept Plan" });
const acceptButton = po.page.getByRole("button", { name: "Accept Plan" }); await expect(acceptButton).toBeVisible({ timeout: Timeout.MEDIUM });
await expect(acceptButton).toBeVisible({ timeout: Timeout.MEDIUM });
// Accept the plan (plans are now always saved to .dyad/plans/)
// Accept the plan (plans are now always saved to .dyad/plans/) await acceptButton.click();
await acceptButton.click();
// Wait for navigation to a different chat
// Wait for navigation to a different chat await expect(async () => {
await expect(async () => { const currentUrl = po.page.url();
const currentUrl = po.page.url(); const match = currentUrl.match(/[?&]id=(\d+)/);
const match = currentUrl.match(/[?&]id=(\d+)/); expect(match).not.toBeNull();
expect(match).not.toBeNull(); expect(match![1]).not.toEqual(initialChatId);
expect(match![1]).not.toEqual(initialChatId); }).toPass({ timeout: Timeout.MEDIUM });
}).toPass({ timeout: Timeout.MEDIUM });
// Verify plan was saved to .dyad/plans/
// Verify plan was saved to .dyad/plans/ const planDir = path.join(appPath!, ".dyad", "plans");
const planDir = path.join(appPath!, ".dyad", "plans"); let mdFiles: string[] = [];
let mdFiles: string[] = []; await expect(async () => {
await expect(async () => { const files = fs.readdirSync(planDir);
const files = fs.readdirSync(planDir); mdFiles = files.filter((f) => f.endsWith(".md"));
mdFiles = files.filter((f) => f.endsWith(".md")); expect(mdFiles.length).toBeGreaterThan(0);
expect(mdFiles.length).toBeGreaterThan(0); }).toPass({ timeout: Timeout.MEDIUM });
}).toPass({ timeout: Timeout.MEDIUM });
// Verify plan content
// Verify plan content const planContent = fs.readFileSync(
const planContent = fs.readFileSync(path.join(planDir, mdFiles[0]), "utf-8"); path.join(planDir, mdFiles[0]),
expect(planContent).toContain("Test Plan"); "utf-8",
}); );
expect(planContent).toContain("Test Plan");
test("plan mode - questionnaire flow", async ({ po }) => { },
test.setTimeout(180000); );
testSkipIfWindows("plan mode - questionnaire flow", async ({ po }) => {
await po.setUpDyadPro({ localAgent: true }); await po.setUpDyadPro({ localAgent: true });
await po.importApp("minimal"); await po.importApp("minimal");
await po.chatActions.selectChatMode("plan"); await po.chatActions.selectChatMode("plan");
// Trigger questionnaire fixture // Trigger questionnaire fixture
await po.sendPrompt("tc=local-agent/questionnaire"); await po.sendPrompt("tc=local-agent/questionnaire", {
skipWaitForCompletion: true,
});
// Wait for questionnaire UI to appear // Wait for questionnaire UI to appear
await expect(po.page.getByText("Project Requirements")).toBeVisible({ await expect(po.page.getByText("Which framework do you prefer?")).toBeVisible(
{
timeout: Timeout.MEDIUM,
},
);
await expect(po.page.getByRole("button", { name: "Submit" })).toBeVisible({
timeout: Timeout.MEDIUM, timeout: Timeout.MEDIUM,
}); });
// Select "Vue" radio option by clicking the label text (Base UI Radio components) // Select "Vue" radio option
await po.page.getByText("Vue", { exact: true }).click(); await po.page.getByText("Vue", { exact: true }).click();
// Click Submit (single question → Submit button shown) // Submit the questionnaire
await po.page.getByRole("button", { name: /Submit/ }).click(); await po.page.getByRole("button", { name: /Submit/ }).click();
// Wait for the LLM response to the submitted answers // Wait for the LLM response after submitting answers
await po.chatActions.waitForChatCompletion(); await po.chatActions.waitForChatCompletion();
// Snapshot the messages // Snapshot the messages
......
...@@ -507,6 +507,73 @@ ...@@ -507,6 +507,73 @@
"additionalProperties": false "additionalProperties": false
} }
} }
},
{
"type": "function",
"function": {
"name": "planning_questionnaire",
"description": "Present a structured questionnaire to gather requirements from the user. The tool displays questions in the UI and waits for the user's responses, returning them as the tool result.\n\n<when_to_use>\nUse this tool when:\n- The user wants to create a NEW app or project\n- The request is vague or open-ended\n- There are multiple reasonable interpretations\nSkip when the request is a specific, concrete change.\n</when_to_use>\n\n<input_schema>\nThe tool accepts ONLY a \"questions\" array.\n\nEach question object has these fields:\n- \"question\" (string, REQUIRED): The question text shown to the user\n- \"type\" (string, REQUIRED): One of \"text\", \"radio\", or \"checkbox\"\n- \"options\" (string array, REQUIRED for radio/checkbox, OMIT for text): 1-3 predefined choices\n- \"id\" (string, optional): Unique identifier, auto-generated if omitted\n- \"required\" (boolean, optional): Defaults to true\n- \"placeholder\" (string, optional): Placeholder for text inputs\n</input_schema>\n\n<correct_example>\nReasoning: The user asked to \"build me a todo app\". I need to clarify the tech stack and key features. I'll use radio for single-choice and checkbox for multi-choice.\n\n{\n \"questions\": [\n {\n \"type\": \"radio\",\n \"question\": \"What visual style do you prefer?\",\n \"options\": [\"Minimal & clean\", \"Colorful & playful\", \"Dark & modern\"]\n },\n {\n \"type\": \"checkbox\",\n \"question\": \"Which features do you want?\",\n \"options\": [\"Due dates\", \"Categories/tags\", \"Priority levels\"]\n }\n ]\n}\n</correct_example>\n\n<incorrect_examples>\nWRONG — Empty questions array:\n{ \"questions\": [] }\n\nWRONG — options on text type:\n{ \"type\": \"text\", \"question\": \"...\", \"options\": [\"a\"] }\n\nWRONG — Empty options array:\n{ \"type\": \"radio\", \"question\": \"...\", \"options\": [] }\n\nWRONG — Missing options for radio:\n{ \"type\": \"radio\", \"question\": \"...\" }\n\nWRONG — More than 3 questions or more than 3 options\n\nWRONG — Array with empty object (missing required \"question\" and \"type\" fields):\n{ \"questions\": [{}] }\n</incorrect_examples>",
"parameters": {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"questions": {
"minItems": 1,
"maxItems": 3,
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"description": "Unique identifier for this question (auto-generated if omitted)",
"type": "string"
},
"question": {
"type": "string",
"description": "The question text to display to the user"
},
"type": {
"type": "string",
"enum": [
"text",
"radio",
"checkbox"
],
"description": "text for free-form input, radio for single choice, checkbox for multiple choice"
},
"options": {
"description": "Options for radio/checkbox questions. Keep to max 3 — users can always provide a custom answer via the free-form text input. Omit for text questions.",
"minItems": 1,
"maxItems": 3,
"type": "array",
"items": {
"type": "string"
}
},
"required": {
"description": "Whether this question requires an answer (defaults to true)",
"type": "boolean"
},
"placeholder": {
"description": "Placeholder text for text inputs",
"type": "string"
}
},
"required": [
"question",
"type"
],
"additionalProperties": false
},
"description": "A non empty array of 1-3 questions to present to the user"
}
},
"required": [
"questions"
],
"additionalProperties": false
}
}
} }
], ],
"tool_choice": "auto", "tool_choice": "auto",
......
- paragraph: tc=local-agent/questionnaire
- paragraph: Let me ask you a few questions to understand your requirements.
- img
- text: Questionnaire Responses 1 answered
- img
- img
- text: Single choice
- paragraph: Which framework do you prefer?
- paragraph: Vue
- paragraph: Thanks, I'll wait for your responses before proceeding.
- button "Copy":
- img
- img
- text: claude-opus-4-5
- img
- text: less than a minute ago
- button "Copy Request ID":
- img
- text: ""
- button "Undo":
- img
- text: ""
- button "Retry":
- img
- text: ""
\ No newline at end of file
...@@ -3,32 +3,15 @@ ...@@ -3,32 +3,15 @@
- button "Copy": - button "Copy":
- img - img
- img - img
- text: Approved
- img
- text: claude-opus-4-5
- img
- text: less than a minute ago
- button "Copy Request ID":
- img
- text: Request ID
- paragraph: "Here are my responses to the questionnaire:"
- paragraph:
- strong: Which framework do you prefer?
- text: Vue
- button "file1.txt file1.txt Edit"
- paragraph: More EOM
- button "Copy":
- img
- img
- text: claude-opus-4-5 - text: claude-opus-4-5
- img - img
- text: less than a minute ago - text: less than a minute ago
- button "Copy Request ID": - button "Copy Request ID":
- img - img
- text: Request ID - text: ""
- button "Undo": - button "Undo":
- img - img
- text: Undo - text: ""
- button "Retry": - button "Retry":
- img - img
- text: Retry - text: ""
\ No newline at end of file
{ {
"name": "dyad", "name": "dyad",
"version": "0.37.0", "version": "0.37.0-beta.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dyad", "name": "dyad",
"version": "0.37.0", "version": "0.37.0-beta.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.46", "@ai-sdk/amazon-bedrock": "^4.0.46",
......
...@@ -80,10 +80,14 @@ After every edit, read the file to verify changes applied correctly. If somethin ...@@ -80,10 +80,14 @@ After every edit, read the file to verify changes applied correctly. If somethin
<development_workflow> <development_workflow>
1. **Understand:** Think about the user's request and the relevant codebase context. Use \`grep\` and \`code_search\` search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions. Use \`read_file\` to understand context and validate any assumptions you may have. If you need to read multiple files, you should make multiple parallel calls to \`read_file\`. 1. **Understand:** Think about the user's request and the relevant codebase context. Use \`grep\` and \`code_search\` search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions. Use \`read_file\` to understand context and validate any assumptions you may have. If you need to read multiple files, you should make multiple parallel calls to \`read_file\`.
2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. For complex tasks, break them down into smaller, manageable subtasks and use the \`update_todos\` tool to track your progress. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process. 2. **Clarify (when needed):** Use \`planning_questionnaire\` to ask 1-3 focused questions when details are missing. Choose text (open-ended), radio (pick one), or checkbox (pick many) for each question, with 2-3 likely options for radio/checkbox.
3. **Implement:** Use the available tools (e.g., \`edit_file\`, \`write_file\`, ...) to act on the plan, strictly adhering to the project's established conventions. When debugging, add targeted console.log statements to trace data flow and identify root causes. **Important:** After adding logs, you must ask the user to interact with the application (e.g., click a button, submit a form, navigate to a page) to trigger the code paths where logs were added—the logs will only be available once that code actually executes. **Use when:** creating a new app/project, the request is vague (e.g. "Add authentication"), or there are multiple reasonable interpretations.
4. **Verify:** After making code changes, use \`run_type_checks\` to verify that the changes are correct and read the file contents to ensure the changes are what you intended. **Skip when:** the request is specific and concrete (e.g. "Fix the login button", "Change color from blue to green").
5. **Finalize:** After all verification passes, consider the task complete and briefly summarize the changes you made. The tool accepts ONLY a \`questions\` array (no empty objects). It returns the user's answers as the tool result.
3. **Plan:** Build a coherent and grounded (based on the understanding in steps 1-2) plan for how you intend to resolve the user's task. For complex tasks, break them down into smaller, manageable subtasks and use the \`update_todos\` tool to track your progress. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process.
4. **Implement:** Use the available tools (e.g., \`edit_file\`, \`write_file\`, ...) to act on the plan, strictly adhering to the project's established conventions. When debugging, add targeted console.log statements to trace data flow and identify root causes. **Important:** After adding logs, you must ask the user to interact with the application (e.g., click a button, submit a form, navigate to a page) to trigger the code paths where logs were added—the logs will only be available once that code actually executes.
5. **Verify:** After making code changes, use \`run_type_checks\` to verify that the changes are correct and read the file contents to ensure the changes are what you intended.
6. **Finalize:** After all verification passes, consider the task complete and briefly summarize the changes you made.
</development_workflow> </development_workflow>
# Tech Stack # Tech Stack
...@@ -249,10 +253,14 @@ After every edit, read the file to verify changes applied correctly. If somethin ...@@ -249,10 +253,14 @@ After every edit, read the file to verify changes applied correctly. If somethin
<development_workflow> <development_workflow>
1. **Understand:** Think about the user's request and the relevant codebase context. Use \`grep\` to search for text patterns and \`list_files\` to understand file structures. Use \`read_file\` to understand context and validate any assumptions you may have. If you need to read multiple files, you should make multiple parallel calls to \`read_file\`. 1. **Understand:** Think about the user's request and the relevant codebase context. Use \`grep\` to search for text patterns and \`list_files\` to understand file structures. Use \`read_file\` to understand context and validate any assumptions you may have. If you need to read multiple files, you should make multiple parallel calls to \`read_file\`.
2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. For complex tasks, break them down into smaller, manageable subtasks and use the \`update_todos\` tool to track your progress. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process. 2. **Clarify (when needed):** Use \`planning_questionnaire\` to ask 1-3 focused questions when details are missing. Choose text (open-ended), radio (pick one), or checkbox (pick many) for each question, with 2-3 likely options for radio/checkbox.
3. **Implement:** Use the available tools (e.g., \`search_replace\`, \`write_file\`, ...) to act on the plan, strictly adhering to the project's established conventions. When debugging, add targeted console.log statements to trace data flow and identify root causes. **Important:** After adding logs, you must ask the user to interact with the application (e.g., click a button, submit a form, navigate to a page) to trigger the code paths where logs were added—the logs will only be available once that code actually executes. **Use when:** creating a new app/project, the request is vague (e.g. "Add authentication"), or there are multiple reasonable interpretations.
4. **Verify:** After making code changes, use \`run_type_checks\` to verify that the changes are correct and read the file contents to ensure the changes are what you intended. **Skip when:** the request is specific and concrete (e.g. "Fix the login button", "Change color from blue to green").
5. **Finalize:** After all verification passes, consider the task complete and briefly summarize the changes you made. The tool accepts ONLY a \`questions\` array (no empty objects). It returns the user's answers as the tool result.
3. **Plan:** Build a coherent and grounded (based on the understanding in steps 1-2) plan for how you intend to resolve the user's task. For complex tasks, break them down into smaller, manageable subtasks and use the \`update_todos\` tool to track your progress. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process.
4. **Implement:** Use the available tools (e.g., \`search_replace\`, \`write_file\`, ...) to act on the plan, strictly adhering to the project's established conventions. When debugging, add targeted console.log statements to trace data flow and identify root causes. **Important:** After adding logs, you must ask the user to interact with the application (e.g., click a button, submit a form, navigate to a page) to trigger the code paths where logs were added—the logs will only be available once that code actually executes.
5. **Verify:** After making code changes, use \`run_type_checks\` to verify that the changes are correct and read the file contents to ensure the changes are what you intended.
6. **Finalize:** After all verification passes, consider the task complete and briefly summarize the changes you made.
</development_workflow> </development_workflow>
# Tech Stack # Tech Stack
......
...@@ -269,6 +269,7 @@ vi.mock("@/pro/main/ipc/handlers/local_agent/tool_definitions", () => ({ ...@@ -269,6 +269,7 @@ vi.mock("@/pro/main/ipc/handlers/local_agent/tool_definitions", () => ({
buildAgentToolSet: vi.fn(() => ({})), buildAgentToolSet: vi.fn(() => ({})),
requireAgentToolConsent: vi.fn(async () => true), requireAgentToolConsent: vi.fn(async () => true),
clearPendingConsentsForChat: vi.fn(), clearPendingConsentsForChat: vi.fn(),
clearPendingQuestionnairesForChat: vi.fn(),
})); }));
vi.mock( vi.mock(
...@@ -928,6 +929,134 @@ describe("handleLocalAgentStream", () => { ...@@ -928,6 +929,134 @@ describe("handleLocalAgentStream", () => {
}); });
}); });
describe("Synthetic planning_questionnaire reflection", () => {
it("injects a non-persisted reflection message after invalid planning_questionnaire input", async () => {
// Arrange
const { event } = createFakeEvent();
mockSettings = buildTestSettings({ enableDyadPro: true });
mockChatData = buildTestChat({
messages: [{ id: 1, role: "user", content: "Help me plan this app" }],
});
const invalidQuestionnaireInput = {
title: "Project Requirements",
questions: [{}],
};
let secondStepPreparedMessages: any[] | undefined;
mockStreamTextImpl = (options) => {
const firstStepMessages = [
{ role: "user", content: "Help me plan this app" },
];
return {
fullStream: (async function* () {
await options.prepareStep?.({
messages: firstStepMessages,
stepNumber: 0,
steps: [],
model: {},
experimental_context: undefined,
});
await options.onStepFinish?.({
content: [
{
type: "tool-error",
toolName: "planning_questionnaire",
toolCallId: "call_plan_q",
input: invalidQuestionnaireInput,
error:
"Invalid input for tool planning_questionnaire: questions[0].question is required",
},
],
usage: { totalTokens: 1234 },
toolCalls: [
{
type: "tool-call",
toolName: "planning_questionnaire",
toolCallId: "call_plan_q",
input: invalidQuestionnaireInput,
},
],
});
const secondStepMessages = [
...firstStepMessages,
{ role: "assistant", content: "retrying questionnaire call" },
];
const preparedSecondStep = (await options.prepareStep?.({
messages: secondStepMessages,
stepNumber: 1,
steps: [],
model: {},
experimental_context: undefined,
})) ?? { messages: secondStepMessages };
secondStepPreparedMessages = preparedSecondStep.messages;
yield {
type: "text-delta",
text: "I fixed the questionnaire call.",
};
})(),
response: Promise.resolve({
messages: [
{
role: "assistant",
content: [
{ type: "text", text: "I fixed the questionnaire call." },
],
},
],
}),
steps: Promise.resolve([{ toolCalls: [{}] }, { toolCalls: [] }]),
};
};
// Act
await handleLocalAgentStream(
event,
{ chatId: 1, prompt: "test" },
new AbortController(),
{
placeholderMessageId: 10,
systemPrompt: "You are helpful",
dyadRequestId,
},
);
// Assert
expect(secondStepPreparedMessages).toBeDefined();
const reflectionMessage = (secondStepPreparedMessages ?? []).find(
(message: any) =>
message.role === "user" &&
Array.isArray(message.content) &&
message.content.some(
(part: any) =>
part.type === "text" &&
typeof part.text === "string" &&
part.text.includes(
"planning_questionnaire tool call had a format error",
),
),
);
expect(reflectionMessage).toBeDefined();
const aiMessagesUpdate = dbOperations.updates.find(
(u) => u.data.aiMessagesJson !== undefined,
);
expect(aiMessagesUpdate).toBeDefined();
const persistedAiMessages = JSON.stringify(
(aiMessagesUpdate!.data.aiMessagesJson as { messages: unknown[] })
.messages,
);
expect(persistedAiMessages).not.toContain(
"planning_questionnaire tool call had a format error",
);
});
});
describe("Abort handling", () => { describe("Abort handling", () => {
it("should stop processing stream chunks when abort signal is triggered", async () => { it("should stop processing stream chunks when abort signal is triggered", async () => {
// Arrange // Arrange
......
...@@ -28,6 +28,12 @@ export interface PendingPlanImplementation { ...@@ -28,6 +28,12 @@ export interface PendingPlanImplementation {
export const pendingPlanImplementationAtom = export const pendingPlanImplementationAtom =
atom<PendingPlanImplementation | null>(null); atom<PendingPlanImplementation | null>(null);
export const pendingQuestionnaireAtom = atom<PlanQuestionnairePayload | null>( export const pendingQuestionnaireAtom = atom<
null, Map<number, PlanQuestionnairePayload>
); >(new Map());
// Transient flag: chatIds that just had a questionnaire submitted (for brief confirmation)
// "visible" = showing, "fading" = fade-out in progress
export const questionnaireSubmittedChatIdsAtom = atom<
Map<number, "visible" | "fading">
>(new Map());
...@@ -37,6 +37,7 @@ import { DyadStatus } from "./DyadStatus"; ...@@ -37,6 +37,7 @@ import { DyadStatus } from "./DyadStatus";
import { DyadCompaction } from "./DyadCompaction"; import { DyadCompaction } from "./DyadCompaction";
import { DyadWritePlan } from "./DyadWritePlan"; import { DyadWritePlan } from "./DyadWritePlan";
import { DyadExitPlan } from "./DyadExitPlan"; import { DyadExitPlan } from "./DyadExitPlan";
import { DyadQuestionnaire } from "./DyadQuestionnaire";
import { mapActionToButton } from "./ChatInput"; import { mapActionToButton } from "./ChatInput";
import { SuggestedAction } from "@/lib/schemas"; import { SuggestedAction } from "@/lib/schemas";
import { FixAllErrorsButton } from "./FixAllErrorsButton"; import { FixAllErrorsButton } from "./FixAllErrorsButton";
...@@ -76,6 +77,7 @@ const DYAD_CUSTOM_TAGS = [ ...@@ -76,6 +77,7 @@ const DYAD_CUSTOM_TAGS = [
// Plan mode tags // Plan mode tags
"dyad-write-plan", "dyad-write-plan",
"dyad-exit-plan", "dyad-exit-plan",
"dyad-questionnaire",
]; ];
interface DyadMarkdownParserProps { interface DyadMarkdownParserProps {
...@@ -762,6 +764,9 @@ function renderCustomTag( ...@@ -762,6 +764,9 @@ function renderCustomTag(
/> />
); );
case "dyad-questionnaire":
return <DyadQuestionnaire>{content}</DyadQuestionnaire>;
default: default:
return null; return null;
} }
......
import React, { useMemo, useState } from "react";
import {
ChevronLeft,
ChevronRight,
ClipboardList,
CheckCircle2,
MessageSquareText,
CircleDot,
ListChecks,
} from "lucide-react";
import { unescapeXmlAttr, unescapeXmlContent } from "../../../shared/xmlEscape";
interface QAEntry {
question: string;
type: string;
answer: string;
}
interface DyadQuestionnaireProps {
children?: React.ReactNode;
}
function parseQAEntries(content: string): QAEntry[] {
const entries: QAEntry[] = [];
const pattern = /<qa\s+question="([^"]*)"\s+type="([^"]*)">([\s\S]*?)<\/qa>/g;
let match;
while ((match = pattern.exec(content)) !== null) {
entries.push({
question: unescapeXmlAttr(match[1]),
type: unescapeXmlAttr(match[2]),
answer: unescapeXmlContent(match[3].trim()),
});
}
return entries;
}
const TYPE_META: Record<string, { icon: React.ReactNode; label: string }> = {
text: {
icon: <MessageSquareText size={12} />,
label: "Free text",
},
radio: {
icon: <CircleDot size={12} />,
label: "Single choice",
},
checkbox: {
icon: <ListChecks size={12} />,
label: "Multiple choice",
},
};
export function DyadQuestionnaire({ children }: DyadQuestionnaireProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const entries = useMemo(
() => parseQAEntries(typeof children === "string" ? children : ""),
[children],
);
if (entries.length === 0) return null;
const current = entries[currentIndex];
const hasPrev = currentIndex > 0;
const hasNext = currentIndex < entries.length - 1;
const meta = TYPE_META[current.type];
return (
<div className="my-4 border rounded-lg overflow-hidden border-primary/20 bg-primary/5">
{/* Header */}
<div className="px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<ClipboardList className="text-primary" size={20} />
<span className="font-semibold text-foreground">
Questionnaire Responses
</span>
<span className="flex items-center text-xs text-primary px-2 py-0.5 bg-primary/10 rounded-md font-medium">
{entries.length} answered
</span>
</div>
<CheckCircle2 className="size-4 text-green-600 dark:text-green-500 shrink-0" />
</div>
{/* Question/Answer content */}
<div className="px-4 pb-4">
<div className="rounded-lg bg-(--background-lightest) dark:bg-zinc-900/60 border border-border/40 overflow-hidden">
{/* Question */}
<div className="px-3.5 pt-3 pb-2.5 bg-muted/40">
<div className="flex items-center gap-1.5 mb-1.5">
{meta && (
<span className="inline-flex items-center gap-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{meta.icon}
{meta.label}
</span>
)}
</div>
<p className="text-sm font-medium text-foreground leading-relaxed">
{current.question}
</p>
</div>
<div className="h-px bg-border" />
{/* Answer */}
<div className="px-3.5 pt-2.5 pb-3">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground mb-1">
Answer
</p>
<p className="text-sm text-foreground/90 leading-relaxed">
{current.answer}
</p>
</div>
</div>
{/* Navigation */}
{entries.length > 1 && (
<div className="flex items-center justify-between mt-3">
{/* Dot indicators */}
<div className="flex items-center gap-1.5">
{entries.map((_, i) => (
<button
key={i}
onClick={() => setCurrentIndex(i)}
className={`rounded-full transition-all duration-200 ${
i === currentIndex
? "w-5 h-1.5 bg-primary"
: "w-1.5 h-1.5 bg-primary/25 hover:bg-primary/40"
}`}
aria-label={`Go to question ${i + 1}`}
/>
))}
</div>
{/* Arrow buttons */}
<div className="flex items-center gap-1">
<button
onClick={() => setCurrentIndex((i) => i - 1)}
disabled={!hasPrev}
className="p-1 rounded-md hover:bg-primary/10 disabled:opacity-25 disabled:cursor-not-allowed transition-colors"
aria-label="Previous question"
>
<ChevronLeft size={16} className="text-muted-foreground" />
</button>
<span className="text-xs text-muted-foreground tabular-nums min-w-[3ch] text-center">
{currentIndex + 1}/{entries.length}
</span>
<button
onClick={() => setCurrentIndex((i) => i + 1)}
disabled={!hasNext}
className="p-1 rounded-md hover:bg-primary/10 disabled:opacity-25 disabled:cursor-not-allowed transition-colors"
aria-label="Next question"
>
<ChevronRight size={16} className="text-muted-foreground" />
</button>
</div>
</div>
)}
</div>
</div>
);
}
...@@ -7,8 +7,9 @@ import { OpenRouterSetupBanner, SetupBanner } from "../SetupBanner"; ...@@ -7,8 +7,9 @@ import { OpenRouterSetupBanner, SetupBanner } from "../SetupBanner";
import { useStreamChat } from "@/hooks/useStreamChat"; import { useStreamChat } from "@/hooks/useStreamChat";
import { selectedChatIdAtom } from "@/atoms/chatAtoms"; import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { questionnaireSubmittedChatIdsAtom } from "@/atoms/planAtoms";
import { useAtomValue, useSetAtom } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { Loader2, RefreshCw, Undo } from "lucide-react"; import { CheckCircle2, Loader2, RefreshCw, Undo } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useVersions } from "@/hooks/useVersions"; import { useVersions } from "@/hooks/useVersions";
import { selectedAppIdAtom } from "@/atoms/appAtoms"; import { selectedAppIdAtom } from "@/atoms/appAtoms";
...@@ -51,6 +52,7 @@ interface FooterContext { ...@@ -51,6 +52,7 @@ interface FooterContext {
// Footer component for Virtuoso - receives context via props // Footer component for Virtuoso - receives context via props
function FooterComponent({ context }: { context?: FooterContext }) { function FooterComponent({ context }: { context?: FooterContext }) {
const submittedChatIds = useAtomValue(questionnaireSubmittedChatIdsAtom);
if (!context) return null; if (!context) return null;
const { const {
...@@ -72,6 +74,9 @@ function FooterComponent({ context }: { context?: FooterContext }) { ...@@ -72,6 +74,9 @@ function FooterComponent({ context }: { context?: FooterContext }) {
renderSetupBanner, renderSetupBanner,
} = context; } = context;
const questionnaireState =
selectedChatId != null ? submittedChatIds.get(selectedChatId) : undefined;
return ( return (
<> <>
{!isStreaming && ( {!isStreaming && (
...@@ -220,6 +225,18 @@ function FooterComponent({ context }: { context?: FooterContext }) { ...@@ -220,6 +225,18 @@ function FooterComponent({ context }: { context?: FooterContext }) {
</div> </div>
)} )}
{questionnaireState && (
<div
className={`flex justify-start px-4 duration-300 ${questionnaireState === "fading" ? "animate-out fade-out-0 slide-out-to-bottom-2" : "animate-in fade-in-0 slide-in-from-bottom-2"}`}
>
<div className="max-w-3xl w-full mx-auto">
<div className="flex items-center gap-1.5 text-sm text-muted-foreground py-2">
<CheckCircle2 className="h-4 w-4 text-green-500" />
Answers submitted
</div>
</div>
</div>
)}
{isStreaming && {isStreaming &&
!settings?.enableDyadPro && !settings?.enableDyadPro &&
!userBudget && !userBudget &&
......
...@@ -172,7 +172,11 @@ export function usePlanEvents() { ...@@ -172,7 +172,11 @@ export function usePlanEvents() {
// Handle questionnaire events // Handle questionnaire events
const unsubscribeQuestionnaire = planEventClient.onQuestionnaire( const unsubscribeQuestionnaire = planEventClient.onQuestionnaire(
(payload: PlanQuestionnairePayload) => { (payload: PlanQuestionnairePayload) => {
setPendingQuestionnaire(payload); setPendingQuestionnaire((prev) => {
const next = new Map(prev);
next.set(payload.chatId, payload);
return next;
});
}, },
); );
......
...@@ -7,6 +7,7 @@ import { getDyadAppPath } from "../../paths/paths"; ...@@ -7,6 +7,7 @@ import { getDyadAppPath } from "../../paths/paths";
import log from "electron-log"; import log from "electron-log";
import { createTypedHandler } from "./base"; import { createTypedHandler } from "./base";
import { planContracts } from "../types/plan"; import { planContracts } from "../types/plan";
import { resolveQuestionnaireResponse } from "../../pro/main/ipc/handlers/local_agent/tool_definitions";
import { import {
slugify, slugify,
buildFrontmatter, buildFrontmatter,
...@@ -150,4 +151,11 @@ export function registerPlanHandlers() { ...@@ -150,4 +151,11 @@ export function registerPlanHandlers() {
} }
logger.info("Deleted plan:", planId); logger.info("Deleted plan:", planId);
}); });
createTypedHandler(
planContracts.respondToQuestionnaire,
async (_, params) => {
resolveQuestionnaireResponse(params.requestId, params.answers);
},
);
} }
...@@ -23,39 +23,39 @@ export const PlanExitSchema = z.object({ ...@@ -23,39 +23,39 @@ export const PlanExitSchema = z.object({
export type PlanExitPayload = z.infer<typeof PlanExitSchema>; export type PlanExitPayload = z.infer<typeof PlanExitSchema>;
const TextQuestionSchema = z.object({ export const QuestionSchema = z
id: z.string(), .object({
type: z.literal("text"), id: z.string(),
question: z.string(), type: z.enum(["text", "radio", "checkbox"]),
required: z.boolean().optional(), question: z.string(),
placeholder: z.string().optional(), options: z.array(z.string()).min(1).optional(),
}); required: z.boolean().optional(),
placeholder: z.string().optional(),
const MultipleChoiceQuestionSchema = z.object({ })
id: z.string(), .refine((q) => q.type === "text" || (q.options && q.options.length >= 1), {
type: z.enum(["radio", "checkbox"]), message: "options are required for radio and checkbox questions",
question: z.string(), path: ["options"],
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 type Question = z.infer<typeof QuestionSchema>;
export const PlanQuestionnaireSchema = z.object({ export const PlanQuestionnaireSchema = z.object({
chatId: z.number(), chatId: z.number(),
title: z.string(), requestId: z.string(),
description: z.string().optional(),
questions: z.array(QuestionSchema), questions: z.array(QuestionSchema),
}); });
export type PlanQuestionnairePayload = z.infer<typeof PlanQuestionnaireSchema>; export type PlanQuestionnairePayload = z.infer<typeof PlanQuestionnaireSchema>;
export const QuestionnaireResponseSchema = z.object({
requestId: z.string(),
answers: z.record(z.string(), z.string()).nullable(),
});
export type QuestionnaireResponsePayload = z.infer<
typeof QuestionnaireResponseSchema
>;
export const PlanSchema = z.object({ export const PlanSchema = z.object({
id: z.string(), id: z.string(),
appId: z.number(), appId: z.number(),
...@@ -140,6 +140,12 @@ export const planContracts = { ...@@ -140,6 +140,12 @@ export const planContracts = {
input: z.object({ appId: z.number(), planId: z.string() }), input: z.object({ appId: z.number(), planId: z.string() }),
output: z.void(), output: z.void(),
}), }),
respondToQuestionnaire: defineContract({
channel: "plan:questionnaire-response",
input: QuestionnaireResponseSchema,
output: z.void(),
}),
} as const; } as const;
// Plan Clients // Plan Clients
......
...@@ -31,6 +31,7 @@ import { ...@@ -31,6 +31,7 @@ import {
buildAgentToolSet, buildAgentToolSet,
requireAgentToolConsent, requireAgentToolConsent,
clearPendingConsentsForChat, clearPendingConsentsForChat,
clearPendingQuestionnairesForChat,
} from "./tool_definitions"; } from "./tool_definitions";
import { import {
deployAllFunctionsIfNeeded, deployAllFunctionsIfNeeded,
...@@ -64,7 +65,6 @@ import { ...@@ -64,7 +65,6 @@ import {
} from "@/ipc/utils/ai_messages_utils"; } from "@/ipc/utils/ai_messages_utils";
import { parseMcpToolKey, sanitizeMcpName } from "@/ipc/utils/mcp_tool_utils"; import { parseMcpToolKey, sanitizeMcpName } from "@/ipc/utils/mcp_tool_utils";
import { addIntegrationTool } from "./tools/add_integration"; import { addIntegrationTool } from "./tools/add_integration";
import { planningQuestionnaireTool } from "./tools/planning_questionnaire";
import { writePlanTool } from "./tools/write_plan"; import { writePlanTool } from "./tools/write_plan";
import { exitPlanTool } from "./tools/exit_plan"; import { exitPlanTool } from "./tools/exit_plan";
import { import {
...@@ -75,6 +75,7 @@ import { ...@@ -75,6 +75,7 @@ import {
import { getPostCompactionMessages } from "@/ipc/handlers/compaction/compaction_utils"; import { getPostCompactionMessages } from "@/ipc/handlers/compaction/compaction_utils";
const logger = log.scope("local_agent_handler"); const logger = log.scope("local_agent_handler");
const PLANNING_QUESTIONNAIRE_TOOL_NAME = "planning_questionnaire";
// ============================================================================ // ============================================================================
// Tool Streaming State Management // Tool Streaming State Management
...@@ -488,7 +489,11 @@ export async function handleLocalAgentStream( ...@@ -488,7 +489,11 @@ export async function handleLocalAgentStream(
// In read-only mode, only include read-only tools and skip 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) // (since we can't determine if MCP tools modify state)
// In plan mode, only include planning tools (read + questionnaire/plan tools) // In plan mode, only include planning tools (read + questionnaire/plan tools)
const agentTools = buildAgentToolSet(ctx, { readOnly, planModeOnly }); const agentTools = buildAgentToolSet(ctx, {
readOnly,
planModeOnly,
basicAgentMode: !readOnly && !planModeOnly && isBasicAgentMode(settings),
});
const mcpTools = const mcpTools =
readOnly || planModeOnly ? {} : await getMcpTools(event, ctx); readOnly || planModeOnly ? {} : await getMcpTools(event, ctx);
const allTools: ToolSet = { ...agentTools, ...mcpTools }; const allTools: ToolSet = { ...agentTools, ...mcpTools };
...@@ -514,6 +519,7 @@ export async function handleLocalAgentStream( ...@@ -514,6 +519,7 @@ export async function handleLocalAgentStream(
// there are still incomplete todos, we append a reminder and do another pass. // there are still incomplete todos, we append a reminder and do another pass.
const maxTodoFollowUpLoops = 1; const maxTodoFollowUpLoops = 1;
let todoFollowUpLoops = 0; let todoFollowUpLoops = 0;
let hasInjectedPlanningQuestionnaireReflection = false;
let currentMessageHistory = messageHistory; let currentMessageHistory = messageHistory;
const accumulatedAiMessages: ModelMessage[] = []; const accumulatedAiMessages: ModelMessage[] = [];
...@@ -551,17 +557,9 @@ export async function handleLocalAgentStream( ...@@ -551,17 +557,9 @@ export async function handleLocalAgentStream(
stopWhen: [ stopWhen: [
stepCountIs(25), stepCountIs(25),
hasToolCall(addIntegrationTool.name), hasToolCall(addIntegrationTool.name),
// In plan mode, stop immediately after presenting a questionnaire, // In plan mode, also stop after writing a plan or exiting plan mode.
// 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 ...(planModeOnly
? [ ? [hasToolCall(writePlanTool.name), hasToolCall(exitPlanTool.name)]
hasToolCall(planningQuestionnaireTool.name),
hasToolCall(writePlanTool.name),
hasToolCall(exitPlanTool.name),
]
: []), : []),
], ],
abortSignal: abortController.signal, abortSignal: abortController.signal,
...@@ -627,13 +625,32 @@ export async function handleLocalAgentStream( ...@@ -627,13 +625,32 @@ export async function handleLocalAgentStream(
// injections/cleanups to apply. If we already replaced the base // injections/cleanups to apply. If we already replaced the base
// message history (e.g., after mid-turn compaction), we still need // message history (e.g., after mid-turn compaction), we still need
// to return the updated options. // to return the updated options.
if (preparedStep) { let result =
return preparedStep; preparedStep ?? (stepOptions === options ? undefined : stepOptions);
}
return stepOptions === options ? undefined : stepOptions; return result;
}, },
onStepFinish: async (step) => { onStepFinish: async (step) => {
if (!hasInjectedPlanningQuestionnaireReflection) {
const questionnaireError =
getPlanningQuestionnaireErrorFromStep(step);
if (questionnaireError) {
pendingUserMessages.push([
{
type: "text",
text: buildPlanningQuestionnaireReflectionMessage(
questionnaireError,
planModeOnly,
),
},
]);
hasInjectedPlanningQuestionnaireReflection = true;
logger.info(
`Injected synthetic planning_questionnaire reflection message for chat ${req.chatId}`,
);
}
}
if ( if (
settings.enableContextCompaction === false || settings.enableContextCompaction === false ||
compactedMidTurn || compactedMidTurn ||
...@@ -700,8 +717,9 @@ export async function handleLocalAgentStream( ...@@ -700,8 +717,9 @@ export async function handleLocalAgentStream(
for await (const part of streamResult.fullStream) { for await (const part of streamResult.fullStream) {
if (abortController.signal.aborted) { if (abortController.signal.aborted) {
logger.log(`Stream aborted for chat ${req.chatId}`); logger.log(`Stream aborted for chat ${req.chatId}`);
// Clean up pending consent requests to prevent stale UI banners // Clean up pending consent/questionnaire requests to prevent stale UI banners
clearPendingConsentsForChat(req.chatId); clearPendingConsentsForChat(req.chatId);
clearPendingQuestionnairesForChat(req.chatId);
break; break;
} }
...@@ -960,9 +978,10 @@ export async function handleLocalAgentStream( ...@@ -960,9 +978,10 @@ export async function handleLocalAgentStream(
return true; // Success return true; // Success
} catch (error) { } catch (error) {
// Clean up any pending consent requests for this chat to prevent // Clean up any pending consent/questionnaire requests for this chat to prevent
// stale UI banners and orphaned promises // stale UI banners and orphaned promises
clearPendingConsentsForChat(req.chatId); clearPendingConsentsForChat(req.chatId);
clearPendingQuestionnairesForChat(req.chatId);
if (abortController.signal.aborted) { if (abortController.signal.aborted) {
// Handle cancellation // Handle cancellation
...@@ -1018,6 +1037,50 @@ function sendResponseChunk( ...@@ -1018,6 +1037,50 @@ function sendResponseChunk(
}); });
} }
function getPlanningQuestionnaireErrorFromStep(step: {
content?: unknown;
}): string | null {
if (!Array.isArray(step.content)) {
return null;
}
for (const part of step.content) {
if (!isRecord(part) || part.toolName !== PLANNING_QUESTIONNAIRE_TOOL_NAME) {
continue;
}
if (part.type === "tool-error") {
return typeof part.error === "string" ? part.error : "Unknown tool error";
}
if (
part.type === "tool-result" &&
typeof part.output === "string" &&
part.output.startsWith("Error:")
) {
return part.output;
}
}
return null;
}
function buildPlanningQuestionnaireReflectionMessage(
errorDetail?: string,
planModeOnly?: boolean,
): string {
const base = "Your planning_questionnaire tool call had a format error.";
const detail = errorDetail ? ` The error was: ${errorDetail}` : "";
if (planModeOnly) {
return `[System]${base}${detail} Review the tool's input schema, fix the issue, and re-call planning_questionnaire with correct arguments.`;
}
return `[System]${base}${detail} Skip the questionnaire step and proceed directly to the planning phase.`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function shouldRunTodoFollowUpPass(params: { function shouldRunTodoFollowUpPass(params: {
readOnly: boolean; readOnly: boolean;
planModeOnly: boolean; planModeOnly: boolean;
......
...@@ -121,6 +121,69 @@ export function clearPendingConsentsForChat(chatId: number): void { ...@@ -121,6 +121,69 @@ export function clearPendingConsentsForChat(chatId: number): void {
} }
} }
// ============================================================================
// Questionnaire Response Management
// ============================================================================
interface PendingQuestionnaireEntry {
chatId: number;
resolve: (answers: Record<string, string> | null) => void;
}
const pendingQuestionnaireResolvers = new Map<
string,
PendingQuestionnaireEntry
>();
const QUESTIONNAIRE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
export function waitForQuestionnaireResponse(
requestId: string,
chatId: number,
): Promise<Record<string, string> | null> {
return new Promise((resolve) => {
const timeout = setTimeout(() => {
const entry = pendingQuestionnaireResolvers.get(requestId);
if (entry) {
pendingQuestionnaireResolvers.delete(requestId);
entry.resolve(null);
}
}, QUESTIONNAIRE_TIMEOUT_MS);
pendingQuestionnaireResolvers.set(requestId, {
chatId,
resolve: (answers) => {
clearTimeout(timeout);
resolve(answers);
},
});
});
}
export function resolveQuestionnaireResponse(
requestId: string,
answers: Record<string, string> | null,
) {
const entry = pendingQuestionnaireResolvers.get(requestId);
if (entry) {
pendingQuestionnaireResolvers.delete(requestId);
entry.resolve(answers);
}
}
/**
* Clean up all pending questionnaire requests for a given chat.
* Called when a stream is cancelled/aborted to prevent orphaned promises.
*/
export function clearPendingQuestionnairesForChat(chatId: number): void {
for (const [requestId, entry] of pendingQuestionnaireResolvers) {
if (entry.chatId === chatId) {
pendingQuestionnaireResolvers.delete(requestId);
entry.resolve(null);
}
}
}
export function getDefaultConsent(toolName: AgentToolName): AgentToolConsent { export function getDefaultConsent(toolName: AgentToolName): AgentToolConsent {
const tool = TOOL_DEFINITIONS.find((t) => t.name === toolName); const tool = TOOL_DEFINITIONS.find((t) => t.name === toolName);
return tool?.defaultConsent ?? "ask"; return tool?.defaultConsent ?? "ask";
...@@ -275,6 +338,11 @@ export interface BuildAgentToolSetOptions { ...@@ -275,6 +338,11 @@ export interface BuildAgentToolSetOptions {
* Plan mode has access to read-only tools plus planning-specific tools. * Plan mode has access to read-only tools plus planning-specific tools.
*/ */
planModeOnly?: boolean; planModeOnly?: boolean;
/**
* If true, exclude Pro-only tools.
* Used for basic agent mode where some tools may not be available.
*/
basicAgentMode?: boolean;
} }
const FILE_EDIT_TOOLS: Set<FileEditToolName> = new Set(FILE_EDIT_TOOL_NAMES); const FILE_EDIT_TOOLS: Set<FileEditToolName> = new Set(FILE_EDIT_TOOL_NAMES);
...@@ -305,15 +373,26 @@ function trackFileEditTool( ...@@ -305,15 +373,26 @@ function trackFileEditTool(
} }
/** /**
* Planning-specific tools that are only available in plan mode. * Tools that should ONLY be available in plan mode (excluded from normal agent mode).
* In plan mode, all non-state-modifying tools are also included automatically. * Note: planning_questionnaire is intentionally omitted so it's available in pro agent mode too.
*/
const PLAN_MODE_ONLY_TOOLS = new Set(["write_plan", "exit_plan"]);
/**
* Planning-specific tools that are allowed in plan mode despite modifying state.
* Superset of PLAN_MODE_ONLY_TOOLS plus tools that participate in planning
* but are also available in normal (pro) agent mode.
*/ */
const PLANNING_SPECIFIC_TOOLS = new Set([ const PLANNING_SPECIFIC_TOOLS = new Set([
...PLAN_MODE_ONLY_TOOLS,
"planning_questionnaire", "planning_questionnaire",
"write_plan",
"exit_plan",
]); ]);
/**
* Tools only available in Pro agent mode (excluded from basic agent mode).
*/
const PRO_AGENT_ONLY_TOOLS = new Set<string>();
/** /**
* Build ToolSet for AI SDK from tool definitions * Build ToolSet for AI SDK from tool definitions
*/ */
...@@ -338,8 +417,13 @@ export function buildAgentToolSet( ...@@ -338,8 +417,13 @@ export function buildAgentToolSet(
continue; continue;
} }
// Skip planning-specific tools when NOT in plan mode // Skip plan-mode-only tools when NOT in plan mode
if (!options.planModeOnly && PLANNING_SPECIFIC_TOOLS.has(tool.name)) { if (!options.planModeOnly && PLAN_MODE_ONLY_TOOLS.has(tool.name)) {
continue;
}
// Skip Pro-only tools in basic agent mode
if (options.basicAgentMode && PRO_AGENT_ONLY_TOOLS.has(tool.name)) {
continue; continue;
} }
......
import { z } from "zod"; import { z } from "zod";
import crypto from "node:crypto";
import log from "electron-log"; import log from "electron-log";
import { ToolDefinition, AgentContext } from "./types"; import { ToolDefinition, AgentContext } from "./types";
import { safeSend } from "@/ipc/utils/safe_sender"; import { safeSend } from "@/ipc/utils/safe_sender";
import { waitForQuestionnaireResponse } from "../tool_definitions";
import {
escapeXmlAttr,
escapeXmlContent,
} from "../../../../../../../shared/xmlEscape";
const logger = log.scope("planning_questionnaire"); const logger = log.scope("planning_questionnaire");
const BaseQuestionFields = { const QuestionSchema = z
id: z.string().describe("Unique identifier for this question"), .object({
question: z.string().describe("The question text to display to the user"), id: z
required: z .string()
.boolean() .optional()
.optional() .describe(
.describe("Whether this question requires an answer (defaults to true)"), "Unique identifier for this question (auto-generated if omitted)",
placeholder: z ),
.string() question: z.string().describe("The question text to display to the user"),
.optional() type: z
.describe("Placeholder text for text inputs"), .enum(["text", "radio", "checkbox"])
}; .describe(
"text for free-form input, radio for single choice, checkbox for multiple choice",
const TextQuestionSchema = z.object({ ),
...BaseQuestionFields, options: z
type: z.literal("text"), .array(z.string())
}); .min(1)
.max(3)
const MultipleChoiceQuestionSchema = z.object({ .optional()
...BaseQuestionFields, .describe(
type: z "Options for radio/checkbox questions. Keep to max 3 — users can always provide a custom answer via the free-form text input. Omit for text questions.",
.enum(["radio", "checkbox"]) ),
.describe("radio for single choice, checkbox for multiple choice"), required: z
options: z .boolean()
.array(z.string()) .optional()
.min(1) .describe("Whether this question requires an answer (defaults to true)"),
.max(3) placeholder: z
.describe( .string()
"Options for the question. Keep to max 3 — users can always provide a custom answer via the free-form text input.", .optional()
), .describe("Placeholder text for text inputs"),
}); })
.refine((q) => q.type === "text" || (q.options && q.options.length >= 1), {
const QuestionSchema = z.union([ message: "options are required for radio and checkbox questions",
TextQuestionSchema, path: ["options"],
MultipleChoiceQuestionSchema, });
]);
const planningQuestionnaireSchema = z.object({ 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 questions: z
.array(QuestionSchema) .array(QuestionSchema)
.min(1) .min(1, "questions array must not be empty")
.max(3) .max(3, "questions array must have at most 3 questions")
.describe("Array of 1-3 questions to present to the user"), .describe("A non empty array of 1-3 questions to present to the user"),
}); });
const DESCRIPTION = ` const DESCRIPTION = `Present a structured questionnaire to gather requirements from the user. The tool displays questions in the UI and waits for the user's responses, returning them as the tool result.
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. <when_to_use>
Use this tool when:
- The user wants to create a NEW app or project
- The request is vague or open-ended
- There are multiple reasonable interpretations
Skip when the request is a specific, concrete change.
</when_to_use>
Use this tool to collect specific information about: <input_schema>
- Feature requirements and expected behavior The tool accepts ONLY a "questions" array.
- Technology preferences or constraints
- Design and UX choices
- Priority decisions
- Edge cases and error handling expectations
Question Types: Each question object has these fields:
- \`text\`: Free-form text input for open-ended questions - "question" (string, REQUIRED): The question text shown to the user
- \`radio\`: Single choice from multiple options (with additional free-form text input) - "type" (string, REQUIRED): One of "text", "radio", or "checkbox"
- \`checkbox\`: Multiple choice (with additional free-form text input) - "options" (string array, REQUIRED for radio/checkbox, OMIT for text): 1-3 predefined choices
- "id" (string, optional): Unique identifier, auto-generated if omitted
- "required" (boolean, optional): Defaults to true
- "placeholder" (string, optional): Placeholder for text inputs
</input_schema>
**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. <correct_example>
Reasoning: The user asked to "build me a todo app". I need to clarify the tech stack and key features. I'll use radio for single-choice and checkbox for multi-choice.
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": [ "questions": [
{ {
"id": "auth_method",
"type": "radio", "type": "radio",
"question": "Which authentication method would you prefer?", "question": "What visual style do you prefer?",
"options": ["Email/Password", "OAuth (Google, GitHub)", "Magic Link"], "options": ["Minimal & clean", "Colorful & playful", "Dark & modern"]
"required": true },
{
"type": "checkbox",
"question": "Which features do you want?",
"options": ["Due dates", "Categories/tags", "Priority levels"]
} }
] ]
} }
`; </correct_example>
<incorrect_examples>
WRONG — Empty questions array:
{ "questions": [] }
WRONG — options on text type:
{ "type": "text", "question": "...", "options": ["a"] }
WRONG — Empty options array:
{ "type": "radio", "question": "...", "options": [] }
WRONG — Missing options for radio:
{ "type": "radio", "question": "..." }
WRONG — More than 3 questions or more than 3 options
WRONG — Array with empty object (missing required "question" and "type" fields):
{ "questions": [{}] }
</incorrect_examples>`;
export const planningQuestionnaireTool: ToolDefinition< export const planningQuestionnaireTool: ToolDefinition<
z.infer<typeof planningQuestionnaireSchema> z.infer<typeof planningQuestionnaireSchema>
...@@ -110,20 +125,52 @@ export const planningQuestionnaireTool: ToolDefinition< ...@@ -110,20 +125,52 @@ export const planningQuestionnaireTool: ToolDefinition<
modifiesState: true, modifiesState: true,
getConsentPreview: (args) => getConsentPreview: (args) =>
`Questionnaire: ${args.title} (${args.questions.length} questions)`, `Questionnaire (${args.questions.length} questions)`,
execute: async (args, ctx: AgentContext) => { execute: async (args, ctx: AgentContext) => {
const requestId = `questionnaire:${crypto.randomUUID()}`;
// Auto-generate missing IDs
const questions = args.questions.map((q) => ({
...q,
id: q.id || `q_${crypto.randomUUID().slice(0, 8)}`,
}));
logger.log( logger.log(
`Presenting questionnaire: ${args.title} (${args.questions.length} questions)`, `Presenting questionnaire (${questions.length} questions), requestId: ${requestId}`,
); );
safeSend(ctx.event.sender, "plan:questionnaire", { safeSend(ctx.event.sender, "plan:questionnaire", {
chatId: ctx.chatId, chatId: ctx.chatId,
title: args.title, requestId,
description: args.description, questions,
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.`; const answers = await waitForQuestionnaireResponse(requestId, ctx.chatId);
if (!answers) {
return "The user dismissed the questionnaire without answering. Ask them how they'd like to proceed, or try asking questions in regular chat text.";
}
const formattedAnswers = questions
.map((q) => {
const answer = answers[q.id] || "(no answer)";
return `**${q.question}**\n${answer}`;
})
.join("\n\n");
// Build XML with questions and answers for the chat UI
const qaEntries = questions
.map((q) => {
const answer = answers[q.id] || "(no answer)";
return `<qa question="${escapeXmlAttr(q.question)}" type="${escapeXmlAttr(q.type)}">${escapeXmlContent(answer)}</qa>`;
})
.join("\n");
ctx.onXmlComplete(
`<dyad-questionnaire count="${questions.length}">\n${qaEntries}\n</dyad-questionnaire>`,
);
return `User responses:\n\n${formattedAnswers}`;
}, },
}; };
...@@ -92,10 +92,14 @@ After every edit, read the file to verify changes applied correctly. If somethin ...@@ -92,10 +92,14 @@ After every edit, read the file to verify changes applied correctly. If somethin
const PRO_DEVELOPMENT_WORKFLOW_BLOCK = `<development_workflow> const PRO_DEVELOPMENT_WORKFLOW_BLOCK = `<development_workflow>
1. **Understand:** Think about the user's request and the relevant codebase context. Use \`grep\` and \`code_search\` search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions. Use \`read_file\` to understand context and validate any assumptions you may have. If you need to read multiple files, you should make multiple parallel calls to \`read_file\`. 1. **Understand:** Think about the user's request and the relevant codebase context. Use \`grep\` and \`code_search\` search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions. Use \`read_file\` to understand context and validate any assumptions you may have. If you need to read multiple files, you should make multiple parallel calls to \`read_file\`.
2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. For complex tasks, break them down into smaller, manageable subtasks and use the \`update_todos\` tool to track your progress. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process. 2. **Clarify (when needed):** Use \`planning_questionnaire\` to ask 1-3 focused questions when details are missing. Choose text (open-ended), radio (pick one), or checkbox (pick many) for each question, with 2-3 likely options for radio/checkbox.
3. **Implement:** Use the available tools (e.g., \`edit_file\`, \`write_file\`, ...) to act on the plan, strictly adhering to the project's established conventions. When debugging, add targeted console.log statements to trace data flow and identify root causes. **Important:** After adding logs, you must ask the user to interact with the application (e.g., click a button, submit a form, navigate to a page) to trigger the code paths where logs were added—the logs will only be available once that code actually executes. **Use when:** creating a new app/project, the request is vague (e.g. "Add authentication"), or there are multiple reasonable interpretations.
4. **Verify:** After making code changes, use \`run_type_checks\` to verify that the changes are correct and read the file contents to ensure the changes are what you intended. **Skip when:** the request is specific and concrete (e.g. "Fix the login button", "Change color from blue to green").
5. **Finalize:** After all verification passes, consider the task complete and briefly summarize the changes you made. The tool accepts ONLY a \`questions\` array (no empty objects). It returns the user's answers as the tool result.
3. **Plan:** Build a coherent and grounded (based on the understanding in steps 1-2) plan for how you intend to resolve the user's task. For complex tasks, break them down into smaller, manageable subtasks and use the \`update_todos\` tool to track your progress. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process.
4. **Implement:** Use the available tools (e.g., \`edit_file\`, \`write_file\`, ...) to act on the plan, strictly adhering to the project's established conventions. When debugging, add targeted console.log statements to trace data flow and identify root causes. **Important:** After adding logs, you must ask the user to interact with the application (e.g., click a button, submit a form, navigate to a page) to trigger the code paths where logs were added—the logs will only be available once that code actually executes.
5. **Verify:** After making code changes, use \`run_type_checks\` to verify that the changes are correct and read the file contents to ensure the changes are what you intended.
6. **Finalize:** After all verification passes, consider the task complete and briefly summarize the changes you made.
</development_workflow>`; </development_workflow>`;
// ============================================================================ // ============================================================================
...@@ -126,10 +130,14 @@ After every edit, read the file to verify changes applied correctly. If somethin ...@@ -126,10 +130,14 @@ After every edit, read the file to verify changes applied correctly. If somethin
const BASIC_DEVELOPMENT_WORKFLOW_BLOCK = `<development_workflow> const BASIC_DEVELOPMENT_WORKFLOW_BLOCK = `<development_workflow>
1. **Understand:** Think about the user's request and the relevant codebase context. Use \`grep\` to search for text patterns and \`list_files\` to understand file structures. Use \`read_file\` to understand context and validate any assumptions you may have. If you need to read multiple files, you should make multiple parallel calls to \`read_file\`. 1. **Understand:** Think about the user's request and the relevant codebase context. Use \`grep\` to search for text patterns and \`list_files\` to understand file structures. Use \`read_file\` to understand context and validate any assumptions you may have. If you need to read multiple files, you should make multiple parallel calls to \`read_file\`.
2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. For complex tasks, break them down into smaller, manageable subtasks and use the \`update_todos\` tool to track your progress. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process. 2. **Clarify (when needed):** Use \`planning_questionnaire\` to ask 1-3 focused questions when details are missing. Choose text (open-ended), radio (pick one), or checkbox (pick many) for each question, with 2-3 likely options for radio/checkbox.
3. **Implement:** Use the available tools (e.g., \`search_replace\`, \`write_file\`, ...) to act on the plan, strictly adhering to the project's established conventions. When debugging, add targeted console.log statements to trace data flow and identify root causes. **Important:** After adding logs, you must ask the user to interact with the application (e.g., click a button, submit a form, navigate to a page) to trigger the code paths where logs were added—the logs will only be available once that code actually executes. **Use when:** creating a new app/project, the request is vague (e.g. "Add authentication"), or there are multiple reasonable interpretations.
4. **Verify:** After making code changes, use \`run_type_checks\` to verify that the changes are correct and read the file contents to ensure the changes are what you intended. **Skip when:** the request is specific and concrete (e.g. "Fix the login button", "Change color from blue to green").
5. **Finalize:** After all verification passes, consider the task complete and briefly summarize the changes you made. The tool accepts ONLY a \`questions\` array (no empty objects). It returns the user's answers as the tool result.
3. **Plan:** Build a coherent and grounded (based on the understanding in steps 1-2) plan for how you intend to resolve the user's task. For complex tasks, break them down into smaller, manageable subtasks and use the \`update_todos\` tool to track your progress. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process.
4. **Implement:** Use the available tools (e.g., \`search_replace\`, \`write_file\`, ...) to act on the plan, strictly adhering to the project's established conventions. When debugging, add targeted console.log statements to trace data flow and identify root causes. **Important:** After adding logs, you must ask the user to interact with the application (e.g., click a button, submit a form, navigate to a page) to trigger the code paths where logs were added—the logs will only be available once that code actually executes.
5. **Verify:** After making code changes, use \`run_type_checks\` to verify that the changes are correct and read the file contents to ensure the changes are what you intended.
6. **Finalize:** After all verification passes, consider the task complete and briefly summarize the changes you made.
</development_workflow>`; </development_workflow>`;
// ============================================================================ // ============================================================================
......
...@@ -15,7 +15,11 @@ Your goal is to have a thoughtful brainstorming session with the user to fully u ...@@ -15,7 +15,11 @@ Your goal is to have a thoughtful brainstorming session with the user to fully u
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. 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: 3. **Ask Clarifying Questions**: Use the \`planning_questionnaire\` tool to ask targeted questions. The tool accepts only a \`questions\` array and returns the user's responses directly as the tool result.
Before calling the tool, consider what are the most impactful questions that would unblock the most decisions, and whether each question should be text, radio, or checkbox type.
Topics to clarify:
- Specific functionality and behavior - Specific functionality and behavior
- Edge cases and error handling - Edge cases and error handling
- UI/UX expectations - UI/UX expectations
...@@ -23,8 +27,6 @@ Your goal is to have a thoughtful brainstorming session with the user to fully u ...@@ -23,8 +27,6 @@ Your goal is to have a thoughtful brainstorming session with the user to fully u
- Performance or security considerations - Performance or security considerations
- User workflows and interactions - 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. 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 ## Phase 2: Plan Creation
...@@ -76,7 +78,7 @@ After presenting the plan: ...@@ -76,7 +78,7 @@ After presenting the plan:
- \`code_search\` - Semantic code search - \`code_search\` - Semantic code search
## Planning Tools (for interaction) ## Planning Tools (for interaction)
- \`planning_questionnaire\` - Present structured questions to the user (supports text, radio, and checkbox question types) - \`planning_questionnaire\` - Present structured questions to the user (accepts only a \`questions\` array; waits for and returns user responses)
- \`write_plan\` - Present or update the implementation plan as a markdown document - \`write_plan\` - Present or update the implementation plan as a markdown document
- \`exit_plan\` - Transition to implementation mode after plan approval - \`exit_plan\` - Transition to implementation mode after plan approval
......
...@@ -26,6 +26,7 @@ import { ...@@ -26,6 +26,7 @@ import {
pendingAgentConsentsAtom, pendingAgentConsentsAtom,
agentTodosByChatIdAtom, agentTodosByChatIdAtom,
} from "./atoms/chatAtoms"; } from "./atoms/chatAtoms";
import { pendingQuestionnaireAtom } from "./atoms/planAtoms";
import { queryKeys } from "./lib/queryKeys"; import { queryKeys } from "./lib/queryKeys";
// @ts-ignore // @ts-ignore
...@@ -168,6 +169,7 @@ function App() { ...@@ -168,6 +169,7 @@ function App() {
// Agent v2 tool consent requests - queue consents instead of overwriting // Agent v2 tool consent requests - queue consents instead of overwriting
const setPendingAgentConsents = useSetAtom(pendingAgentConsentsAtom); const setPendingAgentConsents = useSetAtom(pendingAgentConsentsAtom);
const setPendingQuestionnaire = useSetAtom(pendingQuestionnaireAtom);
const setAgentTodosByChatId = useSetAtom(agentTodosByChatIdAtom); const setAgentTodosByChatId = useSetAtom(agentTodosByChatIdAtom);
// Agent todos updates // Agent todos updates
...@@ -217,9 +219,15 @@ function App() { ...@@ -217,9 +219,15 @@ function App() {
setPendingAgentConsents((prev) => setPendingAgentConsents((prev) =>
prev.filter((consent) => consent.chatId !== chatId), prev.filter((consent) => consent.chatId !== chatId),
); );
setPendingQuestionnaire((prev) => {
if (!prev.has(chatId)) return prev;
const next = new Map(prev);
next.delete(chatId);
return next;
});
}); });
return () => unsubscribe(); return () => unsubscribe();
}, [setPendingAgentConsents]); }, [setPendingAgentConsents, setPendingQuestionnaire]);
// Forward telemetry events from main process to PostHog // Forward telemetry events from main process to PostHog
useEffect(() => { useEffect(() => {
......
...@@ -52,7 +52,7 @@ Never use closely matched colors for an element's background and its foreground ...@@ -52,7 +52,7 @@ Never use closely matched colors for an element's background and its foreground
Follow this workflow when building web apps: Follow this workflow when building web apps:
1. **Determine Design Direction** 1. **Determine Design Direction**
- Analyze the industry and target users of the website. - Analyze the industry and target users of the website.
- Define colors, fonts, mood, and visual style. - Define colors, fonts, mood, and visual style (you are allowed to ask the user if you have access to planning_questionnaire tool).
- Ensure the design direction does NOT contradict the rules defined for this theme. - Ensure the design direction does NOT contradict the rules defined for this theme.
2. **Build the Application** 2. **Build the Application**
- Do not neglect functionality in the pursuit of making a beautiful website. - Do not neglect functionality in the pursuit of making a beautiful website.
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论