Skip to content

Commit 5e87aa6

Browse files
committed
Add per-thread activity indicators in chat thread list
1 parent bbaab2a commit 5e87aa6

File tree

3 files changed

+58
-1
lines changed

3 files changed

+58
-1
lines changed

src/packages/frontend/chat/chatroom.tsx

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,11 @@ export type ThreadMeta = ThreadListItem & {
139139
unreadCount: number;
140140
isAI: boolean;
141141
isPinned: boolean;
142+
lastActivityAt?: number;
142143
};
143144

145+
const ACTIVITY_RECENT_MS = 20_000;
146+
144147
function stripHtml(value: string): string {
145148
if (!value) return "";
146149
return value.replace(/<[^>]*>/g, "");
@@ -155,6 +158,7 @@ export interface ChatPanelProps {
155158
project_id: string;
156159
path: string;
157160
messages?: ChatMessages;
161+
activity?: any;
158162
fontSize?: number;
159163
desc?: NodeDesc;
160164
variant?: "default" | "compact";
@@ -175,6 +179,7 @@ export function ChatPanel({
175179
project_id,
176180
path,
177181
messages,
182+
activity,
178183
fontSize = 13,
179184
desc,
180185
variant = "default",
@@ -220,6 +225,7 @@ export function ChatPanel({
220225
);
221226
const [allowAutoSelectThread, setAllowAutoSelectThread] =
222227
useState<boolean>(true);
228+
const [activityNow, setActivityNow] = useState<number>(Date.now());
223229
const submitMentionsRef = useRef<SubmitMentionsFn | undefined>(undefined);
224230
const scrollToBottomRef = useRef<any>(null);
225231
const selectedThreadDate = useMemo(() => {
@@ -276,6 +282,10 @@ export function ChatPanel({
276282
}
277283
llmCacheRef.current.set(thread.key, isAI);
278284
}
285+
const lastActivityAt =
286+
activity && typeof (activity as any).get === "function"
287+
? (activity as any).get(thread.key)
288+
: undefined;
279289
return {
280290
...thread,
281291
displayLabel,
@@ -284,9 +294,11 @@ export function ChatPanel({
284294
unreadCount,
285295
isAI: !!isAI,
286296
isPinned,
297+
lastActivityAt:
298+
typeof lastActivityAt === "number" ? lastActivityAt : undefined,
287299
};
288300
});
289-
}, [rawThreads, account_id, actions]);
301+
}, [rawThreads, account_id, actions, activity]);
290302

291303
const threadSections = useMemo<ThreadSectionWithUnread[]>(() => {
292304
const grouped = groupThreadsByRecency(threads);
@@ -335,6 +347,11 @@ export function ChatPanel({
335347
}
336348
}, [selectedThreadKey]);
337349

350+
useEffect(() => {
351+
const id = window.setInterval(() => setActivityNow(Date.now()), 5000);
352+
return () => window.clearInterval(id);
353+
}, []);
354+
338355
useEffect(() => {
339356
if (!fragmentId || isAllThreadsSelected || messages == null) {
340357
return;
@@ -509,6 +526,9 @@ export function ChatPanel({
509526
const isHovered = hoveredThread === key;
510527
const isMenuOpen = openThreadMenuKey === key;
511528
const showMenu = isHovered || selectedThreadKey === key || isMenuOpen;
529+
const isRecentlyActive =
530+
thread.lastActivityAt != null &&
531+
activityNow - thread.lastActivityAt < ACTIVITY_RECENT_MS;
512532
const iconTooltip = thread.isAI
513533
? "This thread started with an AI request, so the AI responds automatically."
514534
: "This thread started as human-only. AI replies only when explicitly mentioned.";
@@ -531,6 +551,18 @@ export function ChatPanel({
531551
<Icon name={isAI ? "robot" : "users"} style={{ color: "#888" }} />
532552
</Tooltip>
533553
<div style={THREAD_ITEM_LABEL_STYLE}>{plainLabel}</div>
554+
{isRecentlyActive && (
555+
<span
556+
aria-label="Recent activity"
557+
style={{
558+
width: 8,
559+
height: 8,
560+
borderRadius: "50%",
561+
background: COLORS.BLUE,
562+
flexShrink: 0,
563+
}}
564+
/>
565+
)}
534566
{unreadCount > 0 && !isHovered && (
535567
<Badge
536568
count={unreadCount}
@@ -1194,12 +1226,14 @@ export function ChatRoom({
11941226
}: EditorComponentProps) {
11951227
const useEditor = useEditorRedux<ChatState>({ project_id, path });
11961228
const messages = useEditor("messages") as ChatMessages | undefined;
1229+
const activity = useEditor("activity");
11971230
return (
11981231
<ChatPanel
11991232
actions={actions}
12001233
project_id={project_id}
12011234
path={path}
12021235
messages={messages}
1236+
activity={activity as any}
12031237
fontSize={font_size}
12041238
desc={desc}
12051239
variant="default"

src/packages/frontend/chat/store.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import { Store } from "@cocalc/frontend/app-framework";
77
import type { ChatMessages } from "./types";
8+
import type { Map as ImmutableMap } from "immutable";
89

910
export interface ChatState {
1011
project_id?: string;
@@ -13,6 +14,10 @@ export interface ChatState {
1314
message_plain_text: string; // What the user sees in the chat box eg. stripped of internal mention markup
1415
messages?: ChatMessages;
1516
drafts?: Map<string, any>;
17+
// last activity timestamp per thread (ms since epoch)
18+
activity?: ImmutableMap<string, number>;
19+
// true after the initial sync replay has finished
20+
activityReady?: boolean;
1621
offset?: number; // information about where on screen the chat editor is located
1722
position?: number; // more info about where chat editor is located
1823
saved_position?: number;
@@ -26,6 +31,8 @@ export function getInitialState() {
2631
message_plain_text: "",
2732
messages: undefined,
2833
drafts: undefined,
34+
activity: undefined,
35+
activityReady: false,
2936
offset: undefined,
3037
position: undefined,
3138
saved_position: undefined,

src/packages/frontend/chat/sync.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Map as iMap, fromJS } from "immutable";
22
import type { ChatMessage } from "./types";
3+
import { getThreadRootDate } from "./utils";
34

45
export function initFromSyncDB({ syncdb, store }) {
56
const v = {};
@@ -19,6 +20,7 @@ export function handleSyncDBChange({ syncdb, store, changes }) {
1920
console.warn("handleSyncDBChange: inputs should not be null");
2021
return;
2122
}
23+
const activityReady = store.get("activityReady") === true;
2224
changes.map((obj) => {
2325
obj = obj.toJS();
2426
switch (obj.event) {
@@ -55,6 +57,17 @@ export function handleSyncDBChange({ syncdb, store, changes }) {
5557
}
5658
if (changed) {
5759
store.setState({ messages });
60+
if (activityReady) {
61+
const root =
62+
getThreadRootDate({
63+
date: obj.date.valueOf(),
64+
messages,
65+
}) ?? obj.date.valueOf();
66+
const key = `${root}`;
67+
const now = Date.now();
68+
const activity = (store.get("activity") ?? iMap()).set(key, now);
69+
store.setState({ activity });
70+
}
5871
}
5972
return;
6073
}
@@ -63,6 +76,9 @@ export function handleSyncDBChange({ syncdb, store, changes }) {
6376
console.warn("unknown chat event: ", obj.event);
6477
}
6578
});
79+
if (!activityReady) {
80+
store.setState({ activityReady: true });
81+
}
6682
}
6783

6884
// NOTE: x must be already a plain JS object (.toJS()).

0 commit comments

Comments
 (0)