Skip to content

Commit 20ee5a6

Browse files
committed
feat(webapp): keep the history spinner on a chat closed mid-turn
1 parent fe5d785 commit 20ee5a6

3 files changed

Lines changed: 86 additions & 24 deletions

File tree

apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,12 @@ import {
4949
unreadWorkCount,
5050
} from "./unread-counts";
5151
import { AgentPanelColumn } from "./panel-layout";
52-
import { markerAfterActiveChat, markerAfterActivity } from "./thinking-marker";
52+
import {
53+
markerAfterActiveChat,
54+
markerAfterActivity,
55+
markerChatId,
56+
type ThinkingMarker,
57+
} from "./thinking-marker";
5358
import { concurrencyPath } from "~/utils/pathBuilder";
5459

5560
function serializePageContext(pageContext: AgentPageContext): string | undefined {
@@ -150,10 +155,10 @@ export function DashboardAgentPanel({
150155
[user.id, organization.id, project.id, environment.id, location.pathname, pageContextKey]
151156
);
152157

153-
const [thinkingChatId, setThinkingChatId] = useState<string | null>(null);
158+
const [thinkingMarker, setThinkingMarker] = useState<ThinkingMarker | null>(null);
154159
const handleActivityChange = useCallback(
155160
(chatId: string, activity: TurnActivity | null) => {
156-
setThinkingChatId((previous) => markerAfterActivity(previous, chatId, activity));
161+
setThinkingMarker((previous) => markerAfterActivity(previous, chatId, activity, Date.now()));
157162
onTurnActivityChange?.(chatId, activity !== null);
158163
},
159164
[onTurnActivityChange]
@@ -168,9 +173,22 @@ export function DashboardAgentPanel({
168173

169174
// Ordering-safe: if the new chat has not reported yet, its own report re-sets the marker.
170175
useEffect(() => {
171-
setThinkingChatId((previous) => markerAfterActiveChat(previous, active?.chatId));
176+
setThinkingMarker((previous) => markerAfterActiveChat(previous, active?.chatId, Date.now()));
172177
}, [active?.chatId]);
173178

179+
const thinkingChatId = markerChatId(thinkingMarker, active?.chatId, Date.now());
180+
181+
useEffect(() => {
182+
if (thinkingMarker === null || thinkingMarker.chatId === active?.chatId) return;
183+
const remaining = thinkingMarker.expiresAt - Date.now();
184+
if (remaining <= 0) {
185+
setThinkingMarker(null);
186+
return;
187+
}
188+
const timer = window.setTimeout(() => setThinkingMarker(null), remaining);
189+
return () => window.clearTimeout(timer);
190+
}, [thinkingMarker, active?.chatId]);
191+
174192
const loadHistory = useMemo(
175193
() =>
176194
createCoalescedReload(async () => {
@@ -534,7 +552,7 @@ export function DashboardAgentPanel({
534552
toast.error("We couldn't delete that chat. Try again in a moment.");
535553
return;
536554
}
537-
setThinkingChatId((previous) => (previous === id ? null : previous));
555+
setThinkingMarker((previous) => (previous?.chatId === id ? null : previous));
538556
if (id === active?.chatId) newChat();
539557
void loadHistory();
540558
},
Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,50 @@
11
import { describe, expect, it } from "vitest";
2-
import { markerAfterActiveChat, markerAfterActivity } from "./thinking-marker";
2+
import { markerAfterActiveChat, markerAfterActivity, markerChatId } from "./thinking-marker";
3+
import { TOOL_PENDING_DEADLINE_MS } from "./turn-deadlines";
34

45
describe("thinking marker", () => {
56
it("marks the chat that is working and clears it when the turn settles", () => {
6-
const working = markerAfterActivity(null, "chat_1", "working");
7-
expect(working).toBe("chat_1");
8-
expect(markerAfterActivity(working, "chat_1", null)).toBe(null);
7+
const working = markerAfterActivity(null, "chat_1", "working", 0);
8+
expect(markerChatId(working, "chat_1", 0)).toBe("chat_1");
9+
expect(markerAfterActivity(working, "chat_1", null, 0)).toBe(null);
910
});
1011

1112
it("ignores a settled report from another chat", () => {
12-
expect(markerAfterActivity("chat_1", "chat_2", null)).toBe("chat_1");
13+
const working = markerAfterActivity(null, "chat_1", "working", 0);
14+
const other = markerAfterActivity(working, "chat_2", null, 0);
15+
expect(markerChatId(other, "chat_2", 0)).toBe("chat_1");
1316
});
1417

15-
it("clears the marker when the user switches away mid-turn", () => {
16-
// The streaming chat unmounts without reporting null, so only the switch clears it.
17-
expect(markerAfterActiveChat("chat_1", "chat_2")).toBe(null);
18-
expect(markerAfterActiveChat("chat_1", undefined)).toBe(null);
18+
it("keeps the marker when the chat closes mid-turn", () => {
19+
const working = markerAfterActivity(null, "chat_1", "working", 0);
20+
const closed = markerAfterActiveChat(working, undefined, 1_000);
21+
expect(markerChatId(closed, undefined, 1_000)).toBe("chat_1");
22+
expect(markerChatId(markerAfterActiveChat(working, "chat_2", 1_000), "chat_2", 1_000)).toBe(
23+
"chat_1"
24+
);
1925
});
2026

21-
it("keeps the marker the chat just reported for itself", () => {
22-
expect(markerAfterActiveChat("chat_1", "chat_1")).toBe("chat_1");
27+
it("expires a closed marker once activity reports stop", () => {
28+
const working = markerAfterActivity(null, "chat_1", "working", 0);
29+
const closed = markerAfterActiveChat(working, undefined, 1_000);
30+
expect(markerChatId(closed, undefined, 1_000 + TOOL_PENDING_DEADLINE_MS)).toBe(null);
31+
});
32+
33+
it("does not expire the marker while its chat is open", () => {
34+
const working = markerAfterActivity(null, "chat_1", "working", 0);
35+
expect(markerChatId(working, "chat_1", TOOL_PENDING_DEADLINE_MS * 10)).toBe("chat_1");
36+
});
37+
38+
it("clears the marker when the chat is reopened and already settled", () => {
39+
const working = markerAfterActivity(null, "chat_1", "working", 0);
40+
const reopened = markerAfterActiveChat(working, "chat_1", 1_000);
41+
expect(markerAfterActivity(reopened, "chat_1", null, 1_000)).toBe(null);
42+
});
43+
44+
it("extends the marker when the reopened chat is still streaming", () => {
45+
const working = markerAfterActivity(null, "chat_1", "working", 0);
46+
const closed = markerAfterActiveChat(working, undefined, 1_000);
47+
const streaming = markerAfterActivity(closed, "chat_1", "working", 5_000);
48+
expect(markerChatId(streaming, undefined, 1_000 + TOOL_PENDING_DEADLINE_MS)).toBe("chat_1");
2349
});
2450
});
Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,38 @@
11
import type { TurnActivity } from "./DashboardAgentMessages";
2+
import { TOOL_PENDING_DEADLINE_MS } from "./turn-deadlines";
23

34
// Which chat the history list shows as busy. Only the mounted chat reports.
45

6+
export type ThinkingMarker = { chatId: string; expiresAt: number };
7+
58
export function markerAfterActivity(
6-
previous: string | null,
9+
previous: ThinkingMarker | null,
710
chatId: string,
8-
activity: TurnActivity | null
9-
): string | null {
10-
return activity !== null ? chatId : previous === chatId ? null : previous;
11+
activity: TurnActivity | null,
12+
now: number
13+
): ThinkingMarker | null {
14+
if (activity !== null) return { chatId, expiresAt: now + TOOL_PENDING_DEADLINE_MS };
15+
return previous?.chatId === chatId ? null : previous;
1116
}
1217

1318
// A streaming chat unmounts on a switch without reporting null — the turn carries on
14-
// server-side — so the marker is dropped once another chat (or the draft) is active.
19+
// server-side — so the marker stays and runs on the deadline from the moment it detaches.
1520
export function markerAfterActiveChat(
16-
previous: string | null,
17-
activeChatId: string | undefined
21+
previous: ThinkingMarker | null,
22+
activeChatId: string | undefined,
23+
now: number
24+
): ThinkingMarker | null {
25+
if (previous === null || previous.chatId === activeChatId) return previous;
26+
return { chatId: previous.chatId, expiresAt: now + TOOL_PENDING_DEADLINE_MS };
27+
}
28+
29+
// The mounted chat's status is live truth, so only a detached marker can expire.
30+
export function markerChatId(
31+
marker: ThinkingMarker | null,
32+
activeChatId: string | undefined,
33+
now: number
1834
): string | null {
19-
return previous === activeChatId ? previous : null;
35+
if (marker === null) return null;
36+
if (marker.chatId === activeChatId) return marker.chatId;
37+
return marker.expiresAt > now ? marker.chatId : null;
2038
}

0 commit comments

Comments
 (0)