feat: support multiple Honcho instances (closes #2)

Replace single localStorage config with a named-instance store
({ instances: Instance[], activeId }). Adds an instances manager on
the settings page (list / add / edit / delete / activate) and a
sidebar switcher for quick swaps. Existing single-config users are
migrated transparently on first load.

Switching or deleting an instance clears the TanStack Query cache so
data from another deployment never bleeds into the active view.
This commit is contained in:
Offending Commit
2026-05-04 11:09:28 -05:00
parent 2b0844d4d3
commit f706c83cc1
6 changed files with 575 additions and 47 deletions

View File

@@ -2,7 +2,9 @@ import { Link, useMatchRoute } from "@tanstack/react-router";
import { motion } from "framer-motion";
import {
Boxes,
Check,
ChevronRight,
ChevronsUpDown,
Eye,
EyeOff,
LayoutDashboard,
@@ -10,9 +12,10 @@ import {
Settings,
Sun,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useDemo } from "@/hooks/useDemo";
import { useInstances } from "@/hooks/useInstances";
import { useTheme } from "@/hooks/useTheme";
import { loadConfig } from "@/lib/config";
import { COLOR } from "@/lib/constants";
const navItems = [
@@ -23,9 +26,22 @@ const navItems = [
export function Sidebar() {
const matchRoute = useMatchRoute();
const config = loadConfig();
const { instances, active, activate } = useInstances();
const { theme, toggle } = useTheme();
const { demo, toggle: toggleDemo, mask } = useDemo();
const [switcherOpen, setSwitcherOpen] = useState(false);
const switcherRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!switcherOpen) return;
function onClick(e: MouseEvent) {
if (!switcherRef.current?.contains(e.target as Node)) {
setSwitcherOpen(false);
}
}
window.addEventListener("mousedown", onClick);
return () => window.removeEventListener("mousedown", onClick);
}, [switcherOpen]);
return (
<motion.aside
@@ -58,14 +74,81 @@ export function Sidebar() {
</span>
</div>
</div>
{config && (
<p
className="text-xs mt-2 truncate font-mono hidden sm:block"
style={{ color: "var(--text-4)" }}
title={mask(config.baseUrl)}
>
{mask(config.baseUrl.replace(/^https?:\/\//, ""))}
</p>
{active && (
<div ref={switcherRef} className="relative mt-2 hidden sm:block">
<button
type="button"
onClick={() => setSwitcherOpen((v) => !v)}
className="w-full flex items-center gap-1.5 px-2 py-1.5 rounded-md text-left transition-colors"
style={{
background: switcherOpen ? "var(--surface)" : "transparent",
border: `1px solid ${switcherOpen ? "var(--border)" : "transparent"}`,
}}
title={mask(active.baseUrl)}
>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium truncate" style={{ color: "var(--text-2)" }}>
{active.name}
</p>
<p className="text-xs font-mono truncate" style={{ color: "var(--text-4)" }}>
{mask(active.baseUrl.replace(/^https?:\/\//, ""))}
</p>
</div>
{instances.length > 1 && (
<ChevronsUpDown
className="w-3.5 h-3.5 shrink-0"
style={{ color: "var(--text-4)" }}
strokeWidth={1.5}
/>
)}
</button>
{switcherOpen && instances.length > 1 && (
<div
className="absolute left-0 right-0 top-full mt-1 rounded-lg overflow-hidden z-20"
style={{
background: "var(--bg-2)",
border: "1px solid var(--border)",
boxShadow: "0 8px 24px rgba(0,0,0,0.18)",
}}
>
{instances.map((inst) => (
<button
key={inst.id}
type="button"
onClick={() => {
activate(inst.id);
setSwitcherOpen(false);
}}
className="w-full flex items-center gap-2 px-2.5 py-2 text-left transition-colors"
style={{
background: inst.id === active.id ? "var(--accent-dim)" : "transparent",
}}
>
<div className="min-w-0 flex-1">
<p
className="text-xs font-medium truncate"
style={{
color: inst.id === active.id ? "var(--accent-text)" : "var(--text-2)",
}}
>
{inst.name}
</p>
<p className="text-xs font-mono truncate" style={{ color: "var(--text-4)" }}>
{mask(inst.baseUrl.replace(/^https?:\/\//, ""))}
</p>
</div>
{inst.id === active.id && (
<Check
className="w-3.5 h-3.5 shrink-0"
style={{ color: "var(--accent-text)" }}
strokeWidth={2}
/>
)}
</button>
))}
</div>
)}
</div>
)}
</div>

View File

@@ -0,0 +1,178 @@
import { motion } from "framer-motion";
import { Check, Pencil, Plus, Server, Trash2 } from "lucide-react";
import { useState } from "react";
import { SettingsForm } from "@/components/settings/SettingsForm";
import { Button } from "@/components/ui/button";
import { Muted } from "@/components/ui/typography";
import { useInstances } from "@/hooks/useInstances";
import type { Instance } from "@/lib/config";
import { COLOR } from "@/lib/constants";
type Mode = { kind: "list" } | { kind: "create" } | { kind: "edit"; id: string };
interface InstancesManagerProps {
onActivated?: () => void;
}
export function InstancesManager({ onActivated }: InstancesManagerProps) {
const { instances, activeId, activate, remove } = useInstances();
const [mode, setMode] = useState<Mode>({ kind: "list" });
if (mode.kind === "create") {
return (
<SettingsForm
instance={null}
onSaved={() => {
setMode({ kind: "list" });
onActivated?.();
}}
onCancel={instances.length > 0 ? () => setMode({ kind: "list" }) : undefined}
hideCancel={instances.length === 0}
/>
);
}
if (mode.kind === "edit") {
const target = instances.find((i) => i.id === mode.id);
if (!target) return null;
return (
<SettingsForm
instance={target}
onSaved={() => setMode({ kind: "list" })}
onCancel={() => setMode({ kind: "list" })}
/>
);
}
if (instances.length === 0) {
return (
<SettingsForm
instance={null}
onSaved={() => onActivated?.()}
hideCancel
submitLabel="Save Connection"
/>
);
}
return (
<div className="space-y-3">
<div className="space-y-2">
{instances.map((inst) => (
<InstanceRow
key={inst.id}
instance={inst}
active={inst.id === activeId}
onActivate={() => {
activate(inst.id);
onActivated?.();
}}
onEdit={() => setMode({ kind: "edit", id: inst.id })}
onDelete={() => remove(inst.id)}
/>
))}
</div>
<Button
type="button"
variant="ghost"
onClick={() => setMode({ kind: "create" })}
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} />
Add another instance
</Button>
</div>
);
}
interface InstanceRowProps {
instance: Instance;
active: boolean;
onActivate: () => void;
onEdit: () => void;
onDelete: () => void;
}
function InstanceRow({ instance, active, onActivate, onEdit, onDelete }: InstanceRowProps) {
const [confirmingDelete, setConfirmingDelete] = useState(false);
return (
<motion.div
layout
className="rounded-xl p-3 flex items-center gap-3"
style={{
background: active ? "var(--accent-dim)" : "var(--bg-2)",
border: `1px solid ${active ? "var(--accent-border)" : "var(--border)"}`,
}}
>
<button
type="button"
onClick={onActivate}
className="flex-1 flex items-center gap-3 text-left"
disabled={active}
title={active ? "Active instance" : "Switch to this instance"}
>
<div
className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
style={{
background: active ? "var(--accent)" : "var(--surface)",
color: active ? "white" : "var(--text-3)",
}}
>
{active ? (
<Check className="w-4 h-4" strokeWidth={2} />
) : (
<Server className="w-4 h-4" strokeWidth={1.5} />
)}
</div>
<div className="min-w-0 flex-1">
<p
className="text-sm font-medium truncate"
style={{ color: active ? "var(--accent-text)" : "var(--text-1)" }}
>
{instance.name}
</p>
<Muted className="text-xs font-mono truncate">
{instance.baseUrl.replace(/^https?:\/\//, "")}
</Muted>
</div>
</button>
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={onEdit}
className="w-7 h-7 rounded-md flex items-center justify-center transition-colors"
style={{ color: "var(--text-3)" }}
title="Edit"
>
<Pencil className="w-3.5 h-3.5" strokeWidth={1.5} />
</button>
{confirmingDelete ? (
<button
type="button"
onClick={() => {
onDelete();
setConfirmingDelete(false);
}}
className="text-xs font-medium px-2 py-1 rounded-md"
style={{ color: COLOR.destructive, border: `1px solid ${COLOR.destructive}` }}
>
Confirm
</button>
) : (
<button
type="button"
onClick={() => setConfirmingDelete(true)}
className="w-7 h-7 rounded-md flex items-center justify-center transition-colors"
style={{ color: "var(--text-3)" }}
title="Delete"
>
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
</button>
)}
</div>
</motion.div>
);
}

View File

@@ -5,18 +5,21 @@ import { Button } from "@/components/ui/button";
import { Input, Textarea } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Muted } from "@/components/ui/typography";
import {
type Config,
checkConnection,
configSchema,
type HealthStatus,
loadConfig,
saveConfig,
} from "@/lib/config";
import { useInstances } from "@/hooks/useInstances";
import { checkConnection, type HealthStatus, type Instance, instanceSchema } from "@/lib/config";
import { COLOR } from "@/lib/constants";
interface SettingsFormProps {
onSaved?: () => void;
/** Instance to edit; pass `null` to create a new one. */
instance: Instance | null;
/** Called after a successful save. Receives the saved instance id. */
onSaved?: (id: string) => void;
/** Called when the user cancels (only meaningful when there's something to cancel back to). */
onCancel?: () => void;
/** Hide the cancel button. */
hideCancel?: boolean;
/** Override the submit button label. */
submitLabel?: string;
}
const statusConfig = {
@@ -26,15 +29,25 @@ const statusConfig = {
checking: { icon: Loader, color: COLOR.accentText, label: "Checking..." },
};
export function SettingsForm({ onSaved }: SettingsFormProps) {
const existing = loadConfig();
const [baseUrl, setBaseUrl] = useState(existing?.baseUrl ?? "http://localhost:8000");
const [token, setToken] = useState(existing?.token ?? "");
const [errors, setErrors] = useState<Partial<Record<keyof Config, string>>>({});
export function SettingsForm({
instance,
onSaved,
onCancel,
hideCancel,
submitLabel,
}: SettingsFormProps) {
const { add, update, activate } = useInstances();
const [name, setName] = useState(instance?.name ?? "");
const [baseUrl, setBaseUrl] = useState(instance?.baseUrl ?? "http://localhost:8000");
const [token, setToken] = useState(instance?.token ?? "");
const [errors, setErrors] = useState<Partial<Record<keyof Instance, string>>>({});
const [saved, setSaved] = useState(false);
const [health, setHealth] = useState<{ status: HealthStatus; message: string } | null>(null);
const [checking, setChecking] = useState(false);
const isCreate = instance === null;
async function handleTest() {
setChecking(true);
setHealth({ status: "checking", message: "Connecting..." });
@@ -49,22 +62,46 @@ export function SettingsForm({ onSaved }: SettingsFormProps) {
function handleSubmit(e: React.SyntheticEvent<HTMLFormElement>) {
e.preventDefault();
const result = configSchema.safeParse({ baseUrl, token });
const candidate = {
id: instance?.id ?? "placeholder",
name: name.trim() || "Default",
baseUrl,
token,
};
const result = instanceSchema.safeParse(candidate);
if (!result.success) {
const fieldErrors: typeof errors = {};
for (const issue of result.error.issues) {
const key = issue.path[0] as keyof Config;
const key = issue.path[0] as keyof Instance;
fieldErrors[key] = issue.message;
}
setErrors(fieldErrors);
return;
}
setErrors({});
saveConfig(result.data);
let id: string;
if (isCreate) {
const created = add({
name: result.data.name,
baseUrl: result.data.baseUrl,
token: result.data.token,
});
activate(created.id);
id = created.id;
} else {
update(instance.id, {
name: result.data.name,
baseUrl: result.data.baseUrl,
token: result.data.token,
});
id = instance.id;
}
setSaved(true);
setTimeout(() => {
setSaved(false);
onSaved?.();
onSaved?.(id);
}, 600);
}
@@ -79,6 +116,24 @@ export function SettingsForm({ onSaved }: SettingsFormProps) {
border: "1px solid var(--border)",
}}
>
{/* Name */}
<div>
<Label className="mb-1.5 text-sm">Instance Name</Label>
<Input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Local, Staging, Production"
className="rounded-xl"
/>
{errors.name && (
<p className="text-xs mt-1" style={{ color: COLOR.destructive }}>
{errors.name}
</p>
)}
<Muted className="text-xs mt-1.5">A short label to identify this connection</Muted>
</div>
{/* Base URL */}
<div>
<Label className="mb-1.5 text-sm">Honcho Base URL</Label>
@@ -202,14 +257,26 @@ export function SettingsForm({ onSaved }: SettingsFormProps) {
)}
</div>
<Button
type="submit"
variant="primary"
className="w-full py-2.5 px-4 rounded-xl"
style={saved ? { background: "#059669" } : undefined}
>
{saved ? "✓ Saved" : "Save Connection"}
</Button>
<div className="flex gap-2">
{!hideCancel && onCancel && (
<Button
type="button"
variant="ghost"
onClick={onCancel}
className="py-2.5 px-4 rounded-xl"
>
Cancel
</Button>
)}
<Button
type="submit"
variant="primary"
className="flex-1 py-2.5 px-4 rounded-xl"
style={saved ? { background: "#059669" } : undefined}
>
{saved ? "✓ Saved" : (submitLabel ?? (isCreate ? "Add Instance" : "Save Changes"))}
</Button>
</div>
</form>
);
}