polish(ui): inline editing, skeleton loading, toast feedback, empty states

- Issue detail: click-to-edit title (Input) and description (Textarea)
- Issue detail: acceptance criteria and context refs always addable (even when empty)
- Comment: optimistic create with temp ID + opacity, rollback on error
- Comment: timestamp hover tooltip shows full date
- Issues page: skeleton loading state, empty column text, "No matching issues" with clear filters
- Inbox page: skeleton loading state for two-panel layout
- Settings: replace raw textarea with shadcn Textarea, replace inline saved/error text with toast
- Settings: member operations use toast feedback (add/remove/role change)
- Sidebar: workspace create error shows toast instead of silent console.error

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-03-25 11:15:06 +08:00
parent 3f71957608
commit 5c583ccb82
6 changed files with 272 additions and 141 deletions

View file

@ -1,6 +1,6 @@
[
{ "id": "infra-event-bus-ws", "status": "done", "name": "Infrastructure: Event Bus + WS Isolation + Global Store" },
{ "id": "issue-board-polish", "status": "in_progress", "name": "Issue Board & Detail Polish" },
{ "id": "issue-board-polish", "status": "done", "name": "Issue Board & Detail Polish" },
{ "id": "workspace-permissions", "status": "done", "name": "Workspace & Permissions" },
{ "id": "inbox-notifications", "status": "done", "name": "Inbox & Notifications" }
]

View file

@ -14,6 +14,7 @@ import {
Plus,
Check,
} from "lucide-react";
import { toast } from "sonner";
import { MulticaIcon } from "@/components/multica-icon";
import {
Sidebar,
@ -106,6 +107,7 @@ export function AppSidebar() {
await switchWorkspace(ws.id);
} catch (err) {
console.error("Failed to create workspace:", err);
toast.error("Failed to create workspace");
} finally {
setCreating(false);
}

View file

@ -14,6 +14,7 @@ import {
import type { InboxItem, InboxItemType, InboxSeverity } from "@multica/types";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { api } from "@/shared/api";
// ---------------------------------------------------------------------------
@ -226,8 +227,28 @@ export default function InboxPage() {
if (loading) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Loading...
<div className="flex h-full">
<div className="w-80 shrink-0 border-r">
<div className="flex h-12 items-center border-b px-4">
<Skeleton className="h-5 w-16" />
</div>
<div className="space-y-1 p-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-start gap-3 px-4 py-3">
<Skeleton className="h-4 w-4 shrink-0 rounded" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))}
</div>
</div>
<div className="flex-1 p-6">
<Skeleton className="h-6 w-48" />
<Skeleton className="mt-4 h-4 w-32" />
<Skeleton className="mt-6 h-24 w-full" />
</div>
</div>
);
}

View file

