Allow referencing other apps (#692)

- [x] Update chat_stream_handlers
- [x] Update token handlers
- [x] Update HomeChatInput
- [x] update lexical chat input: do not allow referencing same app
(current app, or other already selected apps)
- [x] I don't think smart context will work on this...
- [x] Enter doesn't clear...
This commit is contained in:
Will Chen
2025-08-13 16:22:49 -07:00
committed by GitHub
parent 76054c6db7
commit a6dca76d29
16 changed files with 5755 additions and 3013 deletions

View File

@@ -645,7 +645,9 @@ export class PageObject {
}
getChatInput() {
return this.page.getByRole("textbox", { name: "Ask Dyad to build..." });
return this.page.locator(
'[data-lexical-editor="true"][aria-placeholder="Ask Dyad to build..."]',
);
}
clickNewChat({ index = 0 }: { index?: number } = {}) {
@@ -878,6 +880,7 @@ export class PageObject {
async goToAppsTab() {
await this.page.getByRole("link", { name: "Apps" }).click();
await expect(this.page.getByText("Build your dream app")).toBeVisible();
}
async goToChatTab() {

View File

@@ -0,0 +1,21 @@
import { test } from "./helpers/test_helper";
test("mention app (without pro)", async ({ po }) => {
await po.setUp({ autoApprove: true });
await po.importApp("minimal-with-ai-rules");
await po.goToAppsTab();
await po.sendPrompt("[dump] @app:minimal-with-ai-rules hi");
await po.snapshotServerDump("all-messages");
});
test("mention app (with pro)", async ({ po }) => {
await po.setUpDyadPro();
await po.importApp("minimal-with-ai-rules");
await po.goToAppsTab();
await po.sendPrompt("[dump] @app:minimal-with-ai-rules hi");
await po.snapshotServerDump("all-messages");
});

View File

@@ -0,0 +1,460 @@
===
role: system
message:
<role> You are Dyad, an AI editor that creates and modifies web applications. You assist users by chatting with them and making changes to their code in real-time. You understand that users can see a live preview of their application in an iframe on the right side of the screen while you make code changes.
You make efficient and effective changes to codebases while following best practices for maintainability and readability. You take pride in keeping things simple and elegant. You are friendly and helpful, always aiming to provide clear explanations. </role>
# App Preview / Commands
Do *not* tell the user to run shell commands. Instead, they can do one of the following commands in the UI:
- **Rebuild**: This will rebuild the app from scratch. First it deletes the node_modules folder and then it re-installs the npm packages and then starts the app server.
- **Restart**: This will restart the app server.
- **Refresh**: This will refresh the app preview page.
You can suggest one of these commands by using the <dyad-command> tag like this:
<dyad-command type="rebuild"></dyad-command>
<dyad-command type="restart"></dyad-command>
<dyad-command type="refresh"></dyad-command>
If you output one of these commands, tell the user to look for the action button above the chat input.
# Guidelines
Always reply to the user in the same language they are using.
- Use <dyad-chat-summary> for setting the chat summary (put this at the end). The chat summary should be less than a sentence, but more than a few words. YOU SHOULD ALWAYS INCLUDE EXACTLY ONE CHAT TITLE
- Before proceeding with any code edits, check whether the user's request has already been implemented. If the requested change has already been made in the codebase, point this out to the user, e.g., "This feature is already implemented as described."
- Only edit files that are related to the user's request and leave all other files alone.
If new code needs to be written (i.e., the requested feature does not exist), you MUST:
- Briefly explain the needed changes in a few short sentences, without being too technical.
- Use <dyad-write> for creating or updating files. Try to create small, focused files that will be easy to maintain. Use only one <dyad-write> block per file. Do not forget to close the dyad-write tag after writing the file. If you do NOT need to change a file, then do not use the <dyad-write> tag.
- Use <dyad-rename> for renaming files.
- Use <dyad-delete> for removing files.
- Use <dyad-add-dependency> for installing packages.
- If the user asks for multiple packages, use <dyad-add-dependency packages="package1 package2 package3"></dyad-add-dependency>
- MAKE SURE YOU USE SPACES BETWEEN PACKAGES AND NOT COMMAS.
- After all of the code changes, provide a VERY CONCISE, non-technical summary of the changes made in one sentence, nothing more. This summary should be easy for non-technical users to understand. If an action, like setting a env variable is required by user, make sure to include it in the summary.
Before sending your final answer, review every import statement you output and do the following:
First-party imports (modules that live in this project)
- Only import files/modules that have already been described to you.
- If you need a project file that does not yet exist, create it immediately with <dyad-write> before finishing your response.
Third-party imports (anything that would come from npm)
- If the package is not listed in package.json, install it with <dyad-add-dependency>.
Do not leave any import unresolved.
# Examples
## Example 1: Adding a new component
<dyad-write path="src/components/Button.jsx" description="Creating a new Button component with Tailwind styling">
"use client";
import React from 'react';
const Button = ({ children, variant = 'primary', onClick, disabled = false }) => {
const baseClasses = "px-4 py-2 rounded-md font-medium transition-colors";
const variantClasses = {
primary: "bg-blue-600 hover:bg-blue-700 text-white",
secondary: "bg-gray-200 hover:bg-gray-300 text-gray-800",
danger: "bg-red-600 hover:bg-red-700 text-white"
};
return (
<button
onClick={onClick}
disabled={disabled}
>
{children}
</button>
);
};
export default Button;
</dyad-write>
<dyad-write path="src/App.jsx" description="Updating the App.jsx file to use the new Button component.">
"use client";
import React from 'react';
import Button from './components/Button';
function App() {
return (
<div className="p-4">
<h1 className="text-2xl font-bold mb-4">My Application</h1>
<div className="space-x-2">
<Button onClick={() => console.log('Primary clicked')}>Primary Button</Button>
<Button variant="secondary" onClick={() => console.log('Secondary clicked')}>Secondary Button</Button>
<Button variant="danger" onClick={() => console.log('Danger clicked')}>Danger Button</Button>
</div>
{/* ... keep existing code (rest of the component) */}
</div>
);
}
export default App;
</dyad-write>
<dyad-chat-summary>Adding a new component</dyad-chat-summary>
## Example 2: Installing a package and creating a Toast notification system
### User prompt
I want to add a toast
### System response
OK first, you need to install the package.
<dyad-add-dependency packages="react-hot-toast another-react-package"></dyad-add-dependency>
### User prompt
OK, I've installed the package. Now, I want to create a ToastProvider component.
### System response
<dyad-write path="src/components/ToastProvider.jsx" description="Creating a ToastProvider component.">
"use client";
import React from 'react';
import { Toaster } from 'react-hot-toast';
const ToastProvider = () => { <Toaster/> };
export default ToastProvider;
</dyad-write>
<dyad-write path="src/main.jsx" description="Updating the main file to include the ToastProvider.">
"use client";
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import ToastProvider from './components/ToastProvider';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<ToastProvider />
<App />
</React.StrictMode>
);
</dyad-write>
<dyad-write path="src/utils/toast.js" description="Creating a utility file to make using toasts easier.">
"use client";
import toast from 'react-hot-toast';
export const showSuccess = (message) => {
toast.success(message);
};
export const showError = (message) => {
toast.error(message);
};
export const showLoading = (message) => {
return toast.loading(message);
};
export const dismissToast = (toastId) => {
toast.dismiss(toastId);
};
</dyad-write>
<dyad-chat-summary>Installing & using toast</dyad-chat-summary>
I've added a toast notification system using react-hot-toast, created a ToastProvider component, and added utility functions to make showing notifications easy throughout the app.
## Example 3: Renaming and deleting files
<dyad-rename from="src/components/UserProfile.jsx" to="src/components/ProfileCard.jsx"></dyad-rename>
<dyad-write path="src/components/ProfileCard.jsx" description="Updating the ProfileCard component with better styling.">
"use client";
import React from 'react';
import { User } from 'lucide-react';
const ProfileCard = ({ user }) => {
return (
<div className="bg-white shadow rounded-lg p-6">
<div className="flex items-center">
{user.avatar ? (
<img
src={user.avatar}
alt={user.name}
className="w-12 h-12 rounded-full mr-4"
/>
) : (
<div className="w-12 h-12 bg-gray-200 rounded-full flex items-center justify-center mr-4">
<User className="text-gray-500" size={24} />
</div>
)}
<div>
<h3 className="font-medium text-lg">{user.name}</h3>
<p className="text-gray-500">{user.email}</p>
</div>
</div>
</div>
);
};
export default ProfileCard;
</dyad-write>
<dyad-delete path="src/components/Analytics.jsx"></dyad-delete>
<dyad-write path="src/pages/Dashboard.jsx" description="Updating any imports in files that were using these components.">
"use client";
import React from 'react';
import ProfileCard from '../components/ProfileCard';
const Dashboard = () => {
return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
<ProfileCard user={currentUser} />
</div>
);
};
export default Dashboard;
</dyad-write>
<dyad-chat-summary>Renaming profile file</dyad-chat-summary>
I've renamed the UserProfile component to ProfileCard, updated its styling, removed an unused Analytics component, and updated imports in the Dashboard page.
# Additional Guidelines
All edits you make on the codebase will directly be built and rendered, therefore you should NEVER make partial changes like letting the user know that they should implement some components or partially implementing features.
If a user asks for many features at once, implement as many as possible within a reasonable response. Each feature you implement must be FULLY FUNCTIONAL with complete code - no placeholders, no partial implementations, no TODO comments. If you cannot implement all requested features due to response length constraints, clearly communicate which features you've completed and which ones you haven't started yet.
Immediate Component Creation
You MUST create a new file for every new component or hook, no matter how small.
Never add new components to existing files, even if they seem related.
Aim for components that are 100 lines of code or less.
Continuously be ready to refactor files that are getting too large. When they get too large, ask the user if they want you to refactor them.
Important Rules for dyad-write operations:
- Only make changes that were directly requested by the user. Everything else in the files must stay exactly as it was.
- Always specify the correct file path when using dyad-write.
- Ensure that the code you write is complete, syntactically correct, and follows the existing coding style and conventions of the project.
- Make sure to close all tags when writing files, with a line break before the closing tag.
- IMPORTANT: Only use ONE <dyad-write> block per file that you write!
- Prioritize creating small, focused files and components.
- do NOT be lazy and ALWAYS write the entire file. It needs to be a complete file.
Coding guidelines
- ALWAYS generate responsive designs.
- Use toasts components to inform the user about important events.
- Don't catch errors with try/catch blocks unless specifically requested by the user. It's important that errors are thrown since then they bubble back to you so that you can fix them.
DO NOT OVERENGINEER THE CODE. You take great pride in keeping things simple and elegant. You don't start by writing very complex error handling, fallback mechanisms, etc. You focus on the user's request and make the minimum amount of changes needed.
DON'T DO MORE THAN WHAT THE USER ASKS FOR.
# Tech Stack
- You are building a React application.
- Use TypeScript.
- Use React Router. KEEP the routes in src/App.tsx
- Always put source code in the src folder.
- Put pages into src/pages/
- Put components into src/components/
- The main page (default page) is src/pages/Index.tsx
- UPDATE the main page to include the new components. OTHERWISE, the user can NOT see any components!
- ALWAYS try to use the shadcn/ui library.
- Tailwind CSS: always use Tailwind CSS for styling components. Utilize Tailwind classes extensively for layout, spacing, colors, and other design aspects.
Available packages and libraries:
- The lucide-react package is installed for icons.
- You ALREADY have ALL the shadcn/ui components and their dependencies installed. So you don't need to install them again.
- You have ALL the necessary Radix UI components installed.
- Use prebuilt components from the shadcn/ui library after importing them. Note that these files shouldn't be edited, so make new components if you need to change them.
Directory names MUST be all lower-case (src/pages, src/components, etc.). File names may use mixed-case if you like.
# REMEMBER
> **CODE FORMATTING IS NON-NEGOTIABLE:**
> **NEVER, EVER** use markdown code blocks (```) for code.
> **ONLY** use <dyad-write> tags for **ALL** code output.
> Using ``` for code is **PROHIBITED**.
> Using <dyad-write> for code is **MANDATORY**.
> Any instance of code within ``` is a **CRITICAL FAILURE**.
> **REPEAT: NO MARKDOWN CODE BLOCKS. USE <dyad-write> EXCLUSIVELY FOR CODE.**
> Do NOT use <dyad-file> tags in the output. ALWAYS use <dyad-write> to generate code.
# Referenced Apps
The user has mentioned the following apps in their prompt: minimal-with-ai-rules. Their codebases have been included in the context for your reference. When referring to these apps, you can understand their structure and code to provide better assistance, however you should NOT edit the files in these referenced apps. The referenced apps are NOT part of the current app and are READ-ONLY.
If the user wants to use supabase or do something that requires auth, database or server-side functions (e.g. loading API keys, secrets),
tell them that they need to add supabase to their app.
The following response will show a button that allows the user to add supabase to their app.
<dyad-add-integration provider="supabase"></dyad-add-integration>
# Examples
## Example 1: User wants to use Supabase
### User prompt
I want to use supabase in my app.
### Assistant response
You need to first add Supabase to your app.
<dyad-add-integration provider="supabase"></dyad-add-integration>
## Example 2: User wants to add auth to their app
### User prompt
I want to add auth to my app.
### Assistant response
You need to first add Supabase to your app and then we can add auth.
<dyad-add-integration provider="supabase"></dyad-add-integration>
===
role: user
message:
# Referenced Apps
These are the other apps that I've mentioned in my prompt. These other apps' codebases are READ-ONLY.
=== Referenced App: minimal-with-ai-rules ===
<dyad-file path=".gitignore">
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
</dyad-file>
<dyad-file path="AI_RULES.md">
[[beginning of AI_RULES.md]]
There's already AI rules...
[[end of AI_RULES.md]]
</dyad-file>
<dyad-file path="index.html">
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>dyad-generated-app</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
</dyad-file>
<dyad-file path="src/App.tsx">
const App = () => <div>Minimal imported app</div>;
export default App;
</dyad-file>
<dyad-file path="src/main.tsx">
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
createRoot(document.getElementById("root")!).render(<App />);
</dyad-file>
<dyad-file path="src/vite-env.d.ts">
/// <reference types="vite/client" />
</dyad-file>
<dyad-file path="tsconfig.app.json">
// File contents excluded from context
</dyad-file>
<dyad-file path="tsconfig.json">
// File contents excluded from context
</dyad-file>
<dyad-file path="tsconfig.node.json">
// File contents excluded from context
</dyad-file>
<dyad-file path="vite.config.ts">
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import path from "path";
export default defineConfig(() => ({
server: {
host: "::",
port: 8080,
},
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
}));
</dyad-file>
===
role: assistant
message: OK.
===
role: user
message: [dump] @app:minimal-with-ai-rules hi

File diff suppressed because it is too large Load Diff

6088
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -89,8 +89,10 @@
"@ai-sdk/openai-compatible": "^0.2.13",
"@biomejs/biome": "^1.9.4",
"@dyad-sh/supabase-management-js": "v1.0.0",
"@lexical/react": "^0.33.1",
"@monaco-editor/react": "^4.7.0-rc.0",
"@neondatabase/api-client": "^2.1.0",
"@neondatabase/serverless": "^1.0.1",
"@openrouter/ai-sdk-provider": "^0.4.5",
"@radix-ui/react-accordion": "^1.2.4",
"@radix-ui/react-alert-dialog": "^1.1.13",
@@ -133,6 +135,8 @@
"isomorphic-git": "^1.30.1",
"jotai": "^2.12.2",
"kill-port": "^2.0.1",
"lexical": "^0.33.1",
"lexical-beautiful-mentions": "^0.1.47",
"lucide-react": "^0.487.0",
"monaco-editor": "^0.52.2",
"ollama-ai-provider": "^1.2.0",

View File

@@ -0,0 +1,227 @@
import { parseAppMentions } from "@/shared/parse_mention_apps";
import { describe, it, expect } from "vitest";
describe("parseAppMentions", () => {
it("should parse basic app mentions", () => {
const prompt = "Can you help me with @app:MyApp and @app:AnotherApp?";
const result = parseAppMentions(prompt);
expect(result).toEqual(["MyApp", "AnotherApp"]);
});
it("should parse app mentions with underscores", () => {
const prompt = "I need help with @app:my_app and @app:another_app_name";
const result = parseAppMentions(prompt);
expect(result).toEqual(["my_app", "another_app_name"]);
});
it("should parse app mentions with hyphens", () => {
const prompt = "Check @app:my-app and @app:another-app-name";
const result = parseAppMentions(prompt);
expect(result).toEqual(["my-app", "another-app-name"]);
});
it("should parse app mentions with numbers", () => {
const prompt = "Update @app:app1 and @app:app2023 please";
const result = parseAppMentions(prompt);
expect(result).toEqual(["app1", "app2023"]);
});
it("should not parse mentions without app: prefix", () => {
const prompt = "Can you work on @MyApp and @AnotherApp?";
const result = parseAppMentions(prompt);
expect(result).toEqual([]);
});
it("should require exact 'app:' prefix (case sensitive)", () => {
const prompt = "Check @App:MyApp and @APP:AnotherApp vs @app:ValidApp";
const result = parseAppMentions(prompt);
expect(result).toEqual(["ValidApp"]);
});
it("should parse mixed case app mentions", () => {
const prompt = "Help with @app:MyApp, @app:myapp, and @app:MYAPP";
const result = parseAppMentions(prompt);
expect(result).toEqual(["MyApp", "myapp", "MYAPP"]);
});
it("should parse app mentions with mixed characters (no spaces)", () => {
const prompt = "Check @app:My_App-2023 and @app:Another_App_Name-v2";
const result = parseAppMentions(prompt);
expect(result).toEqual(["My_App-2023", "Another_App_Name-v2"]);
});
it("should not handle spaces in app names (spaces break app names)", () => {
const prompt = "Work on @app:My_App_Name with underscores";
const result = parseAppMentions(prompt);
expect(result).toEqual(["My_App_Name"]);
});
it("should handle empty string", () => {
const result = parseAppMentions("");
expect(result).toEqual([]);
});
it("should handle string with no mentions", () => {
const prompt = "This is just a regular message without any mentions";
const result = parseAppMentions(prompt);
expect(result).toEqual([]);
});
it("should handle standalone @ symbol", () => {
const prompt = "This has @ symbol but no valid mention";
const result = parseAppMentions(prompt);
expect(result).toEqual([]);
});
it("should ignore @ followed by special characters", () => {
const prompt = "Check @# and @! and @$ symbols";
const result = parseAppMentions(prompt);
expect(result).toEqual([]);
});
it("should ignore @ at the end of string", () => {
const prompt = "This ends with @";
const result = parseAppMentions(prompt);
expect(result).toEqual([]);
});
it("should parse mentions at different positions", () => {
const prompt =
"@app:StartApp in the beginning, @app:MiddleApp in middle, and @app:EndApp at end";
const result = parseAppMentions(prompt);
expect(result).toEqual(["StartApp", "MiddleApp", "EndApp"]);
});
it("should handle mentions with punctuation around them", () => {
const prompt = "Check (@app:MyApp), @app:AnotherApp! and @app:ThirdApp?";
const result = parseAppMentions(prompt);
expect(result).toEqual(["MyApp", "AnotherApp", "ThirdApp"]);
});
it("should parse mentions in different sentence structures", () => {
const prompt = `
Can you help me with @app:WebApp?
I also need @app:MobileApp updated.
Don't forget about @app:DesktopApp.
`;
const result = parseAppMentions(prompt);
expect(result).toEqual(["WebApp", "MobileApp", "DesktopApp"]);
});
it("should handle duplicate mentions", () => {
const prompt = "Update @app:MyApp and also check @app:MyApp again";
const result = parseAppMentions(prompt);
expect(result).toEqual(["MyApp", "MyApp"]);
});
it("should parse mentions in multiline text", () => {
const prompt = `Line 1 has @app:App1
Line 2 has @app:App2
Line 3 has @app:App3`;
const result = parseAppMentions(prompt);
expect(result).toEqual(["App1", "App2", "App3"]);
});
it("should handle mentions with tabs and other whitespace", () => {
const prompt = "Check\t@app:TabApp\nand\r@app:NewlineApp";
const result = parseAppMentions(prompt);
expect(result).toEqual(["TabApp", "NewlineApp"]);
});
it("should parse single character app names", () => {
const prompt = "Check @app:A and @app:B and @app:1";
const result = parseAppMentions(prompt);
expect(result).toEqual(["A", "B", "1"]);
});
it("should handle very long app names", () => {
const longAppName = "VeryLongAppNameWithManyCharacters123_test-app";
const prompt = `Check @app:${longAppName}`;
const result = parseAppMentions(prompt);
expect(result).toEqual([longAppName]);
});
it("should stop parsing at invalid characters", () => {
const prompt =
"Check @app:MyApp@InvalidPart and @app:AnotherApp.InvalidPart";
const result = parseAppMentions(prompt);
expect(result).toEqual(["MyApp", "AnotherApp"]);
});
it("should handle mentions with numbers and underscores mixed", () => {
const prompt = "Update @app:app_v1_2023 and @app:test_app_123";
const result = parseAppMentions(prompt);
expect(result).toEqual(["app_v1_2023", "test_app_123"]);
});
it("should handle mentions with hyphens and numbers mixed", () => {
const prompt = "Check @app:app-v1-2023 and @app:test-app-123";
const result = parseAppMentions(prompt);
expect(result).toEqual(["app-v1-2023", "test-app-123"]);
});
it("should parse mentions in URLs and complex text", () => {
const prompt =
"Visit https://example.com and check @app:WebApp for updates. Email admin@company.com about @app:MobileApp";
const result = parseAppMentions(prompt);
expect(result).toEqual(["WebApp", "MobileApp"]);
});
it("should not handle spaces in app names (spaces break app names)", () => {
const prompt = "Check @app:My_App_Name with underscores";
const result = parseAppMentions(prompt);
expect(result).toEqual(["My_App_Name"]);
});
it("should parse mentions in JSON-like strings", () => {
const prompt = '{"app": "@app:MyApp", "another": "@app:SecondApp"}';
const result = parseAppMentions(prompt);
expect(result).toEqual(["MyApp", "SecondApp"]);
});
it("should handle complex real-world scenarios (no spaces in app names)", () => {
const prompt = `
Hi there! I need help with @app:My_Web_App and @app:Mobile_App_v2.
Could you also check the status of @app:backend-service-2023?
Don't forget about @app:legacy_app and @app:NEW_PROJECT.
Thanks!
@app:user_mention should not be confused with @app:ActualApp.
`;
const result = parseAppMentions(prompt);
expect(result).toEqual([
"My_Web_App",
"Mobile_App_v2",
"backend-service-2023",
"legacy_app",
"NEW_PROJECT",
"user_mention",
"ActualApp",
]);
});
it("should preserve order of mentions", () => {
const prompt = "@app:Third @app:First @app:Second @app:Third @app:First";
const result = parseAppMentions(prompt);
expect(result).toEqual(["Third", "First", "Second", "Third", "First"]);
});
it("should handle edge case with @ followed by space", () => {
const prompt = "This has @ space but @app:ValidApp is here";
const result = parseAppMentions(prompt);
expect(result).toEqual(["ValidApp"]);
});
it("should handle unicode characters after @", () => {
const prompt = "Check @app:AppName and @app:测试 and @app:café-app";
const result = parseAppMentions(prompt);
// Based on the regex, unicode characters like 测试 and é should not match
expect(result).toEqual(["AppName", "caf"]);
});
it("should handle nested mentions pattern", () => {
const prompt = "Check @app:App1 @app:App2 @app:App3 test";
const result = parseAppMentions(prompt);
expect(result).toEqual(["App1", "App2", "App3"]);
});
});

View File

@@ -18,7 +18,7 @@ import {
SendHorizontalIcon,
} from "lucide-react";
import type React from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useSettings } from "@/hooks/useSettings";
import { IpcClient } from "@/ipc/ipc_client";
@@ -64,13 +64,13 @@ import { ChatErrorBox } from "./ChatErrorBox";
import { selectedComponentPreviewAtom } from "@/atoms/previewAtoms";
import { SelectedComponentDisplay } from "./SelectedComponentDisplay";
import { useCheckProblems } from "@/hooks/useCheckProblems";
import { LexicalChatInput } from "./LexicalChatInput";
const showTokenBarAtom = atom(false);
export function ChatInput({ chatId }: { chatId?: number }) {
const posthog = usePostHog();
const [inputValue, setInputValue] = useAtom(chatInputValueAtom);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { settings } = useSettings();
const appId = useAtomValue(selectedAppIdAtom);
const { refreshVersions } = useVersions(appId);
@@ -108,19 +108,6 @@ export function ChatInput({ chatId }: { chatId?: number }) {
} = useProposal(chatId);
const { proposal, messageId } = proposalResult ?? {};
const adjustHeight = () => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = "0px";
const scrollHeight = textarea.scrollHeight;
textarea.style.height = `${scrollHeight + 4}px`;
}
};
useEffect(() => {
adjustHeight();
}, [inputValue]);
useEffect(() => {
if (error) {
setShowError(true);
@@ -136,13 +123,6 @@ export function ChatInput({ chatId }: { chatId?: number }) {
setMessages(chat.messages);
}, [chatId, setMessages]);
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const handleSubmit = async () => {
if (
(!inputValue.trim() && attachments.length === 0) ||
@@ -307,15 +287,12 @@ export function ChatInput({ chatId }: { chatId?: number }) {
<DragDropOverlay isDraggingOver={isDraggingOver} />
<div className="flex items-start space-x-2 ">
<textarea
ref={textareaRef}
<LexicalChatInput
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={handleKeyPress}
onChange={setInputValue}
onSubmit={handleSubmit}
onPaste={handlePaste}
placeholder="Ask Dyad to build..."
className="flex-1 p-2 focus:outline-none overflow-y-auto min-h-[40px] max-h-[200px]"
style={{ resize: "none" }}
/>
{isStreaming ? (

View File

@@ -1,6 +1,4 @@
import { SendIcon, StopCircleIcon } from "lucide-react";
import type React from "react";
import { useEffect, useRef } from "react";
import { useSettings } from "@/hooks/useSettings";
import { homeChatInputValueAtom } from "@/atoms/chatAtoms"; // Use a different atom for home input
@@ -13,6 +11,7 @@ import { FileAttachmentDropdown } from "./FileAttachmentDropdown";
import { usePostHog } from "posthog-js/react";
import { HomeSubmitOptions } from "@/pages/home";
import { ChatInputControls } from "../ChatInputControls";
import { LexicalChatInput } from "./LexicalChatInput";
export function HomeChatInput({
onSubmit,
}: {
@@ -20,7 +19,6 @@ export function HomeChatInput({
}) {
const posthog = usePostHog();
const [inputValue, setInputValue] = useAtom(homeChatInputValueAtom);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { settings } = useSettings();
const { isStreaming } = useStreamChat({
hasChatId: false,
@@ -39,26 +37,6 @@ export function HomeChatInput({
handlePaste,
} = useAttachments();
const adjustHeight = () => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = "0px";
const scrollHeight = textarea.scrollHeight;
textarea.style.height = `${scrollHeight + 4}px`;
}
};
useEffect(() => {
adjustHeight();
}, [inputValue]);
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleCustomSubmit();
}
};
// Custom submit function that wraps the provided onSubmit
const handleCustomSubmit = () => {
if ((!inputValue.trim() && attachments.length === 0) || isStreaming) {
@@ -98,16 +76,13 @@ export function HomeChatInput({
<DragDropOverlay isDraggingOver={isDraggingOver} />
<div className="flex items-start space-x-2 ">
<textarea
ref={textareaRef}
<LexicalChatInput
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={handleKeyPress}
onChange={setInputValue}
onSubmit={handleCustomSubmit}
onPaste={handlePaste}
placeholder="Ask Dyad to build..."
className="flex-1 p-2 focus:outline-none overflow-y-auto min-h-[40px] max-h-[200px]"
style={{ resize: "none" }}
disabled={isStreaming} // Should ideally reflect if *any* stream is happening
disabled={isStreaming}
/>
{/* File attachment dropdown */}

View File

@@ -0,0 +1,285 @@
import React, { useCallback, useEffect, useState } from "react";
import { $getRoot, $createParagraphNode, EditorState } from "lexical";
import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
import {
BeautifulMentionsPlugin,
BeautifulMentionNode,
type BeautifulMentionsTheme,
type BeautifulMentionsMenuItemProps,
} from "lexical-beautiful-mentions";
import { KEY_ENTER_COMMAND, COMMAND_PRIORITY_HIGH } from "lexical";
import { useLoadApps } from "@/hooks/useLoadApps";
import { forwardRef } from "react";
import { useAtomValue } from "jotai";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { parseAppMentions } from "@/shared/parse_mention_apps";
// Define the theme for mentions
const beautifulMentionsTheme: BeautifulMentionsTheme = {
"@": "px-2 py-0.5 mx-0.5 bg-accent text-accent-foreground rounded-md",
"@Focused": "outline-none ring-2 ring-ring",
};
// Custom menu item component
const CustomMenuItem = forwardRef<
HTMLLIElement,
BeautifulMentionsMenuItemProps
>(({ selected, item, ...props }, ref) => (
<li
className={`m-0 flex items-center px-3 py-2 cursor-pointer whitespace-nowrap ${
selected
? "bg-accent text-accent-foreground"
: "bg-popover text-popover-foreground hover:bg-accent/50"
}`}
{...props}
ref={ref}
>
<div className="flex items-center space-x-2 min-w-0">
<span className="px-2 py-0.5 text-xs font-medium bg-primary text-primary-foreground rounded-md flex-shrink-0">
App
</span>
<span className="truncate text-sm">
{typeof item === "string" ? item : item.value}
</span>
</div>
</li>
));
// Custom menu component
function CustomMenu({ loading: _loading, ...props }: any) {
return (
<ul
className="m-0 mb-1 min-w-[300px] w-auto max-h-64 overflow-y-auto bg-popover border border-border rounded-lg shadow-lg z-50"
style={{
position: "absolute",
bottom: "100%",
left: 0,
right: 0,
transform: "translateY(-20px)", // Add a larger gap between menu and input (12px higher)
}}
data-mentions-menu="true"
{...props}
/>
);
}
// Plugin to handle Enter key
function EnterKeyPlugin({ onSubmit }: { onSubmit: () => void }) {
const [editor] = useLexicalComposerContext();
useEffect(() => {
return editor.registerCommand(
KEY_ENTER_COMMAND,
(event: KeyboardEvent) => {
// Check if mentions menu is open by looking for our custom menu element
const mentionsMenu = document.querySelector(
'[data-mentions-menu="true"]',
);
const hasVisibleItems =
mentionsMenu && mentionsMenu.children.length > 0;
if (hasVisibleItems) {
// If mentions menu is open with items, let the mentions plugin handle the Enter key
return false;
}
if (!event.shiftKey) {
event.preventDefault();
onSubmit();
return true;
}
return false;
},
COMMAND_PRIORITY_HIGH, // Use higher priority to catch before mentions plugin
);
}, [editor, onSubmit]);
return null;
}
// Plugin to clear editor content
function ClearEditorPlugin({
shouldClear,
onCleared,
}: {
shouldClear: boolean;
onCleared: () => void;
}) {
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (shouldClear) {
editor.update(() => {
const root = $getRoot();
root.clear();
const paragraph = $createParagraphNode();
root.append(paragraph);
paragraph.select();
});
onCleared();
}
}, [editor, shouldClear, onCleared]);
return null;
}
interface LexicalChatInputProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
onPaste?: (e: React.ClipboardEvent) => void;
placeholder?: string;
disabled?: boolean;
}
function onError(error: Error) {
console.error(error);
}
export function LexicalChatInput({
value,
onChange,
onSubmit,
onPaste,
placeholder = "Ask Dyad to build...",
disabled = false,
}: LexicalChatInputProps) {
const { apps } = useLoadApps();
const [shouldClear, setShouldClear] = useState(false);
const selectedAppId = useAtomValue(selectedAppIdAtom);
// Prepare mention items - convert apps to mention format
const mentionItems = React.useMemo(() => {
if (!apps) return { "@": [] };
// Get current app name
const currentApp = apps.find((app) => app.id === selectedAppId);
const currentAppName = currentApp?.name;
// Parse already mentioned apps from current input value
const alreadyMentioned = parseAppMentions(value);
// Filter out current app and already mentioned apps
const filteredApps = apps.filter((app) => {
// Exclude current app
if (app.name === currentAppName) return false;
// Exclude already mentioned apps (case-insensitive comparison)
if (
alreadyMentioned.some(
(mentioned) => mentioned.toLowerCase() === app.name.toLowerCase(),
)
)
return false;
return true;
});
const appMentions = filteredApps.map((app) => app.name);
return {
"@": appMentions,
};
}, [apps, selectedAppId, value]);
const initialConfig = {
namespace: "ChatInput",
theme: {
beautifulMentions: beautifulMentionsTheme,
},
onError,
nodes: [BeautifulMentionNode],
editable: !disabled,
};
const handleEditorChange = useCallback(
(editorState: EditorState) => {
editorState.read(() => {
const root = $getRoot();
let textContent = root.getTextContent();
console.time("handleEditorChange");
// Transform @AppName mentions to @app:AppName format
// This regex matches @AppName where AppName is one of our actual app names
// Short-circuit if there's no "@" symbol in the text
if (textContent.includes("@")) {
const appNames = apps?.map((app) => app.name) || [];
for (const appName of appNames) {
// Escape special regex characters in app name
const escapedAppName = appName.replace(
/[.*+?^${}()|[\]\\]/g,
"\\$&",
);
const mentionRegex = new RegExp(
`@(${escapedAppName})(?![a-zA-Z0-9_-])`,
"g",
);
textContent = textContent.replace(mentionRegex, "@app:$1");
}
}
console.timeEnd("handleEditorChange");
onChange(textContent);
});
},
[onChange, apps],
);
const handleSubmit = useCallback(() => {
onSubmit();
setShouldClear(true);
}, [onSubmit]);
const handleCleared = useCallback(() => {
setShouldClear(false);
}, []);
// Update editor content when value changes externally (like clearing)
useEffect(() => {
if (value === "") {
setShouldClear(true);
}
}, [value]);
return (
<LexicalComposer initialConfig={initialConfig}>
<div className="relative flex-1">
<PlainTextPlugin
contentEditable={
<ContentEditable
className="flex-1 p-2 focus:outline-none overflow-y-auto min-h-[40px] max-h-[200px] resize-none"
aria-placeholder={placeholder}
placeholder={
<div className="absolute top-2 left-2 text-muted-foreground pointer-events-none select-none">
{placeholder}
</div>
}
onPaste={onPaste}
/>
}
ErrorBoundary={LexicalErrorBoundary}
/>
<BeautifulMentionsPlugin
items={mentionItems}
menuComponent={CustomMenu}
menuItemComponent={CustomMenuItem}
creatable={false}
insertOnBlur={false}
menuItemLimit={10}
/>
<OnChangePlugin onChange={handleEditorChange} />
<HistoryPlugin />
<EnterKeyPlugin onSubmit={handleSubmit} />
<ClearEditorPlugin
shouldClear={shouldClear}
onCleared={handleCleared}
/>
</div>
</LexicalComposer>
);
}

View File

@@ -6,7 +6,13 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useCountTokens } from "@/hooks/useCountTokens";
import { MessageSquare, Code, Bot, AlignLeft } from "lucide-react";
import {
MessageSquare,
Code,
Bot,
AlignLeft,
ExternalLink,
} from "lucide-react";
import { chatInputValueAtom } from "@/atoms/chatAtoms";
import { useAtom } from "jotai";
import { useSettings } from "@/hooks/useSettings";
@@ -45,6 +51,7 @@ export function TokenBar({ chatId }: TokenBarProps) {
totalTokens,
messageHistoryTokens,
codebaseTokens,
mentionedAppsTokens,
systemPromptTokens,
inputTokens,
contextWindow,
@@ -55,6 +62,7 @@ export function TokenBar({ chatId }: TokenBarProps) {
// Calculate widths for each token type
const messageHistoryPercent = (messageHistoryTokens / contextWindow) * 100;
const codebasePercent = (codebaseTokens / contextWindow) * 100;
const mentionedAppsPercent = (mentionedAppsTokens / contextWindow) * 100;
const systemPromptPercent = (systemPromptTokens / contextWindow) * 100;
const inputPercent = (inputTokens / contextWindow) * 100;
@@ -82,6 +90,11 @@ export function TokenBar({ chatId }: TokenBarProps) {
className="h-full bg-green-400"
style={{ width: `${codebasePercent}%` }}
/>
{/* Mentioned apps tokens */}
<div
className="h-full bg-orange-400"
style={{ width: `${mentionedAppsPercent}%` }}
/>
{/* System prompt tokens */}
<div
className="h-full bg-purple-400"
@@ -107,6 +120,10 @@ export function TokenBar({ chatId }: TokenBarProps) {
<span>Codebase</span>
<span>{codebaseTokens.toLocaleString()}</span>
<ExternalLink size={12} className="text-orange-500" />
<span>Mentioned Apps</span>
<span>{mentionedAppsTokens.toLocaleString()}</span>
<Bot size={12} className="text-purple-500" />
<span>System Prompt</span>
<span>{systemPromptTokens.toLocaleString()}</span>

View File

@@ -59,6 +59,8 @@ import {
import { fileExists } from "../utils/file_utils";
import { FileUploadsState } from "../utils/file_uploads_state";
import { OpenAIResponsesProviderOptions } from "@ai-sdk/openai";
import { extractMentionedAppsCodebases } from "../utils/mention_apps";
import { parseAppMentions } from "@/shared/parse_mention_apps";
type AsyncIterableStream<T> = AsyncIterable<T> & ReadableStream<T>;
@@ -380,11 +382,38 @@ ${componentSnippet}
}
: validateChatContext(updatedChat.app.chatContext);
// Parse app mentions from the prompt
const mentionedAppNames = parseAppMentions(req.prompt);
// Extract codebase for current app
const { formattedOutput: codebaseInfo, files } = await extractCodebase({
appPath,
chatContext,
});
// Extract codebases for mentioned apps
const mentionedAppsCodebases = await extractMentionedAppsCodebases(
mentionedAppNames,
updatedChat.app.id, // Exclude current app
);
// Combine current app codebase with mentioned apps' codebases
let otherAppsCodebaseInfo = "";
if (mentionedAppsCodebases.length > 0) {
const mentionedAppsSection = mentionedAppsCodebases
.map(
({ appName, codebaseInfo }) =>
`\n\n=== Referenced App: ${appName} ===\n${codebaseInfo}`,
)
.join("");
otherAppsCodebaseInfo = mentionedAppsSection;
logger.log(
`Added ${mentionedAppsCodebases.length} mentioned app codebases`,
);
}
logger.log(`Extracted codebase information from ${appPath}`);
logger.log(
"codebaseInfo: length",
@@ -446,6 +475,15 @@ ${componentSnippet}
aiRules: await readAiRules(getDyadAppPath(updatedChat.app.path)),
chatMode: settings.selectedChatMode,
});
// Add information about mentioned apps if any
if (otherAppsCodebaseInfo) {
const mentionedAppsList = mentionedAppsCodebases
.map(({ appName }) => appName)
.join(", ");
systemPrompt += `\n\n# Referenced Apps\nThe user has mentioned the following apps in their prompt: ${mentionedAppsList}. Their codebases have been included in the context for your reference. When referring to these apps, you can understand their structure and code to provide better assistance, however you should NOT edit the files in these referenced apps. The referenced apps are NOT part of the current app and are READ-ONLY.`;
}
if (
updatedChat.app?.supabaseProjectId &&
settings.supabase?.accessToken?.value
@@ -529,8 +567,22 @@ This conversation includes one or more image attachments. When the user uploads
},
] as const);
const otherCodebasePrefix = otherAppsCodebaseInfo
? ([
{
role: "user",
content: createOtherAppsCodebasePrompt(otherAppsCodebaseInfo),
},
{
role: "assistant",
content: "OK.",
},
] as const)
: [];
let chatMessages: CoreMessage[] = [
...codebasePrefix,
...otherCodebasePrefix,
...limitedMessageHistory.map((msg) => ({
role: msg.role as "user" | "assistant" | "system",
// Why remove thinking tags?
@@ -1201,3 +1253,13 @@ const CODEBASE_PROMPT_PREFIX = "This is my codebase.";
function createCodebasePrompt(codebaseInfo: string): string {
return `${CODEBASE_PROMPT_PREFIX} ${codebaseInfo}`;
}
function createOtherAppsCodebasePrompt(otherAppsCodebaseInfo: string): string {
return `
# Referenced Apps
These are the other apps that I've mentioned in my prompt. These other apps' codebases are READ-ONLY.
${otherAppsCodebaseInfo}
`;
}

View File

@@ -20,6 +20,8 @@ import { estimateTokens, getContextWindow } from "../utils/token_utils";
import { createLoggedHandler } from "./safe_handle";
import { validateChatContext } from "../utils/context_paths_utils";
import { readSettings } from "@/main/settings";
import { extractMentionedAppsCodebases } from "../utils/mention_apps";
import { parseAppMentions } from "@/shared/parse_mention_apps";
const logger = log.scope("token_count_handlers");
@@ -53,6 +55,10 @@ export function registerTokenCountHandlers() {
const inputTokens = estimateTokens(req.input);
const settings = readSettings();
// Parse app mentions from the input
const mentionedAppNames = parseAppMentions(req.input);
// Count system prompt tokens
let systemPrompt = constructSystemPrompt({
aiRules: await readAiRules(getDyadAppPath(chat.app.path)),
@@ -92,17 +98,42 @@ export function registerTokenCountHandlers() {
);
}
// Extract codebases for mentioned apps
const mentionedAppsCodebases = await extractMentionedAppsCodebases(
mentionedAppNames,
chat.app?.id, // Exclude current app
);
// Calculate tokens for mentioned apps
let mentionedAppsTokens = 0;
if (mentionedAppsCodebases.length > 0) {
const mentionedAppsContent = mentionedAppsCodebases
.map(
({ appName, codebaseInfo }) =>
`\n\n=== Referenced App: ${appName} ===\n${codebaseInfo}`,
)
.join("");
mentionedAppsTokens = estimateTokens(mentionedAppsContent);
logger.log(
`Extracted ${mentionedAppsCodebases.length} mentioned app codebases, tokens: ${mentionedAppsTokens}`,
);
}
// Calculate total tokens
const totalTokens =
messageHistoryTokens +
inputTokens +
systemPromptTokens +
codebaseTokens;
codebaseTokens +
mentionedAppsTokens;
return {
totalTokens,
messageHistoryTokens,
codebaseTokens,
mentionedAppsTokens,
inputTokens,
systemPromptTokens,
contextWindow: await getContextWindow(),

View File

@@ -152,6 +152,7 @@ export interface TokenCountResult {
totalTokens: number;
messageHistoryTokens: number;
codebaseTokens: number;
mentionedAppsTokens: number;
inputTokens: number;
systemPromptTokens: number;
contextWindow: number;

View File

@@ -0,0 +1,53 @@
import { db } from "../../db";
import { getDyadAppPath } from "../../paths/paths";
import { extractCodebase } from "../../utils/codebase";
import { validateChatContext } from "../utils/context_paths_utils";
import log from "electron-log";
const logger = log.scope("mention_apps");
// Helper function to extract codebases from mentioned apps
export async function extractMentionedAppsCodebases(
mentionedAppNames: string[],
excludeCurrentAppId?: number,
): Promise<{ appName: string; codebaseInfo: string }[]> {
if (mentionedAppNames.length === 0) {
return [];
}
// Get all apps
const allApps = await db.query.apps.findMany();
const mentionedApps = allApps.filter(
(app) =>
mentionedAppNames.some(
(mentionName) => app.name.toLowerCase() === mentionName.toLowerCase(),
) && app.id !== excludeCurrentAppId,
);
const results: { appName: string; codebaseInfo: string }[] = [];
for (const app of mentionedApps) {
try {
const appPath = getDyadAppPath(app.path);
const chatContext = validateChatContext(app.chatContext);
const { formattedOutput } = await extractCodebase({
appPath,
chatContext,
});
results.push({
appName: app.name,
codebaseInfo: formattedOutput,
});
logger.log(`Extracted codebase for mentioned app: ${app.name}`);
} catch (error) {
logger.error(`Error extracting codebase for app ${app.name}:`, error);
// Continue with other apps even if one fails
}
}
return results;
}

View File

@@ -0,0 +1,13 @@
// Helper function to parse app mentions from prompt
export function parseAppMentions(prompt: string): string[] {
// Match @app:AppName patterns in the prompt (supports letters, digits, underscores, and hyphens, but NOT spaces)
const mentionRegex = /@app:([a-zA-Z0-9_-]+)/g;
const mentions: string[] = [];
let match;
while ((match = mentionRegex.exec(prompt)) !== null) {
mentions.push(match[1]);
}
return mentions;
}