Skip to content

Commit bc3e5d0

Browse files
committed
chore: merge feat/query-safety-tri-11165 (review fixes)
2 parents 7428874 + dd22919 commit bc3e5d0

10 files changed

Lines changed: 188 additions & 42 deletions

File tree

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

Lines changed: 49 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,10 @@ import {
2323
} from "./panel-layout";
2424
import { nextPendingTurnChatId } from "./pending-turn";
2525
import { nextVisibleChat } from "./unread-counts";
26-
import { planWakeToasts, startWakePolling, wakesToToast } from "./wake-poll";
26+
import { createWakePendingCount, startWakePolling, wakesToToast } from "./wake-poll";
2727
import { shouldPollWakeFeed, subscribeWatchActivity } from "./watch-activity";
2828
import {
29+
dismissWatchWakesSummaryToast,
2930
showWatchWakesSummaryToast,
3031
showWatchWakeToast,
3132
WAKE_TOAST_MAX_INDIVIDUAL,
@@ -101,8 +102,8 @@ export function DashboardAgent({
101102

102103
// The count the still-visible grouped toast claims. Consecutive polls add to it so a
103104
// later batch grows the summary instead of overwriting it with only its own count;
104-
// reset when the user opens the panel from that toast.
105-
const summaryPending = useRef(0);
105+
// reset when the user opens the panel, whichever route they took.
106+
const wakePending = useRef(createWakePendingCount());
106107

107108
// Switching environment re-runs the layout loader but does not remount it, so the seeds
108109
// above would keep the old environment's counts.
@@ -146,35 +147,58 @@ export function DashboardAgent({
146147
undefined
147148
);
148149

149-
const setPanelOpen = useCallback((next: boolean) => {
150-
setOpen(next);
151-
// Pending requests must be dropped or a stale one re-applies on the next open.
152-
if (!next) {
150+
// The single entry point for opening the panel — every open route must go through it.
151+
// Opening acknowledges the wakes counted so far, and the visible summary goes with the
152+
// count it was claiming.
153+
const openPanel = useCallback(() => {
154+
wakePending.current.acknowledge();
155+
dismissWatchWakesSummaryToast();
156+
setOpen(true);
157+
}, []);
158+
159+
const setPanelOpen = useCallback(
160+
(next: boolean) => {
161+
if (next) {
162+
openPanel();
163+
return;
164+
}
165+
setOpen(false);
166+
// Pending requests must be dropped or a stale one re-applies on the next open.
153167
visibleChat.current = null;
154168
setFullscreen(false);
155169
writeAgentFullscreen(false);
156170
setRequestedMessage(undefined);
157171
setOpenChatRequest(undefined);
158172
setWatchRequest(undefined);
159-
}
160-
}, []);
173+
},
174+
[openPanel]
175+
);
161176

162-
const openChat = useCallback((chatId: string) => {
163-
setOpen(true);
164-
setOpenChatRequest((current) => ({ chatId, seq: (current?.seq ?? 0) + 1 }));
165-
}, []);
177+
const openChat = useCallback(
178+
(chatId: string) => {
179+
openPanel();
180+
setOpenChatRequest((current) => ({ chatId, seq: (current?.seq ?? 0) + 1 }));
181+
},
182+
[openPanel]
183+
);
166184

167-
const openWith = useCallback((text: string) => {
168-
const trimmed = text.trim();
169-
if (!trimmed) return;
170-
setOpen(true);
171-
setRequestedMessage((current) => ({ text: trimmed, seq: (current?.seq ?? 0) + 1 }));
172-
}, []);
185+
const openWith = useCallback(
186+
(text: string) => {
187+
const trimmed = text.trim();
188+
if (!trimmed) return;
189+
openPanel();
190+
setRequestedMessage((current) => ({ text: trimmed, seq: (current?.seq ?? 0) + 1 }));
191+
},
192+
[openPanel]
193+
);
173194

174-
const openWithWatch = useCallback((spec: WatchSpec) => {
175-
setOpen(true);
176-
setWatchRequest((current) => ({ spec, seq: (current?.seq ?? 0) + 1 }));
177-
}, []);
195+
const openWithWatch = useCallback(
196+
(spec: WatchSpec) => {
197+
openPanel();
198+
setWatchRequest((current) => ({ spec, seq: (current?.seq ?? 0) + 1 }));
199+
},
200+
[openPanel]
201+
);
178202

179203
// Nothing to be woken about means nothing to poll for. The page load's unread count and
180204
// active-watch flag are the ungated signals; the browser's own memory of a watch starts the
@@ -230,17 +254,9 @@ export function DashboardAgent({
230254
for (const wake of fresh) rememberToasted(wake.watchId);
231255

232256
if (fresh.length > 0) {
233-
const { plan, pending } = planWakeToasts(
234-
fresh,
235-
summaryPending.current,
236-
WAKE_TOAST_MAX_INDIVIDUAL
237-
);
238-
summaryPending.current = pending;
257+
const plan = wakePending.current.plan(fresh, WAKE_TOAST_MAX_INDIVIDUAL);
239258
if (plan.mode === "summary") {
240-
showWatchWakesSummaryToast(plan.count, () => {
241-
summaryPending.current = 0;
242-
setPanelOpen(true);
243-
});
259+
showWatchWakesSummaryToast(plan.count, () => setPanelOpen(true));
244260
} else {
245261
for (const wake of [...plan.wakes].reverse()) {
246262
showWatchWakeToast(wake, openChat);

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { explicitPromptTarget } from "./explicit-prompt";
3939
import { escapeClosesPanel } from "./panel-escape";
4040
import { markChatListRead, unreadWorkCount } from "./unread-counts";
4141
import { AgentPanelColumn } from "./panel-layout";
42+
import { markerAfterActiveChat, markerAfterActivity } from "./thinking-marker";
4243
import { concurrencyPath } from "~/utils/pathBuilder";
4344

4445
function serializePageContext(pageContext: AgentPageContext): string | undefined {
@@ -138,9 +139,7 @@ export function DashboardAgentPanel({
138139
const [thinkingChatId, setThinkingChatId] = useState<string | null>(null);
139140
const handleActivityChange = useCallback(
140141
(chatId: string, activity: TurnActivity | null) => {
141-
setThinkingChatId((previous) =>
142-
activity !== null ? chatId : previous === chatId ? null : previous
143-
);
142+
setThinkingChatId((previous) => markerAfterActivity(previous, chatId, activity));
144143
onTurnActivityChange?.(chatId, activity !== null);
145144
},
146145
[onTurnActivityChange]
@@ -149,6 +148,11 @@ export function DashboardAgentPanel({
149148
// The read POST and its reload can land out of order, so mask the next list.
150149
const justRead = useRef<Set<string>>(new Set());
151150

151+
// Ordering-safe: if the new chat has not reported yet, its own report re-sets the marker.
152+
useEffect(() => {
153+
setThinkingChatId((previous) => markerAfterActiveChat(previous, active?.chatId));
154+
}, [active?.chatId]);
155+
152156
const loadHistory = useMemo(
153157
() =>
154158
createCoalescedReload(async () => {

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

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,10 @@ export function showWatchWakeToast(wake: WatchWake, onOpenChat: (chatId: string)
115115
);
116116
}
117117

118+
// One id for all summaries: a later poll rewrites the count in place instead of stacking a
119+
// second never-expiring toast on top of the first.
120+
const WAKES_SUMMARY_TOAST_ID = "watch-wakes-summary";
121+
118122
/** One persistent toast standing in for a batch too large to narrate one by one. */
119123
export function showWatchWakesSummaryToast(count: number, onOpenChat: () => void) {
120124
show(
@@ -126,8 +130,14 @@ export function showWatchWakesSummaryToast(count: number, onOpenChat: () => void
126130
onOpenChat={onOpenChat}
127131
/>
128132
),
129-
// One id for all summaries: a later poll rewrites the count in place instead
130-
// of stacking a second never-expiring toast on top of the first.
131-
"watch-wakes-summary"
133+
WAKES_SUMMARY_TOAST_ID
132134
);
133135
}
136+
137+
/**
138+
* Takes the summary off screen. Its count only means anything until the user opens the
139+
* panel; left up, a later poll would rewrite it to a smaller number.
140+
*/
141+
export function dismissWatchWakesSummaryToast() {
142+
toast.dismiss(WAKES_SUMMARY_TOAST_ID);
143+
}

apps/webapp/app/components/dashboard-agent/demo/demo.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import { readdirSync, readFileSync, statSync } from "node:fs";
1515
import { join } from "node:path";
1616
import { describe, expect, it } from "vitest";
17+
import { resolveSuggestedPrompts } from "../suggested-prompts";
1718
import * as fixtures from "./fixtures";
1819
import { DEMO_ID_PREFIX, DEMO_MARKER } from "./ids";
1920

@@ -256,6 +257,15 @@ describe("page context and prompt fixtures", () => {
256257
expect(fixtures.demoPromptsAfterDismissal.some((prompt) => prompt.id === id)).toBe(false);
257258
}
258259
});
260+
261+
it("dismisses a chip the resolver actually emits", () => {
262+
const full = resolveSuggestedPrompts(fixtures.demoFailedRunPageContext);
263+
const after = resolveSuggestedPrompts(fixtures.demoFailedRunPageContext, {
264+
dismissedIds: fixtures.demoResolvedDismissedPromptIds,
265+
});
266+
expect(full.map((prompt) => prompt.id)).toContain(fixtures.demoResolvedDismissedPromptIds[0]);
267+
expect(after.map((prompt) => prompt.id)).not.toEqual(full.map((prompt) => prompt.id));
268+
});
259269
});
260270

261271
describe("report fixtures", () => {

apps/webapp/app/components/dashboard-agent/demo/fixtures/page-context.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,12 @@ export const demoPromptSets: Record<DemoPageContextKey, SuggestedPrompt[]> = {
238238

239239
export const demoDismissedPromptIds: string[] = [demoId("prompt-watch-retry")];
240240

241+
/**
242+
* Ids the resolver itself emits, for dismissing against a live resolve. Dismissing the
243+
* fresh-failure chip on `demoFailedRunPageContext` falls back to `sp:run-investigate`.
244+
*/
245+
export const demoResolvedDismissedPromptIds: string[] = ["sp:fresh-failure"];
246+
241247
export const demoPromptsAfterDismissal: SuggestedPrompt[] = demoPromptSets.failedRun
242248
.filter((p) => !demoDismissedPromptIds.includes(p.id))
243249
.slice(0, SUGGESTED_PROMPT_CAP);
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { describe, expect, it } from "vitest";
2+
import { markerAfterActiveChat, markerAfterActivity } from "./thinking-marker";
3+
4+
describe("thinking marker", () => {
5+
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);
9+
});
10+
11+
it("ignores a settled report from another chat", () => {
12+
expect(markerAfterActivity("chat_1", "chat_2", null)).toBe("chat_1");
13+
});
14+
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);
19+
});
20+
21+
it("keeps the marker the chat just reported for itself", () => {
22+
expect(markerAfterActiveChat("chat_1", "chat_1")).toBe("chat_1");
23+
});
24+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { TurnActivity } from "./DashboardAgentMessages";
2+
3+
// Which chat the history list shows as busy. Only the mounted chat reports.
4+
5+
export function markerAfterActivity(
6+
previous: string | null,
7+
chatId: string,
8+
activity: TurnActivity | null
9+
): string | null {
10+
return activity !== null ? chatId : previous === chatId ? null : previous;
11+
}
12+
13+
// 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.
15+
export function markerAfterActiveChat(
16+
previous: string | null,
17+
activeChatId: string | undefined
18+
): string | null {
19+
return previous === activeChatId ? previous : null;
20+
}

apps/webapp/app/components/dashboard-agent/wake-poll.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
import {
3+
createWakePendingCount,
34
planWakeToasts,
45
startWakePolling,
56
UNREAD_POLL_INTERVAL_MS,
@@ -168,3 +169,37 @@ describe("planWakeToasts", () => {
168169
expect(second.pending).toBe(7);
169170
});
170171
});
172+
173+
describe("createWakePendingCount", () => {
174+
const MAX = 3;
175+
const batch = (n: number) => Array.from({ length: n }, (_, i) => i);
176+
177+
it("carries unacknowledged wakes into the summary", () => {
178+
const count = createWakePendingCount();
179+
180+
expect(count.plan(batch(2), MAX)).toEqual({ mode: "individual", wakes: [0, 1] });
181+
expect(count.plan(batch(2), MAX)).toEqual({ mode: "summary", count: 4 });
182+
});
183+
184+
it("does not count wakes the user already opened", () => {
185+
const count = createWakePendingCount();
186+
187+
// Two individual toasts, both opened — from the toast, ⌘J, anywhere.
188+
expect(count.plan(batch(2), MAX).mode).toBe("individual");
189+
count.acknowledge();
190+
191+
// Only the two new wakes are waiting, so they toast individually rather than
192+
// claiming "4 watch updates".
193+
expect(count.plan(batch(2), MAX)).toEqual({ mode: "individual", wakes: [0, 1] });
194+
});
195+
196+
it("starts the next summary from the wakes that arrived after the open", () => {
197+
const count = createWakePendingCount();
198+
199+
expect(count.plan(batch(4), MAX)).toEqual({ mode: "summary", count: 4 });
200+
count.acknowledge();
201+
202+
expect(count.plan(batch(2), MAX).mode).toBe("individual");
203+
expect(count.plan(batch(2), MAX)).toEqual({ mode: "summary", count: 4 });
204+
});
205+
});

apps/webapp/app/components/dashboard-agent/wake-poll.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,27 @@ export function planWakeToasts<T>(
4545
return { plan: { mode: "individual", wakes: fresh }, pending: total };
4646
}
4747

48+
/**
49+
* The running pending count, owned by one holder so the poll and the panel cannot drift.
50+
* Every wake counts until the user opens the panel — by whichever route, including a single
51+
* wake toast — and opening it clears the count so a later grouped toast claims only wakes
52+
* still waiting.
53+
*/
54+
export function createWakePendingCount() {
55+
let pending = 0;
56+
57+
return {
58+
plan<T>(fresh: T[], max: number): WakeToastPlan<T> {
59+
const result = planWakeToasts(fresh, pending, max);
60+
pending = result.pending;
61+
return result.plan;
62+
},
63+
acknowledge() {
64+
pending = 0;
65+
},
66+
};
67+
}
68+
4869
export type WakePollOptions = {
4970
load: () => Promise<void>;
5071
isHidden: () => boolean;

apps/webapp/app/routes/storybook.agent-ui/route.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -174,9 +174,9 @@ const promotedPrompt: SuggestedPrompt = {
174174
source: "promoted",
175175
};
176176

177-
// Pinned, not resolved: the fixture's signal has a fixed timestamp, so a live resolve would
178-
// dismiss a different chip once that timestamp aged out of the freshness window.
179-
const dismissedPromptIds = demoFixtures.demoDismissedPromptIds;
177+
// Resolver-minted ids: the panel resolves its own chips, so a demo-namespaced id would
178+
// match nothing and the state would render undismissed.
179+
const dismissedPromptIds = demoFixtures.demoResolvedDismissedPromptIds;
180180

181181
function toWatchChip(watch: (typeof demoWatches.row)[number]): WatchChip {
182182
return {

0 commit comments

Comments
 (0)