feat(web): add workspace delete and profile discovery to profile-switcher

This commit is contained in:
kumarabhirup 2026-03-02 18:35:03 -08:00
parent af0d34b5db
commit 9542c60f66
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167

View File

@ -19,6 +19,7 @@ export type ProfileSwitcherTriggerProps = {
type ProfileSwitcherProps = {
onProfileSwitch?: () => void;
onWorkspaceDelete?: (profileName: string) => void;
onCreateWorkspace?: () => void;
/** Parent-tracked active profile -- triggers a re-fetch when it changes (e.g. after workspace creation). */
activeProfileHint?: string | null;
@ -26,11 +27,26 @@ type ProfileSwitcherProps = {
trigger?: (props: ProfileSwitcherTriggerProps) => React.ReactNode;
};
export function ProfileSwitcher({ onProfileSwitch, onCreateWorkspace, activeProfileHint, trigger }: ProfileSwitcherProps) {
function shortenPath(p: string): string {
return p
.replace(/^\/Users\/[^/]+/, "~")
.replace(/^\/home\/[^/]+/, "~")
.replace(/^[A-Za-z]:[/\\]Users[/\\][^/\\]+/, "~");
}
export function ProfileSwitcher({
onProfileSwitch,
onWorkspaceDelete,
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 [deletingProfile, setDeletingProfile] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const fetchProfiles = useCallback(async () => {
@ -66,6 +82,7 @@ export function ProfileSwitcher({ onProfileSwitch, onCreateWorkspace, activeProf
setIsOpen(false);
return;
}
setActionError(null);
setSwitching(true);
try {
const res = await fetch("/api/profiles/switch", {
@ -78,15 +95,55 @@ export function ProfileSwitcher({ onProfileSwitch, onCreateWorkspace, activeProf
setActiveProfile(data.activeProfile ?? "default");
onProfileSwitch?.();
void fetchProfiles();
} else {
const data = (await res.json().catch(() => ({}))) as { error?: string };
setActionError(data.error ?? "Failed to switch profile.");
}
} catch {
// ignore
setActionError("Failed to switch profile.");
} finally {
setSwitching(false);
setIsOpen(false);
}
};
const handleDeleteWorkspace = async (profileName: string) => {
const target = profiles.find((p) => p.name === profileName);
if (!target?.workspaceDir) {
return;
}
const confirmed = window.confirm(
`Delete workspace for profile "${profileName}"?\n\nThis runs openclaw --profile ${profileName} workspace delete.`,
);
if (!confirmed) {
return;
}
setActionError(null);
setDeletingProfile(profileName);
try {
const res = await fetch("/api/workspace/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ profile: profileName }),
});
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
setActionError(data.error ?? `Failed to delete workspace for profile '${profileName}'.`);
return;
}
if (profileName === activeProfile) {
onProfileSwitch?.();
}
onWorkspaceDelete?.(profileName);
await fetchProfiles();
} catch {
setActionError(`Failed to delete workspace for profile '${profileName}'.`);
} finally {
setDeletingProfile(null);
}
};
// Don't show the switcher if there's only one profile and no way to create more
const showSwitcher = profiles.length > 0;
const handleToggle = () => {
@ -158,53 +215,98 @@ export function ProfileSwitcher({ onProfileSwitch, onCreateWorkspace, activeProf
{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 && (
<div key={p.name} className="flex items-center gap-1 px-1.5 py-0.5">
<button
onClick={() => void handleSwitch(p.name)}
disabled={switching || !!deletingProfile}
className="flex-1 min-w-0 flex items-center gap-2 px-1.5 py-1.5 rounded text-left text-sm transition-colors hover:bg-[var(--color-surface-hover)] disabled:opacity-50"
style={{ color: "var(--color-text)" }}
>
{/* Active indicator */}
<span
className="text-xs px-1.5 py-0.5 rounded"
className="w-2 h-2 rounded-full flex-shrink-0"
style={{
background: "var(--color-accent-light)",
color: "var(--color-accent)",
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
? shortenPath(p.workspaceDir)
: "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>
{p.workspaceDir && (
<button
onClick={() => void handleDeleteWorkspace(p.name)}
disabled={switching || !!deletingProfile}
title={`Delete workspace for ${p.name}`}
className="p-1.5 rounded transition-colors hover:bg-[var(--color-surface-hover)] disabled:opacity-50"
style={{
color: deletingProfile === p.name
? "var(--color-text-muted)"
: "var(--color-error)",
}}
>
Active
</span>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 6h18" />
<path d="M8 6V4h8v2" />
<path d="M19 6l-1 14H6L5 6" />
<path d="M10 11v6" />
<path d="M14 11v6" />
</svg>
</button>
)}
</button>
</div>
);
})}
</div>
{actionError && (
<p
className="mx-3 mb-2 mt-1 rounded px-2 py-1 text-xs"
style={{
background: "rgba(220, 38, 38, 0.08)",
color: "var(--color-error)",
}}
>
{actionError}
</p>
)}
{/* Create new */}
<div style={{ borderTop: "1px solid var(--color-border)" }}>
<button