Skip to content

Commit dc746d1

Browse files
authored
Guide the user to provider setup when a composer mode has no configured provider (#4739)
1 parent e94a0ed commit dc746d1

5 files changed

Lines changed: 276 additions & 1 deletion

File tree

web/src/components/chat/composer/MediaChatComposer.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ import type {
4444
} from "../../../stores/MediaGenerationStore";
4545
import MediaControlChip from "./MediaControlChip";
4646
import MediaModeMenu from "./MediaModeMenu";
47+
import ModeProviderSetupBanner from "./ModeProviderSetupBanner";
48+
import { useModeProviderSetup } from "./useModeProviderSetup";
4749
import PiComposerControls, { piModeAvailable } from "./PiComposerControls";
4850
import SmartToyOutlinedIcon from "@mui/icons-material/SmartToyOutlined";
4951
import MediaOptionMenu, { MediaOption } from "./MediaOptionMenu";
@@ -204,6 +206,12 @@ const MediaChatComposer: React.FC<MediaChatComposerProps> = ({
204206

205207
const addRecentModel = useModelPreferencesStore((s) => s.addRecent);
206208

209+
// When the selected mode's capability has no configured provider, surface a
210+
// setup banner and route the send into the provider-onboarding dialog
211+
// instead of letting the request fail server-side. Pi routes through its own
212+
// agent socket, so it is exempt.
213+
const providerSetup = useModeProviderSetup(isPi ? null : mode);
214+
207215
const [modeAnchor, setModeAnchor] = useState<HTMLButtonElement | null>(null);
208216
const [durationAnchor, setDurationAnchor] = useState<HTMLButtonElement | null>(null);
209217
const [resolutionAnchor, setResolutionAnchor] = useState<HTMLButtonElement | null>(null);
@@ -501,6 +509,12 @@ const MediaChatComposer: React.FC<MediaChatComposerProps> = ({
501509
if (!canGenerate) {
502510
return;
503511
}
512+
// No configured provider can serve this mode — sending would only fail on
513+
// the server. Keep the prompt and guide the user through provider setup.
514+
if (providerSetup.needsSetup) {
515+
providerSetup.openSetup();
516+
return;
517+
}
504518
const content: MessageContent[] = [];
505519
if (prompt.trim().length > 0) {
506520
content.push({ type: "text", text: prompt });
@@ -520,7 +534,8 @@ const MediaChatComposer: React.FC<MediaChatComposerProps> = ({
520534
getFileContents,
521535
sendMessage,
522536
clearFiles,
523-
recordHistory
537+
recordHistory,
538+
providerSetup
524539
]);
525540

526541
const handlePaste = useCallback(
@@ -890,6 +905,13 @@ const MediaChatComposer: React.FC<MediaChatComposerProps> = ({
890905
/>
891906
{mentionMenu}
892907

908+
{providerSetup.needsSetup && providerSetup.reason && (
909+
<ModeProviderSetupBanner
910+
reason={providerSetup.reason}
911+
onConnect={providerSetup.openSetup}
912+
/>
913+
)}
914+
893915
{queuedMessage && (
894916
<FlexRow
895917
gap={0.5}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import React, { memo } from "react";
2+
import KeyRoundedIcon from "@mui/icons-material/KeyRounded";
3+
import { AlertBanner, EditorButton } from "../../ui_primitives";
4+
5+
interface ModeProviderSetupBannerProps {
6+
reason: string;
7+
onConnect: () => void;
8+
}
9+
10+
/**
11+
* Inline callout shown in the composer when the selected mode has no
12+
* configured provider behind it. Offers the provider-onboarding dialog so the
13+
* user can connect a provider and add an API key without leaving the chat.
14+
*/
15+
const ModeProviderSetupBanner: React.FC<ModeProviderSetupBannerProps> = ({
16+
reason,
17+
onConnect
18+
}) => (
19+
<AlertBanner
20+
severity="info"
21+
compact
22+
icon={<KeyRoundedIcon fontSize="small" />}
23+
action={
24+
<EditorButton
25+
variant="outlined"
26+
color="primary"
27+
size="small"
28+
onClick={onConnect}
29+
>
30+
Connect a provider
31+
</EditorButton>
32+
}
33+
sx={{ mx: 1, mb: 0.5, alignItems: "center" }}
34+
>
35+
{reason}
36+
</AlertBanner>
37+
);
38+
39+
export default memo(ModeProviderSetupBanner);
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { renderHook, act } from "@testing-library/react";
2+
import { useModeProviderSetup } from "../useModeProviderSetup";
3+
import { capabilityForMode, setupReasonForMode } from "../modeProviderSetup";
4+
import { useProvidersByCapability } from "../../../../hooks/useProviders";
5+
import { openProviderOnboarding } from "../../../../stores/ProviderOnboardingStore";
6+
7+
jest.mock("../../../../hooks/useProviders", () => ({
8+
useProvidersByCapability: jest.fn()
9+
}));
10+
11+
jest.mock("../../../../stores/ProviderOnboardingStore", () => ({
12+
openProviderOnboarding: jest.fn()
13+
}));
14+
15+
const mockUseProvidersByCapability =
16+
useProvidersByCapability as jest.MockedFunction<
17+
typeof useProvidersByCapability
18+
>;
19+
const mockOpenProviderOnboarding =
20+
openProviderOnboarding as jest.MockedFunction<typeof openProviderOnboarding>;
21+
22+
const providersResult = (
23+
providers: { provider: string; capabilities: string[] }[],
24+
overrides: Partial<ReturnType<typeof useProvidersByCapability>> = {}
25+
) => ({
26+
providers,
27+
isLoading: false,
28+
isFetching: false,
29+
error: null,
30+
...overrides
31+
});
32+
33+
describe("capabilityForMode", () => {
34+
it("maps each selectable mode to the capability that serves it", () => {
35+
expect(capabilityForMode("chat")).toBe("generate_message");
36+
expect(capabilityForMode("image")).toBe("text_to_image");
37+
expect(capabilityForMode("image_edit")).toBe("text_to_image");
38+
expect(capabilityForMode("video")).toBe("text_to_video");
39+
expect(capabilityForMode("image_to_video")).toBe("text_to_video");
40+
expect(capabilityForMode("audio")).toBe("text_to_speech");
41+
});
42+
43+
it("maps not-yet-selectable modes to null", () => {
44+
expect(capabilityForMode("retake")).toBeNull();
45+
expect(capabilityForMode("extend")).toBeNull();
46+
expect(capabilityForMode("motion_control")).toBeNull();
47+
expect(capabilityForMode("audio_to_video")).toBeNull();
48+
});
49+
50+
it("has a reason for every mode with a capability", () => {
51+
for (const mode of [
52+
"chat",
53+
"image",
54+
"image_edit",
55+
"video",
56+
"image_to_video",
57+
"audio"
58+
] as const) {
59+
expect(setupReasonForMode(mode)).toEqual(expect.any(String));
60+
}
61+
});
62+
});
63+
64+
describe("useModeProviderSetup", () => {
65+
beforeEach(() => {
66+
jest.clearAllMocks();
67+
});
68+
69+
it("flags a mode whose capability has no configured provider", () => {
70+
mockUseProvidersByCapability.mockReturnValue(providersResult([]));
71+
72+
const { result } = renderHook(() => useModeProviderSetup("image"));
73+
74+
expect(mockUseProvidersByCapability).toHaveBeenCalledWith("text_to_image");
75+
expect(result.current.needsSetup).toBe(true);
76+
expect(result.current.reason).toBe(setupReasonForMode("image"));
77+
78+
act(() => result.current.openSetup());
79+
expect(mockOpenProviderOnboarding).toHaveBeenCalledWith({
80+
capability: "text_to_image",
81+
reason: setupReasonForMode("image")
82+
});
83+
});
84+
85+
it("stays quiet when a provider serves the mode", () => {
86+
mockUseProvidersByCapability.mockReturnValue(
87+
providersResult([{ provider: "fal_ai", capabilities: ["text_to_image"] }])
88+
);
89+
90+
const { result } = renderHook(() => useModeProviderSetup("image"));
91+
92+
expect(result.current.needsSetup).toBe(false);
93+
expect(result.current.reason).toBeNull();
94+
});
95+
96+
it("stays quiet while the provider query is loading or errored", () => {
97+
mockUseProvidersByCapability.mockReturnValue(
98+
providersResult([], { isLoading: true, isFetching: true })
99+
);
100+
const loading = renderHook(() => useModeProviderSetup("video"));
101+
expect(loading.result.current.needsSetup).toBe(false);
102+
103+
mockUseProvidersByCapability.mockReturnValue(
104+
providersResult([], { error: new Error("offline") })
105+
);
106+
const errored = renderHook(() => useModeProviderSetup("video"));
107+
expect(errored.result.current.needsSetup).toBe(false);
108+
});
109+
110+
it("is disabled for a null mode (Pi) and never opens onboarding", () => {
111+
mockUseProvidersByCapability.mockReturnValue(providersResult([]));
112+
113+
const { result } = renderHook(() => useModeProviderSetup(null));
114+
115+
expect(result.current.needsSetup).toBe(false);
116+
act(() => result.current.openSetup());
117+
expect(mockOpenProviderOnboarding).not.toHaveBeenCalled();
118+
});
119+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* modeProviderSetup — maps a composer media mode to the provider capability it
3+
* needs, plus the message shown when no provider with that capability is
4+
* configured. Used by the composer's setup banner and its send gate to route
5+
* the user into the provider-onboarding dialog instead of letting a send fail.
6+
*/
7+
import type { MediaMode } from "../../../stores/MediaGenerationStore";
8+
import type { OnboardingCapability } from "../../../stores/ProviderOnboardingStore";
9+
10+
/** Backend capability a mode's generation request is served by. Modes that are
11+
* not selectable yet (retake, extend, …) map to null and are never gated. */
12+
export const capabilityForMode = (
13+
mode: MediaMode
14+
): OnboardingCapability | null => {
15+
switch (mode) {
16+
case "chat":
17+
return "generate_message";
18+
case "image":
19+
case "image_edit":
20+
return "text_to_image";
21+
case "video":
22+
case "image_to_video":
23+
return "text_to_video";
24+
case "audio":
25+
return "text_to_speech";
26+
default:
27+
return null;
28+
}
29+
};
30+
31+
/** One-liner shown in the setup banner and passed to the onboarding dialog. */
32+
export const setupReasonForMode = (mode: MediaMode): string | null => {
33+
switch (mode) {
34+
case "chat":
35+
return "Chat needs a language model. Connect a provider to start.";
36+
case "image":
37+
return "Generating images needs an image provider. Connect one to continue.";
38+
case "image_edit":
39+
return "Editing images needs an image provider. Connect one to continue.";
40+
case "video":
41+
return "Generating videos needs a video provider. Connect one to continue.";
42+
case "image_to_video":
43+
return "Animating images needs a video provider. Connect one to continue.";
44+
case "audio":
45+
return "Generating speech needs a text-to-speech provider. Connect one to continue.";
46+
default:
47+
return null;
48+
}
49+
};
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { useCallback } from "react";
2+
import { useProvidersByCapability } from "../../../hooks/useProviders";
3+
import { openProviderOnboarding } from "../../../stores/ProviderOnboardingStore";
4+
import type { MediaMode } from "../../../stores/MediaGenerationStore";
5+
import { capabilityForMode, setupReasonForMode } from "./modeProviderSetup";
6+
7+
export interface ModeProviderSetup {
8+
/** True when the mode needs a capability no configured provider serves. */
9+
needsSetup: boolean;
10+
/** Banner copy for the current mode (null when nothing is needed). */
11+
reason: string | null;
12+
/** Open the provider-onboarding dialog filtered to the mode's capability. */
13+
openSetup: () => void;
14+
}
15+
16+
/**
17+
* Watches whether the selected composer mode has a configured provider behind
18+
* it. The providers endpoint only lists providers whose credential is present,
19+
* so an empty capability-filtered list means the mode cannot run until the
20+
* user connects a provider. Pass null (e.g. Pi mode) to disable the check.
21+
* While the provider query is loading or errored the check stays quiet — the
22+
* connection banner already surfaces fetch errors.
23+
*/
24+
export const useModeProviderSetup = (
25+
mode: MediaMode | null
26+
): ModeProviderSetup => {
27+
const capability = mode ? capabilityForMode(mode) : null;
28+
const { providers, isLoading, error } = useProvidersByCapability(
29+
capability ?? "generate_message"
30+
);
31+
const needsSetup =
32+
capability !== null && !isLoading && !error && providers.length === 0;
33+
const reason = needsSetup && mode ? setupReasonForMode(mode) : null;
34+
35+
const openSetup = useCallback(() => {
36+
if (!capability) {
37+
return;
38+
}
39+
openProviderOnboarding({
40+
capability,
41+
reason: (mode && setupReasonForMode(mode)) ?? undefined
42+
});
43+
}, [capability, mode]);
44+
45+
return { needsSetup, reason, openSetup };
46+
};

0 commit comments

Comments
 (0)