refactor(web): restructure to feature-based architecture with zustand stores

- Remove tab system entirely (tab-store, tab-bar, tab-link)
- Split monolithic AuthContext into zustand auth + workspace stores
- Move issue components/config to features/issues/
- Move WebSocket provider to features/realtime/
- Move api.ts to shared/
- Migrate all consumers from useAuth() to direct store imports
- Simplify sidebar: replace hand-built dropdown with shadcn DropdownMenu,
  replace custom layout wrapper with SidebarInset
- Remove unused @multica/store and @multica/hooks dependencies
- Add @/ path alias and zustand dependency
- Update CLAUDE.md with feature-based architecture conventions

Net change: +293 / -2435 lines

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-03-24 16:01:23 +08:00
parent 768a555a80
commit a2d7501d57
48 changed files with 604 additions and 1704 deletions

View file

@ -0,0 +1,2 @@
export { useAuthStore } from "./store";
export { AuthInitializer } from "./initializer";

View file

@ -0,0 +1,32 @@
"use client";
import { useEffect, type ReactNode } from "react";
import { useAuthStore } from "./store";
import { useWorkspaceStore } from "@/features/workspace";
import { api } from "@/shared/api";
/**
* Initializes auth + workspace state from localStorage on mount.
* Must wrap the app to ensure stores are hydrated before children render.
*/
export function AuthInitializer({ children }: { children: ReactNode }) {
const initialize = useAuthStore((s) => s.initialize);
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace);
useEffect(() => {
initialize();
}, [initialize]);
useEffect(() => {
if (isLoading || !user) return;
const wsId = localStorage.getItem("multica_workspace_id");
api.listWorkspaces().then((wsList) => {
hydrateWorkspace(wsList, wsId);
}).catch(console.error);
}, [user, isLoading, hydrateWorkspace]);
return <>{children}</>;
}

View file

@ -0,0 +1,61 @@
"use client";
import { create } from "zustand";
import type { User } from "@multica/types";
import { api } from "@/shared/api";
interface AuthState {
user: User | null;
isLoading: boolean;
initialize: () => Promise<void>;
login: (email: string, name?: string) => Promise<User>;
logout: () => void;
setUser: (user: User) => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
isLoading: true,
initialize: async () => {
const token = localStorage.getItem("multica_token");
if (!token) {
set({ isLoading: false });
return;
}
api.setToken(token);
try {
const user = await api.getMe();
set({ user, isLoading: false });
} catch {
api.setToken(null);
api.setWorkspaceId(null);
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
set({ user: null, isLoading: false });
}
},
login: async (email: string, name?: string) => {
const { token, user } = await api.login(email, name);
localStorage.setItem("multica_token", token);
api.setToken(token);
set({ user });
return user;
},
logout: () => {
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
api.setToken(null);
api.setWorkspaceId(null);
set({ user: null });
},
setUser: (user: User) => {
set({ user });
},
}));

View file

@ -0,0 +1,3 @@
export { StatusIcon } from "./status-icon";
export { PriorityIcon } from "./priority-icon";
export { StatusPicker, PriorityPicker, AssigneePicker } from "./pickers";

View file

