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