Skip to content

Commit 70d44bc

Browse files
committed
fix(webapp): keep the wake poll running for a turn started behind a closed panel
DashboardAgent declared turnStarted but nothing set it, so a question asked and then left with the panel closed raised no dot until the next reload. The panel now reports turn activity up to DashboardAgent, which latches onto the chat whose turn is running. Closing the panel reports nothing, so the latch holds; re-opening the chat with the turn over clears it.
1 parent 379b5f8 commit 70d44bc

4 files changed

Lines changed: 93 additions & 8 deletions

File tree

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
readAgentFullscreen,
2424
writeAgentFullscreen,
2525
} from "./panel-layout";
26+
import { nextPendingTurnChatId } from "./pending-turn";
2627
import { startWakePolling } from "./wake-poll";
2728
import { shouldPollWakeFeed, subscribeWatchActivity } from "./watch-activity";
2829
import {
@@ -69,7 +70,10 @@ export function DashboardAgent({
6970
const [unreadWork, setUnreadWork] = useState(initialUnreadWork);
7071
// A turn this tab started may finish after the panel closes; that is exactly the case the
7172
// dot exists for, so the poll has to be running when it lands.
72-
const [turnStarted, setTurnStarted] = useState(false);
73+
const [pendingTurnChatId, setPendingTurnChatId] = useState<string | null>(null);
74+
const handleTurnActivityChange = useCallback((chatId: string, active: boolean) => {
75+
setPendingTurnChatId((current) => nextPendingTurnChatId(current, { chatId, active }));
76+
}, []);
7377
const toastedWakes = useRef(new Set<string>());
7478
// The toast source is recent deliveries, not unread, so the dedupe must survive a reload.
7579
useEffect(() => {
@@ -169,15 +173,15 @@ export function DashboardAgent({
169173
serverUnreadWakes: initialUnreadWakes,
170174
serverHasActiveWatches: hasActiveWatches,
171175
serverUnreadWork: initialUnreadWork,
172-
turnInFlight: turnStarted,
176+
turnInFlight: pendingTurnChatId !== null,
173177
organizationId: organization.id,
174178
})
175179
)
176180
setWatching(true);
177181
};
178182
sync();
179183
return subscribeWatchActivity(sync);
180-
}, [organization.id, initialUnreadWakes, hasActiveWatches, initialUnreadWork, turnStarted]);
184+
}, [organization.id, initialUnreadWakes, hasActiveWatches, initialUnreadWork, pendingTurnChatId]);
181185

