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:
227
src/__tests__/mention_apps.test.ts
Normal file
227
src/__tests__/mention_apps.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
285
src/components/chat/LexicalChatInput.tsx
Normal file
285
src/components/chat/LexicalChatInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -152,6 +152,7 @@ export interface TokenCountResult {
|
||||
totalTokens: number;
|
||||
messageHistoryTokens: number;
|
||||
codebaseTokens: number;
|
||||
mentionedAppsTokens: number;
|
||||
inputTokens: number;
|
||||
systemPromptTokens: number;
|
||||
contextWindow: number;
|
||||
|
||||
53
src/ipc/utils/mention_apps.ts
Normal file
53
src/ipc/utils/mention_apps.ts
Normal 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;
|
||||
}
|
||||
13
src/shared/parse_mention_apps.ts
Normal file
13
src/shared/parse_mention_apps.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user