Skip to content

Commit e954d82

Browse files
committed
fix(webapp): stop a resumed turn navigating the tab that only picked it up
navigateIntentApplies documents startedPath: null as 'this tab never saw the turn start', but the path was inferred from the turn going in flight - and a resumed turn goes in flight too, so the tab stamped wherever it happened to be and the rule always matched. Stamp the path where a turn is actually started instead, which leaves it null for a turn this tab only resumed.
1 parent c86c2ed commit e954d82

2 files changed

Lines changed: 33 additions & 15 deletions

File tree

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

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -208,10 +208,17 @@ export function DashboardAgentChat({
208208
});
209209
}, [appendedMessages, setMessages]);
210210

211+
// Where this tab asked for the running turn, stamped only where a turn is actually started
212+
// here. A turn this tab resumed leaves it null, which is what tells `takeNavigateIntent` the
213+
// tab cannot claim the user is still on the page that asked. Never cleared on settle: the
214+
// navigate intent can be committed alongside the status going ready.
215+
const turnStartedPathRef = useRef<string | null>(null);
216+
211217
const sentFirst = useRef(false);
212218
useEffect(() => {
213219
if (pendingFirstMessage && !sentFirst.current) {
214220
sentFirst.current = true;
221+
turnStartedPathRef.current = renderedPathRef.current;
215222
void sendMessage({ text: pendingFirstMessage });
216223
}
217224
}, [pendingFirstMessage, sendMessage]);
@@ -222,6 +229,7 @@ export function DashboardAgentChat({
222229
// Suggested prompts and card actions bypass the composer, so the cap is enforced here too.
223230
if (!trimmed || isStreaming || atMessageCap) return;
224231
setInput("");
232+
turnStartedPathRef.current = renderedPathRef.current;
225233
void sendMessage({ text: trimmed });
226234
},
227235
[isStreaming, atMessageCap, sendMessage]
@@ -250,6 +258,7 @@ export function DashboardAgentChat({
250258
);
251259
if (!action) return;
252260
clearError();
261+
turnStartedPathRef.current = renderedPathRef.current;
253262
if (action.kind === "regenerate") {
254263
void regenerate();
255264
return;
@@ -313,16 +322,6 @@ export function DashboardAgentChat({
313322
navigatedRef.current = new Set();
314323
pendingNavigateIntents(initialMessages, navigatedRef.current);
315324
}
316-
// Where the running turn was asked for. Never cleared on settle: the navigate intent can be
317-
// committed alongside the status going ready, and it is the started-at path it belongs to.
318-
const turnStartedPathRef = useRef<string | null>(null);
319-
const turnWasInFlight = useRef(false);
320-
useEffect(() => {
321-
const inFlight = status === "submitted" || status === "streaming";
322-
if (inFlight && !turnWasInFlight.current) turnStartedPathRef.current = renderedPathRef.current;
323-
turnWasInFlight.current = inFlight;
324-
}, [status]);
325-
326325
useEffect(() => {
327326
const target = takeNavigateIntent({
328327
messages,

apps/webapp/app/components/dashboard-agent/turn-navigation.test.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,15 +90,34 @@ describe("the chat scopes a turn's navigation to the page it started on", () =>
9090
expect(chat).not.toContain("pendingNavigateIntents(messages");
9191
});
9292

93-
it("records the path only as a turn goes in flight", () => {
94-
expect(chat).toContain(
95-
"if (inFlight && !turnWasInFlight.current) turnStartedPathRef.current = renderedPathRef.current;"
93+
// Structural: the webapp has no DOM test environment, so the wiring is read off the source.
94+
// Going in flight cannot mean "started here" — a resumed turn goes in flight too, and stamping
95+
// on status handed it the current path and let it navigate.
96+
it("does not infer the path from the turn going in flight", () => {
97+
expect(chat).not.toContain("turnWasInFlight");
98+
expect(chat).not.toMatch(
99+
/status === "submitted"[\s\S]{0,160}turnStartedPathRef\.current = renderedPathRef/
96100
);
97101
});
98102

103+
it("stamps the path at every send, so a turn this tab only resumed leaves it null", () => {
104+
const lines = chat.split("\n");
105+
const sends = lines
106+
.map((line, index) => ({ line, index }))
107+
.filter(({ line }) => /void (sendMessage|regenerate)\(/.test(line));
108+
109+
expect(sends.length).toBeGreaterThan(0);
110+
for (const { line, index } of sends) {
111+
const preceding = lines.slice(Math.max(0, index - 5), index).join("\n");
112+
expect(
113+
preceding.includes("turnStartedPathRef.current = renderedPathRef.current"),
114+
`no path stamped before: ${line.trim()}`
115+
).toBe(true);
116+
}
117+
});
118+
99119
it("never clears the path on settle, which can share a commit with the intent", () => {
100-
const assignments = [...chat.matchAll(/turnStartedPathRef\.current = /g)];
101-
expect(assignments).toHaveLength(1);
120+
expect(chat).not.toMatch(/turnStartedPathRef\.current = null/);
102121
});
103122

104123
it("records the path before the intent effect reads it", () => {

0 commit comments

Comments
 (0)