Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/web/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.",
},
Expand Down
161 changes: 112 additions & 49 deletions apps/web/src/pages/model-setup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<typeof useSubscriptionLogins>;
onConnected: () => void;
}) {
const catalog = useModelCatalog();
const { logins, signingIn, signIn } = subscriptions;
const [keyOpen, setKeyOpen] = useState(false);
const [keyProviderId, setKeyProviderId] = useState<string>();
const fault = catalog.error ? (
<Banner tone="error" title={catalog.error.message} />
) : 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}
<KindCardGrid
label={copy.loginGroup}
className="provider-tiles"
options={logins.map((provider) => ({
id: provider.id,
title: provider.label,
description: signingIn === provider.id ? copy.waiting : provider.hint,
icon: <ProviderLogo providerId={provider.id} />,
disabled: signingIn !== null,
}))}
onChange={(id) => {
const provider = logins.find((login) => login.id === id);
if (provider) void signIn(provider);
}}
/>
<hr className="model-setup-divider" />
<KindCardGrid
label={copy.apiKeyGroup}
className="provider-tiles"
options={apiKeyProviders.map((provider) => ({
id: provider.id,
title: provider.label,
description: provider.hint,
icon: <ProviderLogo providerId={provider.id} />,
disabled: signingIn !== null,
}))}
onChange={(id) => {
setKeyProviderId(id);
setKeyOpen(true);
}}
/>
<AddProviderDialog
key={keyProviderId}
providerId={keyProviderId}
open={keyOpen}
onOpenChange={setKeyOpen}
onConnected={onConnected}
/>
</>
);
}