182186
useEffect(() => {
183187
if (!hasAccess || !watching) return;
@@ -313,6 +317,7 @@ export function DashboardAgent({
313317
promotedPrompt={promotedPrompt}
314318
onChatRead={markChatRead}
315319
onUnreadWorkChange={setUnreadWork}
320+
onTurnActivityChange={handleTurnActivityChange}
316321
isFullscreen={fullscreen}
317322
onToggleFullscreen={toggleFullscreen}
318323
/>

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

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export function DashboardAgentPanel({
7272
watchRequest,
7373
onChatRead,
7474
onUnreadWorkChange,
75+
onTurnActivityChange,
7576
isFullscreen = false,
7677
onToggleFullscreen,
7778
}: {
@@ -87,6 +88,8 @@ export function DashboardAgentPanel({
8788
onChatRead?: (chatId: string) => void;
8889
/** How many chats still hold work their owner hasn't seen. */
8990
onUnreadWorkChange?: (count: number) => void;
91+
/** Whether a turn is running in a chat, so a closed panel still knows to expect an answer. */
92+
onTurnActivityChange?: (chatId: string, active: boolean) => void;
9093
}) {
9194
const organization = useOrganization();
9295
const project = useProject();
@@ -130,11 +133,15 @@ export function DashboardAgentPanel({
130133
);
131134

132135
const [thinkingChatId, setThinkingChatId] = useState<string | null>(null);
133-
const handleActivityChange = useCallback((chatId: string, activity: TurnActivity | null) => {
134-
setThinkingChatId((previous) =>
135-
activity !== null ? chatId : previous === chatId ? null : previous
136-
);
137-
}, []);
136+
const handleActivityChange = useCallback(
137+
(chatId: string, activity: TurnActivity | null) => {
138+
setThinkingChatId((previous) =>
139+
activity !== null ? chatId : previous === chatId ? null : previous
140+
);
141+
onTurnActivityChange?.(chatId, activity !== null);
142+
},
143+
[onTurnActivityChange]
144+
);
138145

139146
const historyInFlight = useRef<Promise<void> | null>(null);
140147

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, expect, it } from "vitest";
2+
import { nextPendingTurnChatId } from "./pending-turn";
3+
import { shouldPollWakeFeed } from "./watch-activity";
4+
5+
/**
6+
* The launcher dot only appears if the wake poll is running when the answer lands. A turn
7+
* started in the panel has to keep the poll alive across a close, and let go of it once the
8+
* answer has been seen.
9+
*/
10+
describe("nextPendingTurnChatId", () => {
11+
it("latches onto the chat whose turn started", () => {
12+
expect(nextPendingTurnChatId(null, { chatId: "chat_a", active: true })).toBe("chat_a");
13+
});
14+
15+
it("holds while a newer turn takes over", () => {
16+
const afterA = nextPendingTurnChatId(null, { chatId: "chat_a", active: true });
17+
expect(nextPendingTurnChatId(afterA, { chatId: "chat_b", active: true })).toBe("chat_b");
18+
});
19+
20+
it("lets go once that chat's turn is no longer running", () => {
21+
const pending = nextPendingTurnChatId(null, { chatId: "chat_a", active: true });
22+
expect(nextPendingTurnChatId(pending, { chatId: "chat_a", active: false })).toBe(null);
23+
});
24+
25+
it("keeps waiting when a different chat goes quiet", () => {
26+
const pending = nextPendingTurnChatId(null, { chatId: "chat_a", active: true });
27+
expect(nextPendingTurnChatId(pending, { chatId: "chat_b", active: false })).toBe("chat_a");
28+
});
29+
30+
it("stays clear when nothing is pending", () => {
31+
expect(nextPendingTurnChatId(null, { chatId: "chat_a", active: false })).toBe(null);
32+
});
33+
});
34+
35+
describe("a turn started behind a closed panel", () => {
36+
// The page load knew of nothing: no wake, no watch, no unread work. Only the turn can
37+
// start the poll.
38+
const quietPageLoad = {
39+
serverUnreadWakes: 0,
40+
serverHasActiveWatches: false,
41+
serverUnreadWork: 0,
42+
organizationId: "org_quiet",
43+
};
44+
45+
it("keeps the poll running until the answer is seen", () => {
46+
expect(shouldPollWakeFeed({ ...quietPageLoad, turnInFlight: false })).toBe(false);
47+
48+
// Asked a question, then closed the panel: the panel reports no end, so the latch holds.
49+
const pending = nextPendingTurnChatId(null, { chatId: "chat_a", active: true });
50+
expect(shouldPollWakeFeed({ ...quietPageLoad, turnInFlight: pending !== null })).toBe(true);
51+
52+
// Re-opened the chat with the turn already over.
53+
const seen = nextPendingTurnChatId(pending, { chatId: "chat_a", active: false });
54+
expect(shouldPollWakeFeed({ ...quietPageLoad, turnInFlight: seen !== null })).toBe(false);
55+
});
56+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* Which chat this tab is still waiting on. A turn started here can finish after the panel
3+
* closes — the case the launcher dot exists for — so the wake poll has to keep running until
4+
* the answer has been seen. The panel reports turn activity while it is mounted; closing it
5+
* reports nothing, which is what leaves the latch set.
6+
*/
7+
8+
/** `active` is true while a turn is running in `chatId`, false once it is not. */
9+
export function nextPendingTurnChatId(
10+
current: string | null,
11+
event: { chatId: string; active: boolean }
12+
): string | null {
13+
if (event.active) return event.chatId;
14+
// Only the chat we are waiting on clears the latch; another chat going quiet says nothing
15+
// about this one.
16+
return current === event.chatId ? null : current;
17+
}

0 commit comments

Comments
 (0)