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

@@ -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;
}