Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions apps/web/client/src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { api } from '@/trpc/server';
import { trackEvent } from '@/utils/analytics/server';
import { convertToStreamMessages, getToolSetFromType } from '@onlook/ai';
import { toDbMessage } from '@onlook/db';
import { ChatType, type ChatMessage, type ChatMetadata } from '@onlook/models';
import { stepCountIs, streamText } from 'ai';
import { type NextRequest } from 'next/server';
Expand Down Expand Up @@ -135,6 +137,18 @@ export const streamResponse = async (req: NextRequest, userId: string) => {
usage: part.type === 'finish-step' ? part.usage : undefined,
} satisfies ChatMetadata;
},
onFinish: async ({ messages: finalMessages }) => {
const messagesToStore = finalMessages
.filter(msg =>
(msg.role === 'user' || msg.role === 'assistant')
)
.map(msg => toDbMessage(msg, conversationId));

await api.chat.message.replaceConversationMessages({
conversationId,
messages: messagesToStore,
});
},
onError: errorHandler,
}
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { useChatContext } from '@/app/project/[id]/_hooks/use-chat';
import { useEditorEngine } from '@/components/store/editor';
import { transKeys } from '@/i18n/keys';
import { ChatType, EditorTabValue } from '@onlook/models';
Expand All @@ -23,18 +22,18 @@ export const OverlayChatInput = observer(({
const t = useTranslations();
const [isComposing, setIsComposing] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { sendMessageToChat } = useChatContext();

const handleSubmit = async () => {
try {
toast.promise(async () => {
editorEngine.state.rightPanelTab = EditorTabValue.CHAT;
await editorEngine.chat.addEditMessage(inputState.value);
sendMessageToChat(ChatType.EDIT);
setInputState(DEFAULT_INPUT_STATE);
} catch (error) {
console.error('Error sending message', error);
toast.error('Failed to send message. Please try again.');
}
void editorEngine.chat.sendMessage(inputState.value, ChatType.EDIT);
}, {
loading: 'Sending message...',
success: 'Message sent',
error: 'Failed to send message',
});

setInputState(DEFAULT_INPUT_STATE);
};

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { useChatContext } from '@/app/project/[id]/_hooks/use-chat';
import { useEditorEngine } from '@/components/store/editor';
import { api } from '@/trpc/react';
import { EditorMode } from '@onlook/models';
Expand All @@ -11,7 +10,6 @@ import { DEFAULT_INPUT_STATE } from './helpers';
export const OverlayButtons = observer(() => {
const editorEngine = useEditorEngine();
const { data: settings } = api.user.settings.get.useQuery();
const { isWaiting } = useChatContext();
const [inputState, setInputState] = useState(DEFAULT_INPUT_STATE);
const prevChatPositionRef = useRef<{ x: number; y: number } | null>(null);

Expand All @@ -22,7 +20,7 @@ export const OverlayButtons = observer(() => {
const shouldHideButton =
!selectedRect ||
isPreviewMode ||
isWaiting ||
editorEngine.chat.isStreaming ||
!settings?.chat?.showMiniChat;

useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEditorEngine } from '@/components/store/editor';
import { type ChatMessage } from '@onlook/models/chat';
import { Tooltip, TooltipContent, TooltipTrigger } from '@onlook/ui/tooltip';
import { cn } from '@onlook/ui/utils';
import { observer } from 'mobx-react-lite';
Expand Down Expand Up @@ -29,7 +30,7 @@ export const ContextWheel = observer(({ className }: ContextWheelProps) => {

// Fallback to estimation if no usage data is available yet
const contextItems = editorEngine.chat.context.context;
const messages = editorEngine.chat.conversation.current?.messages || [];
const messages: ChatMessage[] = [];

let estimatedTokens = 0;

Expand Down Expand Up @@ -67,7 +68,7 @@ export const ContextWheel = observer(({ className }: ContextWheelProps) => {
const percentage = Math.min((estimatedTokens / maxContextTokens) * 100, 100);

return Math.round(percentage);
}, [(editorEngine.chat.context as any).cumulativeUsage, editorEngine.chat.context.context, editorEngine.chat.conversation.current?.messages]);
}, [(editorEngine.chat.context as any).cumulativeUsage, editorEngine.chat.context.context]);

const getColorClass = (percentage: number) => {
if (percentage < 50) return 'text-gray-500';
Expand All @@ -89,7 +90,7 @@ export const ContextWheel = observer(({ className }: ContextWheelProps) => {
const strokeDashoffset = circumference - (contextPercentage / 100) * circumference;

const contextItems = editorEngine.chat.context.context;
const messages = editorEngine.chat.conversation.current?.messages || [];
const messages: ChatMessage[] = [];
const cumulativeUsage = (editorEngine.chat.context as any).cumulativeUsage;

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

🧩 Analysis chain

Must mark as Client Component (uses hooks, events, observer).

This file runs client-only code; without a client boundary it will fail in Next.js.

Apply at top:

+ 'use client';

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Looking for the target file (exact path)..."
target="apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/chat-input/index.tsx"
if [ -f "$target" ]; then
  echo "FOUND: $target"
  files=("$target")
else
  echo "Not found at exact path. Searching for 'chat-input/index.tsx' under apps/web/client/src/app..."
  mapfile -t files < <(rg --hidden --files apps/web/client/src/app 2>/dev/null | rg 'chat-input/index.tsx' || true)
  if [ ${#files[@]} -eq 0 ]; then
    echo "No matching files found. Listing top-level tree for apps/web/client/src/app/project (maxdepth 4) to help locate file..."
    if [ -d "apps/web/client/src/app/project" ]; then
      find apps/web/client/src/app/project -maxdepth 4 -type f | sed -n '1,200p'
    else
      echo "Directory apps/web/client/src/app/project does not exist."
    fi
    exit 0
  fi
fi

for file in "${files[@]}"; do
  echo
  echo "===== FILE: $file ====="
  echo "---- head (1..200) ----"
  sed -n '1,200p' "$file" || true
  echo
  echo "---- 'use client' directive (exact match) ----"
  rg -n "^['\"]use client['\"]" "$file" || echo "(none)"
  echo
  echo "---- client-only hooks / events / observer usage ----"
  rg -n -C2 --hidden -S '\b(useState|useEffect|useRef|useCallback|useMemo|useContext|useTransition|onClick|onSubmit|onChange|onBlur|observer)\b' "$file" || echo "(none found)"
  echo
  echo "---- imports referencing observer/mobx/react hooks ----"
  rg -n "import .*observer|from (\"|')mobx|mobx-react|mobx-react-lite|react-use|@hookstate" "$file" || echo "(none found)"
  echo "=============================="
done

echo
echo "---- Files under apps/web/client/src/app that include a top-level 'use client' directive ----"
rg -n --hidden "^['\"]use client['\"]" apps/web/client/src/app || echo "(none)"

Length of output: 20299


Add 'use client' at the top of this file (component uses React hooks, mobx observer, and browser/DOM events).

File: apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/chat-input/index.tsx

+ 'use client';
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
'use client';
🤖 Prompt for AI Agents
In
apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/chat-input/index.tsx
around lines 1 to 1, this component uses React hooks, mobx observer, and
browser/DOM events but is missing the required React Server Components
directive; add the line 'use client' as the very first statement in the file
(above all imports) so the component is executed on the client, then run a quick
build/type-check to ensure no other client-only usages are flagged.

import { useChatContext } from '@/app/project/[id]/_hooks/use-chat';
import type { SendMessage } from '@/app/project/[id]/_hooks/use-chat';
import { useEditorEngine } from '@/components/store/editor';
import { FOCUS_CHAT_INPUT_EVENT } from '@/components/store/editor/chat';
import { transKeys } from '@/i18n/keys';
import type { ChatMessage, ChatSuggestion } from '@onlook/models';
import { ChatType, EditorTabValue, type ImageMessageContext } from '@onlook/models';
import { MessageContextType } from '@onlook/models/chat';
import { Button } from '@onlook/ui/button';
Expand All @@ -21,21 +22,29 @@ import { Suggestions, type SuggestionsRef } from '../suggestions';
import { ActionButtons } from './action-buttons';
import { ChatModeToggle } from './chat-mode-toggle';

interface ChatInputProps {
messages: ChatMessage[];
suggestions: ChatSuggestion[];
isStreaming: boolean;
onStop: () => Promise<void>;
onSendMessage: SendMessage;
}

export const ChatInput = observer(({
inputValue,
setInputValue,
}: {
inputValue: string;
setInputValue: React.Dispatch<React.SetStateAction<string>>;
}) => {
const { sendMessageToChat, stop, isWaiting } = useChatContext();
messages,
suggestions,
isStreaming,
onStop,
onSendMessage,
}: ChatInputProps) => {
const editorEngine = useEditorEngine();
const t = useTranslations();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [isComposing, setIsComposing] = useState(false);
const [actionTooltipOpen, setActionTooltipOpen] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const chatMode = editorEngine.state.chatMode;
const [inputValue, setInputValue] = useState('');

const focusInput = () => {
requestAnimationFrame(() => {
Expand All @@ -44,10 +53,10 @@ export const ChatInput = observer(({
};

useEffect(() => {
if (textareaRef.current && !isWaiting) {
if (textareaRef.current && !isStreaming) {
focusInput();
}
}, [editorEngine.chat.conversation.current?.messages]);
}, [isStreaming, messages]);

useEffect(() => {
if (editorEngine.state.rightPanelTab === EditorTabValue.CHAT) {
Expand All @@ -57,7 +66,7 @@ export const ChatInput = observer(({

useEffect(() => {
const focusHandler = () => {
if (textareaRef.current && !isWaiting) {
if (textareaRef.current && !isStreaming) {
focusInput();
}
};
Expand All @@ -83,7 +92,7 @@ export const ChatInput = observer(({
return () => window.removeEventListener('keydown', handleGlobalKeyDown, true);
}, []);

const disabled = isWaiting
const disabled = isStreaming
const inputEmpty = !inputValue || inputValue.trim().length === 0;

function handleInput(e: React.ChangeEvent<HTMLTextAreaElement>) {
Expand Down Expand Up @@ -116,7 +125,7 @@ export const ChatInput = observer(({
}

if (!inputEmpty) {
sendMessage();
void sendMessage();
}
}
};
Expand All @@ -126,17 +135,13 @@ export const ChatInput = observer(({
console.warn('Empty message');
return;
}
if (isWaiting) {
if (isStreaming) {
console.warn('Already waiting for response');
return;
}
const savedInput = inputValue.trim();
try {
const message = chatMode === ChatType.ASK
? await editorEngine.chat.addAskMessage(savedInput)
: await editorEngine.chat.addEditMessage(savedInput);

await sendMessageToChat(chatMode);
await onSendMessage(savedInput, chatMode);
setInputValue('');
} catch (error) {
console.error('Error sending message', error);
Expand All @@ -162,7 +167,7 @@ export const ChatInput = observer(({
if (!file) {
continue;
}
handleImageEvent(file, 'Pasted image');
void handleImageEvent(file, 'Pasted image');
break;
}
}
Expand All @@ -179,7 +184,7 @@ export const ChatInput = observer(({
if (!file) {
continue;
}
handleImageEvent(file, 'Dropped image');
void handleImageEvent(file, 'Dropped image');
break;
}
}
Expand All @@ -198,14 +203,14 @@ export const ChatInput = observer(({
const reader = new FileReader();
reader.onload = async (event) => {
const compressedImage = await compressImageInBrowser(file);
const base64URL = compressedImage || (event.target?.result as string);
const base64URL = compressedImage ?? (event.target?.result as string);
const contextImage: ImageMessageContext = {
type: MessageContextType.IMAGE,
content: base64URL,
mimeType: file.type,
displayName: displayName ?? file.name,
};
editorEngine.chat.context.context.push(contextImage);
editorEngine.chat.context.addContexts([contextImage]);
};
reader.readAsDataURL(file);
};
Expand All @@ -218,15 +223,13 @@ export const ChatInput = observer(({

const { success, errorMessage } = validateImageLimit(currentImages, 1);
if (!success) {
toast.error(errorMessage);
return;
throw new Error(errorMessage);
}

const framesWithViews = editorEngine.frames.getAll().filter(f => !!f.view);

if (framesWithViews.length === 0) {
toast.error('No active frame available for screenshot');
return;
throw new Error('No active frame available for screenshot');
}

let screenshotData = null;
Expand All @@ -250,8 +253,7 @@ export const ChatInput = observer(({
}

if (!screenshotData) {
toast.error('Failed to capture screenshot. Please refresh the page and try again.');
return;
throw new Error('No screenshot data');
}

const contextImage: ImageMessageContext = {
Expand All @@ -260,10 +262,10 @@ export const ChatInput = observer(({
mimeType: mimeType,
displayName: 'Screenshot',
};
editorEngine.chat.context.context.push(contextImage);
editorEngine.chat.context.addContexts([contextImage]);
toast.success('Screenshot added to chat');
} catch (error) {
toast.error('Failed to capture screenshot. Please try again.');
toast.error('Failed to capture screenshot. Error: ' + error);
}
};

Expand Down Expand Up @@ -326,6 +328,8 @@ export const ChatInput = observer(({
>
<Suggestions
ref={suggestionRef}
suggestions={suggestions}
isStreaming={isStreaming}
disabled={disabled}
inputValue={inputValue}
setInput={(suggestion) => {
Expand Down Expand Up @@ -395,7 +399,7 @@ export const ChatInput = observer(({
handleImageEvent={handleImageEvent}
handleScreenshot={handleScreenshot}
/>
{isWaiting ? (
{isStreaming ? (
<Tooltip open={actionTooltipOpen} onOpenChange={setActionTooltipOpen}>
<TooltipTrigger asChild>
<Button
Expand All @@ -404,7 +408,7 @@ export const ChatInput = observer(({
className="text-smallPlus w-fit h-full py-0.5 px-2.5 text-primary"
onClick={() => {
setActionTooltipOpen(false);
stop();
void onStop();
}}
>
<Icons.Stop />
Expand All @@ -418,7 +422,7 @@ export const ChatInput = observer(({
variant={'secondary'}
className="text-smallPlus w-fit h-full py-0.5 px-2.5 text-primary"
disabled={inputEmpty || disabled}
onClick={sendMessage}
onClick={() => void sendMessage()}
>
<Icons.ArrowRight />
</Button>
Expand Down
Loading