export function ModelSetup({
onConnected,
onLater,
Expand All @@ -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<string>();
const subscriptions = useSubscriptionLogins(connected);
return (
<AuthLayout
brand={
Expand All @@ -82,48 +152,7 @@ export function ModelSetup({
<h1>{copy.connect}</h1>
<p>{copy.noneHint}</p>
</hgroup>
{catalog.error ? (
<Banner tone="error" title={catalog.error.message} />
) : null}
<KindCardGrid
label={copy.loginGroup}
className="provider-tiles"
options={logins.map((provider) => ({
id: provider.id,
title: provider.label,
description:
signingIn === provider.id ? copy.waiting : provider.hint,
icon: <ProviderLogo providerId={provider.id} />,
disabled: signingIn !== null,
}))}
onChange={(id) => {
const provider = logins.find((login) => login.id === id);
if (provider) void signIn(provider);
}}
/>
<hr className="model-setup-divider" />
<KindCardGrid
label={copy.apiKeyGroup}
className="provider-tiles"
options={apiKeyProviders.map((provider) => ({
id: provider.id,
title: provider.label,
description: provider.hint,
icon: <ProviderLogo providerId={provider.id} />,
disabled: signingIn !== null,
}))}
onChange={(id) => {
setKeyProviderId(id);
setKeyOpen(true);
}}
/>
<AddProviderDialog
key={keyProviderId}
providerId={keyProviderId}
open={keyOpen}
onOpenChange={setKeyOpen}
onConnected={connected}
/>
<ProviderTiles subscriptions={subscriptions} onConnected={connected} />
<div className="model-setup-later">
<button
type="button"
Expand All @@ -145,3 +174,37 @@ export function ModelSetup({
</AuthLayout>
);
}

/**
* The same tiles over the open project, so connecting never leaves it. A connection refreshes the
* shared model catalog, which is what the project watches to start its coordinator.
*/
export function EnableAiDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const connected = () => {
clearModelSetupDeferral();
onOpenChange(false);
};
const subscriptions = useSubscriptionLogins(connected);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{copy.connect}</DialogTitle>
<DialogDescription>{copy.noneHint}</DialogDescription>
</DialogHeader>
<DialogBody className="model-setup">
<ProviderTiles
subscriptions={subscriptions}
onConnected={connected}
/>
</DialogBody>
</DialogContent>
</Dialog>
Comment thread
TheGreatAxios marked this conversation as resolved.
Resolved
);
}
79 changes: 59 additions & 20 deletions apps/web/src/pages/workspace/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import { wording } from "../../config.ts";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Paperclip } from "lucide-react";
import { Paperclip, Sparkles } from "lucide-react";
import {
Attachment,
type AttachmentItem,
Expand Down Expand Up @@ -135,6 +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, 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.
Expand Down Expand Up @@ -272,7 +274,9 @@ const MODEL_CHOICE_REFUSALS: ReadonlySet<string> = new Set([
]);

// The hub's own sentence for a project with no model to run on, which only the person can settle.
async function modelRefusal(cause: unknown): Promise<string | undefined> {
async function modelRefusal(
cause: unknown,
): Promise<{ code: string; message: string } | undefined> {
if (!(cause instanceof ProductApiError) || cause.response.status !== 409)
return undefined;
const body: unknown = await cause.response
Expand All @@ -286,7 +290,7 @@ async function modelRefusal(cause: unknown): Promise<string | undefined> {
typeof body.code === "string" &&
MODEL_CHOICE_REFUSALS.has(body.code) &&
typeof body.message === "string"
? body.message
? { code: body.code, message: body.message }
: undefined;
}

Expand Down Expand Up @@ -396,7 +400,13 @@ export function StageWorkspace({
const [awaitingSince, setAwaitingSince] = useState<number | null>(null);
const [startFault, setStartFault] = useState<string | null>(null);
// Why the coordinator has no model to run on, which only the person can settle.
const [modelNeeded, setModelNeeded] = useState<string | null>(null);
const [modelNeeded, setModelNeeded] = useState<{
code: string;
message: string;
} | null>(null);
// Only a missing provider is settled by connecting one; a profile choice needs Settings.
const providerMissing = modelNeeded?.code === "model_provider_missing";
const [enablingAi, setEnablingAi] = useState(false);
const accepted = useQuery({
queryKey: keys.acceptedMaterials.of(projectId),
retry: false,
Expand Down Expand Up @@ -1006,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<string | null>(null);
const restore = async () => {
Expand Down Expand Up @@ -1262,11 +1281,11 @@ export function StageWorkspace({
</div>
</div>
);
} else if (modelNeeded !== null) {
} else if (modelNeeded !== null && !providerMissing) {
Comment thread
TheGreatAxios marked this conversation as resolved.
Resolved
// A coordinator with no model to run on cannot answer, even one still bound from before.
dock = (
<div className="banner" role="alert">
<p className="banner-title">{modelNeeded}</p>
<p className="banner-title">{modelNeeded.message}</p>
<div className="banner-action">
<UiButton type="button" size="sm" onClick={onOpenSettings}>
{wording.action.openSettings}
Expand All @@ -1282,7 +1301,7 @@ export function StageWorkspace({
</div>
</div>
);
} 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) {
Expand Down Expand Up @@ -1346,7 +1365,7 @@ export function StageWorkspace({
</div>
</div>
)}
{replies.length > 0 ? (
{replies.length > 0 && !providerMissing ? (
<div
className="workspace-replies"
role="group"
Expand All @@ -1371,7 +1390,8 @@ export function StageWorkspace({
limits === undefined ||
limits.mimeTypes.length === 0 ||
sending ||
approvalBusy
approvalBusy ||
providerMissing
}
label={wording.conversation.dropFiles}
>
Expand All @@ -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}
Comment thread
TheGreatAxios marked this conversation as resolved.
Resolved
>
{files.length === 0 ? null : (
<PromptInputFloat>
Expand All @@ -1415,17 +1435,23 @@ export function StageWorkspace({
)}
<PromptInputTextarea
ref={composerRef}
placeholder={composerPlaceholder({
approvalOpen: openApproval !== undefined,
questionsOpen:
!archived && questionsOpen(shownTurns, liveRun.results),
status: projectStatus.data,
chatFirst,
})}
placeholder={
providerMissing
? wording.conversation.enableAiFirst
: composerPlaceholder({
approvalOpen: openApproval !== undefined,
questionsOpen:
!archived &&
questionsOpen(shownTurns, liveRun.results),
status: projectStatus.data,
chatFirst,
})
}
/>
<PromptInputToolbar>
{mic}
{limits === undefined ||
{providerMissing ? null : mic}
{providerMissing ||
limits === undefined ||
limits.mimeTypes.length === 0 ? null : (
Comment thread
TheGreatAxios marked this conversation as resolved.
Resolved
<>
<input
Expand All @@ -1449,7 +1475,19 @@ export function StageWorkspace({
</PromptInputButton>
</>
)}
<PromptInputSubmit className="ms-auto" />
{!providerMissing ? (
<PromptInputSubmit className="ms-auto" />
) : (
<UiButton
type="button"
size="sm"
className="ms-auto"
onClick={() => setEnablingAi(true)}
>
{wording.action.enableAi}
<Sparkles size={14} aria-hidden="true" />
</UiButton>
)}
</PromptInputToolbar>
</PromptInput>
)}
Expand Down Expand Up @@ -1554,6 +1592,7 @@ export function StageWorkspace({
</RoomThread>
</DispatchRoomContext.Provider>
{dock}
<EnableAiDialog open={enablingAi} onOpenChange={setEnablingAi} />
</div>
</section>
{chatFirst ? null : (
Expand Down