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
11 changes: 9 additions & 2 deletions packages/web/src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import {
Sun,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { HealthDot } from "@/components/shared/HealthDot";
import { useDemo } from "@/hooks/useDemo";
import { useHealthStatus } from "@/hooks/useHealthStatus";
import { useInstances } from "@/hooks/useInstances";
import { useTheme } from "@/hooks/useTheme";
import { COLOR } from "@/lib/constants";
Expand All @@ -29,6 +31,7 @@ export function Sidebar() {
const { instances, active, activate } = useInstances();
const { theme, toggle } = useTheme();
const { demo, toggle: toggleDemo, mask } = useDemo();
const { data: health } = useHealthStatus();
const [switcherOpen, setSwitcherOpen] = useState(false);
const switcherRef = useRef<HTMLDivElement | null>(null);

Expand Down Expand Up @@ -87,8 +90,12 @@ export function Sidebar() {
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
className="text-xs font-medium truncate flex items-center gap-1.5"
style={{ color: "var(--text-2)" }}
>
<HealthDot status={health?.status} message={health?.message} />
<span className="truncate">{active.name}</span>
</p>
<p className="text-xs font-mono truncate" style={{ color: "var(--text-4)" }}>
{mask(active.baseUrl.replace(/^https?:\/\//, ""))}
Expand Down
203 changes: 181 additions & 22 deletions packages/web/src/components/settings/InstancesManager.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,53 @@
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 { AnimatePresence, motion } from "framer-motion";
import { Check, ChevronRight, Cloud, Pencil, Plus, Server, Sparkles, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { type ConnectionPreset, 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 { checkConnection, HONCHO_CLOUD_URL, type Instance, isCloudInstance } from "@/lib/config";
import { COLOR } from "@/lib/constants";

type Mode = { kind: "list" } | { kind: "create" } | { kind: "edit"; id: string };
const LOCALHOST_PROBE_URL = "http://localhost:8000";

type Mode =
| { kind: "list" }
| { kind: "choose-type" }
| { kind: "create"; preset: ConnectionPreset }
| { 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" });
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") {
return (
<SettingsForm
instance={null}
preset={mode.preset}
onSaved={() => {
setMode({ kind: "list" });
onActivated?.();
}}
onCancel={instances.length > 0 ? () => setMode({ kind: "list" }) : undefined}
hideCancel={instances.length === 0}
onCancel={backFromCreate}
hideCancel={false}
submitLabel={isFirstRun ? "Save Connection" : undefined}
/>
);
}
Expand All @@ -44,17 +64,6 @@ export function InstancesManager({ onActivated }: InstancesManagerProps) {
);
}

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">
Expand All @@ -76,7 +85,7 @@ export function InstancesManager({ onActivated }: InstancesManagerProps) {
<Button
type="button"
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"
>
<Plus className="w-4 h-4" strokeWidth={1.5} />
Expand All @@ -86,6 +95,153 @@ export function InstancesManager({ onActivated }: InstancesManagerProps) {
);
}

interface ConnectionTypeChooserProps {
onPick: (preset: ConnectionPreset) => void;
onCancel?: () => void;
}

function ConnectionTypeChooser({ onPick, onCancel }: ConnectionTypeChooserProps) {
const [localhostDetected, setLocalhostDetected] = useState(false);

useEffect(() => {
let cancelled = false;
void checkConnection(LOCALHOST_PROBE_URL).then((result) => {
if (cancelled) return;
if (result.status === "ok" || result.status === "auth-required") {
setLocalhostDetected(true);
}
});
return () => {
cancelled = true;
};
}, []);

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>

<AnimatePresence>
{localhostDetected && (
<motion.button
type="button"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
onClick={() => onPick("self-hosted")}
className="w-full overflow-hidden rounded-xl p-3 flex items-center gap-2.5 text-left"
style={{
background: COLOR.successDim,
border: `1px solid ${COLOR.successBorder}`,
}}
>
<Sparkles
className="w-4 h-4 shrink-0"
style={{ color: COLOR.success }}
strokeWidth={1.5}
/>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium" style={{ color: COLOR.success }}>
Detected Honcho at {LOCALHOST_PROBE_URL.replace(/^https?:\/\//, "")}
</p>
<Muted className="text-xs mt-0.5">Tap to connect to it</Muted>
</div>
</motion.button>
)}
</AnimatePresence>

<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 {
instance: Instance;
active: boolean;
Expand All @@ -96,6 +252,7 @@ interface InstanceRowProps {

function InstanceRow({ instance, active, onActivate, onEdit, onDelete }: InstanceRowProps) {
const [confirmingDelete, setConfirmingDelete] = useState(false);
const cloud = isCloudInstance(instance);

return (
<motion.div
Expand All @@ -122,6 +279,8 @@ function InstanceRow({ instance, active, onActivate, onEdit, onDelete }: Instanc
>
{active ? (
<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} />
)}
Expand All @@ -134,7 +293,7 @@ function InstanceRow({ instance, active, onActivate, onEdit, onDelete }: Instanc
{instance.name}
</p>
<Muted className="text-xs font-mono truncate">
{instance.baseUrl.replace(/^https?:\/\//, "")}
{cloud ? "Honcho Cloud" : instance.baseUrl.replace(/^https?:\/\//, "")}
</Muted>
</div>
</button>
Expand Down
Loading
Loading