Skip to content
Open
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
6 changes: 3 additions & 3 deletions apps/frontend/public/icons/connectors-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions apps/frontend/public/icons/parla-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
219 changes: 146 additions & 73 deletions apps/frontend/src/components/chat/chat-form/chat-form.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import React, {
type ChangeEvent,
type FormEvent,
type KeyboardEvent,
type MouseEvent,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useInferenceLoadingStatusStore } from "../../../store/use-inference-loading-status-store.ts";
import { SelectedChatItemsCollapsible } from "../selected-chat-items/selected-chat-items-collapsible.tsx";
import { ArrowWhiteRightIcon } from "../../primitives/icons/arrow-white-right-icon.tsx";
import { ChatStopGeneratingIcon } from "../../primitives/icons/chat-stop-generating-icon.tsx";
import { useChatStreamingStore } from "../../../store/use-chat-streaming-store.ts";
import { useUserFolderStore } from "../../../store/use-user-folder-store.ts";
import { useUserDocumentStore } from "../../../store/use-user-document-store.ts";
Expand All @@ -26,10 +27,35 @@ import { ContextPill } from "../../primitives/pill/context-pill.tsx";
import * as Sentry from "@sentry/react";
import { ExternalToolWarningBanner } from "./external-tool-warning-banner.tsx";
import { usePublicDocumentsStore } from "../../../store/use-public-documents-store.ts";
import { useCurrentChatIdStore } from "../../../store/current-chat-id-store.ts";
import { ChatSubmitButton } from "./chat-submit-button.tsx";

export const chatFormId = "chat-form";