@ -31,6 +31,13 @@ import {
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
TooltipProvider,
} from "@/components/ui/tooltip";
import { ActorAvatar } from "@/components/common/actor-avatar";
import type { Issue, Comment, UpdateIssueRequest } from "@multica/types";
import { StatusPicker, PriorityPicker, AssigneePicker } from "@/features/issues/components";
@ -163,40 +170,53 @@ function AcceptanceCriteriaEditor({
onUpdate({ acceptance_criteria: criteria.filter((_, i) => i !== index) });
};
if (criteria.length === 0 && !newItem) {
return null;
}
const [adding, setAdding] = useState(false);
return (
<div className="space-y-2">
<h3 className="text-xs font-medium text-muted-foreground">Acceptance Criteria</h3>
<div className="space-y-1">
{criteria.map((item, i) => (
<div key={i} className="group flex items-start gap-2 text-sm">
<span className="mt-0.5 text-muted-foreground">&bull;</span>
<span className="flex-1">{item}</span>
<Button
variant="ghost"
size="icon-xs"
onClick={() => removeItem(i)}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
<form
onSubmit={(e) => { e.preventDefault(); addItem(); }}
className="flex items-center gap-2"
>
<input
value={newItem}
onChange={(e) => setNewItem(e.target.value)}
placeholder="Add criteria..."
className="flex-1 text-sm bg-transparent outline-none placeholder:text-muted-foreground"
/>
</form>
{criteria.length > 0 && (
<div className="space-y-1">
{criteria.map((item, i) => (
<div key={i} className="group flex items-start gap-2 text-sm">
<span className="mt-0.5 text-muted-foreground">&bull;</span>
<span className="flex-1">{item}</span>
<Button
variant="ghost"
size="icon-xs"
onClick={() => removeItem(i)}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
{(criteria.length > 0 || adding) ? (
<form
onSubmit={(e) => { e.preventDefault(); addItem(); }}
className="flex items-center gap-2"
>
<input
autoFocus={adding}
value={newItem}
onChange={(e) => setNewItem(e.target.value)}
onBlur={() => { if (!newItem.trim()) setAdding(false); }}
placeholder="Add criteria..."
className="flex-1 text-sm bg-transparent outline-none placeholder:text-muted-foreground"
/>
</form>
) : (
<Button
variant="ghost"
size="sm"
className="text-muted-foreground h-7 px-2 text-xs"
onClick={() => setAdding(true)}
>
+ Add acceptance criteria
</Button>
)}
</div>
);
}
@ -224,48 +244,61 @@ function ContextRefsEditor({
onUpdate({ context_refs: refs.filter((_, i) => i !== index) });
};
if (refs.length === 0 && !newRef) {
return null;
}
const [adding, setAdding] = useState(false);
const isUrl = (s: string) => s.startsWith("http://") || s.startsWith("https://");
return (
<div className="space-y-2">
<h3 className="text-xs font-medium text-muted-foreground">Context References</h3>
<div className="space-y-1">
{refs.map((ref, i) => (
<div key={i} className="group flex items-center gap-2 text-sm">
<Link2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
{isUrl(ref) ? (
<a href={ref} target="_blank" rel="noopener noreferrer" className="flex-1 text-info hover:underline truncate">
{ref}
</a>
) : (
<span className="flex-1 truncate">{ref}</span>
)}
<Button
variant="ghost"
size="icon-xs"
onClick={() => removeRef(i)}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
<form
onSubmit={(e) => { e.preventDefault(); addRef(); }}
className="flex items-center gap-2"
>
<input
value={newRef}
onChange={(e) => setNewRef(e.target.value)}
placeholder="Add reference URL..."
className="flex-1 text-sm bg-transparent outline-none placeholder:text-muted-foreground"
/>
</form>
{refs.length > 0 && (
<div className="space-y-1">
{refs.map((ref, i) => (
<div key={i} className="group flex items-center gap-2 text-sm">
<Link2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
{isUrl(ref) ? (
<a href={ref} target="_blank" rel="noopener noreferrer" className="flex-1 text-info hover:underline truncate">
{ref}
</a>
) : (
<span className="flex-1 truncate">{ref}</span>
)}
<Button
variant="ghost"
size="icon-xs"
onClick={() => removeRef(i)}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
{(refs.length > 0 || adding) ? (
<form
onSubmit={(e) => { e.preventDefault(); addRef(); }}
className="flex items-center gap-2"
>
<input
autoFocus={adding}
value={newRef}
onChange={(e) => setNewRef(e.target.value)}
onBlur={() => { if (!newRef.trim()) setAdding(false); }}
placeholder="Add reference URL..."
className="flex-1 text-sm bg-transparent outline-none placeholder:text-muted-foreground"
/>
</form>
) : (
<Button
variant="ghost"
size="sm"
className="text-muted-foreground h-7 px-2 text-xs"
onClick={() => setAdding(true)}
>
+ Add context reference
</Button>
)}
</div>
);
}
@ -291,6 +324,10 @@ export default function IssueDetailPage({
const [deleting, setDeleting] = useState(false);
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
const [editContent, setEditContent] = useState("");
const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState("");
const [editingDesc, setEditingDesc] = useState(false);
const [descDraft, setDescDraft] = useState("");
// Watch the global issue store for real-time updates from other users/agents
const storeIssue = useIssueStore((s) => s.issues.find((i) => i.id === id));
@ -316,14 +353,28 @@ export default function IssueDetailPage({
const handleSubmitComment = async (e: React.FormEvent) => {
e.preventDefault();
if (!commentText.trim() || submitting) return;
if (!commentText.trim() || submitting || !user) return;
const content = commentText.trim();
const tempId = "temp-" + Date.now();
const tempComment: Comment = {
id: tempId,
issue_id: id,
author_type: "member",
author_id: user.id,
content,
type: "comment",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
setComments((prev) => [...prev, tempComment]);
setCommentText("");
setSubmitting(true);
try {
const comment = await api.createComment(id, commentText.trim());
setComments((prev) => [...prev, comment]);
setCommentText("");
} catch (err) {
console.error("Failed to create comment:", err);
const comment = await api.createComment(id, content);
setComments((prev) => prev.map((c) => (c.id === tempId ? comment : c)));
} catch {
setComments((prev) => prev.filter((c) => c.id !== tempId));
toast.error("Failed to send comment");
} finally {
setSubmitting(false);
}
@ -477,28 +528,72 @@ export default function IssueDetailPage({
<div className="mx-auto w-full max-w-3xl px-8 py-8">
<div className="mb-1 text-[13px] text-muted-foreground">{issue.id.slice(0, 8)}</div>
<h1 className="text-xl font-semibold leading-snug tracking-tight">
{issue.title}
</h1>
{editingTitle ? (
<Input
autoFocus
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={() => {
if (titleDraft.trim()) handleUpdateField({ title: titleDraft.trim() });
setEditingTitle(false);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (titleDraft.trim()) handleUpdateField({ title: titleDraft.trim() });
setEditingTitle(false);
} else if (e.key === "Escape") {
setEditingTitle(false);
}
}}
className="text-xl font-semibold leading-snug tracking-tight"
/>
) : (
<h1
className="text-xl font-semibold leading-snug tracking-tight cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1"
onClick={() => { setTitleDraft(issue.title); setEditingTitle(true); }}
>
{issue.title}
</h1>
)}
{issue.description && (
<div className="mt-5 text-[14px] leading-[1.7] text-foreground/85 whitespace-pre-wrap">
{issue.description}
{editingDesc ? (
<Textarea
autoFocus
value={descDraft}
onChange={(e) => setDescDraft(e.target.value)}
onBlur={() => {
handleUpdateField({ description: descDraft.trim() || undefined });
setEditingDesc(false);
}}
onKeyDown={(e) => {
if (e.key === "Escape") setEditingDesc(false);
}}
rows={4}
className="mt-5 text-[14px] leading-[1.7] resize-none"
/>
) : (
<div
className="mt-5 text-[14px] leading-[1.7] whitespace-pre-wrap cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1"
onClick={() => { setDescDraft(issue.description || ""); setEditingDesc(true); }}
>
{issue.description ? (
<span className="text-foreground/85">{issue.description}</span>
) : (
<span className="text-muted-foreground">Add description...</span>
)}
</div>
)}
{(issue.acceptance_criteria.length > 0 || issue.context_refs.length > 0) && (
<div className="space-y-4 mt-4">
<AcceptanceCriteriaEditor
criteria={issue.acceptance_criteria}
onUpdate={handleUpdateField}
/>
<ContextRefsEditor
refs={issue.context_refs}
onUpdate={handleUpdateField}
/>
</div>
)}
<div className="space-y-4 mt-4">
<AcceptanceCriteriaEditor
criteria={issue.acceptance_criteria}
onUpdate={handleUpdateField}
/>
<ContextRefsEditor
refs={issue.context_refs}
onUpdate={handleUpdateField}
/>
</div>
<div className="my-8 border-t" />
@ -510,7 +605,7 @@ export default function IssueDetailPage({
{comments.map((comment) => {
const isOwn = comment.author_type === "member" && comment.author_id === user?.id;
return (
<div key={comment.id} className="group relative py-3">
<div key={comment.id} className={`group relative py-3${comment.id.startsWith("temp-") ? " opacity-60" : ""}`}>
<div className="flex items-center gap-2.5">
<ActorAvatar
actorType={comment.author_type}
@ -522,9 +617,20 @@ export default function IssueDetailPage({
<span className="text-[13px] font-medium">
{getActorName(comment.author_type, comment.author_id)}
</span>
<span className="text-[12px] text-muted-foreground">
{timeAgo(comment.created_at)}
</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<span className="text-[12px] text-muted-foreground cursor-default">
{timeAgo(comment.created_at)}
</span>
}
/>
<TooltipContent side="top">
{new Date(comment.created_at).toLocaleString()}
</TooltipContent>
</Tooltip>
</TooltipProvider>
{isOwn && (
<div className="ml-auto flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button

View file

@ -8,6 +8,7 @@ import {
List,
Plus,
} from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import {
DndContext,
DragOverlay,
@ -159,6 +160,9 @@ function DroppableColumn({
{issues.map((issue) => (
<DraggableBoardCard key={issue.id} issue={issue} />
))}
{issues.length === 0 && (
<p className="py-8 text-center text-xs text-muted-foreground">No issues</p>
)}
</div>
</div>
);
@ -503,8 +507,20 @@ export default function IssuesPage() {
if (loading) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Loading...
<div className="flex h-full flex-col">
<div className="flex h-11 shrink-0 items-center justify-between border-b px-4">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-8 w-24" />
</div>
<div className="flex flex-1 gap-3 overflow-x-auto p-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex min-w-52 flex-1 flex-col gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-24 w-full rounded-lg" />
<Skeleton className="h-24 w-full rounded-lg" />
</div>
))}
</div>
</div>
);
}
@ -576,7 +592,19 @@ export default function IssuesPage() {
</div>
<div className="flex-1 overflow-hidden">
{view === "board" ? (
{issues.length === 0 && !loading ? (
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm text-muted-foreground">
<p>No matching issues</p>
{(filterStatus || filterPriority) && (
<button
className="text-xs text-primary hover:underline"
onClick={() => { setFilterStatus(""); setFilterPriority(""); }}
>
Clear filters
</button>
)}
</div>
) : view === "board" ? (
<BoardView issues={issues} onMoveIssue={handleMoveIssue} />
) : (
<ListView issues={issues} />

View file

@ -14,6 +14,7 @@ import {
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { toast } from "sonner";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { api } from "@/shared/api";
@ -108,16 +109,11 @@ export default function SettingsPage() {
const [profileName, setProfileName] = useState(user?.name ?? "");
const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url ?? "");
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [profileSaving, setProfileSaving] = useState(false);
const [profileSaved, setProfileSaved] = useState(false);
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState<MemberRole>("member");
const [inviteLoading, setInviteLoading] = useState(false);
const [memberActionId, setMemberActionId] = useState<string | null>(null);
const [workspaceError, setWorkspaceError] = useState("");
const [profileError, setProfileError] = useState("");
const [memberError, setMemberError] = useState("");
const currentMember = members.find((member) => member.user_id === user?.id) ?? null;
const canManageWorkspace = currentMember?.role === "owner" || currentMember?.role === "admin";
const isOwner = currentMember?.role === "owner";
@ -136,7 +132,6 @@ export default function SettingsPage() {
const handleSave = async () => {
if (!workspace) return;
setSaving(true);
setWorkspaceError("");
try {
const updated = await api.updateWorkspace(workspace.id, {
name,
@ -144,10 +139,9 @@ export default function SettingsPage() {
context,
});
updateWorkspace(updated);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
toast.success("Workspace settings saved");
} catch (e) {
setWorkspaceError(e instanceof Error ? e.message : "Failed to update workspace");
toast.error(e instanceof Error ? e.message : "Failed to save workspace settings");
} finally {
setSaving(false);
}
@ -155,17 +149,15 @@ export default function SettingsPage() {
const handleProfileSave = async () => {
setProfileSaving(true);
setProfileError("");
try {
const updated = await api.updateMe({
name: profileName,
avatar_url: avatarUrl || undefined,
});
setUser(updated);
setProfileSaved(true);
setTimeout(() => setProfileSaved(false), 2000);
toast.success("Profile updated");
} catch (e) {
setProfileError(e instanceof Error ? e.message : "Failed to update profile");
toast.error(e instanceof Error ? e.message : "Failed to update profile");
} finally {
setProfileSaving(false);
}
@ -174,7 +166,6 @@ export default function SettingsPage() {
const handleAddMember = async () => {
if (!workspace) return;
setInviteLoading(true);
setMemberError("");
try {
await api.createMember(workspace.id, {
email: inviteEmail,
@ -183,8 +174,9 @@ export default function SettingsPage() {
setInviteEmail("");
setInviteRole("member");
await refreshMembers();
toast.success("Member added");
} catch (e) {
setMemberError(e instanceof Error ? e.message : "Failed to add member");
toast.error(e instanceof Error ? e.message : "Failed to add member");
} finally {
setInviteLoading(false);
}
@ -193,12 +185,12 @@ export default function SettingsPage() {
const handleRoleChange = async (memberId: string, role: MemberRole) => {
if (!workspace) return;
setMemberActionId(memberId);
setMemberError("");
try {
await api.updateMember(workspace.id, memberId, { role });
await refreshMembers();
toast.success("Role updated");
} catch (e) {
setMemberError(e instanceof Error ? e.message : "Failed to update member");
toast.error(e instanceof Error ? e.message : "Failed to update member");
} finally {
setMemberActionId(null);
}
@ -209,12 +201,12 @@ export default function SettingsPage() {
if (!window.confirm(`Remove ${member.name} from ${workspace.name}?`)) return;
setMemberActionId(member.id);
setMemberError("");
try {
await api.deleteMember(workspace.id, member.id);
await refreshMembers();
toast.success("Member removed");
} catch (e) {
setMemberError(e instanceof Error ? e.message : "Failed to remove member");
toast.error(e instanceof Error ? e.message : "Failed to remove member");
} finally {
setMemberActionId(null);
}
@ -225,11 +217,10 @@ export default function SettingsPage() {
if (!window.confirm(`Leave ${workspace.name}?`)) return;
setMemberActionId("leave");
setMemberError("");
try {
await leaveWorkspace(workspace.id);
} catch (e) {
setMemberError(e instanceof Error ? e.message : "Failed to leave workspace");
toast.error(e instanceof Error ? e.message : "Failed to leave workspace");
} finally {
setMemberActionId(null);
}
@ -240,11 +231,10 @@ export default function SettingsPage() {
if (!window.confirm(`Delete ${workspace.name}? This cannot be undone.`)) return;
setMemberActionId("delete-workspace");
setMemberError("");
try {
await deleteWorkspace(workspace.id);
} catch (e) {
setMemberError(e instanceof Error ? e.message : "Failed to delete workspace");
toast.error(e instanceof Error ? e.message : "Failed to delete workspace");
} finally {
setMemberActionId(null);
}
@ -290,13 +280,7 @@ export default function SettingsPage() {
className="mt-1"
/>
</div>
{profileError && (
<p className="text-xs text-destructive">{profileError}</p>
)}
<div className="flex items-center justify-end gap-2 pt-1">
{profileSaved && (
<span className="text-xs text-success">Saved!</span>
)}
<Button
size="sm"
onClick={handleProfileSave}
@ -346,12 +330,12 @@ export default function SettingsPage() {
<Label className="text-xs text-muted-foreground">
Context
</Label>
<textarea
<Textarea
value={context}
onChange={(e) => setContext(e.target.value)}
rows={4}
disabled={!canManageWorkspace}
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring resize-none"
className="mt-1 resize-none"
placeholder="Background information and context for AI agents working in this workspace"
/>
</div>
@ -364,12 +348,6 @@ export default function SettingsPage() {
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-1">
{workspaceError && (
<span className="text-xs text-destructive">{workspaceError}</span>
)}
{saved && (
<span className="text-xs text-success">Saved!</span>
)}
<Button
size="sm"
onClick={handleSave}
@ -398,10 +376,6 @@ export default function SettingsPage() {
</div>
</div>
{memberError && (
<p className="text-sm text-destructive">{memberError}</p>
)}
{canManageWorkspace && (
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center gap-2">