Skip to content

Commit a1d889b

Browse files
committed
chat: reorganize the thread column
1 parent d329f10 commit a1d889b

File tree

3 files changed

+210
-71
lines changed

3 files changed

+210
-71
lines changed

src/packages/frontend/chat/actions.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -574,7 +574,11 @@ export class ChatActions extends Actions<ChatState> {
574574
return true;
575575
};
576576

577-
markThreadRead = (threadKey: string, count: number): boolean => {
577+
markThreadRead = (
578+
threadKey: string,
579+
count: number,
580+
commit = true,
581+
): boolean => {
578582
if (this.syncdb == null) {
579583
return false;
580584
}
@@ -588,7 +592,9 @@ export class ChatActions extends Actions<ChatState> {
588592
}
589593
entry.doc[`read-${account_id}`] = count;
590594
this.syncdb.set(entry.doc);
591-
this.syncdb.commit();
595+
if (commit) {
596+
this.syncdb.commit();
597+
}
592598
return true;
593599
};
594600

src/packages/frontend/chat/chatroom.tsx

Lines changed: 127 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
Layout,
1515
Menu,
1616
Modal,
17+
Popconfirm,
1718
Select,
1819
Space,
1920
Switch,
@@ -51,8 +52,12 @@ import {
5152
getThreadRootDate,
5253
markChatAsReadIfUnseen,
5354
} from "./utils";
54-
import { ALL_THREADS_KEY, useThreadList } from "./threads";
55-
import type { ThreadListItem } from "./threads";
55+
import {
56+
ALL_THREADS_KEY,
57+
groupThreadsByRecency,
58+
useThreadList,
59+
} from "./threads";
60+
import type { ThreadListItem, ThreadSection } from "./threads";
5661

5762
const FILTER_RECENT_NONE = {
5863
value: 0,
@@ -118,19 +123,32 @@ const THREAD_ITEM_LABEL_STYLE: React.CSSProperties = {
118123
pointerEvents: "none",
119124
} as const;
120125

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+
121134
export type ThreadMeta = ThreadListItem & {
122135
displayLabel: string;
123136
hasCustomName: boolean;
124137
readCount: number;
125138
unreadCount: number;
126139
isAI: boolean;
140+
isPinned: boolean;
127141
};
128142

129143
function stripHtml(value: string): string {
130144
if (!value) return "";
131145
return value.replace(/<[^>]*>/g, "");
132146
}
133147

148+
interface ThreadSectionWithUnread extends ThreadSection<ThreadMeta> {
149+
unreadCount: number;
150+
}
151+
134152
export interface ChatPanelProps {
135153
actions: ChatActions;
136154
project_id: string;
@@ -223,6 +241,12 @@ export function ChatPanel({
223241
)?.trim();
224242
const hasCustomName = !!storedName;
225243
const displayLabel = storedName || thread.label;
244+
const pinValue = rootMessage?.get("pin");
245+
const isPinned =
246+
pinValue === true ||
247+
pinValue === "true" ||
248+
pinValue === 1 ||
249+
pinValue === "1";
226250
const readField =
227251
account_id && rootMessage
228252
? rootMessage.get(`read-${account_id}`)
@@ -255,10 +279,22 @@ export function ChatPanel({
255279
readCount,
256280
unreadCount,
257281
isAI: !!isAI,
282+
isPinned,
258283
};
259284
});
260285
}, [rawThreads, account_id, actions]);
261286

287+
const threadSections = useMemo<ThreadSectionWithUnread[]>(() => {
288+
const grouped = groupThreadsByRecency(threads);
289+
return grouped.map((section) => ({
290+
...section,
291+
unreadCount: section.threads.reduce(
292+
(sum, thread) => sum + thread.unreadCount,
293+
0,
294+
),
295+
}));
296+
}, [threads]);
297+
262298
useEffect(() => {
263299
if (
264300
storedThreadFromDesc != null &&
@@ -461,7 +497,7 @@ export function ChatPanel({
461497
>
462498
<Icon name={isAI ? "robot" : "users"} style={{ color: "#888" }} />
463499
<div style={THREAD_ITEM_LABEL_STYLE}>{plainLabel}</div>
464-
{unreadCount > 0 && (
500+
{unreadCount > 0 && !isHovered && (
465501
<Badge
466502
count={unreadCount}
467503
size="small"
@@ -490,83 +526,100 @@ export function ChatPanel({
490526
};
491527
};
492528

493-
const renderThreadSection = (
494-
title: string,
495-
icon: "users" | "robot",
496-
list: ThreadMeta[],
529+
const renderUnreadBadge = (
530+
count: number,
531+
section: ThreadSectionWithUnread,
497532
) => {
498-
const unreadTotal = list.reduce(
499-
(sum, thread) => sum + thread.unreadCount,
500-
0,
533+
if (count <= 0) {
534+
return null;
535+
}
536+
const badge = (
537+
<Badge
538+
count={count}
539+
size="small"
540+
style={{
541+
backgroundColor: COLORS.GRAY_L0,
542+
color: COLORS.GRAY_D,
543+
}}
544+
/>
545+
);
546+
if (!actions?.markThreadRead) {
547+
return badge;
548+
}
549+
return (
550+
<Popconfirm
551+
title="Mark all read?"
552+
description="Mark every chat in this section as read."
553+
okText="Mark read"
554+
cancelText="Cancel"
555+
placement="left"
556+
onConfirm={(e) => {
557+
e?.stopPropagation?.();
558+
handleMarkSectionRead(section);
559+
}}
560+
>
561+
<span
562+
onClick={(e) => e.stopPropagation()}
563+
style={{ cursor: "pointer", display: "inline-flex" }}
564+
>
565+
{badge}
566+
</span>
567+
</Popconfirm>
501568
);
569+
};
570+
571+
const renderThreadSection = (section: ThreadSectionWithUnread) => {
572+
const { title, threads: list, unreadCount, key } = section;
573+
if (!list || list.length === 0) {
574+
return null;
575+
}
502576
const items = list.map(renderThreadRow);
503577
return (
504-
<div style={{ marginBottom: "15px" }}>
505-
<div
578+
<div key={key} style={{ marginBottom: "18px" }}>
579+
<div style={THREAD_SECTION_HEADER_STYLE}>
580+
<span style={{ fontWeight: 600 }}>{title}</span>
581+
{renderUnreadBadge(unreadCount, section)}
582+
</div>
583+
<Menu
584+
mode="inline"
585+
selectedKeys={selectedThreadKey ? [selectedThreadKey] : []}
586+
onClick={({ key: menuKey }) => {
587+
setAllowAutoSelectThread(true);
588+
setSelectedThreadKey(String(menuKey));
589+
if (isCompact) {
590+
setSidebarVisible(false);
591+
}
592+
}}
593+
items={items}
506594
style={{
507-
display: "flex",
508-
alignItems: "center",
509-
justifyContent: "space-between",
510-
marginBottom: "6px",
595+
border: "none",
596+
background: "transparent",
597+
padding: "0 10px",
511598
}}
512-
>
513-
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
514-
<Icon name={icon} />
515-
<span style={{ fontWeight: 600 }}>{title}</span>
516-
</div>
517-
{unreadTotal > 0 && (
518-
<Badge
519-
count={unreadTotal}
520-
size="small"
521-
style={{
522-
backgroundColor: COLORS.GRAY_L0,
523-
color: COLORS.GRAY_D,
524-
}}
525-
/>
526-
)}
527-
</div>
528-
{list.length === 0 ? (
529-
<div style={{ color: "#999", fontSize: "12px", marginLeft: "4px" }}>
530-
No chats
531-
</div>
532-
) : (
533-
<Menu
534-
mode="inline"
535-
selectedKeys={selectedThreadKey ? [selectedThreadKey] : []}
536-
onClick={({ key }) => {
537-
setAllowAutoSelectThread(true);
538-
setSelectedThreadKey(String(key));
539-
if (isCompact) {
540-
setSidebarVisible(false);
541-
}
542-
}}
543-
items={items}
544-
style={{
545-
border: "none",
546-
background: "transparent",
547-
padding: 0,
548-
maxHeight: "28vh",
549-
overflowY: "auto",
550-
}}
551-
/>
552-
)}
599+
/>
553600
</div>
554601
);
555602
};
556603

557-
const humanThreads = useMemo(
558-
() => threads.filter((thread) => !thread.isAI),
559-
[threads],
560-
);
561-
const aiThreads = useMemo(
562-
() => threads.filter((thread) => thread.isAI),
563-
[threads],
564-
);
565604
const totalUnread = useMemo(
566-
() => threads.reduce((sum, thread) => sum + thread.unreadCount, 0),
567-
[threads],
605+
() => threadSections.reduce((sum, section) => sum + section.unreadCount, 0),
606+
[threadSections],
568607
);
569608

609+
const handleMarkSectionRead = (section: ThreadSectionWithUnread): void => {
610+
if (!actions?.markThreadRead) return;
611+
const v: { key: string; messageCount: number }[] = [];
612+
for (const thread of section.threads) {
613+
if (thread.unreadCount > 0) {
614+
v.push({ key: thread.key, messageCount: thread.messageCount });
615+
}
616+
}
617+
for (let i = 0; i < v.length; i++) {
618+
const { key, messageCount } = v[i];
619+
actions.markThreadRead(key, messageCount, i == v.length - 1);
620+
}
621+
};
622+
570623
const renderSidebarContent = () => (
571624
<>
572625
<div style={THREAD_SIDEBAR_HEADER}>
@@ -622,8 +675,13 @@ export function ChatPanel({
622675
</>
623676
)}
624677
</div>
625-
{renderThreadSection("Humans", "users", humanThreads)}
626-
{renderThreadSection("AI", "robot", aiThreads)}
678+
{threadSections.length === 0 ? (
679+
<div style={{ color: "#999", fontSize: "12px", padding: "0 20px" }}>
680+
No chats yet.
681+
</div>
682+
) : (
683+
threadSections.map((section) => renderThreadSection(section))
684+
)}
627685
</>
628686
);
629687

src/packages/frontend/chat/threads.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,19 @@ export interface ThreadListItem {
1717
rootMessage?: ChatMessageTyped;
1818
}
1919

20+
export type ThreadSectionKey =
21+
| "pinned"
22+
| "today"
23+
| "yesterday"
24+
| "last7days"
25+
| "older";
26+
27+
export interface ThreadSection<T extends ThreadListItem = ThreadListItem> {
28+
key: ThreadSectionKey;
29+
title: string;
30+
threads: T[];
31+
}
32+
2033
export function useThreadList(messages?: ChatMessages): ThreadListItem[] {
2134
return React.useMemo(() => {
2235
if (messages == null || messages.size === 0) {
@@ -104,3 +117,65 @@ export function deriveThreadLabel(
104117
}
105118
return "Untitled Thread";
106119
}
120+
121+
const DAY_MS = 24 * 60 * 60 * 1000;
122+
123+
interface GroupOptions {
124+
now?: number;
125+
}
126+
127+
type RecencyKey = Exclude<ThreadSectionKey, "pinned">;
128+
129+
const RECENCY_SECTIONS: { key: RecencyKey; title: string }[] = [
130+
{ key: "today", title: "Today" },
131+
{ key: "yesterday", title: "Yesterday" },
132+
{ key: "last7days", title: "Last 7 Days" },
133+
{ key: "older", title: "Older" },
134+
];
135+
136+
function recencyKeyForDelta(delta: number): RecencyKey {
137+
if (delta < DAY_MS) {
138+
return "today";
139+
}
140+
if (delta < 2 * DAY_MS) {
141+
return "yesterday";
142+
}
143+
if (delta < 7 * DAY_MS) {
144+
return "last7days";
145+
}
146+
return "older";
147+
}
148+
149+
export function groupThreadsByRecency<T extends ThreadListItem & { isPinned?: boolean }>(
150+
threads: T[],
151+
options: GroupOptions = {},
152+
): ThreadSection<T>[] {
153+
if (!threads || threads.length === 0) {
154+
return [];
155+
}
156+
const now = options.now ?? Date.now();
157+
const sections: ThreadSection<T>[] = [];
158+
const pinned = threads.filter((thread) => !!thread.isPinned);
159+
const remainder = threads.filter((thread) => !thread.isPinned);
160+
if (pinned.length > 0) {
161+
sections.push({ key: "pinned", title: "Pinned", threads: pinned });
162+
}
163+
const buckets: Record<RecencyKey, T[]> = {
164+
today: [],
165+
yesterday: [],
166+
last7days: [],
167+
older: [],
168+
};
169+
for (const thread of remainder) {
170+
const delta = now - thread.newestTime;
171+
const key = recencyKeyForDelta(delta);
172+
buckets[key].push(thread);
173+
}
174+
for (const def of RECENCY_SECTIONS) {
175+
const list = buckets[def.key];
176+
if (list.length > 0) {
177+
sections.push({ key: def.key, title: def.title, threads: list });
178+
}
179+
}
180+
return sections;
181+
}

0 commit comments

Comments
 (0)