export const ChatForm: React.FC = () => {
interface ChatFormHandle {
focus: () => void;
setContent: (content: string) => void;
}

let activeChatForm: ChatFormHandle | null = null;

export const focusChatForm = () => {
activeChatForm?.focus();
};

export const setChatInputContent = (content: string) => {
activeChatForm?.setContent(content);
};

interface ChatFormProps {
isCompact?: boolean;
onContentChange?: (content: string) => void;
}

export const ChatForm: React.FC<ChatFormProps> = ({
isCompact,
onContentChange,
}) => {
const { status, clearError, isLoading } = useInferenceLoadingStatusStore();
const { selectedUserChatFolders: selectedUserChatFolders } =
useUserFolderStore();
Expand All @@ -42,17 +68,60 @@ export const ChatForm: React.FC = () => {
const { isUploadingOver } = useFileUploadsStore();

const textareaRef = useRef<HTMLTextAreaElement>(null);
const shouldMoveCaretToEnd = useRef(false);
const [textareaContent, setTextareaContent] = useState("");
const { currentChatId, newChatCount } = useCurrentChatIdStore();

// Resize textarea on input
const handleTextAreaInput = () => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
setTextareaContent(textareaRef.current.value);
}
const handleTextAreaChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
setTextareaContent(event.target.value);
onContentChange?.(event.target.value);
};

const setContent = (content: string) => {
setTextareaContent(content);
onContentChange?.(content);
shouldMoveCaretToEnd.current = true;
textareaRef.current?.focus();
};

useLayoutEffect(() => {
const textareaElement = textareaRef.current;
if (!textareaElement) {
return;
}

textareaElement.style.height = "auto";
textareaElement.style.height = `${textareaElement.scrollHeight}px`;

if (shouldMoveCaretToEnd.current) {
shouldMoveCaretToEnd.current = false;
textareaElement.setSelectionRange(
textareaContent.length,
textareaContent.length,
);
}
}, [textareaContent, isCompact]);

useEffect(() => {
const handle: ChatFormHandle = {
focus: () => textareaRef.current?.focus(),
setContent,
};
activeChatForm = handle;
textareaRef.current?.focus();

return () => {
if (activeChatForm === handle) {
activeChatForm = null;
}
};
}, [currentChatId, isCompact]);

// Discard a leftover draft when a new chat is started
useEffect(() => {
setContent("");
}, [newChatCount]);

// Handle Enter key to submit the form
// and create a new line with Shift + Enter
const handleTextAreaKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
Expand All @@ -74,7 +143,6 @@ export const ChatForm: React.FC = () => {
event.preventDefault();

const form = event.currentTarget;
const textarea = textareaRef.current;

// Check if textarea only contains whitespace
const messageText = form.content.value.trim();
Expand All @@ -88,10 +156,8 @@ export const ChatForm: React.FC = () => {
showInfoMessage(null);

// Clear textarea on submit
if (textarea) {
textarea.value = "";
handleTextAreaInput(); // Reset height
}
setTextareaContent("");
onContentChange?.("");

const allowed_document_ids = [
...selectedUserChatDocuments.map(({ id }) => id),
Expand Down Expand Up @@ -165,78 +231,85 @@ export const ChatForm: React.FC = () => {
return Content["chat.textarea.placeholder"];
};

const contextPills = selectedChatTools.map((tool) => (
<ContextPill key={tool} tool={tool} onClose={() => toggleChatTool(tool)} />
));

return (
<form
onSubmit={handleSubmit}
className={`relative flex flex-col max-h-[290px] focus-visible:outline-2px hover:outline hover:outline-offset-[-2px] hover:outline-dunkelblau-100 border border-dunkelblau-100 rounded-[3px]
className={`relative flex flex-col max-h-[290px] focus-visible:outline-2px hover:outline hover:outline-offset-[-2px] hover:outline-dunkelblau-100 border border-dunkelblau-100 rounded-[3px]
${isWebSearchActive && "border-[2px] bg-hellblau-40 focus-visible:outline-3px hover:outline hover:outline-offset-[-1px]"}`}
id={chatFormId}
>
<SelectedChatItemsCollapsible />
<ExternalToolWarningBanner />

<div className="flex flex-col justify-between rounded-b-3px">
<div
className={`rounded-[1px] my-2 pt-1 mx-3 px-1 flex z-10
has-[textarea:focus]:outline
has-[textarea:focus]:outline-[2px]
has-[textarea:focus]:outline-offset-0
has-[textarea:focus]:outline-mittelblau-100
has-[textarea:active]:outline
has-[textarea:active]:outline-[2px]
has-[textarea:active]:outline-offset-1
has-[textarea:active]:outline-dunkelblau-100
items-end
`}
>
<textarea
className={`w-full focus:outline-none min-h-6 max-h-32 resize-none overflow-y-auto text-base leading-6 text-dunkelblau-100 placeholder:text-dunkelblau-80`}
ref={textareaRef}
name="content"
rows={1}
required={true}
placeholder={getTextAreaPlaceholder()}
onKeyDown={handleTextAreaKeyDown}
onInput={handleTextAreaInput}
/>
</div>
<div className="pb-3 pt-1 px-4 flex w-full z-10 justify-between">
<div className="flex items-center gap-3">
{isCompact ? (
<div className="flex flex-col rounded-b-3px pt-[15px] pb-3 pl-3 pr-4">
{contextPills.length > 0 && (
<div className="items-center gap-2 pb-2 hidden md:flex flex-wrap">
{contextPills}
</div>
)}
<div className="flex items-center gap-1 w-full">
<ChatMenuToggleButton />
<div className="items-center gap-2 hidden md:flex">
{selectedChatTools.map((tool) => (
<ContextPill
key={tool}
tool={tool}
onClose={() => toggleChatTool(tool)}
/>
))}
<div className="rounded-[1px] flex z-10 has-[textarea:focus]:outline has-[textarea:focus]:outline-[2px] has-[textarea:focus]:outline-offset-0 has-[textarea:focus]:outline-mittelblau-100 has-[textarea:active]:outline has-[textarea:active]:outline-[2px] has-[textarea:active]:outline-offset-1 has-[textarea:active]:outline-dunkelblau-100 flex-1 min-w-0 px-1">
<textarea
className={`w-full focus:outline-none min-h-6 max-h-32 resize-none overflow-y-auto text-base leading-6 text-dunkelblau-100 placeholder:text-dunkelblau-80`}
ref={textareaRef}
name="content"
rows={1}
required={true}
value={textareaContent}
placeholder={getTextAreaPlaceholder()}
onKeyDown={handleTextAreaKeyDown}
onChange={handleTextAreaChange}
/>
</div>
<div className="flex items-center gap-2.5 shrink-0">
<LlmModelToggleButton />
<ChatSubmitButton
showLoading={isLoading() && !hasError}
handleStop={handleStop}
isDisabled={!textareaContent.trim() || !isUploadingOver()}
/>
</div>
</div>
<div className="flex items-center gap-3">
<LlmModelToggleButton />
{isLoading() && !hasError ? (
<button
type="button"
aria-label={Content["chat.stopGeneratingButton.ariaLabel"]}
onClick={handleStop}
className="rounded-3px size-8 bg-hellblau-50 flex items-center justify-center shrink-0 hover:bg-hellblau-110 focus-visible:outline-2px"
>
<ChatStopGeneratingIcon />
</button>
) : (
<button
type="submit"
disabled={!textareaContent.trim() || !isUploadingOver()}
aria-label={Content["chat.sendButton.ariaLabel"]}
className={`rounded-3px size-8 bg-dunkelblau-100 disabled:bg-dunkelblau-30 p-1.5 hover:bg-dunkelblau-90 focus-visible:outline-2px`}
>
<ArrowWhiteRightIcon />
</button>
)}
</div>
) : (
<div className="flex flex-col justify-between rounded-b-3px">
<div className="rounded-[1px] flex z-10 has-[textarea:focus]:outline has-[textarea:focus]:outline-[2px] has-[textarea:focus]:outline-offset-0 has-[textarea:focus]:outline-mittelblau-100 has-[textarea:active]:outline has-[textarea:active]:outline-[2px] has-[textarea:active]:outline-offset-1 has-[textarea:active]:outline-dunkelblau-100 my-2 pt-1 mx-3 px-1 items-end">
<textarea
className={`w-full focus:outline-none min-h-6 max-h-32 resize-none overflow-y-auto text-base leading-6 text-dunkelblau-100 placeholder:text-dunkelblau-80`}
ref={textareaRef}
name="content"
rows={1}
required={true}
value={textareaContent}
placeholder={getTextAreaPlaceholder()}
onKeyDown={handleTextAreaKeyDown}
onChange={handleTextAreaChange}
/>
</div>
<div className="pb-3 pt-1 px-4 flex w-full z-10 justify-between">
<div className="flex items-center gap-3">
<ChatMenuToggleButton />
<div className="items-center gap-2 hidden md:flex">
{contextPills}
</div>
</div>
<div className="flex items-center gap-3">
<LlmModelToggleButton />
<ChatSubmitButton
showLoading={isLoading() && !hasError}
handleStop={handleStop}
isDisabled={!textareaContent.trim() || !isUploadingOver()}
/>
</div>
</div>
</div>
</div>
)}
</form>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import React from "react";
import Content from "../../../content.ts";
import { ArrowWhiteRightIcon } from "../../primitives/icons/arrow-white-right-icon.tsx";
import { ChatStopGeneratingIcon } from "../../primitives/icons/chat-stop-generating-icon.tsx";

interface ChatSubmitButtonProps {
showLoading: boolean;
handleStop: (event: React.MouseEvent<HTMLButtonElement>) => void;
isDisabled: boolean;
}

export const ChatSubmitButton: React.FC<ChatSubmitButtonProps> = ({
showLoading,
handleStop,
isDisabled,
}) => {
return showLoading ? (
<button
type="button"
aria-label={Content["chat.stopGeneratingButton.ariaLabel"]}
onClick={handleStop}
className="rounded-3px size-8 bg-hellblau-50 flex items-center justify-center shrink-0 hover:bg-hellblau-110 focus-visible:outline-2px"
>
<ChatStopGeneratingIcon />
</button>
) : (
<button
type="submit"
disabled={isDisabled}
aria-label={Content["chat.sendButton.ariaLabel"]}
className={`rounded-3px size-8 bg-dunkelblau-100 disabled:bg-dunkelblau-30 p-1.5 hover:bg-dunkelblau-90 focus-visible:outline-2px shrink-0`}
>
<ArrowWhiteRightIcon />
</button>
);
};
Loading