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,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>
);
}