Skip to content
Closed
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
1 change: 0 additions & 1 deletion apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -869,7 +869,6 @@ export function App() {
listedArchived: openProjectSummary.archivedAt !== null,
})}
onChanged={refresh}
onOpenSettings={openSettings}
{...(openingMessage !== undefined ? { openingMessage } : {})}
/>
) : (
Expand Down
1 change: 1 addition & 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
144 changes: 95 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,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();
Expand All @@ -67,6 +70,60 @@ export function ModelSetup({
);
const [keyOpen, setKeyOpen] = useState(false);
const [keyProviderId, setKeyProviderId] = useState<string>();
return (
<>
{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}
/>
</>
);
}

export function ModelSetup({
onConnected,
onLater,
}: {
onConnected: () => void;
onLater: () => void;
}) {
return (
<AuthLayout
brand={
Expand All @@ -82,48 +139,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 onConnected={onConnected} />
<div className="model-setup-later">
<button
type="button"
Expand All @@ -145,3 +161,33 @@ export function ModelSetup({
</AuthLayout>
);
}

/** The same tiles over the open project, so connecting never leaves it. */
export function EnableAiDialog({
open,
onOpenChange,
onConnected,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onConnected: () => void;
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="enable-ai-dialog">
<DialogHeader>
<DialogTitle>{copy.connect}</DialogTitle>
<DialogDescription>{copy.noneHint}</DialogDescription>
</DialogHeader>
<DialogBody className="model-setup">
<ProviderTiles
onConnected={() => {
onOpenChange(false);
onConnected();
}}
/>
</DialogBody>
</DialogContent>
</Dialog>
);
}
49 changes: 23 additions & 26 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 @@ -128,6 +128,7 @@ 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";

// 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 @@ -287,7 +288,6 @@ export function StageWorkspace({
projectId,
projectTitle,
onChanged,
onOpenSettings,
openingMessage,
listedArchived,
}: {
Expand All @@ -297,7 +297,6 @@ export function StageWorkspace({
/** Whether the project list shows it archived, so an archive or restore made elsewhere is read again. */
listedArchived?: boolean;
onChanged: () => void;
onOpenSettings: () => void;
/** The opening sentence the project was started with. It is not a stored
* artifact but the first conversation message, sent here once the
* coordinator binds. */
Expand Down Expand Up @@ -388,6 +387,7 @@ export function StageWorkspace({
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 [enablingAi, setEnablingAi] = useState(false);
const accepted = useQuery({
queryKey: keys.acceptedMaterials.of(projectId),
retry: false,
Expand Down Expand Up @@ -1267,27 +1267,7 @@ export function StageWorkspace({
</div>
</div>
);
} else if (modelNeeded !== null) {
// 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>
<div className="banner-action">
<UiButton type="button" size="sm" onClick={onOpenSettings}>
{wording.action.openSettings}
</UiButton>
<UiButton
type="button"
size="sm"
variant="outline"
onClick={() => void startCoordinator()}
>
{wording.action.retry}
</UiButton>
</div>
</div>
);
} else if (unbound) {
} else if (unbound && modelNeeded === null) {
// 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 @@ -1393,7 +1373,7 @@ export function StageWorkspace({
onSubmit={send}
canSubmit={value.trim() !== "" || files.length > 0}
status={sending || approvalBusy ? "submitted" : "ready"}
disabled={sending || approvalBusy}
disabled={sending || approvalBusy || modelNeeded !== null}
>
{files.length === 0 ? null : (
<PromptInputFloat>
Expand Down Expand Up @@ -1452,7 +1432,19 @@ export function StageWorkspace({
</PromptInputButton>
</>
)}
<PromptInputSubmit className="ms-auto" />
{modelNeeded === null ? (
<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 @@ -1556,6 +1548,11 @@ export function StageWorkspace({
</RoomThread>
</DispatchRoomContext.Provider>
{dock}
<EnableAiDialog
open={enablingAi}
onOpenChange={setEnablingAi}
onConnected={() => void startCoordinator()}
/>
</div>
</section>
{chatFirst ? null : (
Expand Down