Skip to content

Commit 3f8bbd7

Browse files
committed
fix(webapp): stop the Ask AI host remounting the dashboard, and four review fixes
- `_app`: `AskAIRoot` is a sibling of the app, not a wrapper. Its Kapa provider mounts client-only, so wrapping the outlet remounted the whole signed-in tree once per page load on cloud. Entry points reach it through an open-request bridge instead of a render prop. - The agent no longer hands a `trigger://source/...` target to `navigate`: a resolved GitHub URL opens in a new tab, and only a root-relative path is routed. - Chat history reloads coalesce without answering a request with data fetched before it, so a new chat and its title land. - Help & Feedback and the shortcuts sheet offer each AI surface only where the reader has it: Ask AI is back for users without agent access. - Restore the `AskAgentButton` mount on the deploy blank states, dropped in the PR split. - `agent-shortcuts.test.ts`: import ⌘I from `ask-ai-channels`.
1 parent 3dd9612 commit 3f8bbd7

17 files changed

Lines changed: 472 additions & 99 deletions

apps/webapp/app/components/AskAI.tsx

Lines changed: 10 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { useSearchParams } from "@remix-run/react";
1717
import DOMPurify from "dompurify";
1818
import { motion } from "framer-motion";
1919
import { marked } from "marked";
20-
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
20+
import { useCallback, useEffect, useRef, useState } from "react";
2121
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
2222
import { SparkleListIcon } from "~/assets/icons/SparkleListIcon";
2323
import { useAskAiAvailability } from "~/hooks/useAskAiAvailability";
@@ -27,6 +27,7 @@ import {
2727
ASK_AI_SHORTCUT,
2828
askAiCanOpen,
2929
} from "./dashboard-agent/ask-ai-channels";
30+
import { useAskAiHost } from "./dashboard-agent/askAiOpenRequest";
3031
import { Button } from "./primitives/Buttons";
3132
import { Callout } from "./primitives/Callout";
3233
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./primitives/Dialog";
@@ -81,44 +82,30 @@ function useAskAIState() {
8182
}
8283

