openclaw/apps/web/app/components/workspace/profile-switcher.tsx

229 lines
7.7 KiB
TypeScript

"use client";
import { useState, useEffect, useRef, useCallback } from "react";
export type ProfileInfo = {
name: string;
stateDir: string;
workspaceDir: string | null;
isActive: boolean;
hasConfig: boolean;
};
export type ProfileSwitcherTriggerProps = {
isOpen: boolean;
onClick: () => void;
activeProfile: string;
switching: boolean;
};
type ProfileSwitcherProps = {
onProfileSwitch?: () => void;
onCreateWorkspace?: () => void;
/** Parent-tracked active profile -- triggers a re-fetch when it changes (e.g. after workspace creation). */
activeProfileHint?: string | null;
/** When set, this renders instead of the default button; dropdown still opens below. */
trigger?: (props: ProfileSwitcherTriggerProps) => React.ReactNode;
};
export function ProfileSwitcher({ onProfileSwitch, onCreateWorkspace, activeProfileHint, trigger }: ProfileSwitcherProps) {
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
const [activeProfile, setActiveProfile] = useState("default");
const [isOpen, setIsOpen] = useState(false);
const [switching, setSwitching] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const fetchProfiles = useCallback(async () => {
try {
const res = await fetch("/api/profiles");
const data = await res.json();
setProfiles(data.profiles ?? []);
setActiveProfile(data.activeProfile ?? "default");
} catch {
// ignore
}
}, []);
useEffect(() => {
void fetchProfiles();
}, [fetchProfiles, activeProfileHint]);
// Close dropdown on outside click
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
}
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}
}, [isOpen]);
const handleSwitch = async (profileName: string) => {
if (profileName === activeProfile) {
setIsOpen(false);
return;
}
setSwitching(true);
try {
const res = await fetch("/api/profiles/switch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ profile: profileName }),
});
if (res.ok) {
const data = await res.json();
setActiveProfile(data.activeProfile ?? "default");
onProfileSwitch?.();
void fetchProfiles();
}
} catch {
// ignore
} finally {
setSwitching(false);
setIsOpen(false);
}
};
// Don't show the switcher if there's only one profile and no way to create more
const showSwitcher = profiles.length > 0;
const handleToggle = () => {
if (showSwitcher) { setIsOpen((o) => !o); }
};
if (!trigger && !showSwitcher) { return null; }
return (
<div
className={`relative ${trigger ? "flex-1 min-w-0" : ""}`}
ref={dropdownRef}
>
{trigger ? (
trigger({
isOpen,
onClick: handleToggle,
activeProfile,
switching,
})
) : (
<button
onClick={handleToggle}
disabled={switching}
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs transition-colors hover:bg-[var(--color-surface-hover)] disabled:opacity-50"
style={{ color: "var(--color-text-secondary)" }}
title="Switch workspace profile"
>
{/* Workspace icon */}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
</svg>
<span className="truncate max-w-[120px]">
{activeProfile === "default" ? "Default" : activeProfile}
</span>
<svg
className={`w-3 h-3 transition-transform ${isOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
)}
{showSwitcher && isOpen && (
<div
className="absolute left-0 top-full mt-1 w-64 rounded-lg overflow-hidden z-50"
style={{
background: "var(--color-surface-raised)",
border: "1px solid var(--color-border)",
boxShadow: "var(--shadow-lg)",
}}
>
{/* Header */}
<div
className="px-3 py-2 text-xs font-medium"
style={{
color: "var(--color-text-muted)",
borderBottom: "1px solid var(--color-border)",
}}
>
Workspace Profiles
</div>
{/* Profile list */}
<div className="py-1 max-h-64 overflow-y-auto">
{profiles.map((p) => {
const isCurrent = p.name === activeProfile;
return (
<button
key={p.name}
onClick={() => handleSwitch(p.name)}
className="w-full flex items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-[var(--color-surface-hover)]"
style={{ color: "var(--color-text)" }}
>
{/* Active indicator */}
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{
background: isCurrent ? "var(--color-success)" : "transparent",
border: isCurrent ? "none" : "1px solid var(--color-border-strong)",
}}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="truncate font-medium">
{p.name === "default" ? "Default" : p.name}
</span>
</div>
<div
className="text-xs truncate mt-0.5"
style={{ color: "var(--color-text-muted)" }}
>
{p.workspaceDir
? p.workspaceDir.replace(/^\/Users\/[^/]+/, "~")
: "No workspace yet"}
</div>
</div>
{isCurrent && (
<span
className="text-xs px-1.5 py-0.5 rounded"
style={{
background: "var(--color-accent-light)",
color: "var(--color-accent)",
}}
>
Active
</span>
)}
</button>
);
})}
</div>
{/* Create new */}
<div style={{ borderTop: "1px solid var(--color-border)" }}>
<button
onClick={() => {
setIsOpen(false);
onCreateWorkspace?.();
}}
className="w-full flex items-center gap-2 px-3 py-2.5 text-sm transition-colors hover:bg-[var(--color-surface-hover)]"
style={{ color: "var(--color-accent)" }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 5v14" /><path d="M5 12h14" />
</svg>
New Workspace
</button>
</div>
</div>
)}
</div>
);
}