@ -0,0 +1,145 @@
"use client";
import { useState } from "react";
import { Bot, UserMinus } from "lucide-react";
import type { IssueAssigneeType, UpdateIssueRequest } from "@multica/types";
import { useWorkspaceStore, useActorName } from "@/features/workspace";
import {
PropertyPicker,
PickerItem,
PickerSection,
PickerEmpty,
} from "./property-picker";
export function AssigneePicker({
assigneeType,
assigneeId,
onUpdate,
}: {
assigneeType: IssueAssigneeType | null;
assigneeId: string | null;
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
}) {
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState("");
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
const { getActorName, getActorInitials } = useActorName();
const query = filter.toLowerCase();
const filteredMembers = members.filter((m) =>
m.name.toLowerCase().includes(query),
);
const filteredAgents = agents.filter((a) =>
a.name.toLowerCase().includes(query),
);
const isSelected = (type: string, id: string) =>
assigneeType === type && assigneeId === id;
const triggerLabel =
assigneeType && assigneeId
? getActorName(assigneeType, assigneeId)
: "Unassigned";
return (
<PropertyPicker
open={open}
onOpenChange={(v: boolean) => {
setOpen(v);
if (!v) setFilter("");
}}
width="w-52"
searchable
searchPlaceholder="Assign to..."
onSearchChange={setFilter}
trigger={
assigneeType && assigneeId ? (
<>
<div
className={`inline-flex shrink-0 items-center justify-center rounded-full font-medium text-[8px] ${
assigneeType === "agent"
? "bg-purple-100 text-purple-700 dark:bg-purple-950 dark:text-purple-300"
: "bg-muted text-muted-foreground"
}`}
style={{ width: 18, height: 18 }}
>
{assigneeType === "agent" ? (
<Bot style={{ width: 10, height: 10 }} />
) : (
getActorInitials(assigneeType, assigneeId)
)}
</div>
<span>{triggerLabel}</span>
</>
) : (
<span className="text-muted-foreground">Unassigned</span>
)
}
>
{/* Unassigned option */}
<PickerItem
selected={!assigneeType && !assigneeId}
onClick={() => {
onUpdate({ assignee_type: null, assignee_id: null });
setOpen(false);
}}
>
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Unassigned</span>
</PickerItem>
{/* Members */}
{filteredMembers.length > 0 && (
<PickerSection label="Members">
{filteredMembers.map((m) => (
<PickerItem
key={m.user_id}
selected={isSelected("member", m.user_id)}
onClick={() => {
onUpdate({
assignee_type: "member",
assignee_id: m.user_id,
});
setOpen(false);
}}
>
<div className="inline-flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-medium text-muted-foreground">
{getActorInitials("member", m.user_id)}
</div>
<span>{m.name}</span>
</PickerItem>
))}
</PickerSection>
)}
{/* Agents */}
{filteredAgents.length > 0 && (
<PickerSection label="Agents">
{filteredAgents.map((a) => (
<PickerItem
key={a.id}
selected={isSelected("agent", a.id)}
onClick={() => {
onUpdate({
assignee_type: "agent",
assignee_id: a.id,
});
setOpen(false);
}}
>
<div className="inline-flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-full bg-purple-100 text-purple-700 dark:bg-purple-950 dark:text-purple-300">
<Bot style={{ width: 10, height: 10 }} />
</div>
<span>{a.name}</span>
</PickerItem>
))}
</PickerSection>
)}
{filteredMembers.length === 0 &&
filteredAgents.length === 0 &&
filter && <PickerEmpty />}
</PropertyPicker>
);
}

View file

@ -0,0 +1,4 @@
export { PropertyPicker, PickerItem, PickerSection, PickerEmpty } from "./property-picker";
export { StatusPicker } from "./status-picker";
export { PriorityPicker } from "./priority-picker";
export { AssigneePicker } from "./assignee-picker";

View file