8384
/**
84-
* Hosts Ask AI (Kapa provider, ⌘I shortcut, dialog) around a subtree that renders its own
85-
* triggers. Wrap it around them, never inside, so the dialog and the shortcut survive whatever
86-
* opened them closing. `children` receives the open function, or undefined when Ask AI is
87-
* unavailable (self-hosted, no Kapa website id, or SSR).
85+
* Hosts Ask AI (Kapa provider, ⌘I shortcut, dialog). It renders no page content and wraps
86+
* nothing: entry points reach it through `requestAskAi`, so the Kapa provider mounting after
87+
* hydration can never remount the app around it.
8888
*/
89-
export function AskAIRoot({
90-
children,
91-
}: {
92-
children: (openAskAI: (() => void) | undefined) => ReactNode;
93-
}) {
89+
export function AskAIRoot() {
9490
const availability = useAskAiAvailability();
9591

9692
if (!askAiCanOpen(availability)) {
97-
return <>{children(undefined)}</>;
93+
return null;
9894
}
9995

10096
const websiteId = availability.kapaWebsiteId!;
10197

102-
return (
103-
<ClientOnly fallback={<>{children(undefined)}</>}>
104-
{() => <AskAIRootProvider websiteId={websiteId}>{children}</AskAIRootProvider>}
105-
</ClientOnly>
106-
);
98+
return <ClientOnly>{() => <AskAIRootProvider websiteId={websiteId} />}</ClientOnly>;
10799
}
108100

109-
function AskAIRootProvider({
110-
websiteId,
111-
children,
112-
}: {
113-
websiteId: string;
114-
children: (openAskAI: () => void) => ReactNode;
115-
}) {
101+
function AskAIRootProvider({ websiteId }: { websiteId: string }) {
116102
const { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI } = useAskAIState();
117103

118104
useShortcutKeys({
119105
shortcut: ASK_AI_SHORTCUT,
120106
action: () => openAskAI(),
121107
});
108+
useAskAiHost(openAskAI);
122109

123110
return (
124111
<KapaProvider
@@ -131,7 +118,6 @@ function AskAIRootProvider({
131118
}}
132119
botProtectionMechanism="hcaptcha"
133120
>
134-
{children(() => openAskAI())}
135121
<AskAIDialog
136122
initialQuery={initialQuery}
137123
isOpen={isOpen}

apps/webapp/app/components/BlankStatePanels.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
v3NewProjectAlertPath,
3535
v3NewSchedulePath,
3636
} from "~/utils/pathBuilder";
37+
import { AskAgentButton } from "./dashboard-agent/AskAgentButton";
3738
import { CodeBlock } from "./code/CodeBlock";
3839
import { InlineCode } from "./code/InlineCode";
3940
import { environmentFullTitle, EnvironmentIcon } from "./environments/EnvironmentLabel";
@@ -61,6 +62,14 @@ import {
6162
import { StepContentContainer } from "./StepContentContainer";
6263
import { V4Badge } from "./V4Badge";
6364

65+
/**
66+
* What the agent is asked when it's opened from a deployment setup panel. The panel is the docs
67+
* answer; the agent is for the part the docs can't answer — this project, this environment.
68+
*/
69+
const ASK_AGENT_DEPLOY_PROMPT =
70+
"I'm trying to deploy my tasks to this environment. Walk me through it and tell me if anything about this project or environment is going to get in the way.";
71+
72+
/** The docs links the deployment panels offer to anyone without the agent. */
6473
function DeployDocsLinks() {
6574
return (
6675
<>
@@ -310,7 +319,10 @@ export function DeploymentsNoneDev() {
310319
<Header1>Deploy your tasks</Header1>
311320
</div>
312321
<div className="flex items-center">
313-
<DeployDocsLinks />
322+
{/* One entry point instead of two: the docs links were a guess at which page you
323+
needed, and the agent can look at this project and answer for it. Someone with no
324+
agent still gets the links. */}
325+
<AskAgentButton prompt={ASK_AGENT_DEPLOY_PROMPT} fallback={<DeployDocsLinks />} />
314326
</div>
315327
</div>
316328
<StepNumber stepNumber="→" title="Switch to a deployed environment" />
@@ -676,7 +688,10 @@ function DeploymentOnboardingSteps() {
676688
</Header1>
677689
</div>
678690
<div className="flex items-center">
679-
<DeployDocsLinks />
691+
{/* One entry point instead of two: the docs links were a guess at which page you
692+
needed, and the agent can look at this project and answer for it. Someone with no
693+
agent still gets the links. */}
694+
<AskAgentButton prompt={ASK_AGENT_DEPLOY_PROMPT} fallback={<DeployDocsLinks />} />
680695
</div>
681696
</div>
682697
<ClientTabs defaultValue="github">

apps/webapp/app/components/Shortcuts.tsx

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import { KeyboardIcon } from "~/assets/icons/KeyboardIcon";
22
import { useState } from "react";
33
import { ASK_AGENT_LABEL } from "~/components/dashboard-agent/agent-identity";
4+
import { type AiShortcutRow, aiShortcutRows } from "~/components/dashboard-agent/ai-entry-points";
5+
import { ASK_AI_SHORTCUT, askAiCanOpen } from "~/components/dashboard-agent/ask-ai-channels";
6+
import { useDashboardAgentAvailable } from "~/components/dashboard-agent/dashboardAgentOpenRequest";
47
import { NEW_CHAT_SHORTCUT } from "~/components/dashboard-agent/DashboardAgentHeader";
58
import { TOGGLE_PANEL_SHORTCUT } from "~/components/dashboard-agent/dashboardAgentLauncher";
9+
import { useAskAiAvailability } from "~/hooks/useAskAiAvailability";
610
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
711
import { Header3 } from "./primitives/Headers";
812
import { SideMenuItemButton } from "./navigation/SideMenuItem";
@@ -44,6 +48,11 @@ export function ShortcutsAutoOpen() {
4448
}
4549

4650
function ShortcutContent() {
51+
const agent = useDashboardAgentAvailable();
52+
const askAi = askAiCanOpen(useAskAiAvailability());
53+
const rows = aiShortcutRows({ agent, askAi });
54+
const shows = (row: AiShortcutRow) => rows.includes(row);
55+
4756
return (
4857
<SheetContent>
4958
<SheetHeader>
@@ -65,10 +74,27 @@ function ShortcutContent() {
6574
<ShortcutKey shortcut={{ modifiers: ["mod"] }} variant="medium/bright" />
6675
<ShortcutKey shortcut={{ key: "enter" }} variant="medium/bright" />
6776
</Shortcut>
68-
<Shortcut name={ASK_AGENT_LABEL}>
69-
<ShortcutKey shortcut={{ modifiers: ["mod"] }} variant="medium/bright" />
70-
<ShortcutKey shortcut={{ key: TOGGLE_PANEL_SHORTCUT.key }} variant="medium/bright" />
71-
</Shortcut>
77+
{shows("agent-toggle") && (
78+
<Shortcut name={ASK_AGENT_LABEL}>
79+
<ShortcutKey
80+
shortcut={{ modifiers: TOGGLE_PANEL_SHORTCUT.modifiers }}
81+
variant="medium/bright"
82+
/>
83+
<ShortcutKey
84+
shortcut={{ key: TOGGLE_PANEL_SHORTCUT.key }}
85+
variant="medium/bright"
86+
/>
87+
</Shortcut>
88+
)}
89+
{shows("ask-ai") && (
90+
<Shortcut name="Ask AI">
91+
<ShortcutKey
92+
shortcut={{ modifiers: ASK_AI_SHORTCUT.modifiers }}
93+
variant="medium/bright"
94+
/>
95+
<ShortcutKey shortcut={{ key: ASK_AI_SHORTCUT.key }} variant="medium/bright" />
96+
</Shortcut>
97+
)}
7298
<Shortcut name="Filter">
7399
<ShortcutKey shortcut={{ key: "f" }} variant="medium/bright" />
74100
</Shortcut>
@@ -97,19 +123,23 @@ function ShortcutContent() {
97123
<ShortcutKey shortcut={{ key: "h" }} variant="medium/bright" />
98124
</Shortcut>
99125
</div>
100-
<div className="space-y-3">
101-
<Header3>Chat</Header3>
102-
<Shortcut name="New chat">
103-
<ShortcutKey
104-
shortcut={{ modifiers: NEW_CHAT_SHORTCUT.modifiers }}
105-
variant="medium/bright"
106-
/>
107-
<ShortcutKey shortcut={{ key: NEW_CHAT_SHORTCUT.key }} variant="medium/bright" />
108-
</Shortcut>
109-
<Shortcut name="Close chat">
110-
<ShortcutKey shortcut={{ key: "esc" }} variant="medium/bright" />
111-
</Shortcut>
112-
</div>
126+
{shows("agent-new-chat") && (
127+
<div className="space-y-3">
128+
<Header3>Chat</Header3>
129+
<Shortcut name="New chat">
130+
<ShortcutKey
131+
shortcut={{ modifiers: NEW_CHAT_SHORTCUT.modifiers }}
132+
variant="medium/bright"
133+
/>
134+
<ShortcutKey shortcut={{ key: NEW_CHAT_SHORTCUT.key }} variant="medium/bright" />
135+
</Shortcut>
136+
{shows("agent-close-chat") && (
137+
<Shortcut name="Close chat">
138+
<ShortcutKey shortcut={{ key: "esc" }} variant="medium/bright" />
139+
</Shortcut>
140+
)}
141+
</div>
142+
)}
113143
<div className="space-y-3">
114144
<Header3>Runs page</Header3>
115145
<Shortcut name="Bulk action: Cancel runs">

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

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { DashboardAgentHero } from "./DashboardAgentHero";
1313
import { DashboardAgentMessages, type TurnActivity } from "./DashboardAgentMessages";
1414
import { MESSAGE_TOO_LARGE_ERROR } from "./message-limits";
1515
import { createTranscriptOrder, orderTranscript } from "./message-order";
16-
import { appendRunFilters } from "./navigate-target";
16+
import { navigateDestination } from "./navigate-target";
1717
import { pendingNavigateIntents } from "./pending-intents";
1818
import type { AgentPageContext } from "./page-context-types";
1919
import { retryAction } from "./retry-action";
@@ -214,9 +214,18 @@ export function DashboardAgentChat({
214214
body.set("uri", intent.target);
215215
try {
216216
const res = await fetch(actionPath, { method: "POST", body });
217-
const data = (await res.json()) as { path?: string };
218-
if (!res.ok || !data.path) throw new Error(`Resolve failed (${res.status})`);
219-
navigate(appendRunFilters(data.path, intent.filters));
217+
const data = (await res.json()) as { path?: string; external?: boolean };
218+
if (!res.ok) throw new Error(`Resolve failed (${res.status})`);
219+
const destination = navigateDestination(data, intent.filters);
220+
if (destination.kind === "none") throw new Error("Resolved to nothing routable");
221+
if (destination.kind === "route") {
222+
navigate(destination.path);
223+
return;
224+
}
225+
// A source file lives on GitHub. The fetch above has already broken the gesture chain,
226+
// so a blocked popup falls back to leaving the dashboard rather than doing nothing.
227+
const opened = window.open(destination.url, "_blank", "noopener,noreferrer");
228+
if (!opened) window.location.assign(destination.url);
220229
} catch (error) {
221230
console.error("Dashboard agent: failed to resolve a navigate target", error);
222231
toast.error("Couldn't open that page.");

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

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
type DashboardAgentClientData,
1616
type DashboardAgentSession,
1717
} from "./DashboardAgentChat";
18+
import { createCoalescedReload } from "./coalesced-reload";
1819
import { DashboardAgentDraft } from "./DashboardAgentDraft";
1920
import type { TurnActivity } from "./DashboardAgentMessages";
2021
import { DashboardAgentHeader } from "./DashboardAgentHeader";
@@ -125,26 +126,21 @@ export function DashboardAgentPanel({
125126
);
126127
}, []);
127128

128-
const historyInFlight = useRef<Promise<void> | null>(null);
129-
130-
const loadHistory = useCallback(async () => {
131-
if (historyInFlight.current) return historyInFlight.current;
132-
const request = (async () => {
133-
try {
134-
const res = await fetch(actionPath);
135-
if (!res.ok) throw new Error(`History request failed (${res.status})`);
136-
const data = (await res.json()) as { chats?: DashboardAgentChatListItem[] };
137-
setChats(data.chats ?? []);
138-
} catch (error) {
139-
console.error("Dashboard agent: failed to load chat history", error);
140-
toast.error("We couldn't load your previous chats. Try again in a moment.");
141-
} finally {
142-
historyInFlight.current = null;
143-
}
144-
})();
145-
historyInFlight.current = request;
146-
return request;
147-
}, [actionPath, toast]);
129+
const loadHistory = useMemo(
130+
() =>
131+
createCoalescedReload(async () => {
132+
try {
133+
const res = await fetch(actionPath);
134+
if (!res.ok) throw new Error(`History request failed (${res.status})`);
135+
const data = (await res.json()) as { chats?: DashboardAgentChatListItem[] };
136+
setChats(data.chats ?? []);
137+
} catch (error) {
138+
console.error("Dashboard agent: failed to load chat history", error);
139+
toast.error("We couldn't load your previous chats. Try again in a moment.");
140+
}
141+
}),
142+
[actionPath, toast]
143+
);
148144

149145
// Bumped on each open so a slower earlier open can't overwrite a newer one.
150146
const openChatRequestSeq = useRef(0);

apps/webapp/app/components/dashboard-agent/agent-shortcuts.test.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it } from "vitest";
22
import { hotkeyOptions } from "~/hooks/useShortcutKeys";
3-
import { LEGACY_ASK_AI_SHORTCUT, TOGGLE_PANEL_SHORTCUT } from "./dashboardAgentLauncher";
3+
import { ASK_AI_SHORTCUT } from "./ask-ai-channels";
4+
import { TOGGLE_PANEL_SHORTCUT } from "./dashboardAgentLauncher";
45

56
const enabled = { isEnabled: true };
67

@@ -20,9 +21,7 @@ describe("the agent's shortcuts", () => {
2021
});
2122

2223
it("leaves Cmd-I's default alone", () => {
23-
expect(hotkeyOptions({ shortcut: LEGACY_ASK_AI_SHORTCUT, ...enabled }).preventDefault).toBe(
24-
false
25-
);
24+
expect(hotkeyOptions({ shortcut: ASK_AI_SHORTCUT, ...enabled }).preventDefault).toBe(false);
2625
});
2726
});
2827

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { describe, expect, it } from "vitest";
2+
import { aiMenuEntries, aiShortcutRows } from "./ai-entry-points";
3+
4+
// These prove the decision, not the render: the components map these lists to rows, so a row can
5+
// still be mislabelled — but it can no longer be shown to someone who cannot use it.
6+
7+
describe("aiMenuEntries", () => {
8+
it("offers the agent and Ask AI when the reader has both", () => {
9+
expect(aiMenuEntries({ agent: true, askAi: true })).toEqual(["agent", "ask-ai"]);
10+
});
11+
12+
it("offers Ask AI on its own where Kapa is configured and the agent is not available", () => {
13+
expect(aiMenuEntries({ agent: false, askAi: true })).toEqual(["ask-ai"]);
14+
});
15+
16+
it("offers the agent on its own where Kapa is not configured", () => {
17+
expect(aiMenuEntries({ agent: true, askAi: false })).toEqual(["agent"]);
18+
});
19+
20+
it("offers nothing when neither surface exists", () => {
21+
expect(aiMenuEntries({ agent: false, askAi: false })).toEqual([]);
22+
});
23+
});
24+
25+
describe("aiShortcutRows", () => {
26+
it("lists ⌘J's row only for a reader with the agent", () => {
27+
expect(aiShortcutRows({ agent: true, askAi: false })).toContain("agent-toggle");
28+
expect(aiShortcutRows({ agent: false, askAi: true })).not.toContain("agent-toggle");
29+
});
30+
31+
it("lists ⌘I's row wherever Ask AI can open", () => {
32+
expect(aiShortcutRows({ agent: false, askAi: true })).toContain("ask-ai");
33+
expect(aiShortcutRows({ agent: true, askAi: true })).toContain("ask-ai");
34+
expect(aiShortcutRows({ agent: true, askAi: false })).not.toContain("ask-ai");
35+
});
36+
37+
it("keeps the chat rows with the agent that owns them", () => {
38+
expect(aiShortcutRows({ agent: true, askAi: true })).toEqual([
39+
"agent-toggle",
40+
"ask-ai",
41+
"agent-new-chat",
42+
"agent-close-chat",
43+
]);
44+
expect(aiShortcutRows({ agent: false, askAi: false })).toEqual([]);
45+
});
46+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Which AI surfaces the reader can actually use. The agent is gated on access, Ask AI exists
3+
* only where Kapa is configured, and all four combinations ship — so neither surface may assume
4+
* the other is there, and neither may advertise what the reader cannot reach.
5+
*/
6+
export type AiSurfaces = {
7+
/** A dashboard-agent host is mounted for this user. */
8+
agent: boolean;
9+
/** `askAiCanOpen`: managed cloud with a Kapa website id. */
10+
askAi: boolean;
11+
};
12+
13+
export type AiMenuEntry = "agent" | "ask-ai";
14+
15+
/** Help & Feedback offers every AI surface the reader has, and nothing when they have none. */
16+
export function aiMenuEntries({ agent, askAi }: AiSurfaces): AiMenuEntry[] {
17+
const entries: AiMenuEntry[] = [];
18+
if (agent) entries.push("agent");
19+
if (askAi) entries.push("ask-ai");
20+
return entries;
21+
}
22+
23+
export type AiShortcutRow = "agent-toggle" | "ask-ai" | "agent-new-chat" | "agent-close-chat";
24+
25+
/** The shortcuts sheet lists a keystroke only where its surface registered it. */
26+
export function aiShortcutRows({ agent, askAi }: AiSurfaces): AiShortcutRow[] {
27+
const rows: AiShortcutRow[] = [];
28+
if (agent) rows.push("agent-toggle");
29+
if (askAi) rows.push("ask-ai");
30+
if (agent) rows.push("agent-new-chat", "agent-close-chat");
31+
return rows;
32+
}

0 commit comments

Comments
 (0)