From 4994797d451345ac5d92c5c31577f91c81933777 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 3 Nov 2026 21:31:03 -0800 Subject: [PATCH 1/4] feat(web): connect a model provider from the composer with Enable AI --- apps/web/src/config.ts | 2 + apps/web/src/pages/model-setup.tsx | 144 ++++++++++++++++--------- apps/web/src/pages/workspace/index.tsx | 63 ++++++++--- 3 files changed, 144 insertions(+), 65 deletions(-) diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts index 310a7180..2ee7c42d 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..2d88c21e 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,13 +57,8 @@ export function needsModelSetup( ); } -export function ModelSetup({ - onConnected, - onLater, -}: { - onConnected: () => void; - onLater: () => void; -}) { +/** A tile per subscription login, then a tile per API-key provider opening the Add provider dialog. */ +function ProviderTiles({ onConnected }: { onConnected: () => void }) { const connected = () => { clearModelSetupDeferral(); onConnected(); @@ -67,6 +70,60 @@ export function ModelSetup({ ); const [keyOpen, setKeyOpen] = useState(false); const [keyProviderId, setKeyProviderId] = useState(); + return ( + <> + {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); + }} + /> + + + ); +} + +export function ModelSetup({ + onConnected, + onLater, +}: { + onConnected: () => void; + onLater: () => void; +}) { 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} @@ -1275,7 +1284,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) { @@ -1381,7 +1390,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 : ( @@ -1408,15 +1417,20 @@ export function StageWorkspace({ )} - {mic} - {limits === undefined || + {providerMissing ? null : mic} + {providerMissing || + limits === undefined || limits.mimeTypes.length === 0 ? null : ( <> )} - + {!providerMissing ? ( + + ) : ( + setEnablingAi(true)} + > + {wording.action.enableAi} + + )} )} @@ -1544,6 +1570,11 @@ export function StageWorkspace({ {dock} + void startCoordinator()} + /> {chatFirst ? null : ( From 457c95cef6affa31656fce0ed2c36a2a3a356c4d Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 3 Nov 2026 22:50:03 -0800 Subject: [PATCH 2/4] fix(web): keep the composer inert until AI is enabled --- apps/web/src/pages/model-setup.tsx | 40 +++++++++++++++++--------- apps/web/src/pages/workspace/index.tsx | 5 ++-- 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/apps/web/src/pages/model-setup.tsx b/apps/web/src/pages/model-setup.tsx index 2d88c21e..5a817bd3 100644 --- a/apps/web/src/pages/model-setup.tsx +++ b/apps/web/src/pages/model-setup.tsx @@ -57,14 +57,19 @@ export function needsModelSetup( ); } -/** A tile per subscription login, then a tile per API-key provider opening the Add provider dialog. */ -function ProviderTiles({ onConnected }: { onConnected: () => void }) { - const connected = () => { - clearModelSetupDeferral(); - onConnected(); - }; +/** + * 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 } = useSubscriptionLogins(connected); + const { logins, signingIn, signIn } = subscriptions; const apiKeyProviders = (catalog.data?.providers ?? []).filter( (provider) => provider.kind === "api_key", ); @@ -111,7 +116,7 @@ function ProviderTiles({ onConnected }: { onConnected: () => void }) { providerId={keyProviderId} open={keyOpen} onOpenChange={setKeyOpen} - onConnected={connected} + onConnected={onConnected} /> ); @@ -124,6 +129,11 @@ export function ModelSetup({ onConnected: () => void; onLater: () => void; }) { + const connected = () => { + clearModelSetupDeferral(); + onConnected(); + }; + const subscriptions = useSubscriptionLogins(connected); return ( {copy.connect}

{copy.noneHint}

- +
)} - {replies.length > 0 ? ( + {replies.length > 0 && !providerMissing ? (
From 543ebaa7ade4f5f998e79630fc3300d6292dd09b Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 3 Nov 2026 22:56:04 -0800 Subject: [PATCH 3/4] fix(web): wait for the model catalog before drawing tiles --- apps/web/src/pages/model-setup.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/web/src/pages/model-setup.tsx b/apps/web/src/pages/model-setup.tsx index 5a817bd3..9dccc498 100644 --- a/apps/web/src/pages/model-setup.tsx +++ b/apps/web/src/pages/model-setup.tsx @@ -70,16 +70,17 @@ function ProviderTiles({ }) { const catalog = useModelCatalog(); const { logins, signingIn, signIn } = subscriptions; - const apiKeyProviders = (catalog.data?.providers ?? []).filter( - (provider) => provider.kind === "api_key", - ); const [keyOpen, setKeyOpen] = useState(false); const [keyProviderId, setKeyProviderId] = useState(); + if (catalog.error) + return ; + // No tiles until the catalog says which providers exist. + if (catalog.data === undefined) return null; + const apiKeyProviders = catalog.data.providers.filter( + (provider) => provider.kind === "api_key", + ); return ( <> - {catalog.error ? ( - - ) : null} Date: Tue, 3 Nov 2026 23:05:11 -0800 Subject: [PATCH 4/4] fix(web): start the coordinator once any provider connects --- apps/web/src/pages/model-setup.tsx | 18 ++++++++++-------- apps/web/src/pages/workspace/index.tsx | 18 ++++++++++++------ 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/apps/web/src/pages/model-setup.tsx b/apps/web/src/pages/model-setup.tsx index 9dccc498..7252eebb 100644 --- a/apps/web/src/pages/model-setup.tsx +++ b/apps/web/src/pages/model-setup.tsx @@ -72,15 +72,17 @@ function ProviderTiles({ const { logins, signingIn, signIn } = subscriptions; const [keyOpen, setKeyOpen] = useState(false); const [keyProviderId, setKeyProviderId] = useState(); - if (catalog.error) - return ; - // No tiles until the catalog says which providers exist. - if (catalog.data === undefined) return null; + 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} void; - onConnected: () => void; }) { const connected = () => { clearModelSetupDeferral(); onOpenChange(false); - onConnected(); }; const subscriptions = useSubscriptionLogins(connected); return ( diff --git a/apps/web/src/pages/workspace/index.tsx b/apps/web/src/pages/workspace/index.tsx index 2ad44efa..2aa2aad8 100644 --- a/apps/web/src/pages/workspace/index.tsx +++ b/apps/web/src/pages/workspace/index.tsx @@ -135,7 +135,8 @@ import { NextStepLine, useProjectNextStep } from "./next-step.tsx"; import { composerPlaceholder } from "./composer-placeholder.ts"; import { Dictated } from "../../dictation.jsx"; import { useShellReply } from "../../flourish.tsx"; -import { EnableAiDialog } from "../model-setup.tsx"; +import { EnableAiDialog, needsModelSetup } from "../model-setup.tsx"; +import { useModelCatalog } from "../providers.tsx"; // The stream says when the room moved. This read covers what it cannot see, // such as a coordinator run replaced while its old stream stays quiet. @@ -1015,6 +1016,15 @@ export function StageWorkspace({ else if (status === "archived" && room.data !== undefined) void room.refetch(); }, [projectId, status]); + const catalog = useModelCatalog(); + const providerConnected = + catalog.data !== undefined && !needsModelSetup(catalog.data.providers); + // However the provider gets connected (Enable AI, Settings, another tab, the server), the + // shared catalog read sees it and the refused start is tried again. Only on that change: a hub + // that still refuses would otherwise retry on every refusal. + useEffect(() => { + if (providerConnected && providerMissing) void startCoordinator(); + }, [providerConnected]); const [restoring, setRestoring] = useState(false); const [restoreFault, setRestoreFault] = useState(null); const restore = async () => { @@ -1582,11 +1592,7 @@ export function StageWorkspace({ {dock} - void startCoordinator()} - /> +
{chatFirst ? null : (