Skip to content

Commit 1d89908

Browse files
committed
fix(webapp): cancel a dashboard agent turn when the user leaves the page
Closing the panel or switching chat keeps the turn — that is what the unread dot is for. Only a deliberate stop and navigating away end it.
1 parent d9085d7 commit 1d89908

3 files changed

Lines changed: 108 additions & 1 deletion

File tree

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

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
type SuggestedPrompt,
88
type WatchSpec,
99
} from "@internal/dashboard-agent-contracts";
10-
import { useNavigate } from "@remix-run/react";
10+
import { useLocation, useNavigate } from "@remix-run/react";
1111
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
1212
import { useCallback, useEffect, useRef, useState } from "react";
1313
import { useToast } from "~/components/primitives/Toast";
@@ -27,6 +27,7 @@ import {
2727
hasOpenInvestigation,
2828
pollSettledTranscript,
2929
} from "./settled-transcript";
30+
import { teardownCancelsTurn, unmountTeardown } from "./turn-teardown";
3031
import { useAgentMessageQuota } from "./useAgentMessageQuota";
3132
import { useTriggerUriResolver } from "./useTriggerUriResolver";
3233
import { WatchChips, type WatchChip } from "./WatchChips";
@@ -99,6 +100,7 @@ export function DashboardAgentChat({
99100
}) {
100101
const [input, setInput] = useState("");
101102
const navigate = useNavigate();
103+
const location = useLocation();
102104
const toast = useToast();
103105

104106
const prefilledSeq = useRef<number | undefined>(undefined);
@@ -307,6 +309,23 @@ export function DashboardAgentChat({
307309
aiStop();
308310
}, [transport, chatId, aiStop]);
309311

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+
317+
const teardownRef = useRef<() => void>(() => {});
318+
teardownRef.current = () => {
319+
if (status !== "streaming" && status !== "submitted") return;
320+
const reason = unmountTeardown({
321+
renderedPath: renderedPathRef.current,
322+
livePath: window.location.pathname,
323+
});
324+
if (!teardownCancelsTurn(reason)) return;
325+
stop();
326+
};
327+
useEffect(() => () => teardownRef.current(), []);
328+
310329
// Read by the settle effect, which must not re-run when the transcript changes.
311330
const messagesRef = useRef(messages);
312331
messagesRef.current = messages;
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { readFileSync } from "node:fs";
2+
import { describe, expect, it } from "vitest";
3+
import { teardownCancelsTurn, unmountTeardown } from "./turn-teardown";
4+
5+
describe("teardownCancelsTurn", () => {
6+
it("cancels when the user clicks Stop", () => {
7+
expect(teardownCancelsTurn("stop-clicked")).toBe(true);
8+
});
9+
10+
it("keeps the turn when the panel closes", () => {
11+
expect(teardownCancelsTurn("panel-closed")).toBe(false);
12+
});
13+
14+
it("keeps the turn when the panel changes chat", () => {
15+
expect(teardownCancelsTurn("chat-switched")).toBe(false);
16+
});
17+
18+
it("cancels when the user has left the page", () => {
19+
expect(teardownCancelsTurn("navigated-away")).toBe(true);
20+
});
21+
});
22+
23+
describe("unmountTeardown", () => {
24+
const path = "/orgs/acme/projects/api/env/prod/runs";
25+
26+
it("reads an unmount on the same path as the panel closing", () => {
27+
expect(unmountTeardown({ renderedPath: path, livePath: path })).toBe("panel-closed");
28+
});
29+
30+
it("reads an unmount after the URL moved as a navigation", () => {
31+
expect(unmountTeardown({ renderedPath: path, livePath: "/orgs/acme/settings" })).toBe(
32+
"navigated-away"
33+
);
34+
});
35+
36+
it("ignores the query string, so filtering a page is not leaving it", () => {
37+
// Both sides are pathnames; a filter change never reaches this comparison.
38+
expect(unmountTeardown({ renderedPath: path, livePath: path })).toBe("panel-closed");
39+
});
40+
});
41+
42+
/**
43+
* Structural guards, not behavioural proof: the wiring depends on when React runs the cleanup
44+
* relative to the router, which these assertions pin down without rendering anything.
45+
*/
46+
describe("the chat cancels its turn only on the teardowns that say so", () => {
47+
const chat = readFileSync(new URL("./DashboardAgentChat.tsx", import.meta.url), "utf8");
48+
49+
it("decides through the shared rule rather than unmounting straight into a stop", () => {
50+
expect(chat).toContain("teardownCancelsTurn(");
51+
expect(chat).toContain("unmountTeardown({");
52+
});
53+
54+
it("compares the last rendered path against the live one", () => {
55+
expect(chat).toContain("renderedPath: renderedPathRef.current");
56+
expect(chat).toContain("livePath: window.location.pathname");
57+
});
58+
59+
it("runs the cleanup once, not on every path change", () => {
60+
const teardown = chat.slice(chat.indexOf("const teardownRef"));
61+
expect(teardown).toMatch(
62+
/useEffect\(\s*\(\)\s*=>\s*\(\)\s*=>\s*teardownRef\.current\(\),\s*\[\]\)/
63+
);
64+
});
65+
66+
it("cancels nothing when no turn is in flight", () => {
67+
expect(chat).toContain('if (status !== "streaming" && status !== "submitted") return;');
68+
});
69+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/** Why a chat with a turn in flight is going away. */
2+
export type TurnTeardown = "stop-clicked" | "panel-closed" | "chat-switched" | "navigated-away";
3+
4+
/**
5+
* A turn that finishes behind a closed panel is what the launcher dot exists for, so only a
6+
* deliberate stop and leaving the page end it early.
7+
*/
8+
export function teardownCancelsTurn(reason: TurnTeardown): boolean {
9+
return reason === "stop-clicked" || reason === "navigated-away";
10+
}
11+
12+
/**
13+
* The three unmounts look identical from inside React. A navigation has already moved the URL
14+
* by the time the cleanup runs; closing the panel and switching chat leave it alone, and since
15+
* both keep the turn they share one branch.
16+
*/
17+
export function unmountTeardown(paths: { renderedPath: string; livePath: string }): TurnTeardown {
18+
return paths.renderedPath === paths.livePath ? "panel-closed" : "navigated-away";
19+
}

0 commit comments

Comments
 (0)