Path: blob/master/src/packages/frontend/chat/chatroom.tsx
5903 views
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45import type { MenuProps } from "antd";6import {7Badge,8Button,9Divider,10Drawer,11Dropdown,12Input,13Layout,14Menu,15Modal,16Popconfirm,17Select,18Space,19Switch,20Tooltip,21message as antdMessage,22} from "antd";23import { debounce } from "lodash";24import { FormattedMessage } from "react-intl";25import { IS_MOBILE } from "@cocalc/frontend/feature";26import { Col, Row, Well } from "@cocalc/frontend/antd-bootstrap";27import {28React,29useEditorRedux,30useEffect,31useMemo,32useRef,33useState,34useTypedRedux,35} from "@cocalc/frontend/app-framework";36import { Icon, Loading } from "@cocalc/frontend/components";37import StaticMarkdown from "@cocalc/frontend/editors/slate/static-markdown";38import { hoursToTimeIntervalHuman } from "@cocalc/util/misc";39import { COLORS } from "@cocalc/util/theme";40import type { NodeDesc } from "../frame-editors/frame-tree/types";41import { EditorComponentProps } from "../frame-editors/frame-tree/types";42import type { ChatActions } from "./actions";43import { ChatLog } from "./chat-log";44import Filter from "./filter";45import ChatInput from "./input";46import { LLMCostEstimationChat } from "./llm-cost-estimation";47import type { ChatState } from "./store";48import type { ChatMessageTyped, ChatMessages, SubmitMentionsFn } from "./types";49import {50INPUT_HEIGHT,51getThreadRootDate,52markChatAsReadIfUnseen,53} from "./utils";54import {55ALL_THREADS_KEY,56groupThreadsByRecency,57useThreadList,58} from "./threads";59import type { ThreadListItem, ThreadSection } from "./threads";6061const FILTER_RECENT_NONE = {62value: 0,63label: (64<>65<Icon name="clock" />66</>67),68} as const;6970const PREVIEW_STYLE: React.CSSProperties = {71background: "#f5f5f5",72fontSize: "14px",73borderRadius: "10px 10px 10px 10px",74boxShadow: "#666 3px 3px 3px",75paddingBottom: "20px",76maxHeight: "40vh",77overflowY: "auto",78} as const;7980const GRID_STYLE: React.CSSProperties = {81maxWidth: "1200px",82display: "flex",83flexDirection: "column",84width: "100%",85margin: "auto",86} as const;8788const CHAT_LAYOUT_STYLE: React.CSSProperties = {89height: "100%",90background: "white",91} as const;9293const CHAT_LOG_STYLE: React.CSSProperties = {94padding: "0",95background: "white",96flex: "1 0 auto",97position: "relative",98} as const;99100const THREAD_SIDEBAR_WIDTH = 260;101102const THREAD_SIDEBAR_STYLE: React.CSSProperties = {103background: "#fafafa",104borderRight: "1px solid #eee",105padding: "15px 0",106display: "flex",107flexDirection: "column",108overflow: "auto",109} as const;110111const THREAD_SIDEBAR_HEADER: React.CSSProperties = {112padding: "0 20px 15px",113color: "#666",114} as const;115116const THREAD_ITEM_LABEL_STYLE: React.CSSProperties = {117flex: 1,118minWidth: 0,119overflow: "hidden",120textOverflow: "ellipsis",121whiteSpace: "nowrap",122pointerEvents: "none",123} as const;124125const THREAD_SECTION_HEADER_STYLE: React.CSSProperties = {126display: "flex",127alignItems: "center",128justifyContent: "space-between",129padding: "0 20px 6px",130color: COLORS.GRAY_D,131} as const;132133export type ThreadMeta = ThreadListItem & {134displayLabel: string;135hasCustomName: boolean;136readCount: number;137unreadCount: number;138isAI: boolean;139isPinned: boolean;140};141142function stripHtml(value: string): string {143if (!value) return "";144return value.replace(/<[^>]*>/g, "");145}146147interface ThreadSectionWithUnread extends ThreadSection<ThreadMeta> {148unreadCount: number;149}150151export interface ChatPanelProps {152actions: ChatActions;153project_id: string;154path: string;155messages?: ChatMessages;156fontSize?: number;157desc?: NodeDesc;158variant?: "default" | "compact";159disableFilters?: boolean;160}161162function getDescValue(desc: NodeDesc | undefined, key: string) {163if (desc == null) return undefined;164const getter: any = (desc as any).get;165if (typeof getter === "function") {166return getter.call(desc, key);167}168return (desc as any)[key];169}170171export function ChatPanel({172actions,173project_id,174path,175messages,176fontSize = 13,177desc,178variant = "default",179disableFilters: disableFiltersProp,180}: ChatPanelProps) {181if (IS_MOBILE) {182variant = "compact";183}184const account_id = useTypedRedux("account", "account_id");185const [input, setInput] = useState("");186const search = getDescValue(desc, "data-search") ?? "";187const filterRecentH: number = getDescValue(desc, "data-filterRecentH") ?? 0;188const selectedHashtags = getDescValue(desc, "data-selectedHashtags");189const scrollToIndex = getDescValue(desc, "data-scrollToIndex") ?? null;190const scrollToDate = getDescValue(desc, "data-scrollToDate") ?? null;191const fragmentId = getDescValue(desc, "data-fragmentId") ?? null;192const showPreview = getDescValue(desc, "data-showPreview") ?? null;193const costEstimate = getDescValue(desc, "data-costEstimate");194const [filterRecentHCustom, setFilterRecentHCustom] = useState<string>("");195const [filterRecentOpen, setFilterRecentOpen] = useState<boolean>(false);196const [sidebarVisible, setSidebarVisible] = useState<boolean>(false);197const isCompact = variant === "compact";198const disableFilters = disableFiltersProp ?? isCompact;199const storedThreadFromDesc =200getDescValue(desc, "data-selectedThreadKey") ?? null;201const [selectedThreadKey, setSelectedThreadKey0] = useState<string | null>(202storedThreadFromDesc,203);204const setSelectedThreadKey = (x: string | null) => {205if (x != null && x != ALL_THREADS_KEY) {206actions.clearAllFilters();207actions.setFragment();208}209setSelectedThreadKey0(x);210actions.setSelectedThread?.(x);211};212const [lastThreadKey, setLastThreadKey] = useState<string | null>(null);213const [renamingThread, setRenamingThread] = useState<string | null>(null);214const [renameValue, setRenameValue] = useState<string>("");215const [hoveredThread, setHoveredThread] = useState<string | null>(null);216const [openThreadMenuKey, setOpenThreadMenuKey] = useState<string | null>(217null,218);219const [allowAutoSelectThread, setAllowAutoSelectThread] =220useState<boolean>(true);221const submitMentionsRef = useRef<SubmitMentionsFn | undefined>(undefined);222const scrollToBottomRef = useRef<any>(null);223const selectedThreadDate = useMemo(() => {224if (!selectedThreadKey || selectedThreadKey === ALL_THREADS_KEY) {225return undefined;226}227const millis = parseInt(selectedThreadKey, 10);228if (!isFinite(millis)) return undefined;229return new Date(millis);230}, [selectedThreadKey]);231232const isAllThreadsSelected = selectedThreadKey === ALL_THREADS_KEY;233const singleThreadView = selectedThreadKey != null && !isAllThreadsSelected;234const showThreadFilters = !isCompact && isAllThreadsSelected;235236const llmCacheRef = useRef<Map<string, boolean>>(new Map());237const rawThreads = useThreadList(messages);238const threads = useMemo<ThreadMeta[]>(() => {239return rawThreads.map((thread) => {240const rootMessage = thread.rootMessage;241const storedName = (242rootMessage?.get("name") as string | undefined243)?.trim();244const hasCustomName = !!storedName;245const displayLabel = storedName || thread.label;246const pinValue = rootMessage?.get("pin");247const isPinned =248pinValue === true ||249pinValue === "true" ||250pinValue === 1 ||251pinValue === "1";252const readField =253account_id && rootMessage254? rootMessage.get(`read-${account_id}`)255: null;256const readValue =257typeof readField === "number"258? readField259: typeof readField === "string"260? parseInt(readField, 10)261: 0;262const readCount =263Number.isFinite(readValue) && readValue > 0 ? readValue : 0;264const unreadCount = Math.max(thread.messageCount - readCount, 0);265let isAI = llmCacheRef.current.get(thread.key);266if (isAI == null) {267if (actions?.isLanguageModelThread) {268const result = actions.isLanguageModelThread(269new Date(parseInt(thread.key, 10)),270);271isAI = result !== false;272} else {273isAI = false;274}275llmCacheRef.current.set(thread.key, isAI);276}277return {278...thread,279displayLabel,280hasCustomName,281readCount,282unreadCount,283isAI: !!isAI,284isPinned,285};286});287}, [rawThreads, account_id, actions]);288289const threadSections = useMemo<ThreadSectionWithUnread[]>(() => {290const grouped = groupThreadsByRecency(threads);291return grouped.map((section) => ({292...section,293unreadCount: section.threads.reduce(294(sum, thread) => sum + thread.unreadCount,2950,296),297}));298}, [threads]);299300useEffect(() => {301if (302storedThreadFromDesc != null &&303storedThreadFromDesc !== selectedThreadKey304) {305setSelectedThreadKey(storedThreadFromDesc);306setAllowAutoSelectThread(false);307}308}, [storedThreadFromDesc]);309310useEffect(() => {311if (threads.length === 0) {312if (selectedThreadKey !== null) {313setSelectedThreadKey(null);314}315setAllowAutoSelectThread(true);316return;317}318const exists = threads.some((thread) => thread.key === selectedThreadKey);319if (!exists && allowAutoSelectThread) {320setSelectedThreadKey(threads[0].key);321}322}, [threads, selectedThreadKey, allowAutoSelectThread]);323324useEffect(() => {325if (selectedThreadKey != null && selectedThreadKey !== ALL_THREADS_KEY) {326setLastThreadKey(selectedThreadKey);327}328}, [selectedThreadKey]);329330useEffect(() => {331if (!fragmentId || isAllThreadsSelected || messages == null) {332return;333}334const parsed = parseFloat(fragmentId);335if (!isFinite(parsed)) {336return;337}338const keyStr = `${parsed}`;339let message = messages.get(keyStr) as ChatMessageTyped | undefined;340if (message == null) {341for (const [, msg] of messages) {342const dateField = msg?.get("date");343if (344dateField != null &&345typeof dateField.valueOf === "function" &&346dateField.valueOf() === parsed347) {348message = msg;349break;350}351}352}353if (message == null) return;354const root = getThreadRootDate({ date: parsed, messages }) || parsed;355const threadKey = `${root}`;356if (threadKey !== selectedThreadKey) {357setAllowAutoSelectThread(false);358setSelectedThreadKey(threadKey);359}360}, [fragmentId, isAllThreadsSelected, messages, selectedThreadKey]);361362const mark_as_read = () => markChatAsReadIfUnseen(project_id, path);363364useEffect(() => {365if (!singleThreadView || !selectedThreadKey) {366return;367}368const thread = threads.find((t) => t.key === selectedThreadKey);369if (!thread) {370return;371}372if (thread.unreadCount <= 0) {373return;374}375actions.markThreadRead?.(thread.key, thread.messageCount);376}, [singleThreadView, selectedThreadKey, threads, actions]);377378const handleToggleAllChats = (checked: boolean) => {379if (checked) {380setAllowAutoSelectThread(false);381setSelectedThreadKey(ALL_THREADS_KEY);382} else {383setAllowAutoSelectThread(true);384if (lastThreadKey != null) {385setSelectedThreadKey(lastThreadKey);386} else if (threads[0]?.key) {387setSelectedThreadKey(threads[0].key);388} else {389setSelectedThreadKey(null);390}391}392};393394const performDeleteThread = (threadKey: string) => {395if (actions?.deleteThread == null) {396antdMessage.error("Deleting chats is not available.");397return;398}399const deleted = actions.deleteThread(threadKey);400if (deleted === 0) {401antdMessage.info("This chat has no messages to delete.");402return;403}404if (selectedThreadKey === threadKey) {405setSelectedThreadKey(null);406}407antdMessage.success("Chat deleted.");408};409410const confirmDeleteThread = (threadKey: string) => {411Modal.confirm({412title: "Delete chat?",413content:414"This removes all messages in this chat for everyone. This can only be undone using 'Edit --> Undo', or by browsing TimeTravel.",415okText: "Delete",416okType: "danger",417cancelText: "Cancel",418onOk: () => performDeleteThread(threadKey),419});420};421422const openRenameModal = (423threadKey: string,424currentLabel: string,425useCurrentLabel: boolean,426) => {427setRenamingThread(threadKey);428setRenameValue(useCurrentLabel ? currentLabel : "");429};430431const closeRenameModal = () => {432setRenamingThread(null);433setRenameValue("");434};435436const handleRenameSave = () => {437if (!renamingThread) return;438if (actions?.renameThread == null) {439antdMessage.error("Renaming chats is not available.");440return;441}442const success = actions.renameThread(renamingThread, renameValue.trim());443if (!success) {444antdMessage.error("Unable to rename chat.");445return;446}447antdMessage.success(448renameValue.trim() ? "Chat renamed." : "Chat name reset to default.",449);450closeRenameModal();451};452453const threadMenuProps = (454threadKey: string,455displayLabel: string,456hasCustomName: boolean,457isPinned: boolean,458): MenuProps => ({459items: [460{461key: "rename",462label: "Rename chat",463},464{465key: isPinned ? "unpin" : "pin",466label: isPinned ? "Unpin chat" : "Pin chat",467},468{469type: "divider",470},471{472key: "delete",473label: <span style={{ color: COLORS.ANTD_RED }}>Delete chat</span>,474},475],476onClick: ({ key }) => {477if (key === "rename") {478openRenameModal(threadKey, displayLabel, hasCustomName);479} else if (key === "pin" || key === "unpin") {480if (!actions?.setThreadPin) {481antdMessage.error("Pinning chats is not available.");482return;483}484const pinned = key === "pin";485const success = actions.setThreadPin(threadKey, pinned);486if (!success) {487antdMessage.error("Unable to update chat pin state.");488return;489}490antdMessage.success(pinned ? "Chat pinned." : "Chat unpinned.");491} else if (key === "delete") {492confirmDeleteThread(threadKey);493}494},495});496497const renderThreadRow = (thread: ThreadMeta) => {498const { key, displayLabel, hasCustomName, unreadCount, isAI, isPinned } =499thread;500const plainLabel = stripHtml(displayLabel);501const isHovered = hoveredThread === key;502const isMenuOpen = openThreadMenuKey === key;503const showMenu = isHovered || selectedThreadKey === key || isMenuOpen;504const iconTooltip = thread.isAI505? "This thread started with an AI request, so the AI responds automatically."506: "This thread started as human-only. AI replies only when explicitly mentioned.";507return {508key,509label: (510<div511style={{512display: "flex",513alignItems: "center",514gap: "8px",515width: "100%",516}}517onMouseEnter={() => setHoveredThread(key)}518onMouseLeave={() =>519setHoveredThread((prev) => (prev === key ? null : prev))520}521>522<Tooltip title={iconTooltip}>523<Icon name={isAI ? "robot" : "users"} style={{ color: "#888" }} />524</Tooltip>525<div style={THREAD_ITEM_LABEL_STYLE}>{plainLabel}</div>526{unreadCount > 0 && !isHovered && (527<Badge528count={unreadCount}529size="small"530overflowCount={99}531style={{532backgroundColor: COLORS.GRAY_L0,533color: COLORS.GRAY_D,534}}535/>536)}537{showMenu && (538<Dropdown539menu={threadMenuProps(key, plainLabel, hasCustomName, isPinned)}540trigger={["click"]}541open={openThreadMenuKey === key}542onOpenChange={(open) => {543setOpenThreadMenuKey(open ? key : null);544if (!open) {545setHoveredThread((prev) => (prev === key ? null : prev));546}547}}548>549<Button550type="text"551size="small"552onClick={(event) => event.stopPropagation()}553icon={<Icon name="ellipsis" />}554/>555</Dropdown>556)}557</div>558),559};560};561562const renderUnreadBadge = (563count: number,564section: ThreadSectionWithUnread,565) => {566if (count <= 0) {567return null;568}569const badge = (570<Badge571count={count}572size="small"573style={{574backgroundColor: COLORS.GRAY_L0,575color: COLORS.GRAY_D,576}}577/>578);579if (!actions?.markThreadRead) {580return badge;581}582return (583<Popconfirm584title="Mark all read?"585description="Mark every chat in this section as read."586okText="Mark read"587cancelText="Cancel"588placement="left"589onConfirm={(e) => {590e?.stopPropagation?.();591handleMarkSectionRead(section);592}}593>594<span595onClick={(e) => e.stopPropagation()}596style={{ cursor: "pointer", display: "inline-flex" }}597>598{badge}599</span>600</Popconfirm>601);602};603604const renderThreadSection = (section: ThreadSectionWithUnread) => {605const { title, threads: list, unreadCount, key } = section;606if (!list || list.length === 0) {607return null;608}609const items = list.map(renderThreadRow);610return (611<div key={key} style={{ marginBottom: "18px" }}>612<div style={THREAD_SECTION_HEADER_STYLE}>613<span style={{ fontWeight: 600 }}>{title}</span>614{renderUnreadBadge(unreadCount, section)}615</div>616<Menu617mode="inline"618selectedKeys={selectedThreadKey ? [selectedThreadKey] : []}619onClick={({ key: menuKey }) => {620setAllowAutoSelectThread(true);621setSelectedThreadKey(String(menuKey));622if (isCompact) {623setSidebarVisible(false);624}625}}626items={items}627style={{628border: "none",629background: "transparent",630padding: "0 10px",631}}632/>633</div>634);635};636637const totalUnread = useMemo(638() => threadSections.reduce((sum, section) => sum + section.unreadCount, 0),639[threadSections],640);641642const handleMarkSectionRead = (section: ThreadSectionWithUnread): void => {643if (!actions?.markThreadRead) return;644const v: { key: string; messageCount: number }[] = [];645for (const thread of section.threads) {646if (thread.unreadCount > 0) {647v.push({ key: thread.key, messageCount: thread.messageCount });648}649}650for (let i = 0; i < v.length; i++) {651const { key, messageCount } = v[i];652actions.markThreadRead(key, messageCount, i == v.length - 1);653}654};655656const renderSidebarContent = () => (657<>658<div style={THREAD_SIDEBAR_HEADER}>659<div660style={{661display: "flex",662alignItems: "center",663justifyContent: "space-between",664marginBottom: "8px",665}}666>667<span668style={{669fontWeight: 600,670fontSize: "15px",671textTransform: "uppercase",672}}673>674Chats675</span>676{!isCompact && (677<Space size="small">678<span style={{ fontSize: "12px" }}>All</span>679<Switch680size="small"681checked={isAllThreadsSelected}682onChange={handleToggleAllChats}683/>684</Space>685)}686</div>687{!isCompact && (688<>689<Button690block691type={!selectedThreadKey ? "primary" : "default"}692onClick={() => {693setAllowAutoSelectThread(false);694setSelectedThreadKey(null);695}}696>697New Chat698</Button>699<Button700block701style={{ marginTop: "8px" }}702onClick={() => {703actions?.frameTreeActions?.show_search();704}}705>706Search707</Button>708</>709)}710</div>711{threadSections.length === 0 ? (712<div style={{ color: "#999", fontSize: "12px", padding: "0 20px" }}>713No chats yet.714</div>715) : (716threadSections.map((section) => renderThreadSection(section))717)}718</>719);720721function isValidFilterRecentCustom(): boolean {722const v = parseFloat(filterRecentHCustom);723return isFinite(v) && v >= 0;724}725726function renderFilterRecent() {727if (messages == null || messages.size <= 5) {728return null;729}730if (disableFilters) {731return null;732}733return (734<Tooltip title="Only show recent threads.">735<Select736open={filterRecentOpen}737onDropdownVisibleChange={(v) => setFilterRecentOpen(v)}738value={filterRecentH}739status={filterRecentH > 0 ? "warning" : undefined}740allowClear741onClear={() => {742actions.setFilterRecentH(0);743setFilterRecentHCustom("");744}}745popupMatchSelectWidth={false}746onSelect={(val: number) => actions.setFilterRecentH(val)}747options={[748FILTER_RECENT_NONE,749...[1, 6, 12, 24, 48, 24 * 7, 14 * 24, 28 * 24].map((value) => {750const label = hoursToTimeIntervalHuman(value);751return { value, label };752}),753]}754labelRender={({ label, value }) => {755if (!label) {756if (isValidFilterRecentCustom()) {757value = parseFloat(filterRecentHCustom);758label = hoursToTimeIntervalHuman(value);759} else {760({ label, value } = FILTER_RECENT_NONE);761}762}763return (764<Tooltip765title={766value === 0767? undefined768: `Only threads with messages sent in the past ${label}.`769}770>771{label}772</Tooltip>773);774}}775dropdownRender={(menu) => (776<>777{menu}778<Divider style={{ margin: "8px 0" }} />779<Input780placeholder="Number of hours"781allowClear782value={filterRecentHCustom}783status={784filterRecentHCustom == "" || isValidFilterRecentCustom()785? undefined786: "error"787}788onChange={debounce(789(e: React.ChangeEvent<HTMLInputElement>) => {790const v = e.target.value;791setFilterRecentHCustom(v);792const val = parseFloat(v);793if (isFinite(val) && val >= 0) {794actions.setFilterRecentH(val);795} else if (v == "") {796actions.setFilterRecentH(FILTER_RECENT_NONE.value);797}798},799150,800{ leading: true, trailing: true },801)}802onKeyDown={(e) => e.stopPropagation()}803onPressEnter={() => setFilterRecentOpen(false)}804addonAfter={<span style={{ paddingLeft: "5px" }}>hours</span>}805/>806</>807)}808/>809</Tooltip>810);811}812813function render_button_row() {814if (!showThreadFilters || disableFilters) {815return null;816}817if (messages == null || messages.size <= 5) {818return null;819}820return (821<Space style={{ marginTop: "5px", marginLeft: "15px" }} wrap>822<Filter823actions={actions}824search={search}825style={{826margin: 0,827width: "100%",828}}829/>830{renderFilterRecent()}831</Space>832);833}834835function sendMessage(836replyToOverride?: Date | null,837extraInput?: string,838): void {839const reply_to =840replyToOverride === undefined841? selectedThreadDate842: (replyToOverride ?? undefined);843if (!reply_to) {844setAllowAutoSelectThread(true);845}846const timeStamp = actions.sendChat({847submitMentionsRef,848reply_to,849extraInput,850});851if (!reply_to && timeStamp) {852setSelectedThreadKey(timeStamp);853setTimeout(() => {854setSelectedThreadKey(timeStamp);855}, 100);856}857setTimeout(() => {858scrollToBottomRef.current?.(true);859}, 100);860actions.deleteDraft(0);861setInput("");862}863function on_send(): void {864sendMessage();865}866867const renderThreadSidebar = () => (868<Layout.Sider width={THREAD_SIDEBAR_WIDTH} style={THREAD_SIDEBAR_STYLE}>869{renderSidebarContent()}870</Layout.Sider>871);872873const renderChatContent = () => (874<div className="smc-vfill" style={GRID_STYLE}>875{render_button_row()}876{selectedThreadKey ? (877<div className="smc-vfill" style={CHAT_LOG_STYLE}>878<ChatLog879actions={actions}880project_id={project_id}881path={path}882scrollToBottomRef={scrollToBottomRef}883mode={variant === "compact" ? "sidechat" : "standalone"}884fontSize={fontSize}885search={search}886filterRecentH={filterRecentH}887selectedHashtags={selectedHashtags}888selectedThread={889singleThreadView ? (selectedThreadKey ?? undefined) : undefined890}891scrollToIndex={scrollToIndex}892scrollToDate={scrollToDate}893selectedDate={fragmentId}894costEstimate={costEstimate}895/>896{showPreview && input.length > 0 && (897<Row style={{ position: "absolute", bottom: "0px", width: "100%" }}>898<Col xs={0} sm={2} />899<Col xs={10} sm={9}>900<Well style={PREVIEW_STYLE}>901<div902className="pull-right lighten"903style={{904marginRight: "-8px",905marginTop: "-10px",906cursor: "pointer",907fontSize: "13pt",908}}909onClick={() => actions.setShowPreview(false)}910>911<Icon name="times" />912</div>913<StaticMarkdown value={input} />914<div className="small lighten" style={{ marginTop: "15px" }}>915Preview (press Shift+Enter to send)916</div>917</Well>918</Col>919<Col sm={1} />920</Row>921)}922</div>923) : (924<div925className="smc-vfill"926style={{927...CHAT_LOG_STYLE,928display: "flex",929alignItems: "center",930justifyContent: "center",931color: "#888",932fontSize: "14px",933}}934>935<div style={{ textAlign: "center" }}>936{threads.length === 0937? "No chats yet. Start a new conversation."938: "Select a chat or start a new conversation."}939<Button940size="small"941type="primary"942style={{ marginLeft: "8px" }}943onClick={() => {944setAllowAutoSelectThread(false);945setSelectedThreadKey(null);946}}947>948New Chat949</Button>950</div>951</div>952)}953<div style={{ display: "flex", marginBottom: "5px", overflow: "auto" }}>954<div955style={{956flex: "1",957padding: "0px 5px 0px 2px",958}}959>960<ChatInput961fontSize={fontSize}962autoFocus963cacheId={`${path}${project_id}-new`}964input={input}965on_send={on_send}966height={INPUT_HEIGHT}967onChange={(value) => {968setInput(value);969const inputText =970submitMentionsRef.current?.(undefined, true) ?? value;971actions?.llmEstimateCost({ date: 0, input: inputText });972}}973submitMentionsRef={submitMentionsRef}974syncdb={actions.syncdb}975date={0}976editBarStyle={{ overflow: "auto" }}977/>978</div>979<div980style={{981display: "flex",982flexDirection: "column",983padding: "0",984marginBottom: "0",985}}986>987<div style={{ flex: 1 }} />988{costEstimate?.get("date") == 0 && (989<LLMCostEstimationChat990costEstimate={costEstimate?.toJS()}991compact992style={{993flex: 0,994fontSize: "85%",995textAlign: "center",996margin: "0 0 5px 0",997}}998/>999)}1000<Tooltip1001title={1002<FormattedMessage1003id="chatroom.chat_input.send_button.tooltip"1004defaultMessage={"Send message (shift+enter)"}1005/>1006}1007>1008<Button1009onClick={() => sendMessage()}1010disabled={input.trim() === ""}1011type="primary"1012style={{ height: "47.5px" }}1013icon={<Icon name="paper-plane" />}1014>1015<FormattedMessage1016id="chatroom.chat_input.send_button.label"1017defaultMessage={"Send"}1018/>1019</Button>1020</Tooltip>1021<div style={{ height: "5px" }} />1022<Button1023type={showPreview ? "dashed" : undefined}1024onClick={() => actions.setShowPreview(!showPreview)}1025style={{ height: "47.5px" }}1026>1027<FormattedMessage1028id="chatroom.chat_input.preview_button.label"1029defaultMessage={"Preview"}1030/>1031</Button>1032<div style={{ height: "5px" }} />1033<Button1034style={{ height: "47.5px" }}1035onClick={() => {1036const message = actions?.frameTreeActions1037?.getVideoChat()1038.startChatting(actions);1039if (!message) {1040return;1041}1042sendMessage(undefined, "\n\n" + message);1043}}1044>1045<Icon name="video-camera" /> Video1046</Button>1047</div>1048</div>1049</div>1050);10511052const renderDefaultLayout = () => (1053<Layout style={CHAT_LAYOUT_STYLE}>1054{renderThreadSidebar()}1055<Layout.Content className="smc-vfill" style={{ background: "white" }}>1056{renderChatContent()}1057</Layout.Content>1058</Layout>1059);10601061const renderCompactLayout = () => (1062<div className="smc-vfill" style={{ background: "white" }}>1063<Drawer1064open={sidebarVisible}1065onClose={() => setSidebarVisible(false)}1066placement="right"1067width={THREAD_SIDEBAR_WIDTH + 40}1068title="Chats"1069destroyOnClose1070>1071{renderSidebarContent()}1072</Drawer>1073<div1074style={{1075padding: "10px",1076display: "flex",1077gap: "8px",1078justifyContent: "flex-end",1079}}1080>1081<Button1082icon={<Icon name="bars" />}1083onClick={() => setSidebarVisible(true)}1084>1085Chats1086<Badge1087count={totalUnread}1088overflowCount={99}1089style={{1090backgroundColor: COLORS.GRAY_L0,1091color: COLORS.GRAY_D,1092}}1093/>1094</Button>1095<Button1096type={!selectedThreadKey ? "primary" : "default"}1097onClick={() => {1098setAllowAutoSelectThread(false);1099setSelectedThreadKey(null);1100}}1101>1102New Chat1103</Button>1104</div>1105{renderChatContent()}1106</div>1107);11081109if (messages == null) {1110return <Loading theme={"medium"} />;1111}11121113return (1114<div1115onMouseMove={mark_as_read}1116onClick={mark_as_read}1117className="smc-vfill"1118>1119{variant === "compact" ? renderCompactLayout() : renderDefaultLayout()}1120<Modal1121title="Rename chat"1122open={renamingThread != null}1123onCancel={closeRenameModal}1124onOk={handleRenameSave}1125okText="Save"1126destroyOnClose1127>1128<Input1129placeholder="Chat name"1130value={renameValue}1131onChange={(e) => setRenameValue(e.target.value)}1132onPressEnter={handleRenameSave}1133/>1134</Modal>1135</div>1136);1137}11381139export function ChatRoom({1140actions,1141project_id,1142path,1143font_size,1144desc,1145}: EditorComponentProps) {1146const useEditor = useEditorRedux<ChatState>({ project_id, path });1147const messages = useEditor("messages") as ChatMessages | undefined;1148return (1149<ChatPanel1150actions={actions}1151project_id={project_id}1152path={path}1153messages={messages}1154fontSize={font_size}1155desc={desc}1156variant="default"1157/>1158);1159}116011611162