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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user