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

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