refactor(web): update workspace components for workspace model

ProfileSwitcher uses workspaces; create-workspace-dialog, empty-state, sidebar updated.
This commit is contained in:
kumarabhirup 2026-03-03 13:46:54 -08:00
parent 974ba61b48
commit 1adb7b926b
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
5 changed files with 100 additions and 213 deletions

View File

@ -351,7 +351,7 @@ export function Sidebar({
const [mainMemory, setMainMemory] = useState<string | null>(null);
const [dailyLogs, setDailyLogs] = useState<MemoryFile[]>([]);
const [workspaceTree, setWorkspaceTree] = useState<TreeNode[]>([]);
const [activeProfile, setActiveProfile] = useState("default");
const [activeWorkspace, setActiveWorkspace] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const toggleSection = (section: SidebarSection) => {
@ -368,19 +368,19 @@ export function Sidebar({
async function load() {
setLoading(true);
try {
const [webSessionsRes, skillsRes, memoriesRes, workspaceRes, profilesRes] = await Promise.all([
const [webSessionsRes, skillsRes, memoriesRes, workspaceRes, workspaceListRes] = await Promise.all([
fetch("/api/web-sessions").then((r) => r.json()),
fetch("/api/skills").then((r) => r.json()),
fetch("/api/memories").then((r) => r.json()),
fetch("/api/workspace/tree").then((r) => r.json()).catch(() => ({ tree: [] })),
fetch("/api/profiles").then((r) => r.json()).catch(() => ({ activeProfile: "default" })),
fetch("/api/workspace/list").then((r) => r.json()).catch(() => ({ activeWorkspace: null })),
]);
setWebSessions(webSessionsRes.sessions ?? []);
setSkills(skillsRes.skills ?? []);
setMainMemory(memoriesRes.mainMemory ?? null);
setDailyLogs(memoriesRes.dailyLogs ?? []);
setWorkspaceTree(workspaceRes.tree ?? []);
setActiveProfile(String(profilesRes.activeProfile || "default"));
setActiveWorkspace((workspaceListRes.activeWorkspace ?? null) as string | null);
} catch (err) {
console.error("Failed to load sidebar data:", err);
} finally {
@ -428,7 +428,9 @@ export function Sidebar({
</svg>
</button>
</div>
<p className="text-xs text-[var(--color-text-muted)]">Profile: {activeProfile}</p>
<p className="text-xs text-[var(--color-text-muted)]">
Workspace: {activeWorkspace ?? "none"}
</p>
</div>
{/* Content */}

View File

@ -1,7 +1,6 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { DirectoryPickerModal } from "./directory-picker-modal";
type CreateWorkspaceDialogProps = {
isOpen: boolean;
@ -16,17 +15,9 @@ function shortenPath(p: string): string {
.replace(/^[A-Za-z]:[/\\]Users[/\\][^/\\]+/, "~");
}
function pathBasename(p: string): string {
return p.replaceAll("\\", "/").split("/").pop() || p;
}
export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWorkspaceDialogProps) {
const [profileName, setProfileName] = useState("");
const [customPath, setCustomPath] = useState("");
const [useCustomPath, setUseCustomPath] = useState(false);
const [showDirPicker, setShowDirPicker] = useState(false);
const [workspaceName, setWorkspaceName] = useState("");
const [seedBootstrap, setSeedBootstrap] = useState(true);
const [copyConfigAuth, setCopyConfigAuth] = useState(true);
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<{ workspaceDir: string; seededFiles: string[] } | null>(null);
@ -36,30 +27,26 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork
// Focus input on open
useEffect(() => {
if (isOpen) {
setProfileName("");
setCustomPath("");
setUseCustomPath(false);
setShowDirPicker(false);
setCopyConfigAuth(true);
setWorkspaceName("");
setError(null);
setResult(null);
setTimeout(() => inputRef.current?.focus(), 100);
}
}, [isOpen]);
// Close on Escape (only if dir picker is not open)
// Close on Escape.
useEffect(() => {
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape" && !showDirPicker) {onClose();}
if (e.key === "Escape") {onClose();}
}
if (isOpen) {
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}
}, [isOpen, onClose, showDirPicker]);
}, [isOpen, onClose]);
const handleCreate = async () => {
const name = profileName.trim();
const name = workspaceName.trim();
if (!name) {
setError("Please enter a workspace name.");
return;
@ -74,13 +61,9 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork
try {
const body: Record<string, unknown> = {
profile: name,
workspace: name,
seedBootstrap,
copyConfigAuth,
};
if (useCustomPath && customPath.trim()) {
body.path = customPath.trim();
}
const res = await fetch("/api/workspace/init", {
method: "POST",
@ -196,9 +179,9 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork
<input
ref={inputRef}
type="text"
value={profileName}
value={workspaceName}
onChange={(e) => {
setProfileName(e.target.value);
setWorkspaceName(e.target.value);
setError(null);
}}
onKeyDown={(e) => {
@ -216,91 +199,10 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork
className="text-xs mt-1"
style={{ color: "var(--color-text-muted)" }}
>
This creates a new profile with its own workspace directory.
This creates a workspace under ~/.openclaw-ironclaw/workspace-{"{name}"}.
</p>
</div>
{/* Custom path toggle */}
<div>
<button
onClick={() => setUseCustomPath(!useCustomPath)}
className="flex items-center gap-2 text-xs transition-colors hover:opacity-80"
style={{ color: "var(--color-text-muted)" }}
>
<svg
className={`w-3.5 h-3.5 transition-transform ${useCustomPath ? "rotate-90" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
Custom directory path
</button>
{useCustomPath && (
<div className="mt-2 space-y-2">
{customPath ? (
<div
className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg"
style={{
background: "var(--color-bg)",
border: "1px solid var(--color-border)",
}}
>
<div
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
style={{ background: "rgba(245, 158, 11, 0.12)", color: "#f59e0b" }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" />
</svg>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate" style={{ color: "var(--color-text)" }}>
{pathBasename(customPath)}
</p>
<p className="text-[11px] truncate" style={{ color: "var(--color-text-muted)" }} title={customPath}>
{shortenPath(customPath)}
</p>
</div>
<button
onClick={() => setShowDirPicker(true)}
className="px-2 py-1 text-xs rounded-md transition-colors hover:opacity-80"
style={{ color: "var(--color-accent)" }}
>
Change
</button>
<button
onClick={() => setCustomPath("")}
className="p-1 rounded-md transition-colors hover:bg-[var(--color-surface-hover)]"
style={{ color: "var(--color-text-muted)" }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
</button>
</div>
) : (
<button
onClick={() => setShowDirPicker(true)}
className="w-full flex items-center justify-center gap-2 px-3 py-3 rounded-lg text-sm transition-colors hover:opacity-90"
style={{
background: "var(--color-bg)",
border: "1px dashed var(--color-border-strong)",
color: "var(--color-text-muted)",
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" />
</svg>
Browse for a directory...
</button>
)}
</div>
)}
</div>
{/* Bootstrap toggle */}
<label className="flex items-center gap-2 cursor-pointer">
<input
@ -317,22 +219,6 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork
Seed bootstrap files and workspace database
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={copyConfigAuth}
onChange={(e) => setCopyConfigAuth(e.target.checked)}
className="rounded"
style={{ accentColor: "var(--color-accent)" }}
/>
<span
className="text-sm"
style={{ color: "var(--color-text-secondary)" }}
>
Copy Ironclaw config and auth profiles
</span>
</label>
{error && (
<p
className="text-sm px-3 py-2 rounded-lg"
@ -375,7 +261,7 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork
</button>
<button
onClick={() => void handleCreate()}
disabled={creating || !profileName.trim()}
disabled={creating || !workspaceName.trim()}
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
style={{
background: "var(--color-accent)",
@ -388,14 +274,6 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork
)}
</div>
</div>
{/* Directory picker modal */}
<DirectoryPickerModal
open={showDirPicker}
onClose={() => setShowDirPicker(false)}
onSelect={(path) => setCustomPath(path)}
startDir="~"
/>
</div>
);
}

View File

@ -136,7 +136,7 @@ export function EmptyState({
>
{expectedPath
? shortenPath(expectedPath)
: "~/.openclaw/workspace"}
: "~/.openclaw-ironclaw/workspace-<name>"}
</code>
</span>
</div>

View File

@ -2,7 +2,7 @@
import { useState, useEffect, useRef, useCallback } from "react";
export type ProfileInfo = {
export type WorkspaceInfo = {
name: string;
stateDir: string;
workspaceDir: string | null;
@ -13,16 +13,16 @@ export type ProfileInfo = {
export type ProfileSwitcherTriggerProps = {
isOpen: boolean;
onClick: () => void;
activeProfile: string;
activeWorkspace: string | null;
switching: boolean;
};
type ProfileSwitcherProps = {
onProfileSwitch?: () => void;
onWorkspaceDelete?: (profileName: string) => void;
onWorkspaceSwitch?: () => void;
onWorkspaceDelete?: (workspaceName: string) => void;
onCreateWorkspace?: () => void;
/** Parent-tracked active profile -- triggers a re-fetch when it changes (e.g. after workspace creation). */
activeProfileHint?: string | null;
/** Parent-tracked active workspace, used to trigger refetches after changes. */
activeWorkspaceHint?: string | null;
/** When set, this renders instead of the default button; dropdown still opens below. */
trigger?: (props: ProfileSwitcherTriggerProps) => React.ReactNode;
};
@ -35,34 +35,42 @@ function shortenPath(p: string): string {
}
export function ProfileSwitcher({
onProfileSwitch,
onWorkspaceSwitch,
onWorkspaceDelete,
onCreateWorkspace,
activeProfileHint,
activeWorkspaceHint,
trigger,
}: ProfileSwitcherProps) {
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
const [activeProfile, setActiveProfile] = useState("default");
const [workspaces, setWorkspaces] = useState<WorkspaceInfo[]>([]);
const [activeWorkspace, setActiveWorkspace] = useState<string | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [switching, setSwitching] = useState(false);
const [deletingProfile, setDeletingProfile] = useState<string | null>(null);
const [deletingWorkspace, setDeletingWorkspace] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const fetchProfiles = useCallback(async () => {
const fetchWorkspaces = useCallback(async () => {
try {
const res = await fetch("/api/profiles");
const res = await fetch("/api/workspace/list");
const data = await res.json();
setProfiles(data.profiles ?? []);
setActiveProfile(data.activeProfile ?? "default");
const nextWorkspaces = ((data.workspaces ?? data.profiles ?? []) as WorkspaceInfo[])
.filter((workspace) => Boolean(workspace.workspaceDir));
const nextActiveWorkspace =
(data.activeWorkspace ?? data.activeProfile ?? null) as string | null;
const activeFromList =
nextActiveWorkspace && nextWorkspaces.some((workspace) => workspace.name === nextActiveWorkspace)
? nextActiveWorkspace
: (nextWorkspaces.find((workspace) => workspace.isActive)?.name ?? nextWorkspaces[0]?.name ?? null);
setWorkspaces(nextWorkspaces);
setActiveWorkspace(activeFromList);
} catch {
// ignore
}
}, []);
useEffect(() => {
void fetchProfiles();
}, [fetchProfiles, activeProfileHint]);
void fetchWorkspaces();
}, [fetchWorkspaces, activeWorkspaceHint]);
// Close dropdown on outside click
useEffect(() => {
@ -77,75 +85,74 @@ export function ProfileSwitcher({
}
}, [isOpen]);
const handleSwitch = async (profileName: string) => {
if (profileName === activeProfile) {
const handleSwitch = async (workspaceName: string) => {
if (workspaceName === activeWorkspace) {
setIsOpen(false);
return;
}
setActionError(null);
setSwitching(true);
try {
const res = await fetch("/api/profiles/switch", {
const res = await fetch("/api/workspace/switch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ profile: profileName }),
body: JSON.stringify({ workspace: workspaceName }),
});
if (res.ok) {
const data = await res.json();
setActiveProfile(data.activeProfile ?? "default");
onProfileSwitch?.();
void fetchProfiles();
setActiveWorkspace((data.activeWorkspace ?? data.activeProfile ?? null) as string | null);
onWorkspaceSwitch?.();
void fetchWorkspaces();
} else {
const data = (await res.json().catch(() => ({}))) as { error?: string };
setActionError(data.error ?? "Failed to switch profile.");
setActionError(data.error ?? "Failed to switch workspace.");
}
} catch {
setActionError("Failed to switch profile.");
setActionError("Failed to switch workspace.");
} finally {
setSwitching(false);
setIsOpen(false);
}
};
const handleDeleteWorkspace = async (profileName: string) => {
const target = profiles.find((p) => p.name === profileName);
const handleDeleteWorkspace = async (workspaceName: string) => {
const target = workspaces.find((workspace) => workspace.name === workspaceName);
if (!target?.workspaceDir) {
return;
}
const confirmed = window.confirm(
`Delete workspace for profile "${profileName}"?\n\nThis runs openclaw --profile ${profileName} workspace delete.`,
`Delete workspace "${workspaceName}"?\n\nThis permanently removes:\n${shortenPath(target.workspaceDir)}\n\nThis cannot be undone.`,
);
if (!confirmed) {
return;
}
setActionError(null);
setDeletingProfile(profileName);
setDeletingWorkspace(workspaceName);
try {
const res = await fetch("/api/workspace/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ profile: profileName }),
body: JSON.stringify({ workspace: workspaceName }),
});
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
setActionError(data.error ?? `Failed to delete workspace for profile '${profileName}'.`);
setActionError(data.error ?? `Failed to delete workspace '${workspaceName}'.`);
return;
}
if (profileName === activeProfile) {
onProfileSwitch?.();
if (workspaceName === activeWorkspace) {
onWorkspaceSwitch?.();
}
onWorkspaceDelete?.(profileName);
await fetchProfiles();
onWorkspaceDelete?.(workspaceName);
await fetchWorkspaces();
} catch {
setActionError(`Failed to delete workspace for profile '${profileName}'.`);
setActionError(`Failed to delete workspace '${workspaceName}'.`);
} finally {
setDeletingProfile(null);
setDeletingWorkspace(null);
}
};
// Don't show the switcher if there's only one profile and no way to create more
const showSwitcher = profiles.length > 0;
const showSwitcher = workspaces.length > 0;
const handleToggle = () => {
if (showSwitcher) { setIsOpen((o) => !o); }
};
@ -161,7 +168,7 @@ export function ProfileSwitcher({
trigger({
isOpen,
onClick: handleToggle,
activeProfile,
activeWorkspace,
switching,
})
) : (
@ -170,14 +177,14 @@ export function ProfileSwitcher({
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"
title="Switch workspace"
>
{/* 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}
{activeWorkspace ?? "No workspace"}
</span>
<svg
className={`w-3 h-3 transition-transform ${isOpen ? "rotate-180" : ""}`}
@ -207,18 +214,18 @@ export function ProfileSwitcher({
borderBottom: "1px solid var(--color-border)",
}}
>
Workspace Profiles
Workspaces
</div>
{/* Profile list */}
{/* Workspace list */}
<div className="py-1 max-h-64 overflow-y-auto">
{profiles.map((p) => {
const isCurrent = p.name === activeProfile;
{workspaces.map((workspace) => {
const isCurrent = workspace.name === activeWorkspace;
return (
<div key={p.name} className="flex items-center gap-1 px-1.5 py-0.5">
<div key={workspace.name} className="flex items-center gap-1 px-1.5 py-0.5">
<button
onClick={() => void handleSwitch(p.name)}
disabled={switching || !!deletingProfile}
onClick={() => void handleSwitch(workspace.name)}
disabled={switching || !!deletingWorkspace}
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)" }}
>
@ -234,15 +241,15 @@ export function ProfileSwitcher({
<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}
{workspace.name}
</span>
</div>
<div
className="text-xs truncate mt-0.5"
style={{ color: "var(--color-text-muted)" }}
>
{p.workspaceDir
? shortenPath(p.workspaceDir)
{workspace.workspaceDir
? shortenPath(workspace.workspaceDir)
: "No workspace yet"}
</div>
</div>
@ -260,14 +267,14 @@ export function ProfileSwitcher({
)}
</button>
{p.workspaceDir && (
{workspace.workspaceDir && (
<button
onClick={() => void handleDeleteWorkspace(p.name)}
disabled={switching || !!deletingProfile}
title={`Delete workspace for ${p.name}`}
onClick={() => void handleDeleteWorkspace(workspace.name)}
disabled={switching || !!deletingWorkspace}
title={`Delete workspace ${workspace.name}`}
className="p-1.5 rounded transition-colors hover:bg-[var(--color-surface-hover)] disabled:opacity-50"
style={{
color: deletingProfile === p.name
color: deletingWorkspace === workspace.name
? "var(--color-text-muted)"
: "var(--color-error)",
}}

View File

@ -48,10 +48,10 @@ type WorkspaceSidebarProps = {
onToggleHidden?: () => void;
/** Called when the user clicks the collapse/hide sidebar button. */
onCollapse?: () => void;
/** Active profile hint used by the profile switcher. */
activeProfile?: string | null;
/** Called after profile switches or workspace creation so parent can refresh state. */
onProfileChanged?: () => void;
/** Active workspace hint used by the switcher. */
activeWorkspace?: string | null;
/** Called after workspace switches or workspace creation so parent can refresh state. */
onWorkspaceChanged?: () => void;
};
function HomeIcon() {
@ -431,8 +431,8 @@ export function WorkspaceSidebar({
onToggleHidden,
width: widthProp,
onCollapse,
activeProfile,
onProfileChanged,
activeWorkspace,
onWorkspaceChanged,
}: WorkspaceSidebarProps) {
const isBrowsing = browseDir != null;
const width = mobile ? "280px" : (widthProp ?? 260);
@ -519,28 +519,28 @@ export function WorkspaceSidebar({
</div>
<div className="mt-1">
<ProfileSwitcher
activeProfileHint={activeProfile ?? null}
onProfileSwitch={() => {
onProfileChanged?.();
activeWorkspaceHint={activeWorkspace ?? null}
onWorkspaceSwitch={() => {
onWorkspaceChanged?.();
}}
onWorkspaceDelete={() => {
onProfileChanged?.();
onWorkspaceChanged?.();
}}
onCreateWorkspace={() => {
setCreateWorkspaceOpen(true);
}}
trigger={({ onClick, activeProfile: profileName, switching }) => (
trigger={({ onClick, activeWorkspace: workspaceName, switching }) => (
<button
type="button"
onClick={onClick}
disabled={switching}
className="text-[11px] flex items-center gap-1 truncate w-full transition-colors"
style={{ color: "var(--color-text-muted)" }}
title="Switch profile"
title="Switch workspace"
>
<span>Profile</span>
<span>Workspace</span>
<span className="px-1 py-0.5 rounded text-[10px] shrink-0 bg-stone-200 text-stone-600 dark:bg-stone-700 dark:text-stone-300">
{profileName || "default"}
{workspaceName || "-"}
</span>
<svg
width="10"
@ -670,7 +670,7 @@ export function WorkspaceSidebar({
isOpen={createWorkspaceOpen}
onClose={() => setCreateWorkspaceOpen(false)}
onCreated={() => {
onProfileChanged?.();
onWorkspaceChanged?.();
}}
/>
</>
@ -689,7 +689,7 @@ export function WorkspaceSidebar({
isOpen={createWorkspaceOpen}
onClose={() => setCreateWorkspaceOpen(false)}
onCreated={() => {
onProfileChanged?.();
onWorkspaceChanged?.();
}}
/>
</>