Files
AgentCompany/src/renderer/src/pages/chat/ChatConversation.tsx
T

210 lines
6.7 KiB
TypeScript

import { useLayoutEffect, useRef, useState, type FormEvent, type MutableRefObject } from "react";
import { AvatarStack, Icon, IconButton } from "../../shared/ui";
import type {
ApprovalRecord,
ChatAttachmentRecord,
ChatMentionRecord,
ChatMessageRecord,
ChatSpaceRecord,
ChatSlashInvocationRecord,
FileObjectRecord,
} from "../../../../shared/domain";
import { ChatComposer, type SendState } from "./ChatComposer";
import { ChatTimeline } from "./ChatTimeline";
import type { MessageContext } from "./chat-helpers";
import { t } from "../../i18n/i18n";
export function ChatConversation({
space,
participants,
messagesInSpace,
pendingApprovals,
attachments,
mentions,
slashInvocations,
files,
context,
attachmentFileId,
currentEmployeeId,
onAttachmentChange,
onAttach,
onApprove,
onDecline,
onEditMessage,
onDeleteMessage,
onOpenFile,
onSubmit,
send,
composerRef,
}: {
space: ChatSpaceRecord | null;
participants: string[];
messagesInSpace: ChatMessageRecord[];
pendingApprovals: ApprovalRecord[];
attachments: ChatAttachmentRecord[];
mentions: ChatMentionRecord[];
slashInvocations: ChatSlashInvocationRecord[];
files: FileObjectRecord[];
context: MessageContext;
attachmentFileId: string;
currentEmployeeId: string;
onAttachmentChange: (next: string) => void;
onAttach: () => void;
onApprove: (approvalId: string) => void;
onDecline: (approvalId: string) => void;
onEditMessage: (messageId: string, bodyMd: string) => void;
onDeleteMessage: (messageId: string) => void;
onOpenFile: (fileId: string) => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
send: SendState;
composerRef: MutableRefObject<HTMLTextAreaElement | null>;
}) {
const [query, setQuery] = useState("");
const trimmed = query.trim().toLowerCase();
const filtered = trimmed
? messagesInSpace.filter((m) => m.bodyMd.toLowerCase().includes(trimmed))
: messagesInSpace;
// Messenger scroll contract: open at the latest message, stay pinned there
// while the reader is at the bottom, and offer a jump back the moment they
// scroll up into history.
const scrollRef = useRef<HTMLDivElement | null>(null);
const atBottomRef = useRef(true);
const [atBottom, setAtBottom] = useState(true);
const lastMessageId = filtered.length > 0 ? filtered[filtered.length - 1].id : null;
const measureAtBottom = () => {
const el = scrollRef.current;
if (!el) return true;
return el.scrollHeight - el.scrollTop - el.clientHeight < 48;
};
const onScroll = () => {
const near = measureAtBottom();
atBottomRef.current = near;
setAtBottom(near);
};
const jumpToLatest = (behavior: ScrollBehavior) => {
const el = scrollRef.current;
if (el) el.scrollTo({ top: el.scrollHeight, behavior });
};
// The component is keyed by space, so mount = opening this conversation.
useLayoutEffect(() => {
jumpToLatest("auto");
}, []);
// New messages keep a bottom-pinned reader pinned; a reader up in history
// is left alone (the jump pill is their way back).
useLayoutEffect(() => {
if (atBottomRef.current) jumpToLatest("auto");
}, [lastMessageId]);
return (
<section style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", background: "var(--panel)" }}>
{space && <ConversationHeader space={space} participants={participants} query={query} onQueryChange={setQuery} />}
<div style={{ position: "relative", flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
<div
ref={scrollRef}
onScroll={onScroll}
role="log"
aria-label={t("Conversation messages")}
style={{ flex: 1, overflow: "auto", padding: "20px 24px 8px" }}
>
{space && (
<ChatTimeline
key={space.id}
messagesInSpace={filtered}
pendingApprovals={pendingApprovals}
attachments={attachments}
mentions={mentions}
slashInvocations={slashInvocations}
files={files}
context={context}
currentEmployeeId={currentEmployeeId}
onApprove={onApprove}
onDecline={onDecline}
onEditMessage={onEditMessage}
onDeleteMessage={onDeleteMessage}
onOpenFile={onOpenFile}
/>
)}
</div>
{!atBottom && (
<button type="button" className="jump-latest" onClick={() => jumpToLatest("smooth")}>
<span aria-hidden style={{ fontSize: 13, lineHeight: 1 }}>↓</span>
{t("Jump to latest")}
</button>
)}
</div>
<ChatComposer
disabled={!space}
send={send}
files={files}
agents={context.agents}
employees={context.employees}
attachmentFileId={attachmentFileId}
onAttachmentChange={onAttachmentChange}
onAttach={onAttach}
onSubmit={onSubmit}
composerRef={composerRef}
/>
</section>
);
}
function ConversationHeader({
space,
participants,
query,
onQueryChange,
}: {
space: ChatSpaceRecord;
participants: string[];
query: string;
onQueryChange: (next: string) => void;
}) {
const [searchOpen, setSearchOpen] = useState(false);
const toggleSearch = () => {
setSearchOpen((open) => {
if (open) onQueryChange("");
return !open;
});
};
return (
<header className="row" style={{ height: 52, padding: "0 24px", borderBottom: "1px solid var(--border-soft)", flexShrink: 0 }}>
<Icon name={space.visibility === "restricted" ? "lock" : "hash"} size={15} color="var(--text-3)" />
<span style={{ fontSize: 14, fontWeight: 600 }}>{space.name}</span>
<span
style={{
fontSize: 12,
color: "var(--text-3)",
borderLeft: "1px solid var(--border)",
paddingLeft: 10,
marginLeft: 4,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flex: 1,
minWidth: 0,
}}
>
{space.topic}
</span>
<div style={{ marginLeft: "auto", display: "flex", gap: 8, alignItems: "center" }}>
{searchOpen && (
<input
className="input"
autoFocus
aria-label={t("Search messages")}
value={query}
onChange={(event) => onQueryChange(event.target.value)}
onKeyDown={(event) => { if (event.key === "Escape") toggleSearch(); }}
style={{ width: 200, height: 28, padding: "0 10px" }}
/>
)}
{!searchOpen && participants.length > 0 && <AvatarStack names={participants.slice(0, 5)} bot />}
<IconButton icon="search" label={t("Search")} onClick={toggleSearch} />
</div>
</header>
);
}