Skip to content

Commit b75e85c

Browse files
committed
fix(webapp): scope a turn's navigation to the page it was asked on
A turn that outlives its page no longer moves a user who has since walked to another screen. The answer's own button still takes them there.
1 parent 1d89908 commit b75e85c

3 files changed

Lines changed: 96 additions & 6 deletions

File tree

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

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
hasOpenInvestigation,
2828
pollSettledTranscript,
2929
} from "./settled-transcript";
30+
import { navigateIntentApplies } from "./turn-navigation";
3031
import { teardownCancelsTurn, unmountTeardown } from "./turn-teardown";
3132
import { useAgentMessageQuota } from "./useAgentMessageQuota";
3233
import { useTriggerUriResolver } from "./useTriggerUriResolver";
@@ -103,6 +104,11 @@ export function DashboardAgentChat({
103104
const location = useLocation();
104105
const toast = useToast();
105106

107+
// The path this chat last rendered on. React never unmounts on a page teardown, so an
108+
// unmount whose live URL has moved is the router having navigated out from under it.
109+
const renderedPathRef = useRef(location.pathname);
110+
renderedPathRef.current = location.pathname;
111+
106112
const prefilledSeq = useRef<number | undefined>(undefined);
107113
useEffect(() => {
108114
if (!prefill || prefilledSeq.current === prefill.seq) return;
@@ -287,10 +293,27 @@ export function DashboardAgentChat({
287293
navigatedRef.current = new Set();
288294
pendingNavigateIntents(initialMessages, navigatedRef.current);
289295
}
296+
// Where the running turn was asked for. Never cleared on settle: the navigate intent can be
297+
// committed alongside the status going ready, and it is the started-at path it belongs to.
298+
const turnStartedPathRef = useRef<string | null>(null);
299+
const turnWasInFlight = useRef(false);
300+
useEffect(() => {
301+
const inFlight = status === "submitted" || status === "streaming";
302+
if (inFlight && !turnWasInFlight.current) turnStartedPathRef.current = renderedPathRef.current;
303+
turnWasInFlight.current = inFlight;
304+
}, [status]);
305+
290306
useEffect(() => {
291307
const pending = pendingNavigateIntents(messages, navigatedRef.current!);
292308
const target = pending.at(-1);
293-
if (target) void goTo(target);
309+
if (!target) return;
310+
// Marked handled above either way, so a dropped navigation stays dropped and the answer's
311+
// own button remains the way to take it.
312+
const applies = navigateIntentApplies({
313+
startedPath: turnStartedPathRef.current,
314+
currentPath: renderedPathRef.current,
315+
});
316+
if (applies) void goTo(target);
294317
}, [messages, goTo]);
295318

296319
const watchProposedRef = useRef<Set<string> | null>(null);
@@ -309,11 +332,6 @@ export function DashboardAgentChat({
309332
aiStop();
310333
}, [transport, chatId, aiStop]);
311334

312-
// The path this chat last rendered on. React never unmounts on a page teardown, so an
313-
// unmount whose live URL has moved is the router having navigated out from under it.
314-
const renderedPathRef = useRef(location.pathname);
315-
renderedPathRef.current = location.pathname;
316-
317335
const teardownRef = useRef<() => void>(() => {});
318336
teardownRef.current = () => {
319337
if (status !== "streaming" && status !== "submitted") return;
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { readFileSync } from "node:fs";
2+
import { describe, expect, it } from "vitest";
3+
import { navigateIntentApplies } from "./turn-navigation";
4+
5+
const runs = "/orgs/acme/projects/api/env/prod/runs";
6+
const queues = "/orgs/acme/projects/api/env/prod/queues";
7+
8+
describe("navigateIntentApplies", () => {
9+
it("navigates when the user is still where the turn was asked for", () => {
10+
expect(navigateIntentApplies({ startedPath: runs, currentPath: runs })).toBe(true);
11+
});
12+
13+
it("drops the navigation once the user has walked to another screen", () => {
14+
expect(navigateIntentApplies({ startedPath: runs, currentPath: queues })).toBe(false);
15+
});
16+
17+
it("drops it when this tab never saw the turn start", () => {
18+
// A resumed turn: nothing here knows the page it was asked on.
19+
expect(navigateIntentApplies({ startedPath: null, currentPath: runs })).toBe(false);
20+
});
21+
});
22+
23+
/**
24+
* Structural guards, not behavioural proof: whether the started-at path is still right when the
25+
* intent lands depends on effect order and on nothing clearing it, which these assertions pin
26+
* down without rendering anything.
27+
*/
28+
describe("the chat scopes a turn's navigation to the page it started on", () => {
29+
const chat = readFileSync(new URL("./DashboardAgentChat.tsx", import.meta.url), "utf8");
30+
31+
it("gates the navigate intent on the shared rule", () => {
32+
expect(chat).toContain("navigateIntentApplies({");
33+
expect(chat).toContain("startedPath: turnStartedPathRef.current");
34+
expect(chat).not.toContain("if (target) void goTo(target);");
35+
});
36+
37+
it("records the path only as a turn goes in flight", () => {
38+
expect(chat).toContain(
39+
"if (inFlight && !turnWasInFlight.current) turnStartedPathRef.current = renderedPathRef.current;"
40+
);
41+
});
42+
43+
it("never clears the path on settle, which can share a commit with the intent", () => {
44+
const assignments = [...chat.matchAll(/turnStartedPathRef\.current = /g)];
45+
expect(assignments).toHaveLength(1);
46+
});
47+
48+
it("records the path before the intent effect reads it", () => {
49+
expect(chat.indexOf("turnStartedPathRef.current = renderedPathRef.current")).toBeLessThan(
50+
chat.indexOf("navigateIntentApplies({")
51+
);
52+
});
53+
54+
it("marks a dropped navigation handled, so it cannot fire on a later commit", () => {
55+
const effect = chat.slice(chat.indexOf("const pending = pendingNavigateIntents(messages"));
56+
expect(effect.indexOf("pendingNavigateIntents(messages")).toBeLessThan(
57+
effect.indexOf("navigateIntentApplies({")
58+
);
59+
});
60+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* The panel follows the user around the dashboard, so a turn can outlive the page it was asked
3+
* on. Its navigation applies only there: someone who has since walked to another screen keeps
4+
* the screen they chose, and the answer's own button is still theirs to click.
5+
*/
6+
export function navigateIntentApplies(paths: {
7+
/** Null when this tab never saw the turn start, so it cannot claim the user is still there. */
8+
startedPath: string | null;
9+
currentPath: string;
10+
}): boolean {
11+
return paths.startedPath === paths.currentPath;
12+
}

0 commit comments

Comments
 (0)