Skip to content

Commit c4df021

Browse files
committed
fix(webapp): send every prompt the user clicks, never just fill the composer
Investigate and the prompt chips now always send. With a chat open the message goes there; mid-turn or mid-open it waits for the chat rather than barging in.
1 parent b75e85c commit c4df021

5 files changed

Lines changed: 111 additions & 24 deletions

File tree

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

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export function DashboardAgentChat({
6262
currentPage,
6363
pendingFirstMessage,
6464
streaming,
65-
prefill,
65+
sendRequest,
6666
promotedPrompt,
6767
watches,
6868
pagePaths,
@@ -86,8 +86,9 @@ export function DashboardAgentChat({
8686
// Undefined for head-started and resumed chats.
8787
pendingFirstMessage?: string;
8888
streaming?: boolean;
89-
// `seq` makes each request distinct so the same text can be sent twice.
90-
prefill?: { text: string; seq: number };
89+
// A prompt the user asked for by clicking. `seq` makes each request distinct so the same
90+
// text can be sent twice.
91+
sendRequest?: { text: string; seq: number };
9192
promotedPrompt?: SuggestedPrompt;
9293
watches: WatchChip[];
9394
pagePaths?: Record<string, string>;
@@ -109,13 +110,6 @@ export function DashboardAgentChat({
109110
const renderedPathRef = useRef(location.pathname);
110111
renderedPathRef.current = location.pathname;
111112

112-
const prefilledSeq = useRef<number | undefined>(undefined);
113-
useEffect(() => {
114-
if (!prefill || prefilledSeq.current === prefill.seq) return;
115-
prefilledSeq.current = prefill.seq;
116-
setInput(prefill.text);
117-
}, [prefill]);
118-
119113
const transport = useTriggerChatTransport<typeof dashboardAgent>({
120114
task: "dashboard-agent",
121115
baseURL: apiOrigin,
@@ -232,6 +226,14 @@ export function DashboardAgentChat({
232226
[isStreaming, atMessageCap, sendMessage]
233227
);
234228

229+
// The panel only sends when the chat can take it, so this never lands mid-turn.
230+
const sentRequestSeq = useRef<number | undefined>(undefined);
231+
useEffect(() => {
232+
if (!sendRequest || sentRequestSeq.current === sendRequest.seq) return;
233+
sentRequestSeq.current = sendRequest.seq;
234+
submit(sendRequest.text);
235+
}, [sendRequest, submit]);
236+
235237
const retry = useCallback(() => {
236238
// A watch's consent record is a user message nobody typed, so retry never treats it as one.
237239
const action = retryAction(
@@ -416,7 +418,7 @@ export function DashboardAgentChat({
416418
onSubmit={() => submit(input)}
417419
onStop={stop}
418420
isStreaming={isStreaming}
419-
focusKey={prefill?.seq}
421+
focusKey={sendRequest?.seq}
420422
context={
421423
<DashboardAgentContextBanner
422424
projectSlug={projectSlug}

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

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import type { SuggestedPrompt, WatchSpec } from "@internal/dashboard-agent-contr
2727
import { resolveOpenedChat, type OpenedChatResponse } from "./opened-chat";
2828
import type { AgentPageContext } from "./page-context-types";
2929
import { agentPageLabel } from "./page-label";
30+
import { explicitPromptTarget } from "./explicit-prompt";
3031
import { escapeClosesPanel } from "./panel-escape";
3132
import { markChatListRead, unreadWorkCount } from "./unread-counts";
3233
import { AgentPanelColumn } from "./panel-layout";
@@ -341,20 +342,25 @@ export function DashboardAgentPanel({
341342
}, [chats, chatsLoaded, onUnreadWorkChange]);
342343

343344
// Bound to its chat, which remounts with a fresh guard ref on every switch.
344-
const [prefill, setPrefill] = useState<{ text: string; seq: number; chatId: string } | undefined>(
345-
undefined
346-
);
345+
const [sendRequest, setSendRequest] = useState<
346+
{ text: string; seq: number; chatId: string } | undefined
347+
>(undefined);
347348
const handledRequestSeq = useRef<number | undefined>(undefined);
348349
useEffect(() => {
349-
if (!requestedMessage || loading) return;
350-
if (handledRequestSeq.current === requestedMessage.seq) return;
350+
if (!requestedMessage || handledRequestSeq.current === requestedMessage.seq) return;
351+
const target = explicitPromptTarget({
352+
chat: loading ? "opening" : active ? "open" : "none",
353+
turnInFlight: thinkingChatId !== null && thinkingChatId === active?.chatId,
354+
});
355+
// Held requests are re-asked by this same effect once the panel settles.
356+
if (target === "hold") return;
351357
handledRequestSeq.current = requestedMessage.seq;
352-
if (active) {
353-
setPrefill({ ...requestedMessage, chatId: active.chatId });
354-
} else {
358+
if (target === "new-chat") {
355359
void createChat(requestedMessage.text);
360+
return;
356361
}
357-
}, [requestedMessage, loading, active, createChat]);
362+
setSendRequest({ ...requestedMessage, chatId: active!.chatId });
363+
}, [requestedMessage, loading, active, thinkingChatId, createChat]);
358364

359365
// Carries its chat id so a later-mounted chat cannot adopt another chat's block.
360366
const [appendedMessages, setAppendedMessages] = useState<
@@ -574,7 +580,9 @@ export function DashboardAgentPanel({
574580
session={active.session}
575581
pendingFirstMessage={active.pendingFirstMessage}
576582
streaming={active.streaming}
577-
prefill={prefill && prefill.chatId === active.chatId ? prefill : undefined}
583+
sendRequest={
584+
sendRequest && sendRequest.chatId === active.chatId ? sendRequest : undefined
585+
}
578586
clientData={clientData}
579587
apiOrigin={apiOrigin}
580588
actionPath={actionPath}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { readFileSync } from "node:fs";
2+
import { describe, expect, it } from "vitest";
3+
import { explicitPromptTarget } from "./explicit-prompt";
4+
5+
describe("explicitPromptTarget", () => {
6+
it("starts a chat when the panel has none", () => {
7+
expect(explicitPromptTarget({ chat: "none", turnInFlight: false })).toBe("new-chat");
8+
});
9+
10+
it("sends into the chat the user is already in", () => {
11+
expect(explicitPromptTarget({ chat: "open", turnInFlight: false })).toBe("send-to-open-chat");
12+
});
13+
14+
it("holds while a chat is still opening, rather than racing it into a new one", () => {
15+
expect(explicitPromptTarget({ chat: "opening", turnInFlight: false })).toBe("hold");
16+
});
17+
18+
it("holds while the open chat is mid-turn instead of barging in", () => {
19+
expect(explicitPromptTarget({ chat: "open", turnInFlight: true })).toBe("hold");
20+
});
21+
22+
it("never fills the composer and leaves the sending to the user", () => {
23+
const targets = (["none", "opening", "open"] as const).flatMap((chat) =>
24+
[true, false].map((turnInFlight) => explicitPromptTarget({ chat, turnInFlight }))
25+
);
26+
expect(targets).not.toContain("prefill");
27+
});
28+
});
29+
30+
/**
31+
* Structural guards, not behavioural proof: whether a held request is asked again, and whether
32+
* the old prefill path is really gone, live in the wiring rather than in the rule.
33+
*/
34+
describe("the panel sends every explicit prompt", () => {
35+
const panel = readFileSync(new URL("./DashboardAgentPanel.tsx", import.meta.url), "utf8");
36+
const chat = readFileSync(new URL("./DashboardAgentChat.tsx", import.meta.url), "utf8");
37+
38+
it("routes through the shared rule", () => {
39+
expect(panel).toContain("explicitPromptTarget({");
40+
});
41+
42+
it("keeps a held request pending instead of marking it handled", () => {
43+
const effect = panel.slice(panel.indexOf("const target = explicitPromptTarget({"));
44+
expect(effect.indexOf('if (target === "hold") return;')).toBeLessThan(
45+
effect.indexOf("handledRequestSeq.current = requestedMessage.seq;")
46+
);
47+
});
48+
49+
it("re-asks once the panel settles, so a hold cannot strand the prompt", () => {
50+
expect(panel).toContain("}, [requestedMessage, loading, active, thinkingChatId, createChat]);");
51+
});
52+
53+
it("leaves no prefill path behind", () => {
54+
expect(panel).not.toMatch(/prefill/i);
55+
expect(chat).not.toMatch(/prefill/i);
56+
});
57+
58+
it("submits the request in the chat rather than typing it into the composer", () => {
59+
expect(chat).toContain("submit(sendRequest.text);");
60+
expect(chat).not.toContain("setInput(sendRequest.text)");
61+
});
62+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/** What to do with a prompt the user asked for by clicking, rather than by typing. */
2+
export type ExplicitPromptTarget = "new-chat" | "send-to-open-chat" | "hold";
3+
4+
/**
5+
* A click on Investigate or a prompt chip always ends in a sent message; only where it lands
6+
* depends on the panel. `hold` is not a refusal — the request stays pending and is asked again
7+
* once the chat has opened or its turn has finished.
8+
*/
9+
export function explicitPromptTarget(panel: {
10+
chat: "none" | "opening" | "open";
11+
turnInFlight: boolean;
12+
}): ExplicitPromptTarget {
13+
if (panel.chat === "opening") return "hold";
14+
if (panel.chat === "none") return "new-chat";
15+
return panel.turnInFlight ? "hold" : "send-to-open-chat";
16+
}

apps/webapp/app/components/dashboard-agent/turn-teardown.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,8 @@ export function teardownCancelsTurn(reason: TurnTeardown): boolean {
1010
}
1111

1212
/**
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.
13+
* The three unmounts look identical from inside React. Only a navigation has already moved the
14+
* URL by the time the cleanup runs; the other two keep the turn, so they share one branch.
1615
*/
1716
export function unmountTeardown(paths: { renderedPath: string; livePath: string }): TurnTeardown {
1817
return paths.renderedPath === paths.livePath ? "panel-closed" : "navigated-away";

0 commit comments

Comments
 (0)