feat: add Honcho Cloud connection preset

Adds a "choose-type" step to the settings flow so users can pick between
Honcho Cloud (https://api.honcho.dev, API key required) and Self-Hosted
(URL + optional token) when creating a connection. Multi-instance support
already exists in the data layer, so cloud and self-hosted instances can
coexist.

- new HONCHO_CLOUD_URL constant and isCloudInstance helper in config.ts
- SettingsForm accepts a preset prop; cloud variant locks the endpoint
  and enforces an API key
- InstancesManager gains a ConnectionTypeChooser entry point and renders
  a Cloud icon for cloud instances in the list
- unit tests for both preset paths and cloud edit-mode detection
This commit is contained in:
Claude
2026-05-14 23:21:28 +00:00
parent 4fc54a3581
commit f0717624eb
6 changed files with 315 additions and 49 deletions

View File

@@ -1,14 +1,18 @@
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { Check, Pencil, Plus, Server, Trash2 } from "lucide-react"; import { Check, ChevronRight, Cloud, Pencil, Plus, Server, Trash2 } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { SettingsForm } from "@/components/settings/SettingsForm"; import { type ConnectionPreset, SettingsForm } from "@/components/settings/SettingsForm";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Muted } from "@/components/ui/typography"; import { Muted } from "@/components/ui/typography";
import { useInstances } from "@/hooks/useInstances"; import { useInstances } from "@/hooks/useInstances";
import type { Instance } from "@/lib/config"; import { HONCHO_CLOUD_URL, type Instance, isCloudInstance } from "@/lib/config";
import { COLOR } from "@/lib/constants"; import { COLOR } from "@/lib/constants";
type Mode = { kind: "list" } | { kind: "create" } | { kind: "edit"; id: string }; type Mode =
| { kind: "list" }
| { kind: "choose-type" }
| { kind: "create"; preset: ConnectionPreset }
| { kind: "edit"; id: string };
interface InstancesManagerProps { interface InstancesManagerProps {
onActivated?: () => void; onActivated?: () => void;
@@ -16,18 +20,32 @@ interface InstancesManagerProps {
export function InstancesManager({ onActivated }: InstancesManagerProps) { export function InstancesManager({ onActivated }: InstancesManagerProps) {
const { instances, activeId, activate, remove } = useInstances(); const { instances, activeId, activate, remove } = useInstances();
const [mode, setMode] = useState<Mode>({ kind: "list" }); const isFirstRun = instances.length === 0;
const [mode, setMode] = useState<Mode>(isFirstRun ? { kind: "choose-type" } : { kind: "list" });
const backFromCreate = () => setMode(isFirstRun ? { kind: "choose-type" } : { kind: "list" });
if (mode.kind === "choose-type") {
return (
<ConnectionTypeChooser
onPick={(preset) => setMode({ kind: "create", preset })}
onCancel={isFirstRun ? undefined : () => setMode({ kind: "list" })}
/>
);
}
if (mode.kind === "create") { if (mode.kind === "create") {
return ( return (
<SettingsForm <SettingsForm
instance={null} instance={null}
preset={mode.preset}
onSaved={() => { onSaved={() => {
setMode({ kind: "list" }); setMode({ kind: "list" });
onActivated?.(); onActivated?.();
}} }}
onCancel={instances.length > 0 ? () => setMode({ kind: "list" }) : undefined} onCancel={backFromCreate}
hideCancel={instances.length === 0} hideCancel={false}
submitLabel={isFirstRun ? "Save Connection" : undefined}
/> />
); );
} }
@@ -44,17 +62,6 @@ export function InstancesManager({ onActivated }: InstancesManagerProps) {
); );
} }
if (instances.length === 0) {
return (
<SettingsForm
instance={null}
onSaved={() => onActivated?.()}
hideCancel
submitLabel="Save Connection"
/>
);
}
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-2"> <div className="space-y-2">
@@ -76,7 +83,7 @@ export function InstancesManager({ onActivated }: InstancesManagerProps) {
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
onClick={() => setMode({ kind: "create" })} onClick={() => setMode({ kind: "choose-type" })}
className="w-full py-2.5 px-4 rounded-xl flex items-center justify-center gap-2" className="w-full py-2.5 px-4 rounded-xl flex items-center justify-center gap-2"
> >
<Plus className="w-4 h-4" strokeWidth={1.5} /> <Plus className="w-4 h-4" strokeWidth={1.5} />
@@ -86,6 +93,109 @@ export function InstancesManager({ onActivated }: InstancesManagerProps) {
); );
} }
interface ConnectionTypeChooserProps {
onPick: (preset: ConnectionPreset) => void;
onCancel?: () => void;
}
function ConnectionTypeChooser({ onPick, onCancel }: ConnectionTypeChooserProps) {
return (
<div
className="rounded-2xl p-6 space-y-3"
style={{
background: "var(--bg-2)",
border: "1px solid var(--border)",
}}
>
<div className="mb-2">
<h2 className="text-base font-medium" style={{ color: "var(--text-1)" }}>
How do you want to connect?
</h2>
<Muted className="text-xs mt-1">
You can add more connections later Cloud, self-hosted, or both.
</Muted>
</div>
<ConnectionTypeButton
icon={Cloud}
title="Honcho Cloud"
description={`Hosted at ${HONCHO_CLOUD_URL.replace(/^https?:\/\//, "")} — sign in with your API key`}
accent
onClick={() => onPick("cloud")}
/>
<ConnectionTypeButton
icon={Server}
title="Self-Hosted"
description="Connect to your own Honcho deployment"
onClick={() => onPick("self-hosted")}
/>
{onCancel && (
<div className="pt-1">
<Button
type="button"
variant="ghost"
onClick={onCancel}
className="w-full py-2 px-4 rounded-xl"
>
Cancel
</Button>
</div>
)}
</div>
);
}
interface ConnectionTypeButtonProps {
icon: typeof Cloud;
title: string;
description: string;
accent?: boolean;
onClick: () => void;
}
function ConnectionTypeButton({
icon: Icon,
title,
description,
accent,
onClick,
}: ConnectionTypeButtonProps) {
return (
<button
type="button"
onClick={onClick}
className="w-full rounded-xl p-4 flex items-center gap-3 text-left transition-colors"
style={{
background: "var(--surface)",
border: `1px solid ${accent ? "var(--accent-border)" : "var(--border)"}`,
}}
>
<div
className="w-10 h-10 rounded-lg flex items-center justify-center shrink-0"
style={{
background: accent ? "var(--accent)" : "var(--bg-2)",
color: accent ? "white" : "var(--text-2)",
}}
>
<Icon className="w-5 h-5" strokeWidth={1.5} />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium" style={{ color: "var(--text-1)" }}>
{title}
</p>
<Muted className="text-xs mt-0.5">{description}</Muted>
</div>
<ChevronRight
className="w-4 h-4 shrink-0"
style={{ color: "var(--text-3)" }}
strokeWidth={1.5}
/>
</button>
);
}
interface InstanceRowProps { interface InstanceRowProps {
instance: Instance; instance: Instance;
active: boolean; active: boolean;
@@ -96,6 +206,7 @@ interface InstanceRowProps {
function InstanceRow({ instance, active, onActivate, onEdit, onDelete }: InstanceRowProps) { function InstanceRow({ instance, active, onActivate, onEdit, onDelete }: InstanceRowProps) {
const [confirmingDelete, setConfirmingDelete] = useState(false); const [confirmingDelete, setConfirmingDelete] = useState(false);
const cloud = isCloudInstance(instance);
return ( return (
<motion.div <motion.div
@@ -122,6 +233,8 @@ function InstanceRow({ instance, active, onActivate, onEdit, onDelete }: Instanc
> >
{active ? ( {active ? (
<Check className="w-4 h-4" strokeWidth={2} /> <Check className="w-4 h-4" strokeWidth={2} />
) : cloud ? (
<Cloud className="w-4 h-4" strokeWidth={1.5} />
) : ( ) : (
<Server className="w-4 h-4" strokeWidth={1.5} /> <Server className="w-4 h-4" strokeWidth={1.5} />
)} )}
@@ -134,7 +247,7 @@ function InstanceRow({ instance, active, onActivate, onEdit, onDelete }: Instanc
{instance.name} {instance.name}
</p> </p>
<Muted className="text-xs font-mono truncate"> <Muted className="text-xs font-mono truncate">
{instance.baseUrl.replace(/^https?:\/\//, "")} {cloud ? "Honcho Cloud" : instance.baseUrl.replace(/^https?:\/\//, "")}
</Muted> </Muted>
</div> </div>
</button> </button>

View File

@@ -1,17 +1,37 @@
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { AlertCircle, CheckCircle, Loader, Lock, LockOpen, Wifi, WifiOff } from "lucide-react"; import {
AlertCircle,
CheckCircle,
Cloud,
Loader,
Lock,
LockOpen,
Wifi,
WifiOff,
} from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input, Textarea } from "@/components/ui/input"; import { Input, Textarea } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Muted } from "@/components/ui/typography"; import { Muted } from "@/components/ui/typography";
import { useInstances } from "@/hooks/useInstances"; import { useInstances } from "@/hooks/useInstances";
import { checkConnection, type HealthStatus, type Instance, instanceSchema } from "@/lib/config"; import {
checkConnection,
type HealthStatus,
HONCHO_CLOUD_URL,
type Instance,
instanceSchema,
isCloudInstance,
} from "@/lib/config";
import { COLOR } from "@/lib/constants"; import { COLOR } from "@/lib/constants";
export type ConnectionPreset = "cloud" | "self-hosted";
interface SettingsFormProps { interface SettingsFormProps {
/** Instance to edit; pass `null` to create a new one. */ /** Instance to edit; pass `null` to create a new one. */
instance: Instance | null; instance: Instance | null;
/** Whether this form is for a Cloud or Self-Hosted connection. Inferred from `instance` in edit mode. */
preset?: ConnectionPreset;
/** Called after a successful save. Receives the saved instance id. */ /** Called after a successful save. Receives the saved instance id. */
onSaved?: (id: string) => void; onSaved?: (id: string) => void;
/** Called when the user cancels (only meaningful when there's something to cancel back to). */ /** Called when the user cancels (only meaningful when there's something to cancel back to). */
@@ -31,6 +51,7 @@ const statusConfig = {
export function SettingsForm({ export function SettingsForm({
instance, instance,
preset,
onSaved, onSaved,
onCancel, onCancel,
hideCancel, hideCancel,
@@ -38,8 +59,17 @@ export function SettingsForm({
}: SettingsFormProps) { }: SettingsFormProps) {
const { add, update, activate } = useInstances(); const { add, update, activate } = useInstances();
const [name, setName] = useState(instance?.name ?? ""); const resolvedPreset: ConnectionPreset =
const [baseUrl, setBaseUrl] = useState(instance?.baseUrl ?? "http://localhost:8000"); preset ?? (instance && isCloudInstance(instance) ? "cloud" : "self-hosted");
const isCloud = resolvedPreset === "cloud";
const initialName = instance?.name ?? (isCloud ? "Honcho Cloud" : "");
const initialBaseUrl = isCloud
? HONCHO_CLOUD_URL
: (instance?.baseUrl ?? "http://localhost:8000");
const [name, setName] = useState(initialName);
const [baseUrl, setBaseUrl] = useState(initialBaseUrl);
const [token, setToken] = useState(instance?.token ?? ""); const [token, setToken] = useState(instance?.token ?? "");
const [errors, setErrors] = useState<Partial<Record<keyof Instance, string>>>({}); const [errors, setErrors] = useState<Partial<Record<keyof Instance, string>>>({});
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
@@ -64,8 +94,8 @@ export function SettingsForm({
e.preventDefault(); e.preventDefault();
const candidate = { const candidate = {
id: instance?.id ?? "placeholder", id: instance?.id ?? "placeholder",
name: name.trim() || "Default", name: name.trim() || (isCloud ? "Honcho Cloud" : "Default"),
baseUrl, baseUrl: isCloud ? HONCHO_CLOUD_URL : baseUrl,
token, token,
}; };
const result = instanceSchema.safeParse(candidate); const result = instanceSchema.safeParse(candidate);
@@ -78,6 +108,10 @@ export function SettingsForm({
setErrors(fieldErrors); setErrors(fieldErrors);
return; return;
} }
if (isCloud && !token.trim()) {
setErrors({ token: "API key is required for Honcho Cloud" });
return;
}
setErrors({}); setErrors({});
let id: string; let id: string;
@@ -136,18 +170,36 @@ export function SettingsForm({
{/* Base URL */} {/* Base URL */}
<div> <div>
<Label className="mb-1.5 text-sm">Honcho Base URL</Label> <Label className="mb-1.5 text-sm">{isCloud ? "Endpoint" : "Honcho Base URL"}</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input {isCloud ? (
type="url" <div
value={baseUrl} className="flex-1 font-mono rounded-xl px-3 py-2 text-sm flex items-center gap-2"
onChange={(e) => { style={{
setBaseUrl(e.target.value); background: "var(--surface)",
setHealth(null); border: "1px solid var(--border)",
}} color: "var(--text-2)",
placeholder="http://localhost:8000" }}
className="flex-1 font-mono rounded-xl" >
/> <Cloud
className="w-4 h-4 shrink-0"
style={{ color: "var(--accent)" }}
strokeWidth={1.5}
/>
<span className="truncate">{HONCHO_CLOUD_URL}</span>
</div>
) : (
<Input
type="url"
value={baseUrl}
onChange={(e) => {
setBaseUrl(e.target.value);
setHealth(null);
}}
placeholder="http://localhost:8000"
className="flex-1 font-mono rounded-xl"
/>
)}
<Button <Button
type="button" type="button"
variant="accent" variant="accent"
@@ -168,12 +220,16 @@ export function SettingsForm({
<span className="hidden sm:block">Test</span> <span className="hidden sm:block">Test</span>
</Button> </Button>
</div> </div>
{errors.baseUrl && ( {errors.baseUrl && !isCloud && (
<p className="text-xs mt-1" style={{ color: COLOR.destructive }}> <p className="text-xs mt-1" style={{ color: COLOR.destructive }}>
{errors.baseUrl} {errors.baseUrl}
</p> </p>
)} )}
<Muted className="text-xs mt-1.5">URL of your self-hosted Honcho instance</Muted> <Muted className="text-xs mt-1.5">
{isCloud
? "Hosted Honcho service — endpoint is fixed"
: "URL of your self-hosted Honcho instance"}
</Muted>
</div> </div>
{/* Health status */} {/* Health status */}
@@ -225,27 +281,39 @@ export function SettingsForm({
strokeWidth={1.5} strokeWidth={1.5}
/> />
)} )}
API Token {isCloud ? "API key" : "API Token"}
<span <span
className="ml-1 text-xs font-normal px-1.5 py-0.5 rounded-full" className="ml-1 text-xs font-normal px-1.5 py-0.5 rounded-full"
style={{ style={{
background: "var(--surface)", background: "var(--surface)",
border: "1px solid var(--border)", border: `1px solid ${isCloud ? COLOR.accentText : "var(--border)"}`,
color: "var(--text-3)", color: isCloud ? COLOR.accentText : "var(--text-3)",
}} }}
> >
optional {isCloud ? "required" : "optional"}
</span> </span>
</Label> </Label>
<Textarea <Textarea
id="honcho-token" id="honcho-token"
value={token} value={token}
onChange={(e) => setToken(e.target.value)} onChange={(e) => {
setToken(e.target.value);
if (errors.token) setErrors({ ...errors, token: undefined });
}}
rows={2} rows={2}
placeholder="eyJ... (required only if your instance has auth enabled)" placeholder={
isCloud
? "Paste your Honcho Cloud API key"
: "eyJ... (required only if your instance has auth enabled)"
}
className="font-mono rounded-xl" className="font-mono rounded-xl"
/> />
{health?.status === "auth-required" && !token && ( {errors.token && (
<p className="text-xs mt-1" style={{ color: COLOR.destructive }}>
{errors.token}
</p>
)}
{!errors.token && health?.status === "auth-required" && !token && (
<motion.p <motion.p
initial={{ opacity: 0, y: -4 }} initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
@@ -274,7 +342,10 @@ export function SettingsForm({
className="flex-1 py-2.5 px-4 rounded-xl" className="flex-1 py-2.5 px-4 rounded-xl"
style={saved ? { background: "#059669" } : undefined} style={saved ? { background: "#059669" } : undefined}
> >
{saved ? "✓ Saved" : (submitLabel ?? (isCreate ? "Add Instance" : "Save Changes"))} {saved
? "✓ Saved"
: (submitLabel ??
(isCreate ? (isCloud ? "Connect to Honcho Cloud" : "Add Instance") : "Save Changes"))}
</Button> </Button>
</div> </div>
</form> </form>

View File

@@ -4,6 +4,16 @@ import { httpFetch } from "@/lib/http";
const LEGACY_KEY = "openconcho:config"; const LEGACY_KEY = "openconcho:config";
const STORE_KEY = "openconcho:instances"; const STORE_KEY = "openconcho:instances";
export const HONCHO_CLOUD_URL = "https://api.honcho.dev";
function normalizeBaseUrl(url: string): string {
return url.trim().replace(/\/+$/, "").toLowerCase();
}
export function isCloudInstance(instance: Pick<Instance, "baseUrl">): boolean {
return normalizeBaseUrl(instance.baseUrl) === HONCHO_CLOUD_URL;
}
export const configSchema = z.object({ export const configSchema = z.object({
baseUrl: z.string().url({ message: "Must be a valid URL" }), baseUrl: z.string().url({ message: "Must be a valid URL" }),
token: z.string().optional().default(""), token: z.string().optional().default(""),

View File

@@ -35,7 +35,7 @@ function SettingsPage() {
</h1> </h1>
<p className="text-sm mt-1" style={{ color: "var(--text-3)" }}> <p className="text-sm mt-1" style={{ color: "var(--text-3)" }}>
{isFirstRun {isFirstRun
? "Connect to your self-hosted Honcho instance" ? "Connect to Honcho Cloud or your self-hosted instance"
: "Manage your Honcho connections"} : "Manage your Honcho connections"}
</p> </p>
</div> </div>

View File

@@ -29,7 +29,7 @@ describe("first load with no config", () => {
// Should be visible immediately — bug 1: RootLayout returns null while // Should be visible immediately — bug 1: RootLayout returns null while
// a useEffect-driven navigate fires, leaving a blank screen. // a useEffect-driven navigate fires, leaving a blank screen.
expect( expect(
await screen.findByText(/Connect to your self-hosted Honcho instance/i), await screen.findByText(/Connect to Honcho Cloud or your self-hosted instance/i),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
}); });

View File

@@ -0,0 +1,72 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { SettingsForm } from "@/components/settings/SettingsForm";
import { HONCHO_CLOUD_URL, loadStore } from "@/lib/config";
function renderForm(ui: React.ReactElement) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}
describe("SettingsForm — cloud preset", () => {
it("does not render the editable Base URL input", () => {
renderForm(<SettingsForm instance={null} preset="cloud" />);
expect(screen.queryByPlaceholderText("http://localhost:8000")).not.toBeInTheDocument();
});
it("marks the API key as required", () => {
renderForm(<SettingsForm instance={null} preset="cloud" />);
expect(screen.getByText("required")).toBeInTheDocument();
});
it("blocks save when the API key is empty", async () => {
const onSaved = vi.fn();
const user = userEvent.setup();
renderForm(<SettingsForm instance={null} preset="cloud" onSaved={onSaved} />);
await user.click(screen.getByRole("button", { name: /Connect to Honcho Cloud/i }));
expect(screen.getByText(/API key is required for Honcho Cloud/i)).toBeInTheDocument();
});
it("saves with the Honcho Cloud URL when a token is provided", async () => {
const user = userEvent.setup();
renderForm(<SettingsForm instance={null} preset="cloud" />);
await user.type(screen.getByPlaceholderText(/Paste your Honcho Cloud API key/i), "sk-test-1");
await user.click(screen.getByRole("button", { name: /Connect to Honcho Cloud/i }));
const store = loadStore();
expect(store.instances[0]?.baseUrl).toBe(HONCHO_CLOUD_URL);
});
});
describe("SettingsForm — self-hosted preset", () => {
it("renders the editable Base URL input", () => {
renderForm(<SettingsForm instance={null} preset="self-hosted" />);
expect(screen.getByPlaceholderText("http://localhost:8000")).toBeInTheDocument();
});
it("allows saving without a token", async () => {
const user = userEvent.setup();
renderForm(<SettingsForm instance={null} preset="self-hosted" />);
await user.click(screen.getByRole("button", { name: /Add Instance/i }));
const store = loadStore();
expect(store.instances).toHaveLength(1);
});
});
describe("SettingsForm — edit mode auto-detects cloud", () => {
it("renders the cloud variant when editing an instance with the cloud URL", () => {
renderForm(
<SettingsForm
instance={{
id: "id-1",
name: "My Cloud",
baseUrl: HONCHO_CLOUD_URL,
token: "sk-existing",
}}
/>,
);
expect(screen.queryByPlaceholderText("http://localhost:8000")).not.toBeInTheDocument();
expect(screen.getByText("required")).toBeInTheDocument();
});
});