Wire event attachments into chat composer and messages

This commit is contained in:
Josh Hawkins 2026-04-08 17:07:33 -05:00
parent d62ae17fcc
commit 01519607de

View File

@ -3,16 +3,20 @@ import { Input } from "@/components/ui/input";
import { FaArrowUpLong } from "react-icons/fa6";
import { LuCircleAlert } from "react-icons/lu";
import { useTranslation } from "react-i18next";
import { useState, useCallback, useRef, useEffect } from "react";
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import axios from "axios";
import { ChatEventThumbnailsRow } from "@/components/chat/ChatEventThumbnailsRow";
import { MessageBubble } from "@/components/chat/ChatMessage";
import { ToolCallsGroup } from "@/components/chat/ToolCallsGroup";
import { ChatStartingState } from "@/components/chat/ChatStartingState";
import { ChatAttachmentChip } from "@/components/chat/ChatAttachmentChip";
import { ChatQuickReplies } from "@/components/chat/ChatQuickReplies";
import { ChatPaperclipButton } from "@/components/chat/ChatPaperclipButton";
import type { ChatMessage } from "@/types/chat";
import {
getEventIdsFromSearchObjectsToolCalls,
getFindSimilarObjectsFromToolCalls,
prependAttachment,
streamChatCompletion,
} from "@/utils/chatUtil";
@ -22,6 +26,7 @@ export default function ChatPage() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [attachedEventId, setAttachedEventId] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@ -75,12 +80,31 @@ export default function ChatPage() {
[isLoading, t],
);
const sendMessage = useCallback(() => {
const text = input.trim();
if (!text || isLoading) return;
setInput("");
submitConversation([...messages, { role: "user", content: text }]);
}, [input, isLoading, messages, submitConversation]);
const recentEventIds = useMemo(() => {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role !== "assistant" || !msg.toolCalls) continue;
const similar = getFindSimilarObjectsFromToolCalls(msg.toolCalls);
if (similar) return similar.results.map((e) => e.id);
const events = getEventIdsFromSearchObjectsToolCalls(msg.toolCalls);
if (events.length > 0) return events.map((e) => e.id);
}
return [];
}, [messages]);
const sendMessage = useCallback(
(textOverride?: string) => {
const text = (textOverride ?? input).trim();
if (!text || isLoading) return;
const wireText = attachedEventId
? prependAttachment(text, attachedEventId)
: text;
setInput("");
setAttachedEventId(null);
submitConversation([...messages, { role: "user", content: wireText }]);
},
[attachedEventId, input, isLoading, messages, submitConversation],
);
const handleEditSubmit = useCallback(
(messageIndex: number, newContent: string) => {
@ -93,6 +117,10 @@ export default function ChatPage() {
[messages, submitConversation],
);
const handleClearAttachment = useCallback(() => {
setAttachedEventId(null);
}, []);
return (
<div className="flex size-full justify-center p-2 md:p-4">
<div className="flex size-full flex-col xl:w-[50%] 3xl:w-[35%]">
@ -170,13 +198,19 @@ export default function ChatPage() {
<ChatEventThumbnailsRow
events={similar.results}
anchor={similar.anchor}
onAttach={setAttachedEventId}
/>
);
}
const events = getEventIdsFromSearchObjectsToolCalls(
msg.toolCalls,
);
return <ChatEventThumbnailsRow events={events} />;
return (
<ChatEventThumbnailsRow
events={events}
onAttach={setAttachedEventId}
/>
);
})()}
</div>
);
@ -200,6 +234,10 @@ export default function ChatPage() {
sendMessage={sendMessage}
isLoading={isLoading}
placeholder={t("placeholder")}
attachedEventId={attachedEventId}
onClearAttachment={handleClearAttachment}
onAttach={setAttachedEventId}
recentEventIds={recentEventIds}
/>
)}
</div>
@ -210,9 +248,13 @@ export default function ChatPage() {
type ChatEntryProps = {
input: string;
setInput: (value: string) => void;
sendMessage: () => void;
sendMessage: (textOverride?: string) => void;
isLoading: boolean;
placeholder: string;
attachedEventId: string | null;
onClearAttachment: () => void;
onAttach: (eventId: string) => void;
recentEventIds: string[];
};
function ChatEntry({
@ -221,6 +263,10 @@ function ChatEntry({
sendMessage,
isLoading,
placeholder,
attachedEventId,
onClearAttachment,
onAttach,
recentEventIds,
}: ChatEntryProps) {
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
@ -230,8 +276,28 @@ function ChatEntry({
};
return (
<div className="mt-2 flex w-full flex-col items-center justify-center rounded-xl bg-secondary p-3">
<div className="mt-2 flex w-full flex-col items-stretch justify-center gap-2 rounded-xl bg-secondary p-3">
{attachedEventId && (
<div className="flex items-center">
<ChatAttachmentChip
eventId={attachedEventId}
mode="composer"
onRemove={onClearAttachment}
/>
</div>
)}
{attachedEventId && (
<ChatQuickReplies
onSend={(text) => sendMessage(text)}
disabled={isLoading}
/>
)}
<div className="flex w-full flex-row items-center gap-2">
<ChatPaperclipButton
recentEventIds={recentEventIds}
onAttach={onAttach}
disabled={isLoading || attachedEventId != null}
/>
<Input
className="w-full flex-1 border-transparent bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent"
placeholder={placeholder}
@ -244,7 +310,7 @@ function ChatEntry({
variant="select"
className="size-10 shrink-0 rounded-full"
disabled={!input.trim() || isLoading}
onClick={sendMessage}
onClick={() => sendMessage()}
>
<FaArrowUpLong size="16" />
</Button>