Add multi-workspace support to desktop app (#409)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-28 04:47:11 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent f1cef90376
commit a9024c2c78
35 changed files with 1212 additions and 55 deletions
+9
View File
@@ -0,0 +1,9 @@
export type Workspace = {
id: string;
name: string;
relayUrl: string;
token?: string;
nsec?: string;
pubkey?: string;
addedAt: string;
};
@@ -0,0 +1,157 @@
import * as React from "react";
import type { Workspace } from "@/features/workspaces/types";
import {
deriveWorkspaceName,
normalizeRelayUrl,
} from "@/features/workspaces/workspaceStorage";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
type AddWorkspaceDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (workspace: Workspace) => void;
};
export function AddWorkspaceDialog({
open,
onOpenChange,
onSubmit,
}: AddWorkspaceDialogProps) {
const [name, setName] = React.useState("");
const [relayUrl, setRelayUrl] = React.useState("");
const [token, setToken] = React.useState("");
const [nsec, setNsec] = React.useState("");
const handleClose = React.useCallback(() => {
onOpenChange(false);
setName("");
setRelayUrl("");
setToken("");
setNsec("");
}, [onOpenChange]);
const handleSubmit = React.useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if (!relayUrl.trim()) {
return;
}
const workspace: Workspace = {
id: crypto.randomUUID(),
name: name.trim() || deriveWorkspaceName(relayUrl.trim()),
relayUrl: normalizeRelayUrl(relayUrl.trim()),
token: token.trim() || undefined,
nsec: nsec.trim() || undefined,
addedAt: new Date().toISOString(),
};
onSubmit(workspace);
handleClose();
},
[name, relayUrl, token, nsec, onSubmit, handleClose],
);
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Add Workspace</DialogTitle>
<DialogDescription>
Connect to another Sprout relay. Each workspace has its own
channels, messages, and identity.
</DialogDescription>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="ws-relay-url"
>
Relay URL
</label>
<Input
autoFocus
id="ws-relay-url"
onChange={(e) => setRelayUrl(e.target.value)}
placeholder="wss://relay.example.com"
type="text"
value={relayUrl}
/>
</div>
<div className="flex flex-col gap-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="ws-name"
>
Name
<span className="ml-1 text-xs font-normal text-muted-foreground">
(optional)
</span>
</label>
<Input
id="ws-name"
onChange={(e) => setName(e.target.value)}
placeholder="My Workspace"
type="text"
value={name}
/>
</div>
<div className="flex flex-col gap-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="ws-token"
>
API Token
<span className="ml-1 text-xs font-normal text-muted-foreground">
(optional)
</span>
</label>
<Input
id="ws-token"
onChange={(e) => setToken(e.target.value)}
placeholder="sprout_..."
type="password"
value={token}
/>
</div>
<div className="flex flex-col gap-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="ws-nsec"
>
Private Key (nsec)
<span className="ml-1 text-xs font-normal text-muted-foreground">
(optional — uses current identity if blank)
</span>
</label>
<Input
id="ws-nsec"
onChange={(e) => setNsec(e.target.value)}
placeholder="nsec1..."
type="password"
value={nsec}
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button onClick={handleClose} type="button" variant="outline">
Cancel
</Button>
<Button disabled={!relayUrl.trim()} type="submit">
Add Workspace
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,183 @@
import * as React from "react";
import type { Workspace } from "@/features/workspaces/types";
import { normalizeRelayUrl } from "@/features/workspaces/workspaceStorage";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
type EditWorkspaceDialogProps = {
workspace: Workspace | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (
id: string,
updates: Partial<Pick<Workspace, "name" | "relayUrl" | "token">>,
) => void;
onRemove?: (id: string) => void;
canRemove?: boolean;
};
export function EditWorkspaceDialog({
workspace,
open,
onOpenChange,
onSave,
onRemove,
canRemove,
}: EditWorkspaceDialogProps) {
const [name, setName] = React.useState("");
const [relayUrl, setRelayUrl] = React.useState("");
const [token, setToken] = React.useState("");
// Sync form state when the dialog opens with a workspace
React.useEffect(() => {
if (workspace && open) {
setName(workspace.name);
setRelayUrl(workspace.relayUrl);
setToken(workspace.token ?? "");
}
}, [workspace, open]);
const handleClose = React.useCallback(() => {
onOpenChange(false);
}, [onOpenChange]);
const handleSubmit = React.useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if (!workspace || !relayUrl.trim()) {
return;
}
const updates: Partial<Pick<Workspace, "name" | "relayUrl" | "token">> =
{};
const trimmedName = name.trim();
if (trimmedName && trimmedName !== workspace.name) {
updates.name = trimmedName;
}
const normalizedUrl = normalizeRelayUrl(relayUrl.trim());
if (normalizedUrl !== workspace.relayUrl) {
updates.relayUrl = normalizedUrl;
}
const trimmedToken = token.trim() || undefined;
if (trimmedToken !== workspace.token) {
updates.token = trimmedToken;
}
if (Object.keys(updates).length > 0) {
onSave(workspace.id, updates);
}
handleClose();
},
[workspace, name, relayUrl, token, onSave, handleClose],
);
const handleRemove = React.useCallback(() => {
if (workspace && onRemove) {
onRemove(workspace.id);
handleClose();
}
}, [workspace, onRemove, handleClose]);
if (!workspace) {
return null;
}
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Edit Workspace</DialogTitle>
<DialogDescription>
Update this workspace's name or relay URL.
</DialogDescription>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="edit-ws-name"
>
Name
</label>
<Input
autoFocus
id="edit-ws-name"
onChange={(e) => setName(e.target.value)}
placeholder="My Workspace"
type="text"
value={name}
/>
</div>
<div className="flex flex-col gap-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="edit-ws-relay-url"
>
Relay URL
</label>
<Input
id="edit-ws-relay-url"
onChange={(e) => setRelayUrl(e.target.value)}
placeholder="wss://relay.example.com"
type="text"
value={relayUrl}
/>
</div>
<div className="flex flex-col gap-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="edit-ws-token"
>
API Token
<span className="ml-1 text-xs font-normal text-muted-foreground">
(optional)
</span>
</label>
<Input
id="edit-ws-token"
onChange={(e) => setToken(e.target.value)}
placeholder="sprout_..."
type="password"
value={token}
/>
</div>
<div className="flex items-center justify-between pt-2">
<div>
{canRemove && onRemove ? (
<Button
className="text-destructive hover:text-destructive"
onClick={handleRemove}
size="sm"
type="button"
variant="ghost"
>
Remove Workspace
</Button>
) : null}
</div>
<div className="flex gap-2">
<Button onClick={handleClose} type="button" variant="outline">
Cancel
</Button>
<Button disabled={!name.trim() || !relayUrl.trim()} type="submit">
Save Changes
</Button>
</div>
</div>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,129 @@
import * as React from "react";
import { getIdentity, getNsec } from "@/shared/api/tauri";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import type { Workspace } from "../types";
import {
deriveWorkspaceName,
normalizeRelayUrl,
saveActiveWorkspaceId,
saveWorkspaces,
} from "../workspaceStorage";
const LOCAL_RELAY_URL = "ws://localhost:3000";
type WelcomeSetupProps = {
defaultRelayUrl: string;
onComplete: () => void;
};
export function WelcomeSetup({
defaultRelayUrl,
onComplete,
}: WelcomeSetupProps) {
const isInternalBuild = defaultRelayUrl !== LOCAL_RELAY_URL;
const [relayUrl, setRelayUrl] = React.useState(defaultRelayUrl);
const [isConnecting, setIsConnecting] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const handleConnect = React.useCallback(async () => {
const trimmedUrl = relayUrl.trim();
if (!trimmedUrl) {
setError("Please enter a relay URL.");
return;
}
const normalizedUrl = normalizeRelayUrl(trimmedUrl);
setIsConnecting(true);
setError(null);
try {
const [identity, nsec] = await Promise.all([getIdentity(), getNsec()]);
const workspace: Workspace = {
id: crypto.randomUUID(),
name: deriveWorkspaceName(normalizedUrl),
relayUrl: normalizedUrl,
nsec,
pubkey: identity.pubkey,
addedAt: new Date().toISOString(),
};
saveWorkspaces([workspace]);
saveActiveWorkspaceId(workspace.id);
// The reload triggered by onComplete() will re-run useWorkspaceInit,
// which calls applyWorkspace with the saved config. No need to apply here.
onComplete();
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to connect. Try again.",
);
setIsConnecting(false);
}
}, [relayUrl, onComplete]);
const workspaceName = React.useMemo(
() => deriveWorkspaceName(relayUrl.trim() || LOCAL_RELAY_URL),
[relayUrl],
);
return (
<div className="flex min-h-dvh items-center justify-center bg-[radial-gradient(circle_at_top,hsl(var(--primary)/0.14),transparent_48%),linear-gradient(180deg,hsl(var(--background)),hsl(var(--muted)/0.55))] px-4 py-8">
<div className="w-full max-w-sm rounded-[28px] border border-border/70 bg-background/92 p-8 shadow-2xl backdrop-blur">
<p className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">
Sprout
</p>
<h1 className="mt-3 text-2xl font-semibold tracking-tight text-foreground">
Welcome
</h1>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
{isInternalBuild
? "Connect to your workspace to get started."
: "Running a local relay? Connect now. Or enter a custom relay URL."}
</p>
<div className="mt-6 space-y-4">
{!isInternalBuild ? (
<div className="space-y-1.5">
<label
className="text-xs font-medium text-muted-foreground"
htmlFor="relay-url"
>
Relay URL
</label>
<Input
id="relay-url"
onChange={(e) => {
setRelayUrl(e.target.value);
setError(null);
}}
placeholder="ws://localhost:3000"
type="url"
value={relayUrl}
/>
</div>
) : null}
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<Button
className="w-full"
disabled={isConnecting || !relayUrl.trim()}
onClick={handleConnect}
size="default"
type="button"
>
{isConnecting
? "Connecting..."
: isInternalBuild
? `Connect to ${workspaceName}`
: "Connect"}
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,124 @@
import { Check, ChevronDown, MoreHorizontal, Plus } from "lucide-react";
import * as React from "react";
import type { Workspace } from "@/features/workspaces/types";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/shared/ui/sidebar";
import { EditWorkspaceDialog } from "./EditWorkspaceDialog";
type WorkspaceSwitcherProps = {
activeWorkspace: Workspace | null;
workspaces: Workspace[];
onSwitchWorkspace: (id: string) => void;
onAddWorkspace: () => void;
onUpdateWorkspace: (
id: string,
updates: Partial<Pick<Workspace, "name" | "relayUrl" | "token">>,
) => void;
onRemoveWorkspace: (id: string) => void;
};
export function WorkspaceSwitcher({
activeWorkspace,
workspaces,
onSwitchWorkspace,
onAddWorkspace,
onUpdateWorkspace,
onRemoveWorkspace,
}: WorkspaceSwitcherProps) {
const [editingWorkspace, setEditingWorkspace] =
React.useState<Workspace | null>(null);
const [dropdownOpen, setDropdownOpen] = React.useState(false);
return (
<>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
className="h-auto gap-2 rounded-xl px-2.5 py-2 data-[state=open]:bg-sidebar-accent"
data-testid="workspace-switcher"
type="button"
>
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-primary/15 text-xs leading-none">
🌱
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{activeWorkspace?.name ?? "No workspace"}
</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-sidebar-foreground/50" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="w-[--radix-dropdown-menu-trigger-width] min-w-[220px]"
onCloseAutoFocus={(e) => e.preventDefault()}
side="bottom"
sideOffset={4}
>
{workspaces.map((workspace) => (
<DropdownMenuItem
key={workspace.id}
className="group flex items-center gap-2 pr-1"
onSelect={() => {
onSwitchWorkspace(workspace.id);
}}
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
{activeWorkspace?.id === workspace.id ? (
<Check className="h-3.5 w-3.5 text-primary" />
) : null}
</span>
<span className="min-w-0 flex-1 truncate">
{workspace.name}
</span>
<button
aria-label={`Edit ${workspace.name}`}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded opacity-0 hover:bg-accent group-hover:opacity-100 group-focus:opacity-100"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setDropdownOpen(false);
setEditingWorkspace(workspace);
}}
type="button"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onAddWorkspace}>
<Plus className="h-4 w-4" />
<span>Add Workspace</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
<EditWorkspaceDialog
canRemove={workspaces.length > 1}
onOpenChange={(open) => {
if (!open) setEditingWorkspace(null);
}}
onRemove={onRemoveWorkspace}
onSave={onUpdateWorkspace}
open={editingWorkspace !== null}
workspace={editingWorkspace}
/>
</>
);
}
@@ -0,0 +1,93 @@
import { useEffect, useState } from "react";
import { applyWorkspace, getDefaultRelayUrl } from "@/shared/api/tauri";
import {
loadActiveWorkspaceId,
loadWorkspaces,
saveActiveWorkspaceId,
} from "./workspaceStorage";
type WorkspaceInitResult =
| { isReady: true; needsSetup: false }
| { isReady: false; needsSetup: true; defaultRelayUrl: string }
| { isReady: false; needsSetup: false };
/**
* Runs once on mount. Loads the active workspace from localStorage
* and calls the Tauri backend to apply the workspace config
* (keys, relay URL, token).
*
* Returns a discriminated union — only render the app after the
* workspace is applied. When `needsSetup` is true, the caller
* should show a first-run welcome screen.
*/
export function useWorkspaceInit(): WorkspaceInitResult {
const [result, setResult] = useState<WorkspaceInitResult>({
isReady: false,
needsSetup: false,
});
useEffect(() => {
let cancelled = false;
async function init() {
const workspaces = loadWorkspaces();
if (workspaces.length === 0) {
// No workspaces at all — fetch the build default relay URL
// so the welcome screen can pre-fill it.
try {
const defaultRelayUrl = await getDefaultRelayUrl();
if (!cancelled) {
setResult({ isReady: false, needsSetup: true, defaultRelayUrl });
}
} catch {
// If we can't get the default, fall back to localhost
if (!cancelled) {
setResult({
isReady: false,
needsSetup: true,
defaultRelayUrl: "ws://localhost:3000",
});
}
}
return;
}
// Determine active workspace
let activeId = loadActiveWorkspaceId();
if (!activeId || !workspaces.find((w) => w.id === activeId)) {
activeId = workspaces[0].id;
saveActiveWorkspaceId(activeId);
}
const active = workspaces.find((w) => w.id === activeId);
if (!active) {
if (!cancelled) {
setResult({ isReady: true, needsSetup: false });
}
return;
}
// Apply workspace config to the Tauri backend
try {
await applyWorkspace(active.relayUrl, active.nsec, active.token);
} catch (error) {
console.error("Failed to apply workspace to backend:", error);
}
if (!cancelled) {
setResult({ isReady: true, needsSetup: false });
}
}
void init();
return () => {
cancelled = true;
};
}, []);
return result;
}
@@ -0,0 +1,137 @@
import { useCallback, useMemo, useRef, useState } from "react";
import type { Workspace } from "./types";
import {
loadActiveWorkspaceId,
loadWorkspaces,
saveActiveWorkspaceId,
saveWorkspaces,
} from "./workspaceStorage";
export type UseWorkspacesReturn = {
workspaces: Workspace[];
activeWorkspace: Workspace | null;
/** Add a workspace, deduplicating by relayUrl. Returns the final ID in the list. */
addWorkspace: (workspace: Workspace) => string;
removeWorkspace: (id: string) => void;
switchWorkspace: (id: string) => void;
updateWorkspace: (
id: string,
updates: Partial<Pick<Workspace, "name" | "relayUrl" | "token">>,
) => void;
};
export function useWorkspaces(): UseWorkspacesReturn {
const [workspaces, setWorkspacesState] =
useState<Workspace[]>(loadWorkspaces);
const [activeId, setActiveId] = useState<string | null>(
loadActiveWorkspaceId,
);
const workspacesRef = useRef(workspaces);
workspacesRef.current = workspaces;
const activeWorkspace = useMemo(
() => workspaces.find((w) => w.id === activeId) ?? workspaces[0] ?? null,
[workspaces, activeId],
);
const addWorkspace = useCallback((workspace: Workspace): string => {
const existing = workspacesRef.current.find(
(w) => w.relayUrl === workspace.relayUrl,
);
const resolvedId = existing?.id ?? workspace.id;
setWorkspacesState((prev) => {
const dup = prev.find((w) => w.relayUrl === workspace.relayUrl);
let next: Workspace[];
if (dup) {
next = prev.map((w) =>
w.id === dup.id
? {
...w,
name: workspace.name || w.name,
token: workspace.token ?? w.token,
nsec: workspace.nsec ?? w.nsec,
pubkey: workspace.pubkey ?? w.pubkey,
}
: w,
);
} else {
next = [...prev, workspace];
}
saveWorkspaces(next);
return next;
});
return resolvedId;
}, []);
const removeWorkspace = useCallback(
(id: string) => {
setWorkspacesState((prev) => {
// Never allow removing the last workspace
if (prev.length <= 1) {
return prev;
}
const next = prev.filter((w) => w.id !== id);
saveWorkspaces(next);
// If removing the active workspace, switch to first remaining
if (activeId === id && next.length > 0) {
setActiveId(next[0].id);
saveActiveWorkspaceId(next[0].id);
window.location.reload();
}
return next;
});
},
[activeId],
);
const switchWorkspace = useCallback(
(id: string) => {
if (id === activeId) {
return;
}
saveActiveWorkspaceId(id);
window.location.reload();
},
[activeId],
);
const updateWorkspace = useCallback(
(
id: string,
updates: Partial<Pick<Workspace, "name" | "relayUrl" | "token">>,
) => {
setWorkspacesState((prev) => {
// Prevent duplicate relay URLs across workspaces
if (
updates.relayUrl &&
prev.some((w) => w.id !== id && w.relayUrl === updates.relayUrl)
) {
return prev;
}
const next = prev.map((w) => (w.id === id ? { ...w, ...updates } : w));
saveWorkspaces(next);
return next;
});
// If the active workspace's relay URL or token changed, reload to reconnect
if (
id === activeId &&
(updates.relayUrl || updates.token !== undefined)
) {
window.location.reload();
}
},
[activeId],
);
return {
workspaces,
activeWorkspace,
addWorkspace,
removeWorkspace,
switchWorkspace,
updateWorkspace,
};
}
@@ -0,0 +1,63 @@
import type { Workspace } from "./types";
const WORKSPACES_KEY = "sprout-workspaces";
const ACTIVE_WORKSPACE_KEY = "sprout-active-workspace-id";
export function loadWorkspaces(): Workspace[] {
try {
const raw = localStorage.getItem(WORKSPACES_KEY);
if (!raw) {
return [];
}
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed as Workspace[];
} catch {
return [];
}
}
export function saveWorkspaces(workspaces: Workspace[]): void {
localStorage.setItem(WORKSPACES_KEY, JSON.stringify(workspaces));
}
export function loadActiveWorkspaceId(): string | null {
return localStorage.getItem(ACTIVE_WORKSPACE_KEY);
}
export function saveActiveWorkspaceId(id: string): void {
localStorage.setItem(ACTIVE_WORKSPACE_KEY, id);
}
export function normalizeRelayUrl(url: string): string {
if (!url.startsWith("ws://") && !url.startsWith("wss://")) {
return `wss://${url}`;
}
return url;
}
export function deriveWorkspaceName(relayUrl: string): string {
try {
const url = new URL(
relayUrl.replace("ws://", "http://").replace("wss://", "https://"),
);
const host = url.hostname;
if (host === "localhost" || host === "127.0.0.1") {
return "Local Dev";
}
const parts = host.split(".");
// Detect staging environments (e.g. sprout-oss.stage.blox.sqprod.co)
if (parts.some((p) => p === "stage" || p === "staging")) {
return "Sprout (staging)";
}
// Use the first subdomain segment or the domain itself
if (parts.length >= 2) {
return parts[0] === "relay" ? parts[1] : parts[0];
}
return host;
} catch {
return "Workspace";
}
}