Skip to content

Commit e315926

Browse files
committed
fix(webapp): send a deep-linked question once
setSearchParams only starts the navigation that drops the param, so a render before it commits saw the question again and asked it a second time. The reader now records what it sent and forgets it once the URL no longer carries it, so a later visit with the same question still works.
1 parent a871179 commit e315926

2 files changed

Lines changed: 90 additions & 5 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, expect, it } from "vitest";
2+
import { consumeDeepLinkQuestion } from "./dashboardAgentOpenRequest";
3+
4+
const NAMES = ["aiHelp"];
5+
6+
/**
7+
* The reader is an effect over `useSearchParams`. Dropping the param is a navigation, so the
8+
* effect can run again on the original params before it commits; without a record of what was
9+
* already sent, the deep-linked question is asked twice.
10+
*/
11+
describe("consumeDeepLinkQuestion", () => {
12+
const params = (search: string) => new URLSearchParams(search);
13+
14+
it("hands over the question the first time it sees it", () => {
15+
expect(consumeDeepLinkQuestion(params("?aiHelp=why+is+it+slow"), NAMES, null)).toEqual({
16+
question: "why is it slow",
17+
sent: "why is it slow",
18+
});
19+
});
20+
21+
it("does not hand it over again while the param is still in the URL", () => {
22+
const first = consumeDeepLinkQuestion(params("?aiHelp=why"), NAMES, null);
23+
expect(first.question).toBe("why");
24+
expect(consumeDeepLinkQuestion(params("?aiHelp=why"), NAMES, first.sent).question).toBeNull();
25+
});
26+
27+
it("keeps the record until the param is gone, however many renders that takes", () => {
28+
let sent: string | null = null;
29+
let asked = 0;
30+
for (let render = 0; render < 5; render++) {
31+
const result = consumeDeepLinkQuestion(params("?aiHelp=why"), NAMES, sent);
32+
sent = result.sent;
33+
if (result.question !== null) asked++;
34+
}
35+
expect(asked).toBe(1);
36+
});
37+
38+
it("forgets the question once the URL no longer carries it, so a later visit works", () => {
39+
const first = consumeDeepLinkQuestion(params("?aiHelp=why"), NAMES, null);
40+
const cleared = consumeDeepLinkQuestion(params(""), NAMES, first.sent);
41+
expect(cleared).toEqual({ question: null, sent: null });
42+
expect(consumeDeepLinkQuestion(params("?aiHelp=why"), NAMES, cleared.sent).question).toBe(
43+
"why"
44+
);
45+
});
46+
47+
it("asks a different question that arrives before the first is dropped", () => {
48+
const first = consumeDeepLinkQuestion(params("?aiHelp=why"), NAMES, null);
49+
expect(consumeDeepLinkQuestion(params("?aiHelp=how"), NAMES, first.sent).question).toBe("how");
50+
});
51+
52+
it("has nothing to ask when no watched param is present", () => {
53+
expect(consumeDeepLinkQuestion(params("?other=x"), NAMES, null)).toEqual({
54+
question: null,
55+
sent: null,
56+
});
57+
expect(consumeDeepLinkQuestion(params("?aiHelp=why"), [], null).question).toBeNull();
58+
});
59+
60+
it("ignores an empty question", () => {
61+
expect(consumeDeepLinkQuestion(params("?aiHelp="), NAMES, null).question).toBeNull();
62+
});
63+
});

apps/webapp/app/components/dashboard-agent/dashboardAgentOpenRequest.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useSyncExternalStore } from "react";
1+
import { useEffect, useRef, useSyncExternalStore } from "react";
22
import { useSearchParams } from "@remix-run/react";
33

44
// Module-level bridge: `DashboardAgentProvider` is mounted by the environment layout, so
@@ -49,6 +49,24 @@ export function useDashboardAgentAvailable(): boolean {
4949
);
5050
}
5151

52+
/**
53+
* The deep-link question still in the URL, or null when there is none left to ask. `sent` is the
54+
* question already handed to the agent: `setSearchParams` only starts the navigation that drops
55+
* the param, so every render until it commits sees the question again. Clearing `sent` once the
56+
* param is gone lets the same question arrive a second time on a later visit.
57+
*/
58+
export function consumeDeepLinkQuestion(
59+
params: URLSearchParams,
60+
names: readonly string[],
61+
sent: string | null
62+
): { question: string | null; sent: string | null } {
63+
const name = names.find((candidate) => params.get(candidate));
64+
if (!name) return { question: null, sent: null };
65+
const question = params.get(name)!;
66+
if (question === sent) return { question: null, sent };
67+
return { question, sent: question };
68+
}
69+
5270
/** While `enabled` is false nothing is registered, so every entry point stays hidden. */
5371
export function useDashboardAgentOpenRequests({
5472
enabled,
@@ -68,12 +86,16 @@ export function useDashboardAgentOpenRequests({
6886
}, [enabled, openWith, setOpen]);
6987

7088
const [searchParams, setSearchParams] = useSearchParams();
89+
const sent = useRef<string | null>(null);
7190
useEffect(() => {
7291
if (!enabled) return;
73-
const param = deepLinkParams.find((name) => searchParams.get(name));
74-
if (!param) return;
75-
const question = searchParams.get(param)!;
76-
// Consume it before opening, or a re-render asks the same question twice.
92+
const { question, sent: nextSent } = consumeDeepLinkQuestion(
93+
searchParams,
94+
deepLinkParams,
95+
sent.current
96+
);
97+
sent.current = nextSent;
98+
if (question === null) return;
7799
const next = new URLSearchParams(searchParams);
78100
for (const name of deepLinkParams) next.delete(name);
79101
setSearchParams(next, { replace: true, preventScrollReset: true });

0 commit comments

Comments
 (0)