Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
210 lines
6.7 KiB
TypeScript
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>
|
|
);
|
|
}
|