Files
multica/packages/views/settings/components/workspace-tab.tsx
Naiyuan Qing c76ba2f58e fix(workspace): seed React Query cache at all list-acquisition points
- staleTime: 0 on fetchQuery after leave/delete so fresh data is fetched
- setQueryData before switchWorkspace in createWorkspace so sidebar is
  consistent on first render
- seed workspaceKeys.list() cache in login, Google callback, and
  settings save so the first useQuery(workspaceListOptions()) hit is free
- remove dead onError from WorkspaceStoreOptions (used only by the
  deleted refreshWorkspaces action)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 14:00:28 +08:00

259 lines
9.2 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { Save, LogOut } from "lucide-react";
import { Input } from "@multica/ui/components/ui/input";
import { Textarea } from "@multica/ui/components/ui/textarea";
import { Label } from "@multica/ui/components/ui/label";
import { Button } from "@multica/ui/components/ui/button";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction,
} from "@multica/ui/components/ui/alert-dialog";
import { toast } from "sonner";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
import { useLeaveWorkspace, useDeleteWorkspace } from "@multica/core/workspace/mutations";
import { useWorkspaceId } from "@multica/core/hooks";
import { memberListOptions, workspaceKeys } from "@multica/core/workspace/queries";
import { api } from "@multica/core/api";
import type { Workspace } from "@multica/core/types";
export function WorkspaceTab() {
const user = useAuthStore((s) => s.user);
const workspace = useWorkspaceStore((s) => s.workspace);
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const qc = useQueryClient();
const updateWorkspace = useWorkspaceStore((s) => s.updateWorkspace);
const leaveWorkspace = useLeaveWorkspace();
const deleteWorkspace = useDeleteWorkspace();
const [name, setName] = useState(workspace?.name ?? "");
const [description, setDescription] = useState(workspace?.description ?? "");
const [context, setContext] = useState(workspace?.context ?? "");
const [saving, setSaving] = useState(false);
const [actionId, setActionId] = useState<string | null>(null);
const [confirmAction, setConfirmAction] = useState<{
title: string;
description: string;
variant?: "destructive";
onConfirm: () => Promise<void>;
} | null>(null);
const currentMember = members.find((m) => m.user_id === user?.id) ?? null;
const canManageWorkspace = currentMember?.role === "owner" || currentMember?.role === "admin";
const isOwner = currentMember?.role === "owner";
useEffect(() => {
setName(workspace?.name ?? "");
setDescription(workspace?.description ?? "");
setContext(workspace?.context ?? "");
}, [workspace]);
const handleSave = async () => {
if (!workspace) return;
setSaving(true);
try {
const updated = await api.updateWorkspace(workspace.id, {
name,
description,
context,
});
updateWorkspace(updated);
qc.setQueryData(workspaceKeys.list(), (old: Workspace[] | undefined) =>
old?.map((ws) => (ws.id === updated.id ? updated : ws)),
);
toast.success("Workspace settings saved");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to save workspace settings");
} finally {
setSaving(false);
}
};
const handleLeaveWorkspace = () => {
if (!workspace) return;
setConfirmAction({
title: "Leave workspace",
description: `Leave ${workspace.name}? You will lose access until re-invited.`,
variant: "destructive",
onConfirm: async () => {
setActionId("leave");
try {
await leaveWorkspace.mutateAsync(workspace.id);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to leave workspace");
} finally {
setActionId(null);
}
},
});
};
const handleDeleteWorkspace = () => {
if (!workspace) return;
setConfirmAction({
title: "Delete workspace",
description: `Delete ${workspace.name}? This cannot be undone. All issues, agents, and data will be permanently removed.`,
variant: "destructive",
onConfirm: async () => {
setActionId("delete-workspace");
try {
await deleteWorkspace.mutateAsync(workspace.id);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to delete workspace");
} finally {
setActionId(null);
}
},
});
};
if (!workspace) return null;
return (
<div className="space-y-8">
{/* Workspace settings */}
<section className="space-y-4">
<h2 className="text-sm font-semibold">General</h2>
<Card>
<CardContent className="space-y-3">
<div>
<Label className="text-xs text-muted-foreground">Name</Label>
<Input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={!canManageWorkspace}
className="mt-1"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">Description</Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
disabled={!canManageWorkspace}
className="mt-1 resize-none"
placeholder="What does this workspace focus on?"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">Context</Label>
<Textarea
value={context}
onChange={(e) => setContext(e.target.value)}
rows={4}
disabled={!canManageWorkspace}
className="mt-1 resize-none"
placeholder="Background information and context for AI agents working in this workspace"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">Slug</Label>
<div className="mt-1 rounded-md border bg-muted/50 px-3 py-2 text-sm text-muted-foreground">
{workspace.slug}
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-1">
<Button
size="sm"
onClick={handleSave}
disabled={saving || !name.trim() || !canManageWorkspace}
>
<Save className="h-3 w-3" />
{saving ? "Saving..." : "Save"}
</Button>
</div>
{!canManageWorkspace && (
<p className="text-xs text-muted-foreground">
Only admins and owners can update workspace settings.
</p>
)}
</CardContent>
</Card>
</section>
{/* Danger Zone */}
<section className="space-y-4">
<div className="flex items-center gap-2">
<LogOut className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">Danger Zone</h2>
</div>
<Card>
<CardContent className="space-y-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-medium">Leave workspace</p>
<p className="text-xs text-muted-foreground">
Remove yourself from this workspace.
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleLeaveWorkspace}
disabled={actionId === "leave"}
>
{actionId === "leave" ? "Leaving..." : "Leave workspace"}
</Button>
</div>
{isOwner && (
<div className="flex flex-col gap-2 border-t pt-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-medium text-destructive">Delete workspace</p>
<p className="text-xs text-muted-foreground">
Permanently delete this workspace and its data.
</p>
</div>
<Button
variant="destructive"
size="sm"
onClick={handleDeleteWorkspace}
disabled={actionId === "delete-workspace"}
>
{actionId === "delete-workspace" ? "Deleting..." : "Delete workspace"}
</Button>
</div>
)}
</CardContent>
</Card>
</section>
<AlertDialog open={!!confirmAction} onOpenChange={(v) => { if (!v) setConfirmAction(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{confirmAction?.title}</AlertDialogTitle>
<AlertDialogDescription>{confirmAction?.description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
variant={confirmAction?.variant === "destructive" ? "destructive" : "default"}
onClick={async () => {
await confirmAction?.onConfirm();
setConfirmAction(null);
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}