@ -0,0 +1,49 @@
"use client";
import { useState } from "react";
import type { IssuePriority, UpdateIssueRequest } from "@multica/types";
import { PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
import { PriorityIcon } from "../priority-icon";
import { PropertyPicker, PickerItem } from "./property-picker";
export function PriorityPicker({
priority,
onUpdate,
}: {
priority: IssuePriority;
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
}) {
const [open, setOpen] = useState(false);
const cfg = PRIORITY_CONFIG[priority];
return (
<PropertyPicker
open={open}
onOpenChange={setOpen}
width="w-44"
trigger={
<>
<PriorityIcon priority={priority} />
<span>{cfg.label}</span>
</>
}
>
{PRIORITY_ORDER.map((p) => {
const c = PRIORITY_CONFIG[p];
return (
<PickerItem
key={p}
selected={p === priority}
onClick={() => {
onUpdate({ priority: p });
setOpen(false);
}}
>
<PriorityIcon priority={p} />
<span>{c.label}</span>
</PickerItem>
);
})}
</PropertyPicker>
);
}

View file

@ -0,0 +1,133 @@
"use client";
import { useState, useCallback } from "react";
import { Check } from "lucide-react";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@multica/ui/components/ui/popover";
// ---------------------------------------------------------------------------
// PropertyPicker — generic Popover shell with optional search
// ---------------------------------------------------------------------------
export function PropertyPicker({
open,
onOpenChange,
trigger,
width = "w-48",
align = "end",
searchable = false,
searchPlaceholder = "Filter...",
onSearchChange,
children,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
trigger: React.ReactNode;
width?: string;
align?: "start" | "center" | "end";
searchable?: boolean;
searchPlaceholder?: string;
onSearchChange?: (query: string) => void;
children: React.ReactNode;
}) {
const [query, setQuery] = useState("");
const handleOpenChange = useCallback(
(v: boolean) => {
onOpenChange(v);
if (!v) {
setQuery("");
onSearchChange?.("");
}
},
[onOpenChange, onSearchChange],
);
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors">
{trigger}
</PopoverTrigger>
<PopoverContent align={align} className={`${width} gap-0 p-0`}>
{searchable && (
<div className="px-2 py-1.5 border-b">
<input
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
onSearchChange?.(e.target.value);
}}
placeholder={searchPlaceholder}
className="w-full bg-transparent text-[13px] placeholder:text-muted-foreground outline-none"
/>
</div>
)}
<div className="p-1 max-h-60 overflow-y-auto">{children}</div>
</PopoverContent>
</Popover>
);
}
// ---------------------------------------------------------------------------
// PickerItem — single selectable row
// ---------------------------------------------------------------------------
export function PickerItem({
selected,
onClick,
hoverClassName,
children,
}: {
selected: boolean;
onClick: () => void;
hoverClassName?: string;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-[13px] ${hoverClassName ?? "hover:bg-accent"} transition-colors`}
>
<span className="flex flex-1 items-center gap-2">{children}</span>
{selected && <Check className="h-3.5 w-3.5 text-muted-foreground" />}
</button>
);
}
// ---------------------------------------------------------------------------
// PickerSection — group header
// ---------------------------------------------------------------------------
export function PickerSection({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div>
<div className="px-2 pt-2 pb-1 text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
{label}
</div>
{children}
</div>
);
}
// ---------------------------------------------------------------------------
// PickerEmpty — no results state
// ---------------------------------------------------------------------------
export function PickerEmpty() {
return (
<div className="px-2 py-3 text-center text-[13px] text-muted-foreground">
No results
</div>
);
}

View file

@ -0,0 +1,50 @@
"use client";
import { useState } from "react";
import type { IssueStatus, UpdateIssueRequest } from "@multica/types";
import { ALL_STATUSES, STATUS_CONFIG } from "@/features/issues/config";
import { StatusIcon } from "../status-icon";
import { PropertyPicker, PickerItem } from "./property-picker";
export function StatusPicker({
status,
onUpdate,
}: {
status: IssueStatus;
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
}) {
const [open, setOpen] = useState(false);
const cfg = STATUS_CONFIG[status];
return (
<PropertyPicker
open={open}
onOpenChange={setOpen}
width="w-44"
trigger={
<>
<StatusIcon status={status} className="h-3.5 w-3.5" />
<span>{cfg.label}</span>
</>
}
>
{ALL_STATUSES.map((s) => {
const c = STATUS_CONFIG[s];
return (
<PickerItem
key={s}
selected={s === status}
hoverClassName={c.hoverBg}
onClick={() => {
onUpdate({ status: s });
setOpen(false);
}}
>
<StatusIcon status={s} className="h-3.5 w-3.5" />
<span>{c.label}</span>
</PickerItem>
);
})}
</PropertyPicker>
);
}

View file

@ -0,0 +1,57 @@
import type { IssuePriority } from "@multica/types";
import { PRIORITY_CONFIG } from "@/features/issues/config";
export function PriorityIcon({
priority,
className = "",
}: {
priority: IssuePriority;
className?: string;
}) {
const cfg = PRIORITY_CONFIG[priority];
// "none" — simple horizontal dashes
if (cfg.bars === 0) {
return (
<svg
viewBox="0 0 16 16"
className={`h-3.5 w-3.5 text-muted-foreground shrink-0 ${className}`}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
>
<line x1="3" y1="8" x2="13" y2="8" />
</svg>
);
}
const isUrgent = priority === "urgent";
return (
<svg
viewBox="0 0 16 16"
className={`h-3.5 w-3.5 ${cfg.color} shrink-0 ${className}`}
fill="currentColor"
style={isUrgent ? { animation: "priority-pulse 2s ease-in-out infinite" } : undefined}
>
{[0, 1, 2, 3].map((i) => (
<rect
key={i}
x={1 + i * 4}
width="3"
rx="0.5"
style={{
y: 12 - (i + 1) * 3,
height: (i + 1) * 3,
opacity: i < cfg.bars ? 1 : 0.2,
transition: "y 0.2s ease, height 0.2s ease, opacity 0.2s ease",
}}
/>
))}
{isUrgent && (
<style>{`@keyframes priority-pulse{0%,100%{transform:scale(1)}50%{transform:scale(1.08)}}`}</style>
)}
</svg>
);
}

View file

@ -0,0 +1,169 @@
import type { IssueStatus } from "@multica/types";
import { STATUS_CONFIG } from "@/features/issues/config";
// ---------------------------------------------------------------------------
// Circle geometry constants (viewBox 0 0 16 16, center 8,8, radius 6)
// ---------------------------------------------------------------------------
const CX = 8;
const CY = 8;
const R = 6;
// ---------------------------------------------------------------------------
// Per-status SVG renderers — Linear-style icons
// ---------------------------------------------------------------------------
/** 16 small dots arranged in a ring */
function BacklogIcon() {
const count = 16;
const dotR = 0.65;
return (
<g>
{Array.from({ length: count }, (_, i) => {
const angle = (i / count) * Math.PI * 2 - Math.PI / 2;
return (
<circle
key={i}
cx={CX + R * Math.cos(angle)}
cy={CY + R * Math.sin(angle)}
r={dotR}
fill="currentColor"
/>
);
})}
</g>
);
}
/** Empty circle, solid outline */
function TodoIcon() {
return (
<circle
cx={CX}
cy={CY}
r={R}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
);
}
/** Circle outline + right half filled (D-shape) */
function InProgressIcon() {
return (
<>
<circle
cx={CX}
cy={CY}
r={R}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d={`M${CX},${CY - R} A${R},${R} 0 0,1 ${CX},${CY + R} Z`}
fill="currentColor"
/>
</>
);
}
/** Circle outline + 75% pie fill (bottom-left quarter empty) */
function InReviewIcon() {
return (
<>
<circle
cx={CX}
cy={CY}
r={R}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d={`M${CX},${CY} L${CX},${CY - R} A${R},${R} 0 1,1 ${CX - R},${CY} Z`}
fill="currentColor"
/>
</>
);
}
/** Solid filled circle + white checkmark */
function DoneIcon() {
return (
<>
<circle cx={CX} cy={CY} r={R} fill="currentColor" />
<path
d="M5.5 8.2 L7.2 9.8 L10.5 6.2"
fill="none"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</>
);
}
/** Circle outline + X inside */
function CancelledIcon() {
return (
<>
<circle
cx={CX}
cy={CY}
r={R}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M5.75 5.75 L10.25 10.25 M10.25 5.75 L5.75 10.25"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</>
);
}
// ---------------------------------------------------------------------------
// Renderer map
// ---------------------------------------------------------------------------
const STATUS_RENDERERS: Record<IssueStatus, () => React.ReactNode> = {
backlog: BacklogIcon,
todo: TodoIcon,
in_progress: InProgressIcon,
in_review: InReviewIcon,
done: DoneIcon,
blocked: CancelledIcon, // fallback if backend sends blocked
cancelled: CancelledIcon,
};
// ---------------------------------------------------------------------------
// Public component
// ---------------------------------------------------------------------------
export function StatusIcon({
status,
className = "h-4 w-4",
}: {
status: IssueStatus;
className?: string;
}) {
const cfg = STATUS_CONFIG[status];
const Renderer = STATUS_RENDERERS[status];
return (
<svg
viewBox="0 0 16 16"
fill="none"
className={`${className} ${cfg.iconColor} shrink-0`}
>
<Renderer />
</svg>
);
}

View file

@ -0,0 +1,2 @@
export { STATUS_ORDER, ALL_STATUSES, STATUS_CONFIG } from "./status";
export { PRIORITY_ORDER, PRIORITY_CONFIG } from "./priority";

View file

@ -0,0 +1,20 @@
import type { IssuePriority } from "@multica/types";
export const PRIORITY_ORDER: IssuePriority[] = [
"urgent",
"high",
"medium",
"low",
"none",
];
export const PRIORITY_CONFIG: Record<
IssuePriority,
{ label: string; bars: number; color: string }
> = {
urgent: { label: "Urgent", bars: 4, color: "text-destructive" },
high: { label: "High", bars: 3, color: "text-warning" },
medium: { label: "Medium", bars: 2, color: "text-warning" },
low: { label: "Low", bars: 1, color: "text-info" },
none: { label: "No priority", bars: 0, color: "text-muted-foreground" },
};

View file

@ -0,0 +1,32 @@
import type { IssueStatus } from "@multica/types";
export const STATUS_ORDER: IssueStatus[] = [
"backlog",
"todo",
"in_progress",
"in_review",
"done",
"cancelled",
];
export const ALL_STATUSES: IssueStatus[] = [
"backlog",
"todo",
"in_progress",
"in_review",
"done",
"cancelled",
];
export const STATUS_CONFIG: Record<
IssueStatus,
{ label: string; iconColor: string; hoverBg: string }
> = {
backlog: { label: "Backlog", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
todo: { label: "Todo", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
in_progress: { label: "In Progress", iconColor: "text-warning", hoverBg: "hover:bg-warning/10" },
in_review: { label: "In Review", iconColor: "text-success", hoverBg: "hover:bg-success/10" },
done: { label: "Done", iconColor: "text-info", hoverBg: "hover:bg-info/10" },
blocked: { label: "Blocked", iconColor: "text-destructive", hoverBg: "hover:bg-destructive/10" },
cancelled: { label: "Cancelled", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
};

View file

@ -0,0 +1,2 @@
export { StatusIcon, PriorityIcon, StatusPicker, PriorityPicker, AssigneePicker } from "./components";
export * from "./config";

View file

@ -0,0 +1,20 @@
"use client";
import { useEffect } from "react";
import type { WSEventType } from "@multica/types";
import { useWS } from "./provider";
type EventHandler = (payload: unknown) => void;
/**
* Hook that subscribes to a WebSocket event and calls the handler.
* Automatically unsubscribes on cleanup.
*/
export function useWSEvent(event: WSEventType, handler: EventHandler) {
const { subscribe } = useWS();
useEffect(() => {
const unsub = subscribe(event, handler);
return unsub;
}, [event, handler, subscribe]);
}

View file

@ -0,0 +1,2 @@
export { WSProvider, useWS } from "./provider";
export { useWSEvent } from "./hooks";

View file

@ -0,0 +1,62 @@
"use client";
import {
createContext,
useContext,
useEffect,
useRef,
useCallback,
type ReactNode,
} from "react";
import { WSClient } from "@multica/sdk";
import type { WSEventType } from "@multica/types";
import { useAuthStore } from "@/features/auth";
const WS_URL = process.env.NEXT_PUBLIC_WS_URL ?? "ws://localhost:8080/ws";
type EventHandler = (payload: unknown) => void;
interface WSContextValue {
subscribe: (event: WSEventType, handler: EventHandler) => () => void;
}
const WSContext = createContext<WSContextValue | null>(null);
export function WSProvider({ children }: { children: ReactNode }) {
const user = useAuthStore((s) => s.user);
const wsRef = useRef<WSClient | null>(null);
useEffect(() => {
if (!user) return;
const ws = new WSClient(WS_URL);
wsRef.current = ws;
ws.connect();
return () => {
ws.disconnect();
wsRef.current = null;
};
}, [user]);
const subscribe = useCallback(
(event: WSEventType, handler: EventHandler) => {
const ws = wsRef.current;
if (!ws) return () => {};
return ws.on(event, handler);
},
[],
);
return (
<WSContext.Provider value={{ subscribe }}>
{children}
</WSContext.Provider>
);
}
export function useWS() {
const ctx = useContext(WSContext);
if (!ctx) throw new Error("useWS must be used within WSProvider");
return ctx;
}

View file

@ -0,0 +1,36 @@
"use client";
import { useWorkspaceStore } from "./store";
export function useActorName() {
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
const getMemberName = (userId: string) => {
const m = members.find((m) => m.user_id === userId);
return m?.name ?? "Unknown";
};
const getAgentName = (agentId: string) => {
const a = agents.find((a) => a.id === agentId);
return a?.name ?? "Unknown Agent";
};
const getActorName = (type: string, id: string) => {
if (type === "member") return getMemberName(id);
if (type === "agent") return getAgentName(id);
return "System";
};
const getActorInitials = (type: string, id: string) => {
const name = getActorName(type, id);
return name
.split(" ")
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2);
};
return { getMemberName, getAgentName, getActorName, getActorInitials };
}

View file

@ -0,0 +1,2 @@
export { useWorkspaceStore } from "./store";
export { useActorName } from "./hooks";

View file

@ -0,0 +1,142 @@
"use client";
import { create } from "zustand";
import type { Workspace, MemberWithUser, Agent } from "@multica/types";
import { api } from "@/shared/api";
interface WorkspaceState {
workspace: Workspace | null;
workspaces: Workspace[];
members: MemberWithUser[];
agents: Agent[];
}
interface WorkspaceActions {
hydrateWorkspace: (
wsList: Workspace[],
preferredWorkspaceId?: string | null,
) => Promise<Workspace | null>;
switchWorkspace: (workspaceId: string) => Promise<void>;
refreshWorkspaces: () => Promise<Workspace[]>;
refreshMembers: () => Promise<void>;
refreshAgents: () => Promise<void>;
createWorkspace: (data: {
name: string;
slug: string;
description?: string;
}) => Promise<Workspace>;
updateWorkspace: (ws: Workspace) => void;
leaveWorkspace: (workspaceId: string) => Promise<void>;
deleteWorkspace: (workspaceId: string) => Promise<void>;
clearWorkspace: () => void;
}
type WorkspaceStore = WorkspaceState & WorkspaceActions;
export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
// State
workspace: null,
workspaces: [],
members: [],
agents: [],
// Actions
hydrateWorkspace: async (wsList, preferredWorkspaceId) => {
set({ workspaces: wsList });
const nextWorkspace =
(preferredWorkspaceId
? wsList.find((item) => item.id === preferredWorkspaceId)
: null) ??
wsList[0] ??
null;
if (!nextWorkspace) {
api.setWorkspaceId(null);
localStorage.removeItem("multica_workspace_id");
set({ workspace: null, members: [], agents: [] });
return null;
}
api.setWorkspaceId(nextWorkspace.id);
localStorage.setItem("multica_workspace_id", nextWorkspace.id);
set({ workspace: nextWorkspace });
const [nextMembers, nextAgents] = await Promise.all([
api.listMembers(nextWorkspace.id),
api.listAgents({ workspace_id: nextWorkspace.id }),
]);
set({ members: nextMembers, agents: nextAgents });
return nextWorkspace;
},
switchWorkspace: async (workspaceId) => {
const { workspaces, hydrateWorkspace } = get();
const ws = workspaces.find((item) => item.id === workspaceId);
if (!ws) return;
await hydrateWorkspace(workspaces, ws.id);
},
refreshWorkspaces: async () => {
const { workspace, hydrateWorkspace } = get();
const storedWorkspaceId = localStorage.getItem("multica_workspace_id");
const wsList = await api.listWorkspaces();
await hydrateWorkspace(wsList, workspace?.id ?? storedWorkspaceId);
return wsList;
},
refreshMembers: async () => {
const { workspace } = get();
if (!workspace) return;
const members = await api.listMembers(workspace.id);
set({ members });
},
refreshAgents: async () => {
const { workspace } = get();
if (!workspace) return;
const agents = await api.listAgents({ workspace_id: workspace.id });
set({ agents });
},
createWorkspace: async (data) => {
const ws = await api.createWorkspace(data);
set((state) => ({ workspaces: [...state.workspaces, ws] }));
return ws;
},
updateWorkspace: (ws) => {
set((state) => ({
workspace: state.workspace?.id === ws.id ? ws : state.workspace,
workspaces: state.workspaces.map((item) =>
item.id === ws.id ? ws : item,
),
}));
},
leaveWorkspace: async (workspaceId) => {
await api.leaveWorkspace(workspaceId);
const { workspace, hydrateWorkspace } = get();
const wsList = await api.listWorkspaces();
const preferredWorkspaceId =
workspace?.id === workspaceId ? null : (workspace?.id ?? null);
await hydrateWorkspace(wsList, preferredWorkspaceId);
},
deleteWorkspace: async (workspaceId) => {
await api.deleteWorkspace(workspaceId);
const { workspace, hydrateWorkspace } = get();
const wsList = await api.listWorkspaces();
const preferredWorkspaceId =
workspace?.id === workspaceId ? null : (workspace?.id ?? null);
await hydrateWorkspace(wsList, preferredWorkspaceId);
},
clearWorkspace: () => {
api.setWorkspaceId(null);
localStorage.removeItem("multica_workspace_id");
set({ workspace: null, workspaces: [], members: [], agents: [] });
},
}));