diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts index 8ed3dd09..a5bc2ad2 100644 --- a/apps/web/src/config.ts +++ b/apps/web/src/config.ts @@ -84,6 +84,7 @@ export const wording = { signOut: "Sign out", tryAgain: "Try again", openSettings: "Open Settings", + enableAi: "Enable AI", unarchive: "Unarchive", markShipped: "Mark as shipped", reopen: "Reopen", @@ -376,6 +377,7 @@ export const wording = { empty: "Nothing in the conversation yet", emptyLead: "Replies show here. Saved documents show on the right.", startingCoordinator: "Starting the coordinator…", + enableAiFirst: "Enable AI to start the conversation", dropFiles: "Drop files to attach them", noCoordinator: "This project has no running coordinator.", }, diff --git a/apps/web/src/pages/model-setup.tsx b/apps/web/src/pages/model-setup.tsx index fb20c9e5..7252eebb 100644 --- a/apps/web/src/pages/model-setup.tsx +++ b/apps/web/src/pages/model-setup.tsx @@ -6,6 +6,14 @@ import { useState } from "react"; import { productName, wording } from "../config.ts"; import { AuthLayout, DitherCanvas, KindCardGrid } from "@corbits/react-ui"; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@corbits/react-ui/ui/dialog"; import { Banner, Mark, QUIET_CONTROL } from "../components.jsx"; import { AddProviderDialog, @@ -49,6 +57,74 @@ export function needsModelSetup( ); } +/** + * A tile per subscription login, then a tile per API-key provider opening the Add provider dialog. + * The caller holds the logins, so a sign-in in flight outlives the tiles when a dialog closes. + */ +function ProviderTiles({ + subscriptions, + onConnected, +}: { + subscriptions: ReturnType; + onConnected: () => void; +}) { + const catalog = useModelCatalog(); + const { logins, signingIn, signIn } = subscriptions; + const [keyOpen, setKeyOpen] = useState(false); + const [keyProviderId, setKeyProviderId] = useState(); + const fault = catalog.error ? ( + + ) : null; + // No tiles until the catalog says which providers exist; a failed refetch keeps the last read. + if (catalog.data === undefined) return fault; + const apiKeyProviders = catalog.data.providers.filter( + (provider) => provider.kind === "api_key", + ); + return ( + <> + {fault} + ({ + id: provider.id, + title: provider.label, + description: signingIn === provider.id ? copy.waiting : provider.hint, + icon: , + disabled: signingIn !== null, + }))} + onChange={(id) => { + const provider = logins.find((login) => login.id === id); + if (provider) void signIn(provider); + }} + /> +
+ ({ + id: provider.id, + title: provider.label, + description: provider.hint, + icon: , + disabled: signingIn !== null, + }))} + onChange={(id) => { + setKeyProviderId(id); + setKeyOpen(true); + }} + /> + + + ); +} + export function ModelSetup({ onConnected, onLater, @@ -60,13 +136,7 @@ export function ModelSetup({ clearModelSetupDeferral(); onConnected(); }; - const catalog = useModelCatalog(); - const { logins, signingIn, signIn } = useSubscriptionLogins(connected); - const apiKeyProviders = (catalog.data?.providers ?? []).filter( - (provider) => provider.kind === "api_key", - ); - const [keyOpen, setKeyOpen] = useState(false); - const [keyProviderId, setKeyProviderId] = useState(); + const subscriptions = useSubscriptionLogins(connected); return ( {copy.connect}

{copy.noneHint}

- {catalog.error ? ( - - ) : null} - ({ - id: provider.id, - title: provider.label, - description: - signingIn === provider.id ? copy.waiting : provider.hint, - icon: , - disabled: signingIn !== null, - }))} - onChange={(id) => { - const provider = logins.find((login) => login.id === id); - if (provider) void signIn(provider); - }} - /> -
- ({ - id: provider.id, - title: provider.label, - description: provider.hint, - icon: , - disabled: signingIn !== null, - }))} - onChange={(id) => { - setKeyProviderId(id); - setKeyOpen(true); - }} - /> - +
); - } else if (modelNeeded !== null) { + } else if (modelNeeded !== null && !providerMissing) { // A coordinator with no model to run on cannot answer, even one still bound from before. dock = (
-

{modelNeeded}

+

{modelNeeded.message}

{wording.action.openSettings} @@ -1282,7 +1301,7 @@ export function StageWorkspace({
); - } else if (unbound) { + } else if (unbound && !providerMissing) { // The person does not start the coordinator by typing: an unbound room waits for the // start that opening it asked for, and a refused start says why and offers it again. if (startFault === null) { @@ -1346,7 +1365,7 @@ export function StageWorkspace({ )} - {replies.length > 0 ? ( + {replies.length > 0 && !providerMissing ? (
@@ -1388,7 +1408,7 @@ export function StageWorkspace({ onSubmit={send} canSubmit={value.trim() !== "" || files.length > 0} status={sending || approvalBusy ? "submitted" : "ready"} - disabled={sending || approvalBusy} + disabled={sending || approvalBusy || providerMissing} > {files.length === 0 ? null : ( @@ -1415,17 +1435,23 @@ export function StageWorkspace({ )} - {mic} - {limits === undefined || + {providerMissing ? null : mic} + {providerMissing || + limits === undefined || limits.mimeTypes.length === 0 ? null : ( <> )} - + {!providerMissing ? ( + + ) : ( + setEnablingAi(true)} + > + {wording.action.enableAi} + + )} )} @@ -1554,6 +1592,7 @@ export function StageWorkspace({ {dock} +
{chatFirst ? null : (