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

Handle cross-app references in a scalable way (#3219)

上级 5f823117
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -62,6 +62,10 @@
"type": "string",
"description": "The file path to read"
},
"app_name": {
"description": "Optional. Name of a referenced app (from `@app:Name` mentions in the user's prompt) to read from instead of the current app. Omit to read from the current app.",
"type": "string"
},
"start_line_one_indexed": {
"description": "The one-indexed line number to start reading from (inclusive).",
"type": "integer",
......@@ -95,6 +99,10 @@
"description": "Optional subdirectory to list",
"type": "string"
},
"app_name": {
"description": "Optional. Name of a referenced app (from `@app:Name` mentions in the user's prompt) to list from instead of the current app. Omit to list the current app.",
"type": "string"
},
"recursive": {
"description": "Whether to list files recursively (default: false)",
"type": "boolean"
......@@ -121,6 +129,10 @@
"type": "string",
"description": "The regex pattern to search for"
},
"app_name": {
"description": "Optional. Name of a referenced app (from `@app:Name` mentions in the user's prompt) to search in instead of the current app. Omit to search the current app.",
"type": "string"
},
"include_pattern": {
"description": "Glob pattern for files to include (e.g. '*.ts' for TypeScript files)",
"type": "string"
......@@ -163,6 +175,10 @@
"query": {
"type": "string",
"description": "Search query to find relevant files"
},
"app_name": {
"description": "Optional. Name of a referenced app (from `@app:Name` mentions in the user's prompt) to search in instead of the current app. Omit to search the current app.",
"type": "string"
}
},
"required": [
......
......@@ -230,6 +230,10 @@
"type": "string",
"description": "The file path to read"
},
"app_name": {
"description": "Optional. Name of a referenced app (from `@app:Name` mentions in the user's prompt) to read from instead of the current app. Omit to read from the current app.",
"type": "string"
},
"start_line_one_indexed": {
"description": "The one-indexed line number to start reading from (inclusive).",
"type": "integer",
......@@ -261,6 +265,10 @@
"description": "Optional subdirectory to list",
"type": "string"
},
"app_name": {
"description": "Optional. Name of a referenced app (from `@app:Name` mentions in the user's prompt) to list from instead of the current app. Omit to list the current app.",
"type": "string"
},
"recursive": {
"description": "Whether to list files recursively (default: false)",
"type": "boolean"
......@@ -285,6 +293,10 @@
"type": "string",
"description": "The regex pattern to search for"
},
"app_name": {
"description": "Optional. Name of a referenced app (from `@app:Name` mentions in the user's prompt) to search in instead of the current app. Omit to search the current app.",
"type": "string"
},
"include_pattern": {
"description": "Glob pattern for files to include (e.g. '*.ts' for TypeScript files)",
"type": "string"
......@@ -325,6 +337,10 @@
"query": {
"type": "string",
"description": "Search query to find relevant files"
},
"app_name": {
"description": "Optional. Name of a referenced app (from `@app:Name` mentions in the user's prompt) to search in instead of the current app. Omit to search the current app.",
"type": "string"
}
},
"required": [
......
......@@ -227,6 +227,10 @@
"type": "string",
"description": "The file path to read"
},
"app_name": {
"description": "Optional. Name of a referenced app (from `@app:Name` mentions in the user's prompt) to read from instead of the current app. Omit to read from the current app.",
"type": "string"
},
"start_line_one_indexed": {
"description": "The one-indexed line number to start reading from (inclusive).",
"type": "integer",
......@@ -260,6 +264,10 @@
"description": "Optional subdirectory to list",
"type": "string"
},
"app_name": {
"description": "Optional. Name of a referenced app (from `@app:Name` mentions in the user's prompt) to list from instead of the current app. Omit to list the current app.",
"type": "string"
},
"recursive": {
"description": "Whether to list files recursively (default: false)",
"type": "boolean"
......@@ -286,6 +294,10 @@
"type": "string",
"description": "The regex pattern to search for"
},
"app_name": {
"description": "Optional. Name of a referenced app (from `@app:Name` mentions in the user's prompt) to search in instead of the current app. Omit to search the current app.",
"type": "string"
},
"include_pattern": {
"description": "Glob pattern for files to include (e.g. '*.ts' for TypeScript files)",
"type": "string"
......@@ -328,6 +340,10 @@
"query": {
"type": "string",
"description": "Search query to find relevant files"
},
"app_name": {
"description": "Optional. Name of a referenced app (from `@app:Name` mentions in the user's prompt) to search in instead of the current app. Omit to search the current app.",
"type": "string"
}
},
"required": [
......
......@@ -2,1096 +2,10 @@
role: system
message: [[SYSTEM_MESSAGE]]
===
role: user
message: This is my codebase. <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">
# 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.
</dyad-file>
<dyad-file path="components.json">
// File contents excluded from context
</dyad-file>
<dyad-file path="eslint.config.js">
// File contents excluded from context
</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="postcss.config.js">
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
</dyad-file>
<dyad-file path="public/favicon.ico">
// File contents excluded from context
</dyad-file>
<dyad-file path="public/placeholder.svg">
// File contents excluded from context
</dyad-file>
<dyad-file path="public/robots.txt">
// File contents excluded from context
</dyad-file>
<dyad-file path="README.md">
# Welcome to your Dyad app
</dyad-file>
<dyad-file path="src/App.css">
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
</dyad-file>
<dyad-file path="src/App.tsx">
import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Index from "./pages/Index";
import NotFound from "./pages/NotFound";
const queryClient = new QueryClient();
const App = () => (
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<Toaster />
<Sonner />
<BrowserRouter>
<Routes>
<Route path="/" element={<Index />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
</TooltipProvider>
</QueryClientProvider>
);
export default App;
</dyad-file>
<dyad-file path="src/components/made-with-dyad.tsx">
export const MadeWithDyad = () => {
return (
<div className="p-4 text-center">
<a
href="https://www.dyad.sh/"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Made with Dyad
</a>
</div>
);
};
</dyad-file>
<dyad-file path="src/components/ui/accordion.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/alert-dialog.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/alert.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/aspect-ratio.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/avatar.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/badge.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/breadcrumb.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/button.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/calendar.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/card.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/carousel.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/chart.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/checkbox.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/collapsible.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/command.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/context-menu.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/dialog.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/drawer.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/dropdown-menu.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/form.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/hover-card.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/input-otp.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/input.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/label.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/menubar.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/navigation-menu.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/pagination.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/popover.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/progress.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/radio-group.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/resizable.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/scroll-area.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/select.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/separator.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/sheet.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/sidebar.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/skeleton.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/slider.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/sonner.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/switch.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/table.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/tabs.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/textarea.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/toast.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/toaster.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/toggle-group.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/toggle.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/tooltip.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/use-toast.ts">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/globals.css">
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
</dyad-file>
<dyad-file path="src/hooks/use-mobile.tsx">
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}
</dyad-file>
<dyad-file path="src/hooks/use-toast.ts">
import * as React from "react";
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const _actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof _actionTypes;
type Action =
| {
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t,
),
};
case "DISMISS_TOAST": {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
};
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { useToast, toast };
</dyad-file>
<dyad-file path="src/lib/utils.ts">
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
</dyad-file>
<dyad-file path="src/main.tsx">
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./globals.css";
createRoot(document.getElementById("root")!).render(<App />);
</dyad-file>
<dyad-file path="src/pages/Index.tsx">
// Update this page (the content is just a fallback if you fail to update the page)
import { MadeWithDyad } from "@/components/made-with-dyad";
const Index = () => {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="text-center">
<h1 className="text-4xl font-bold mb-4">Welcome to Your Blank App</h1>
<p className="text-xl text-gray-600">
Start building your amazing project here!
</p>
</div>
<MadeWithDyad />
</div>
);
};
export default Index;
</dyad-file>
<dyad-file path="src/pages/NotFound.tsx">
import { useLocation } from "react-router-dom";
import { useEffect } from "react";
const NotFound = () => {
const location = useLocation();
useEffect(() => {
console.error(
"404 Error: User attempted to access non-existent route:",
location.pathname,
);
}, [location.pathname]);
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="text-center">
<h1 className="text-4xl font-bold mb-4">404</h1>
<p className="text-xl text-gray-600 mb-4">Oops! Page not found</p>
<a href="/" className="text-blue-500 hover:text-blue-700 underline">
Return to Home
</a>
</div>
</div>
);
};
export default NotFound;
</dyad-file>
<dyad-file path="src/utils/toast.ts">
import { toast } from "sonner";
export const showSuccess = (message: string) => {
toast.success(message);
};
export const showError = (message: string) => {
toast.error(message);
};
export const showLoading = (message: string) => {
return toast.loading(message);
};
export const dismissToast = (toastId: string) => {
toast.dismiss(toastId);
};
</dyad-file>
<dyad-file path="src/vite-env.d.ts">
/// <reference types="vite/client" />
</dyad-file>
<dyad-file path="tailwind.config.ts">
import type { Config } from "tailwindcss";
export default {
darkMode: ["class"],
content: [
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
],
prefix: "",
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
sidebar: {
DEFAULT: "hsl(var(--sidebar-background))",
foreground: "hsl(var(--sidebar-foreground))",
primary: "hsl(var(--sidebar-primary))",
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
accent: "hsl(var(--sidebar-accent))",
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
border: "hsl(var(--sidebar-border))",
ring: "hsl(var(--sidebar-ring))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: {
height: "0",
},
to: {
height: "var(--radix-accordion-content-height)",
},
},
"accordion-up": {
from: {
height: "var(--radix-accordion-content-height)",
},
to: {
height: "0",
},
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
} satisfies Config;
</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="vercel.json">
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"rewrites": [
{
"source": "/(.*)",
"destination": "/index.html"
}
]
}
</dyad-file>
<dyad-file path="vite.config.ts">
import { defineConfig } from "vite";
import dyadComponentTagger from "@dyad-sh/react-vite-component-tagger";
import react from "@vitejs/plugin-react-swc";
import path from "path";
export default defineConfig(() => ({
server: {
host: "::",
port: 8080,
},
plugins: [dyadComponentTagger(), react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
}));
</dyad-file>
===
role: assistant
message: OK, got it. I'm ready to help
===
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
<system-reminder>
The user has mentioned the following apps in their prompt: `minimal-with-ai-rules`. These apps are separate from the current app and are READ-ONLY. To inspect them, pass the app name as the `app_name` parameter to read-only tools (`read_file`, `list_files`, `grep`, `code_search`); matching is case-insensitive. Write tools cannot target these apps. Omit `app_name` to operate on the current app.
</system-reminder>
\ No newline at end of file
......@@ -13,7 +13,9 @@ import {
interface DyadCodeSearchProps {
children?: ReactNode;
node?: { properties?: { query?: string; state?: CustomTagState } };
node?: {
properties?: { query?: string; state?: CustomTagState; appName?: string };
};
}
export const DyadCodeSearch: React.FC<DyadCodeSearchProps> = ({
......@@ -24,6 +26,7 @@ export const DyadCodeSearch: React.FC<DyadCodeSearchProps> = ({
const query =
node?.properties?.query || (typeof children === "string" ? children : "");
const state = node?.properties?.state as CustomTagState;
const appName = node?.properties?.appName || "";
const inProgress = state === "pending";
return (
......@@ -35,6 +38,7 @@ export const DyadCodeSearch: React.FC<DyadCodeSearchProps> = ({
>
<DyadCardHeader icon={<FileCode size={15} />} accentColor="indigo">
<DyadBadge color="indigo">Code Search</DyadBadge>
{appName && <DyadBadge color="sky">{appName}</DyadBadge>}
{!isExpanded && query && (
<span className="text-sm text-muted-foreground italic truncate">
{query}
......
......@@ -25,6 +25,7 @@ interface DyadGrepProps {
count?: string;
total?: string;
truncated?: string;
appName?: string;
};
};
}
......@@ -43,6 +44,7 @@ export const DyadGrep: React.FC<DyadGrepProps> = ({ children, node }) => {
const count = node?.properties?.count || "";
const total = node?.properties?.total || "";
const truncated = node?.properties?.truncated === "true";
const appName = node?.properties?.appName || "";
let description = `"${query}"`;
if (includePattern) {
......@@ -71,6 +73,7 @@ export const DyadGrep: React.FC<DyadGrepProps> = ({ children, node }) => {
>
<DyadCardHeader icon={<Search size={15} />} accentColor="violet">
<DyadBadge color="violet">GREP</DyadBadge>
{appName && <DyadBadge color="sky">{appName}</DyadBadge>}
<span className="font-medium text-sm text-foreground truncate">
{description}
</span>
......
......@@ -17,13 +17,15 @@ interface DyadListFilesProps {
recursive?: string;
include_ignored?: string;
state?: CustomTagState;
appName?: string;
};
};
children: React.ReactNode;
}
export function DyadListFiles({ node, children }: DyadListFilesProps) {
const { directory, recursive, include_ignored, state } = node.properties;
const { directory, recursive, include_ignored, state, appName } =
node.properties;
const isLoading = state === "pending";
const isRecursive = recursive === "true";
const isIncludeIgnored = include_ignored === "true";
......@@ -44,6 +46,7 @@ export function DyadListFiles({ node, children }: DyadListFilesProps) {
<span className="font-medium text-sm text-foreground truncate">
{title}
</span>
{appName && <DyadBadge color="sky">{appName}</DyadBadge>}
{isRecursive && <DyadBadge color="slate">recursive</DyadBadge>}
{isIncludeIgnored && (
<DyadBadge color="slate">include ignored</DyadBadge>
......
......@@ -378,6 +378,7 @@ function renderCustomTag(
path: attributes.path || "",
startLine: attributes.start_line || "",
endLine: attributes.end_line || "",
appName: attributes.app_name || "",
},
}}
>
......@@ -426,6 +427,7 @@ function renderCustomTag(
properties: {
query: attributes.query || "",
state: getState({ isStreaming, inProgress }),
appName: attributes.app_name || "",
},
}}
>
......@@ -581,6 +583,7 @@ function renderCustomTag(
count: attributes.count || "",
total: attributes.total || "",
truncated: attributes.truncated || "",
appName: attributes.app_name || "",
},
}}
>
......@@ -713,6 +716,7 @@ function renderCustomTag(
include_ignored:
attributes.include_ignored || attributes.include_hidden || "",
state: getState({ isStreaming, inProgress }),
appName: attributes.app_name || "",
},
}}
>
......
import type React from "react";
import type { ReactNode } from "react";
import { FileText } from "lucide-react";
import { DyadBadge } from "./DyadCardPrimitives";
interface DyadReadProps {
children?: ReactNode;
......@@ -20,6 +21,7 @@ export const DyadRead: React.FC<DyadReadProps> = ({
const path = pathProp || node?.properties?.path || "";
const startLine = startLineProp || node?.properties?.startLine || "";
const endLine = endLineProp || node?.properties?.endLine || "";
const appName = node?.properties?.appName || "";
const fileName = path ? path.split("/").pop() : "";
const dirPath = path
? path.slice(0, path.length - (fileName?.length || 0))
......@@ -43,6 +45,7 @@ export const DyadRead: React.FC<DyadReadProps> = ({
<div className="flex items-center gap-1 py-1">
<FileText size={14} className="shrink-0 text-muted-foreground/50" />
<span className="text-[13px] font-medium text-foreground/70">Read</span>
{appName && <DyadBadge color="sky">{appName}</DyadBadge>}
{path && (
<span
className="text-[13px] truncate min-w-0"
......
......@@ -82,7 +82,12 @@ import {
appendCancelledResponseNotice,
filterCancelledMessagePairs,
} from "@/shared/chatCancellation";
import { extractMentionedAppsCodebases } from "../utils/mention_apps";
import {
extractMentionedAppsCodebases,
extractMentionedAppsReferences,
type MentionedAppCodebaseEntry,
type MentionedAppReference,
} from "../utils/mention_apps";
import { parseAppMentions } from "@/shared/parse_mention_apps";
import {
parseMediaMentions,
......@@ -100,6 +105,7 @@ import { mcpManager } from "../utils/mcp_manager";
import z from "zod";
import {
isBasicAgentMode,
isLocalAgentBackedMode,
isSupabaseConnected,
isTurboEditsV2Enabled,
} from "@/lib/schemas";
......@@ -664,26 +670,48 @@ ${componentSnippet}
// Parse app mentions from the prompt
const mentionedAppNames = parseAppMentions(req.prompt);
// Extract codebases for mentioned apps
const mentionedAppsCodebases = await extractMentionedAppsCodebases(
const isLocalAgentMode = selectedChatMode === "local-agent";
const isAskMode = selectedChatMode === "ask";
const isPlanMode = selectedChatMode === "plan";
const willUseLocalAgentStream =
isLocalAgentBackedMode(selectedChatMode);
// Agent/ask/plan modes reach referenced apps via tool calls (`app_name`
// on read-only tools), so we only need name/path pairs — skip the heavy
// codebase extraction entirely. Build mode still injects full codebases.
let mentionedAppsCodebases: MentionedAppCodebaseEntry[] = [];
let referencedAppsForAgent: MentionedAppReference[] = [];
if (willUseLocalAgentStream) {
referencedAppsForAgent = await extractMentionedAppsReferences(
mentionedAppNames,
updatedChat.app.id, // Exclude current app
);
const willUseLocalAgentStream =
(selectedChatMode === "local-agent" || selectedChatMode === "ask") &&
!mentionedAppsCodebases.length;
} else {
mentionedAppsCodebases = await extractMentionedAppsCodebases(
mentionedAppNames,
updatedChat.app.id, // Exclude current app
);
referencedAppsForAgent = mentionedAppsCodebases.map(
({ appName, appPath }) => ({ appName, appPath }),
);
}
const useReferencedAppManifest =
willUseLocalAgentStream && referencedAppsForAgent.length > 0;
const isDeepContextEnabled =
isEngineEnabled &&
settings.enableProSmartFilesContextMode &&
// Anything besides balanced will use deep context.
settings.proSmartContextOption !== "balanced" &&
mentionedAppsCodebases.length === 0;
referencedAppsForAgent.length === 0;
logger.log(`isDeepContextEnabled: ${isDeepContextEnabled}`);
// Combine current app codebase with mentioned apps' codebases
// Combine current app codebase with mentioned apps' codebases.
// In agent/ask/plan modes we skip the full codebase injection — the
// model can read referenced apps on-demand via tool calls with `app_name`
// instead of carrying their full contents in the system prompt.
let otherAppsCodebaseInfo = "";
if (mentionedAppsCodebases.length > 0) {
if (mentionedAppsCodebases.length > 0 && !useReferencedAppManifest) {
const mentionedAppsSection = mentionedAppsCodebases
.map(
({ appName, codebaseInfo }) =>
......@@ -794,7 +822,13 @@ ${componentSnippet}
basicAgentMode: isBasicAgentMode(settings),
});
// Add information about mentioned apps if any
// Add information about mentioned apps for build mode only.
// Full codebase injection (build mode): full file contents already
// concatenated into `otherAppsCodebaseInfo`.
//
// Agent/ask/plan modes don't need anything in the system prompt —
// handleLocalAgentStream injects a `<system-reminder>` into the
// user's latest message so the system prompt stays static.
if (otherAppsCodebaseInfo) {
const mentionedAppsList = mentionedAppsCodebases
.map(({ appName }) => appName)
......@@ -889,9 +923,8 @@ ${componentSnippet}
// print out the dyad-write tags.
// Usually, AI models will want to use the image as reference to generate code (e.g. UI mockups) anyways, so
// it's not that critical to include the image analysis instructions.
const isAskMode = selectedChatMode === "ask";
if (hasUploadedAttachments) {
if (willUseLocalAgentStream && !isAskMode) {
if (isLocalAgentMode) {
systemPrompt += `
When files are attached for upload to the codebase, use the \`copy_file\` tool to copy them from their path into the project.
......@@ -903,7 +936,7 @@ copy_file(from=".dyad/media/abc123.png", to="src/assets/logo.png", description="
The file paths are provided in the attachment information above.
`;
} else if (!isAskMode) {
} else if (!isAskMode && !isPlanMode) {
systemPrompt += `
When files are attached for upload to the codebase, copy them into the project using this format:
......@@ -1180,7 +1213,7 @@ This conversation includes one or more image attachments. When the user uploads
// Handle ask mode: use local-agent in read-only mode
// This gives users access to code reading tools while in ask mode
// Ask mode does not consume free agent quota
if (selectedChatMode === "ask" && !mentionedAppsCodebases.length) {
if (isAskMode) {
// Reconstruct system prompt for local-agent read-only mode
const readOnlySystemPrompt = constructSystemPrompt({
aiRules,
......@@ -1210,6 +1243,7 @@ This conversation includes one or more image attachments. When the user uploads
readOnly: true,
messageOverride: isSummarizeIntent ? chatMessages : undefined,
settingsOverride: settings,
referencedApps: referencedAppsForAgent,
},
);
if (!streamSuccess) {
......@@ -1222,7 +1256,7 @@ This conversation includes one or more image attachments. When the user uploads
// Handle plan mode: use local-agent with plan tools only
// Plan mode is for requirements gathering and creating implementation plans
if (selectedChatMode === "plan" && !mentionedAppsCodebases.length) {
if (isPlanMode) {
// Reconstruct system prompt for plan mode
const planModeSystemPrompt = constructSystemPrompt({
aiRules,
......@@ -1238,17 +1272,18 @@ This conversation includes one or more image attachments. When the user uploads
planModeOnly: true,
messageOverride: isSummarizeIntent ? chatMessages : undefined,
settingsOverride: settings,
referencedApps: referencedAppsForAgent,
});
return;
}
// Handle local-agent mode (Agent v2)
// Mentioned apps can't be handled by the local agent (defer to balanced smart context
// in build mode)
if (
selectedChatMode === "local-agent" &&
!mentionedAppsCodebases.length
) {
// Handle local-agent mode (Agent v2).
// Referenced apps (from `@app:Name` mentions) are accessed by the
// agent via tool calls with an `app_name` parameter — see
// resolveTargetAppPath in the local agent tools. handleLocalAgentStream
// injects a `<system-reminder>` into the user's latest message telling
// the agent which `app_name` values are valid.
if (isLocalAgentMode) {
// Check quota for Basic Agent mode (non-Pro users)
const isBasicAgentModeRequest = isBasicAgentMode(settings);
if (isBasicAgentModeRequest) {
......@@ -1284,6 +1319,7 @@ This conversation includes one or more image attachments. When the user uploads
dyadRequestId: dyadRequestId ?? "[no-request-id]",
messageOverride: isSummarizeIntent ? chatMessages : undefined,
settingsOverride: settings,
referencedApps: referencedAppsForAgent,
},
);
} finally {
......@@ -1368,7 +1404,7 @@ This conversation includes one or more image attachments. When the user uploads
});
fullResponse = result.fullResponse;
if (selectedChatMode !== "ask" && isTurboEditsV2Enabled(settings)) {
if (isTurboEditsV2Enabled(settings)) {
let issues = await dryRunSearchReplace({
fullResponse,
appPath: getDyadAppPath(updatedChat.app.path),
......@@ -1467,7 +1503,6 @@ ${formattedSearchReplaceIssues}`,
if (
!abortController.signal.aborted &&
selectedChatMode !== "ask" &&
hasUnclosedDyadWrite(fullResponse)
) {
let continuationAttempts = 0;
......@@ -1520,8 +1555,7 @@ ${formattedSearchReplaceIssues}`,
// because there's going to be type errors since the packages aren't
// installed yet.
addDependencies.length === 0 &&
settings.enableAutoFixProblems &&
selectedChatMode !== "ask"
settings.enableAutoFixProblems
) {
try {
// IF auto-fix is enabled
......
......@@ -26,7 +26,7 @@ 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";
import { isTurboEditsV2Enabled } from "@/lib/schemas";
import { isLocalAgentBackedMode, isTurboEditsV2Enabled } from "@/lib/schemas";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { resolveChatModeForTurn } from "./chat_mode_resolution";
......@@ -145,14 +145,21 @@ export function registerTokenCountHandlers() {
);
}
// Extract codebases for mentioned apps
// Agent/ask/plan modes reach referenced apps via tool calls rather than
// injecting full codebases into the prompt, so mentioned apps contribute
// ~0 tokens upfront. Match the extraction behavior in chat_stream_handlers
// so the UI estimate tracks what's actually sent.
const willUseLocalAgentStream = isLocalAgentBackedMode(
settings.selectedChatMode,
);
let mentionedAppsTokens = 0;
if (!willUseLocalAgentStream) {
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(
......@@ -167,6 +174,7 @@ export function registerTokenCountHandlers() {
`Extracted ${mentionedAppsCodebases.length} mentioned app codebases, tokens: ${mentionedAppsTokens}`,
);
}
}
// Calculate total tokens
const totalTokens =
......
......@@ -6,16 +6,24 @@ import log from "electron-log";
const logger = log.scope("mention_apps");
// Helper function to extract codebases from mentioned apps
export async function extractMentionedAppsCodebases(
export interface MentionedAppReference {
appName: string;
appPath: string;
}
export interface MentionedAppCodebaseEntry extends MentionedAppReference {
codebaseInfo: string;
files: CodebaseFile[];
}
async function resolveMentionedApps(
mentionedAppNames: string[],
excludeCurrentAppId?: number,
): Promise<{ appName: string; codebaseInfo: string; files: CodebaseFile[] }[]> {
) {
if (mentionedAppNames.length === 0) {
return [];
}
// Get all apps
const allApps = await db.query.apps.findMany();
const mentionedApps = allApps.filter(
......@@ -25,13 +33,58 @@ export async function extractMentionedAppsCodebases(
) && app.id !== excludeCurrentAppId,
);
const results: {
appName: string;
codebaseInfo: string;
files: CodebaseFile[];
}[] = [];
// Deduplicate by case-insensitive name: referenced apps are keyed by name
// downstream (e.g., AgentContext.referencedApps Map), so two apps sharing a
// name would silently collide. Keep the first match and warn.
const dedupedApps: typeof mentionedApps = [];
const seenNames = new Set<string>();
for (const app of mentionedApps) {
const key = app.name.toLowerCase();
if (seenNames.has(key)) {
logger.warn(
`Multiple apps share the name "${app.name}"; skipping duplicate (app id: ${app.id}). Rename apps to disambiguate references.`,
);
continue;
}
seenNames.add(key);
dedupedApps.push(app);
}
return dedupedApps;
}
/**
* Lightweight resolver for `@app:Name` mentions. Returns only name/path pairs
* without reading any file contents — use this when the caller just needs
* to expose referenced apps to on-demand tools (agent/ask/plan modes).
*/
export async function extractMentionedAppsReferences(
mentionedAppNames: string[],
excludeCurrentAppId?: number,
): Promise<MentionedAppReference[]> {
const dedupedApps = await resolveMentionedApps(
mentionedAppNames,
excludeCurrentAppId,
);
return dedupedApps.map((app) => ({
appName: app.name,
appPath: getDyadAppPath(app.path),
}));
}
// Helper function to extract codebases from mentioned apps
export async function extractMentionedAppsCodebases(
mentionedAppNames: string[],
excludeCurrentAppId?: number,
): Promise<MentionedAppCodebaseEntry[]> {
const dedupedApps = await resolveMentionedApps(
mentionedAppNames,
excludeCurrentAppId,
);
const results: MentionedAppCodebaseEntry[] = [];
for (const app of dedupedApps) {
try {
const appPath = getDyadAppPath(app.path);
const chatContext = validateChatContext(app.chatContext);
......@@ -43,6 +96,7 @@ export async function extractMentionedAppsCodebases(
results.push({
appName: app.name,
appPath,
codebaseInfo: formattedOutput,
files,
});
......
......@@ -163,6 +163,17 @@ export type StoredChatMode = z.infer<typeof StoredChatModeSchema>;
export const ChatModeSchema = z.enum(["build", "ask", "local-agent", "plan"]);
export type ChatMode = z.infer<typeof ChatModeSchema>;
/**
* Modes that stream through the local agent (tool-calling) path rather than
* the build-mode path that injects full codebases into the prompt. Keep this
* in sync with the chat-stream and token-count handlers: whenever a new mode
* routes through the local agent, add it here so the token estimate matches
* what's actually sent to the model.
*/
export function isLocalAgentBackedMode(mode: ChatMode | undefined): boolean {
return mode === "local-agent" || mode === "ask" || mode === "plan";
}
export const GitHubSecretsSchema = z.object({
accessToken: SecretSchema.nullable(),
});
......
......@@ -228,6 +228,34 @@ function buildChatMessageHistory(
);
}
/**
* Append a `<system-reminder>` to the latest user message listing referenced
* apps so the agent knows which `app_name` values it can pass to read-only
* tools (`read_file`, `list_files`, `grep`, `code_search`). Mutates the last
* user message in-place to avoid copying unrelated parts of the history.
*/
function injectReferencedAppsReminder(
messageHistory: ModelMessage[],
referencedApps: readonly { appName: string }[],
): void {
const list = referencedApps.map(({ appName }) => `\`${appName}\``).join(", ");
const reminder = `\n\n<system-reminder>\nThe user has mentioned the following apps in their prompt: ${list}. These apps are separate from the current app and are READ-ONLY. To inspect them, pass the app name as the \`app_name\` parameter to read-only tools (\`read_file\`, \`list_files\`, \`grep\`, \`code_search\`); matching is case-insensitive. Write tools cannot target these apps. Omit \`app_name\` to operate on the current app.\n</system-reminder>`;
for (let i = messageHistory.length - 1; i >= 0; i--) {
const msg = messageHistory[i];
if (msg.role !== "user") continue;
if (typeof msg.content === "string") {
messageHistory[i] = { ...msg, content: msg.content + reminder };
} else {
messageHistory[i] = {
...msg,
content: [...msg.content, { type: "text", text: reminder }],
};
}
return;
}
}
function getMidTurnCompactionSummaryIds(
chatMessages: Array<{
id: number;
......@@ -272,6 +300,7 @@ export async function handleLocalAgentStream(
planModeOnly = false,
messageOverride,
settingsOverride,
referencedApps = [],
}: {
placeholderMessageId: number;
systemPrompt: string;
......@@ -292,6 +321,14 @@ export async function handleLocalAgentStream(
*/
messageOverride?: ModelMessage[];
settingsOverride?: UserSettings;
/**
* Apps referenced via `@app:Name` mentions in the user's prompt.
* Read-only tools can target these via an `app_name` parameter.
*/
referencedApps?: {
appName: string;
appPath: string;
}[];
},
): Promise<boolean> {
const settings = settingsOverride ?? readSettings();
......@@ -332,10 +369,13 @@ export async function handleLocalAgentStream(
!isDyadProEnabled(settings) &&
!isBasicAgentMode(settings)
) {
const errorMessage =
referencedApps.length > 0
? "Referencing other apps (@app:Name) in local-agent mode requires Dyad Pro. Please enable Dyad Pro in Settings → Pro."
: "Agent v2 requires Dyad Pro. Please enable Dyad Pro in Settings → Pro.";
safeSend(event.sender, "chat:response:error", {
chatId: req.chatId,
error:
"Agent v2 requires Dyad Pro. Please enable Dyad Pro in Settings → Pro.",
error: errorMessage,
});
return false;
}
......@@ -506,10 +546,14 @@ export async function handleLocalAgentStream(
// Build tool execute context
const fileEditTracker: FileEditTracker = Object.create(null);
const referencedAppsMap = new Map(
referencedApps.map((ref) => [ref.appName.toLowerCase(), ref.appPath]),
);
const ctx: AgentContext = {
event,
appId: chat.app.id,
appPath,
referencedApps: referencedAppsMap,
chatId: chat.id,
supabaseProjectId: chat.app.supabaseProjectId,
supabaseOrganizationSlug: chat.app.supabaseOrganizationSlug,
......@@ -597,6 +641,13 @@ export async function handleLocalAgentStream(
? messageOverride
: buildChatMessageHistory(chat.messages);
// Inject the referenced-apps manifest into the user's latest message as a
// `<system-reminder>` block (instead of appending it to the system prompt)
// so the system prompt stays static and cacheable.
if (referencedApps.length > 0) {
injectReferencedAppsReminder(messageHistory, referencedApps);
}
// Used to swap out pre-compaction history while preserving in-flight turn steps.
let baseMessageHistoryCount = messageHistory.length;
let compactBeforeNextStep = false;
......@@ -771,6 +822,16 @@ export async function handleLocalAgentStream(
excludeMessageIds: new Set([placeholderMessageId]),
},
);
// The referenced-apps reminder lives only in-memory on the
// latest user message and is not persisted, so rebuilding
// history from the DB drops it. Re-inject so post-compaction
// tool steps keep the explicit app_name allow-list.
if (referencedApps.length > 0) {
injectReferencedAppsReminder(
compactedMessageHistory,
referencedApps,
);
}
baseMessageHistoryCount = compactedMessageHistory.length;
// The compacted history includes the compaction summary, but the
// AI SDK's initialMessages does not. Track the delta so we can
......
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { codeSearchTool } from "./code_search";
import type { AgentContext } from "./types";
vi.mock("electron-log", () => ({
default: {
scope: () => ({
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
},
}));
const engineFetchMock = vi.fn();
vi.mock("./engine_fetch", () => ({
engineFetch: (...args: any[]) => engineFetchMock(...args),
}));
function mockEngineResponse(relevantFiles: string[]) {
engineFetchMock.mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
text: async () => "",
json: async () => ({ relevantFiles }),
} as any);
}
describe("codeSearchTool", () => {
let testDir: string;
let otherAppDir: string;
let mockContext: AgentContext;
beforeEach(async () => {
testDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), "code-search-test-"),
);
otherAppDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), "code-search-other-"),
);
await fs.promises.writeFile(
path.join(testDir, "current.ts"),
`export const foo = "current-app-file";`,
);
await fs.promises.writeFile(
path.join(otherAppDir, "other.ts"),
`export const bar = "other-app-file";`,
);
mockContext = {
event: {} as any,
appId: 1,
appPath: testDir,
referencedApps: new Map(),
chatId: 1,
supabaseProjectId: null,
supabaseOrganizationSlug: null,
neonProjectId: null,
neonActiveBranchId: null,
frameworkType: null,
messageId: 1,
isSharedModulesChanged: false,
isDyadPro: true,
todos: [],
dyadRequestId: "test-request",
fileEditTracker: {},
onXmlStream: vi.fn(),
onXmlComplete: vi.fn(),
requireConsent: vi.fn().mockResolvedValue(true),
appendUserMessage: vi.fn(),
onUpdateTodos: vi.fn(),
};
engineFetchMock.mockReset();
});
afterEach(async () => {
await fs.promises.rm(testDir, { recursive: true, force: true });
await fs.promises.rm(otherAppDir, { recursive: true, force: true });
vi.clearAllMocks();
});
describe("schema", () => {
it("has the correct name", () => {
expect(codeSearchTool.name).toBe("code_search");
});
it("accepts optional app_name", () => {
const parsed = codeSearchTool.inputSchema.parse({
query: "foo",
app_name: "other-app",
});
expect(parsed.app_name).toBe("other-app");
});
});
describe("getConsentPreview", () => {
it("omits app label when app_name is not provided", () => {
const preview = codeSearchTool.getConsentPreview?.({ query: "foo" });
expect(preview).toBe('Search for "foo"');
});
it("appends (app: <name>) when app_name is provided", () => {
const preview = codeSearchTool.getConsentPreview?.({
query: "foo",
app_name: "other-app",
});
expect(preview).toBe('Search for "foo" (app: other-app)');
});
});
describe("buildXml", () => {
it("includes app_name attribute while streaming when provided", () => {
const xml = codeSearchTool.buildXml?.(
{ query: "foo", app_name: "other-app" },
false,
);
expect(xml).toContain('app_name="other-app"');
expect(xml).toContain('query="foo"');
});
it("omits app_name attribute while streaming when not provided", () => {
const xml = codeSearchTool.buildXml?.({ query: "foo" }, false);
expect(xml).not.toContain("app_name=");
});
it("returns undefined when complete (execute handles final XML)", () => {
const xml = codeSearchTool.buildXml?.(
{ query: "foo", app_name: "other-app" },
true,
);
expect(xml).toBeUndefined();
});
});
describe("execute - app_name (referenced apps)", () => {
it("routes to the referenced app's path when app_name matches", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
mockEngineResponse(["other.ts"]);
await codeSearchTool.execute(
{ query: "bar", app_name: "other-app" },
mockContext,
);
expect(engineFetchMock).toHaveBeenCalledTimes(1);
const [, , opts] = engineFetchMock.mock.calls[0];
const body = JSON.parse(opts.body);
// The referenced app's file should be the one searched — not the current app's file.
const searchedPaths = body.filesContext.map(
(f: { path: string }) => f.path,
);
expect(searchedPaths).toContain("other.ts");
expect(searchedPaths).not.toContain("current.ts");
});
it("throws a clear error when app_name is not in the allow-list", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
await expect(
codeSearchTool.execute(
{ query: "bar", app_name: "does-not-exist" },
mockContext,
),
).rejects.toThrow(/Unknown app_name 'does-not-exist'/);
expect(engineFetchMock).not.toHaveBeenCalled();
});
it("emits app_name in the final XML output", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
mockEngineResponse(["other.ts"]);
await codeSearchTool.execute(
{ query: "bar", app_name: "other-app" },
mockContext,
);
const xmlCall = (mockContext.onXmlComplete as any).mock.calls[0]?.[0];
expect(xmlCall).toContain('app_name="other-app"');
expect(xmlCall).toContain('query="bar"');
});
it("omits app_name from final XML when not provided", async () => {
mockEngineResponse(["current.ts"]);
await codeSearchTool.execute({ query: "foo" }, mockContext);
const xmlCall = (mockContext.onXmlComplete as any).mock.calls[0]?.[0];
expect(xmlCall).not.toContain("app_name=");
});
});
});
......@@ -9,11 +9,21 @@ import {
import { extractCodebase } from "../../../../../../utils/codebase";
import { engineFetch } from "./engine_fetch";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import {
filterDyadInternalFiles,
resolveTargetAppPath,
} from "./resolve_app_context";
const logger = log.scope("code_search");
const codeSearchSchema = z.object({
query: z.string().describe("Search query to find relevant files"),
app_name: z
.string()
.optional()
.describe(
"Optional. Name of a referenced app (from `@app:Name` mentions in the user's prompt) to search in instead of the current app. Omit to search the current app.",
),
});
const FileContextSchema = z.object({
......@@ -25,15 +35,31 @@ const codeSearchResponseSchema = z.object({
relevantFiles: z.array(z.string()).describe("Paths of relevant files"),
});
type CodeSearchArgs = z.infer<typeof codeSearchSchema>;
function buildCodeSearchAttributes(args: Partial<CodeSearchArgs>) {
const queryAttr = args.query ? ` query="${escapeXmlAttr(args.query)}"` : "";
const appNameAttr = args.app_name
? ` app_name="${escapeXmlAttr(args.app_name)}"`
: "";
return `${queryAttr}${appNameAttr}`;
}
async function callCodeSearch(
params: {
query: string;
app_name?: string;
filesContext: z.infer<typeof FileContextSchema>[];
},
ctx: AgentContext,
): Promise<string[]> {
// Stream initial state to UI
ctx.onXmlStream(`<dyad-code-search query="${escapeXmlAttr(params.query)}">`);
ctx.onXmlStream(
`<dyad-code-search${buildCodeSearchAttributes({
query: params.query,
app_name: params.app_name,
})}>`,
);
const response = await engineFetch(ctx, "/tools/code-search", {
method: "POST",
......@@ -71,8 +97,7 @@ Skip this tool for:
3. Simple symbol lookups (use \`grep\`)
`;
export const codeSearchTool: ToolDefinition<z.infer<typeof codeSearchSchema>> =
{
export const codeSearchTool: ToolDefinition<CodeSearchArgs> = {
name: "code_search",
description: DESCRIPTION,
inputSchema: codeSearchSchema,
......@@ -81,20 +106,24 @@ export const codeSearchTool: ToolDefinition<z.infer<typeof codeSearchSchema>> =
// Requires Dyad Pro engine API
isEnabled: (ctx) => ctx.isDyadPro,
getConsentPreview: (args) => `Search for "${args.query}"`,
getConsentPreview: (args) =>
args.app_name
? `Search for "${args.query}" (app: ${args.app_name})`
: `Search for "${args.query}"`,
buildXml: (args, isComplete) => {
if (!args.query) return undefined;
if (isComplete) return undefined;
return `<dyad-code-search query="${escapeXmlAttr(args.query)}">Searching...`;
return `<dyad-code-search${buildCodeSearchAttributes(args)}>Searching...`;
},
execute: async (args, ctx: AgentContext) => {
logger.log(`Executing code search: ${args.query}`);
const targetAppPath = resolveTargetAppPath(ctx, args.app_name);
// Gather all files from the project
const { files } = await extractCodebase({
appPath: ctx.appPath,
appPath: targetAppPath,
chatContext: {
contextPaths: [],
smartContextAutoIncludes: [],
......@@ -102,8 +131,10 @@ export const codeSearchTool: ToolDefinition<z.infer<typeof codeSearchSchema>> =
},
});
const filteredFiles = filterDyadInternalFiles(files, args.app_name);
// Map files to FileContext format
const filesContext = files.map((file) => ({
const filesContext = filteredFiles.map((file) => ({
path: file.path,
content: file.content,
}));
......@@ -116,6 +147,7 @@ export const codeSearchTool: ToolDefinition<z.infer<typeof codeSearchSchema>> =
const relevantFiles = await callCodeSearch(
{
query: args.query,
app_name: args.app_name,
filesContext,
},
ctx,
......@@ -129,7 +161,7 @@ export const codeSearchTool: ToolDefinition<z.infer<typeof codeSearchSchema>> =
// Write final result to UI and DB with dyad-code-search wrapper
ctx.onXmlComplete(
`<dyad-code-search query="${escapeXmlAttr(args.query)}">${escapeXmlContent(resultText)}</dyad-code-search>`,
`<dyad-code-search${buildCodeSearchAttributes(args)}>${escapeXmlContent(resultText)}</dyad-code-search>`,
);
logger.log(`Code search completed for query: ${args.query}`);
......@@ -140,4 +172,4 @@ export const codeSearchTool: ToolDefinition<z.infer<typeof codeSearchSchema>> =
return `Found ${relevantFiles.length} relevant file(s):\n${resultText}`;
},
};
};
......@@ -41,6 +41,7 @@ describe("deleteFileTool", () => {
event: {} as any,
appId: 1,
appPath: "/test/app",
referencedApps: new Map(),
chatId: 1,
supabaseProjectId: null,
supabaseOrganizationSlug: null,
......
......@@ -88,6 +88,7 @@ function deepHello() {
event: {} as any,
appId: 1,
appPath: testDir,
referencedApps: new Map(),
chatId: 1,
supabaseProjectId: null,
supabaseOrganizationSlug: null,
......@@ -584,5 +585,69 @@ function deepHello() {
});
expect(preview).toBe('Search for "hello" including ignored files');
});
it("includes app_name in preview", () => {
const preview = grepTool.getConsentPreview?.({
query: "hello",
app_name: "other-app",
});
expect(preview).toBe('Search for "hello" (app: other-app)');
});
});
describe("app_name (referenced apps)", () => {
let otherAppDir: string;
beforeEach(async () => {
otherAppDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), "grep-other-app-"),
);
await fs.promises.writeFile(
path.join(otherAppDir, "only-in-other.ts"),
`const onlyInOther = "unique-other-app-token";`,
);
});
afterEach(async () => {
await fs.promises.rm(otherAppDir, { recursive: true, force: true });
});
it("searches the referenced app when app_name is provided", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
const result = await grepTool.execute(
{ query: "unique-other-app-token", app_name: "other-app" },
mockContext,
);
expect(result).toContain("only-in-other.ts");
expect(result).toContain("unique-other-app-token");
});
it("does not see current-app matches when app_name targets another app", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
const result = await grepTool.execute(
{ query: "goodbye", app_name: "other-app" },
mockContext,
);
expect(result).toBe("No matches found.");
});
it("throws on unknown app_name", async () => {
await expect(
grepTool.execute(
{ query: "hello", app_name: "does-not-exist" },
mockContext,
),
).rejects.toThrow(/Unknown app_name 'does-not-exist'/);
});
it("includes app_name in the final XML output", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
await grepTool.execute(
{ query: "unique-other-app-token", app_name: "other-app" },
mockContext,
);
const xmlCall = (mockContext.onXmlComplete as any).mock.calls[0]?.[0];
expect(xmlCall).toContain('app_name="other-app"');
});
});
});
......@@ -11,6 +11,10 @@ import {
MAX_FILE_SEARCH_SIZE,
RIPGREP_EXCLUDED_GLOBS,
} from "@/ipc/utils/ripgrep_utils";
import {
DYAD_INTERNAL_RIPGREP_EXCLUDE,
resolveTargetAppPath,
} from "./resolve_app_context";
import log from "electron-log";
const logger = log.scope("grep");
......@@ -21,6 +25,12 @@ const MAX_LINE_LENGTH = 500;
const grepSchema = z.object({
query: z.string().describe("The regex pattern to search for"),
app_name: z
.string()
.optional()
.describe(
"Optional. Name of a referenced app (from `@app:Name` mentions in the user's prompt) to search in instead of the current app. Omit to search the current app.",
),
include_pattern: z
.string()
.optional()
......@@ -66,6 +76,9 @@ function buildGrepAttributes(
if (args.query) {
attrs.push(`query="${escapeXmlAttr(args.query)}"`);
}
if (args.app_name) {
attrs.push(`app_name="${escapeXmlAttr(args.app_name)}"`);
}
if (args.include_pattern) {
attrs.push(`include="${escapeXmlAttr(args.include_pattern)}"`);
}
......@@ -103,6 +116,7 @@ async function runRipgrep({
includeIgnored,
caseSensitive,
maxMatches,
excludeDyadFolder,
}: {
appPath: string;
query: string;
......@@ -111,6 +125,7 @@ async function runRipgrep({
includeIgnored?: boolean;
caseSensitive?: boolean;
maxMatches?: number;
excludeDyadFolder?: boolean;
}): Promise<{ matches: RipgrepMatch[]; stoppedEarly: boolean }> {
return new Promise((resolve, reject) => {
const results: RipgrepMatch[] = [];
......@@ -149,6 +164,10 @@ async function runRipgrep({
: RIPGREP_EXCLUDED_GLOBS;
args.push(...exclusionGlobs.flatMap((glob) => ["--glob", glob]));
if (excludeDyadFolder) {
args.push("--glob", DYAD_INTERNAL_RIPGREP_EXCLUDE);
}
args.push("--", query, ".");
const rg = spawn(getRgExecutablePath(), args, { cwd: appPath });
......@@ -252,6 +271,9 @@ export const grepTool: ToolDefinition<z.infer<typeof grepSchema>> = {
if (args.include_ignored) {
preview += " including ignored files";
}
if (args.app_name) {
preview += ` (app: ${args.app_name})`;
}
return preview;
},
......@@ -267,17 +289,19 @@ export const grepTool: ToolDefinition<z.infer<typeof grepSchema>> = {
},
execute: async (args, ctx: AgentContext) => {
const targetAppPath = resolveTargetAppPath(ctx, args.app_name);
const includePatWasWildcard = args.include_pattern === "*";
const limit = Math.min(args.limit ?? DEFAULT_LIMIT, MAX_LIMIT);
const { matches: allMatches, stoppedEarly } = await runRipgrep({
appPath: ctx.appPath,
appPath: targetAppPath,
query: args.query,
includePat: args.include_pattern,
excludePat: args.exclude_pattern,
includeIgnored: args.include_ignored,
caseSensitive: args.case_sensitive,
maxMatches: args.include_ignored ? limit + 1 : undefined,
excludeDyadFolder: Boolean(args.app_name),
});
const totalCount = allMatches.length;
......
......@@ -19,14 +19,31 @@ vi.mock("electron-log", () => ({
describe("listFilesTool", () => {
let testDir: string;
let otherAppDir: string;
let mockContext: AgentContext;
beforeEach(async () => {
testDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), "list-files-test-"),
);
otherAppDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), "list-files-other-"),
);
await fs.promises.writeFile(path.join(testDir, "src.ts"), "source");
await fs.promises.writeFile(
path.join(testDir, "current-a.ts"),
"export const a = 1;",
);
await fs.promises.writeFile(
path.join(testDir, "current-b.ts"),
"export const b = 2;",
);
await fs.promises.mkdir(path.join(testDir, "nested"));
await fs.promises.writeFile(
path.join(testDir, "nested", "deep.ts"),
"export const deep = 3;",
);
await fs.promises.mkdir(path.join(testDir, "node_modules", "pkg"), {
recursive: true,
});
......@@ -45,10 +62,28 @@ describe("listFilesTool", () => {
"should stay hidden",
);
await fs.promises.writeFile(
path.join(otherAppDir, "other-a.ts"),
"export const otherA = 1;",
);
await fs.promises.mkdir(path.join(otherAppDir, "other-nested"));
await fs.promises.writeFile(
path.join(otherAppDir, "other-nested", "inside.ts"),
"export const inside = 2;",
);
// Hidden .dyad directory in the referenced app for include_ignored tests
await fs.promises.mkdir(path.join(otherAppDir, ".dyad"));
await fs.promises.writeFile(
path.join(otherAppDir, ".dyad", "rules.md"),
"# rules",
);
mockContext = {
event: {} as any,
appId: 1,
appPath: testDir,
referencedApps: new Map(),
chatId: 1,
supabaseProjectId: null,
supabaseOrganizationSlug: null,
......@@ -71,6 +106,7 @@ describe("listFilesTool", () => {
afterEach(async () => {
await fs.promises.rm(testDir, { recursive: true, force: true });
await fs.promises.rm(otherAppDir, { recursive: true, force: true });
vi.clearAllMocks();
});
......@@ -169,4 +205,137 @@ describe("listFilesTool", () => {
expect.stringContaining('truncated="true"'),
);
});
describe("schema", () => {
it("has the correct name", () => {
expect(listFilesTool.name).toBe("list_files");
});
it("accepts optional app_name", () => {
const parsed = listFilesTool.inputSchema.parse({
app_name: "other-app",
});
expect(parsed.app_name).toBe("other-app");
});
});
describe("getConsentPreview", () => {
it("omits app suffix when app_name is absent", () => {
expect(listFilesTool.getConsentPreview?.({ directory: "src" })).toBe(
"List src",
);
expect(listFilesTool.getConsentPreview?.({})).toBe("List all files");
});
it("uses consistent trailing (app: <name>) format for both dir and no-dir cases", () => {
expect(
listFilesTool.getConsentPreview?.({
directory: "src/components",
app_name: "other-app",
}),
).toBe("List src/components (app: other-app)");
expect(listFilesTool.getConsentPreview?.({ app_name: "other-app" })).toBe(
"List all files (app: other-app)",
);
});
it("includes recursive and include_ignored flags before app suffix", () => {
expect(
listFilesTool.getConsentPreview?.({
directory: "src",
recursive: true,
include_ignored: true,
app_name: "other-app",
}),
).toBe("List src (recursive) (include ignored) (app: other-app)");
});
});
describe("buildXml (streaming)", () => {
it("includes app_name attribute when provided", () => {
const xml = listFilesTool.buildXml?.(
{ directory: "src", app_name: "other-app" },
false,
);
expect(xml).toContain('app_name="other-app"');
expect(xml).toContain('directory="src"');
});
it("omits app_name attribute when not provided", () => {
const xml = listFilesTool.buildXml?.({ directory: "src" }, false);
expect(xml).not.toContain("app_name=");
});
it("returns undefined when complete (execute handles final XML)", () => {
const xml = listFilesTool.buildXml?.({ app_name: "other-app" }, true);
expect(xml).toBeUndefined();
});
});
describe("execute - app_name (referenced apps)", () => {
it("lists files from the referenced app's path (non-recursive)", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
const result = await listFilesTool.execute(
{ app_name: "other-app" },
mockContext,
);
expect(result).toContain("other-a.ts");
expect(result).not.toContain("current-a.ts");
});
it("lists files recursively from the referenced app", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
const result = await listFilesTool.execute(
{ app_name: "other-app", recursive: true },
mockContext,
);
expect(result).toContain("other-a.ts");
expect(result).toContain("other-nested/inside.ts");
});
it("throws a clear error when app_name is unknown", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
await expect(
listFilesTool.execute({ app_name: "does-not-exist" }, mockContext),
).rejects.toThrow(/Unknown app_name 'does-not-exist'/);
});
it("excludes .dyad files from referenced apps even when include_ignored is true", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
const result = await listFilesTool.execute(
{
app_name: "other-app",
directory: ".dyad",
include_ignored: true,
recursive: true,
},
mockContext,
);
expect(result).not.toContain(".dyad/rules.md");
});
it("excludes .dyad files from referenced apps in the default (non-include_ignored) listing", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
const result = await listFilesTool.execute(
{ app_name: "other-app", recursive: true },
mockContext,
);
expect(result).not.toContain(".dyad/rules.md");
expect(result).toContain("other-a.ts");
});
it("emits app_name attribute in the final XML output", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
await listFilesTool.execute({ app_name: "other-app" }, mockContext);
const xmlCall = (mockContext.onXmlComplete as any).mock.calls[0]?.[0];
expect(xmlCall).toContain('app_name="other-app"');
});
it("operates on current app when app_name is omitted even if referencedApps is populated", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
const result = await listFilesTool.execute({}, mockContext);
expect(result).toContain("current-a.ts");
expect(result).not.toContain("other-a.ts");
});
});
});
......@@ -10,11 +10,22 @@ import {
import { extractCodebase } from "../../../../../../utils/codebase";
import { resolveDirectoryWithinAppPath } from "./path_safety";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import {
DYAD_INTERNAL_GLOB,
filterDyadInternalFiles,
resolveTargetAppPath,
} from "./resolve_app_context";
const MAX_PATHS_TO_RETURN = 1_000;
const listFilesSchema = z.object({
directory: z.string().optional().describe("Optional subdirectory to list"),
app_name: z
.string()
.optional()
.describe(
"Optional. Name of a referenced app (from `@app:Name` mentions in the user's prompt) to list from instead of the current app. Omit to list the current app.",
),
recursive: z
.boolean()
.optional()
......@@ -51,6 +62,9 @@ function getXmlAttributes(args: ListFilesArgs, count?: number, total?: number) {
const dirAttr = args.directory
? ` directory="${escapeXmlAttr(args.directory)}"`
: "";
const appNameAttr = args.app_name
? ` app_name="${escapeXmlAttr(args.app_name)}"`
: "";
const recursiveAttr =
args.recursive !== undefined ? ` recursive="${args.recursive}"` : "";
const includeIgnoredAttr =
......@@ -61,7 +75,7 @@ function getXmlAttributes(args: ListFilesArgs, count?: number, total?: number) {
const totalAttr =
total !== undefined && total > (count ?? 0) ? ` total="${total}"` : "";
const truncatedAttr = totalAttr ? ` truncated="true"` : "";
return `${dirAttr}${recursiveAttr}${includeIgnoredAttr}${countAttr}${totalAttr}${truncatedAttr}`;
return `${dirAttr}${appNameAttr}${recursiveAttr}${includeIgnoredAttr}${countAttr}${totalAttr}${truncatedAttr}`;
}
export const listFilesTool: ToolDefinition<ListFilesArgs> = {
......@@ -74,9 +88,9 @@ export const listFilesTool: ToolDefinition<ListFilesArgs> = {
getConsentPreview: (args) => {
const recursiveText = args.recursive ? " (recursive)" : "";
const ignoredText = args.include_ignored ? " (include ignored)" : "";
return args.directory
? `List ${args.directory}${recursiveText}${ignoredText}`
: `List all files${recursiveText}${ignoredText}`;
const appSuffix = args.app_name ? ` (app: ${args.app_name})` : "";
const target = args.directory ?? "all files";
return `List ${target}${recursiveText}${ignoredText}${appSuffix}`;
},
buildXml: (args, isComplete) => {
......@@ -87,11 +101,13 @@ export const listFilesTool: ToolDefinition<ListFilesArgs> = {
},
execute: async (args, ctx: AgentContext) => {
const targetAppPath = resolveTargetAppPath(ctx, args.app_name);
// Validate directory path to prevent path traversal attacks
let sanitizedDirectory: string | undefined;
if (args.directory) {
const relativePathFromApp = resolveDirectoryWithinAppPath({
appPath: ctx.appPath,
appPath: targetAppPath,
directory: args.directory,
});
......@@ -121,18 +137,21 @@ export const listFilesTool: ToolDefinition<ListFilesArgs> = {
let allPaths: ListedPath[];
if (args.include_ignored) {
const normalizedAppPath = ctx.appPath.replace(/\\/g, "/");
const normalizedAppPath = targetAppPath.replace(/\\/g, "/");
const globPattern = `${normalizedAppPath}/${globPath}`;
const ignoredGlobs = args.app_name
? ["**/.git", "**/.git/**", DYAD_INTERNAL_GLOB]
: ["**/.git", "**/.git/**"];
const ignoredPaths = await glob(globPattern, {
withFileTypes: true,
dot: true,
ignore: ["**/.git", "**/.git/**"],
ignore: ignoredGlobs,
});
allPaths = sortListedPaths(
ignoredPaths.map((entry) => ({
path: path
.relative(ctx.appPath, entry.fullpath())
.relative(targetAppPath, entry.fullpath())
.split(path.sep)
.join("/"),
isDirectory: entry.isDirectory(),
......@@ -140,7 +159,7 @@ export const listFilesTool: ToolDefinition<ListFilesArgs> = {
);
} else {
const { files } = await extractCodebase({
appPath: ctx.appPath,
appPath: targetAppPath,
chatContext: {
contextPaths: [{ globPath }],
smartContextAutoIncludes: [],
......@@ -148,9 +167,11 @@ export const listFilesTool: ToolDefinition<ListFilesArgs> = {
},
});
const filteredFiles = filterDyadInternalFiles(files, args.app_name);
// Build the list of file paths
allPaths = sortListedPaths(
files.map((file) => ({
filteredFiles.map((file) => ({
path: file.path,
isDirectory: false,
})),
......
......@@ -53,6 +53,7 @@ line 5`;
event: {} as any,
appId: 1,
appPath: testDir,
referencedApps: new Map(),
chatId: 1,
supabaseProjectId: null,
supabaseOrganizationSlug: null,
......@@ -435,5 +436,155 @@ line 5`;
expect(result).toContain("&lt;");
expect(result).toContain("&gt;");
});
it("includes app_name attribute when provided", () => {
const result = readFileTool.buildXml?.(
{ path: "src/App.tsx", app_name: "other-app" },
false,
);
expect(result).toBe(
'<dyad-read path="src/App.tsx" app_name="other-app"></dyad-read>',
);
});
});
describe("execute - app_name (referenced apps)", () => {
let otherAppDir: string;
beforeEach(async () => {
otherAppDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), "read-file-other-app-"),
);
await fs.promises.writeFile(
path.join(otherAppDir, "other.txt"),
"hello from the other app",
);
});
afterEach(async () => {
await fs.promises.rm(otherAppDir, { recursive: true, force: true });
});
it("reads from referenced app when app_name matches", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
const result = await readFileTool.execute(
{ path: "other.txt", app_name: "other-app" },
mockContext,
);
expect(result).toBe("hello from the other app");
});
it("reads from current app when app_name is omitted even if referencedApps is populated", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
const result = await readFileTool.execute(
{ path: "test.txt" },
mockContext,
);
expect(result).toBe(testFileContent);
});
it("throws a clear error when app_name is not in the allow-list", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
await expect(
readFileTool.execute(
{ path: "other.txt", app_name: "does-not-exist" },
mockContext,
),
).rejects.toThrow(/Unknown app_name 'does-not-exist'/);
});
it("error lists available referenced apps", async () => {
mockContext.referencedApps.set("app-a", otherAppDir);
mockContext.referencedApps.set("app-b", otherAppDir);
await expect(
readFileTool.execute(
{ path: "other.txt", app_name: "nope" },
mockContext,
),
).rejects.toThrow(/app-a, app-b/);
});
it("error indicates none available when referencedApps is empty", async () => {
await expect(
readFileTool.execute(
{ path: "other.txt", app_name: "whatever" },
mockContext,
),
).rejects.toThrow(/\(none available\)/);
});
it("file-not-found error includes app_name when reading from a referenced app", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
await expect(
readFileTool.execute(
{ path: "missing.txt", app_name: "other-app" },
mockContext,
),
).rejects.toThrow("File does not exist: missing.txt (in app: other-app)");
});
it("blocks .dyad/ paths when targeting a referenced app", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
await expect(
readFileTool.execute(
{ path: ".dyad/chats/secret.md", app_name: "other-app" },
mockContext,
),
).rejects.toThrow(/Cannot read \.dyad\/ paths from referenced apps/);
});
it("blocks .dyad/ paths reached via traversal aliases (e.g. src/../.dyad/...)", async () => {
mockContext.referencedApps.set("other-app", otherAppDir);
const dyadDir = path.join(otherAppDir, ".dyad");
await fs.promises.mkdir(dyadDir, { recursive: true });
await fs.promises.writeFile(
path.join(dyadDir, "secret.md"),
"should not be exposed",
);
await fs.promises.mkdir(path.join(otherAppDir, "src"), {
recursive: true,
});
await expect(
readFileTool.execute(
{ path: "src/../.dyad/secret.md", app_name: "other-app" },
mockContext,
),
).rejects.toThrow(/Cannot read \.dyad\/ paths from referenced apps/);
});
it("allows .dyad/ paths on the current app (no app_name)", async () => {
const dyadDir = path.join(testDir, ".dyad");
await fs.promises.mkdir(dyadDir, { recursive: true });
await fs.promises.writeFile(
path.join(dyadDir, "notes.md"),
"local dyad metadata",
);
const result = await readFileTool.execute(
{ path: ".dyad/notes.md" },
mockContext,
);
expect(result).toBe("local dyad metadata");
});
});
describe("getConsentPreview with app_name", () => {
it("prefixes the location with the app_name", () => {
const preview = readFileTool.getConsentPreview?.({
path: "src/App.tsx",
app_name: "other-app",
});
expect(preview).toBe("Read other-app:src/App.tsx");
});
it("prefixes the location when line range is provided", () => {
const preview = readFileTool.getConsentPreview?.({
path: "src/App.tsx",
app_name: "other-app",
start_line_one_indexed: 10,
end_line_one_indexed_inclusive: 50,
});
expect(preview).toBe("Read other-app:src/App.tsx (lines 10-50)");
});
});
});
......@@ -3,12 +3,22 @@ import { z } from "zod";
import { ToolDefinition, AgentContext, escapeXmlAttr } from "./types";
import { safeJoin } from "@/ipc/utils/path_utils";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import {
assertDyadInternalAccessAllowed,
resolveTargetAppPath,
} from "./resolve_app_context";
const readFile = fs.promises.readFile;
const readFileSchema = z
.object({
path: z.string().describe("The file path to read"),
app_name: z
.string()
.optional()
.describe(
"Optional. Name of a referenced app (from `@app:Name` mentions in the user's prompt) to read from instead of the current app. Omit to read from the current app.",
),
start_line_one_indexed: z
.number()
.int()
......@@ -51,21 +61,27 @@ export const readFileTool: ToolDefinition<z.infer<typeof readFileSchema>> = {
defaultConsent: "always",
getConsentPreview: (args) => {
const location = args.app_name
? `${args.app_name}:${args.path}`
: args.path;
const start = args.start_line_one_indexed;
const end = args.end_line_one_indexed_inclusive;
if (start != null && end != null) {
return `Read ${args.path} (lines ${start}-${end})`;
return `Read ${location} (lines ${start}-${end})`;
} else if (start != null) {
return `Read ${args.path} (from line ${start})`;
return `Read ${location} (from line ${start})`;
} else if (end != null) {
return `Read ${args.path} (to line ${end})`;
return `Read ${location} (to line ${end})`;
}
return `Read ${args.path}`;
return `Read ${location}`;
},
buildXml: (args, _isComplete) => {
if (!args.path) return undefined;
const attrs = [`path="${escapeXmlAttr(args.path)}"`];
if (args.app_name) {
attrs.push(`app_name="${escapeXmlAttr(args.app_name)}"`);
}
if (args.start_line_one_indexed != null) {
attrs.push(
`start_line="${escapeXmlAttr(String(args.start_line_one_indexed))}"`,
......@@ -80,11 +96,20 @@ export const readFileTool: ToolDefinition<z.infer<typeof readFileSchema>> = {
},
execute: async (args, ctx: AgentContext) => {
const fullFilePath = safeJoin(ctx.appPath, args.path);
const targetAppPath = resolveTargetAppPath(ctx, args.app_name);
const fullFilePath = safeJoin(targetAppPath, args.path);
assertDyadInternalAccessAllowed({
targetAppPath,
fullFilePath,
appName: args.app_name,
});
if (!fs.existsSync(fullFilePath)) {
const appContext = args.app_name ? ` (in app: ${args.app_name})` : "";
throw new DyadError(
`File does not exist: ${args.path}`,
`File does not exist: ${args.path}${appContext}`,
DyadErrorKind.NotFound,
);
}
......
import path from "node:path";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import type { AgentContext } from "./types";
/**
* Resolve the app path a read-only tool should target.
*
* - Omitted `appName` → current app (`ctx.appPath`).
* - Provided `appName` → must match a referenced app from the current turn's
* `@app:Name` mentions. Any other value is rejected.
*
* Write tools do not call this — they operate only on `ctx.appPath` so that
* referenced apps remain structurally unreachable for modification.
*/
export function resolveTargetAppPath(
ctx: AgentContext,
appName: string | undefined,
): string {
if (!appName) {
return ctx.appPath;
}
const appPath = ctx.referencedApps.get(appName.toLowerCase());
if (appPath) {
return appPath;
}
const available = [...ctx.referencedApps.keys()];
const availableStr =
available.length > 0 ? available.join(", ") : "(none available)";
throw new DyadError(
`Unknown app_name '${appName}'. Available referenced apps: ${availableStr}`,
DyadErrorKind.NotFound,
);
}
/**
* Glob pattern for `.dyad/` internals, for use in the node `glob` library's
* ignore list.
*
* A referenced app's `.dyad/` folder (rules, chat history, snapshots, etc.) is
* not part of the `@app:Name` reference contract and must not be exposed to
* read-only tools when targeting another app.
*/
export const DYAD_INTERNAL_GLOB = "**/.dyad/**";
/**
* Negated glob for ripgrep's `--glob` flag, excluding `.dyad/` at the app root
* (ripgrep globs are relative to cwd, which is the target app path).
*/
export const DYAD_INTERNAL_RIPGREP_EXCLUDE = "!.dyad/**";
/**
* Is `relativePath` inside a `.dyad/` folder at the app root?
*
* Accepts slashes in either direction and a leading `./`; callers should pass a
* path already resolved relative to the app root (so traversal aliases like
* `src/../.dyad/...` normalize correctly before being checked).
*/
export function isDyadInternalPath(relativePath: string): boolean {
const normalized = relativePath.replace(/\\/g, "/").replace(/^\.\//, "");
return normalized.split("/")[0] === ".dyad";
}
/**
* Strip `.dyad/` entries from a file list when targeting a referenced app.
* No-op for the current app (`appName` omitted) — the user's own `.dyad/`
* internals are always visible to them.
*/
export function filterDyadInternalFiles<T extends { path: string }>(
files: T[],
appName: string | undefined,
): T[] {
if (!appName) {
return files;
}
return files.filter((file) => !isDyadInternalPath(file.path));
}
/**
* Throw if a resolved path inside a referenced app points into its `.dyad/`
* folder. No-op when `appName` is omitted (current app). The relative path is
* computed from the resolved `fullFilePath`, so normalized traversal aliases
* (e.g. `src/../.dyad/...`) are caught.
*/
export function assertDyadInternalAccessAllowed({
targetAppPath,
fullFilePath,
appName,
}: {
targetAppPath: string;
fullFilePath: string;
appName: string | undefined;
}): void {
if (!appName) {
return;
}
const relativeFromApp = path.relative(targetAppPath, fullFilePath);
if (isDyadInternalPath(relativeFromApp)) {
throw new DyadError(
`Cannot read .dyad/ paths from referenced apps — these files are not part of the @app reference contract.`,
DyadErrorKind.Validation,
);
}
}
......@@ -40,6 +40,7 @@ describe("searchReplaceTool", () => {
event: {} as any,
appId: 1,
appPath: "/test/app",
referencedApps: new Map(),
chatId: 1,
supabaseProjectId: null,
supabaseOrganizationSlug: null,
......
......@@ -45,6 +45,14 @@ export interface AgentContext {
event: IpcMainInvokeEvent;
appId: number;
appPath: string;
/**
* Apps referenced via `@app:Name` in the current turn. Read-only tools
* can target these via an `app_name` parameter; write tools cannot reach them.
* Keyed by lowercased app name so lookups are case-insensitive (matching
* the mention-extraction pipeline in `mention_apps.ts`). Value is the
* absolute app path.
*/
referencedApps: Map<string, string>;
chatId: number;
supabaseProjectId: string | null;
supabaseOrganizationSlug: string | null;
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论