From 5c583ccb8254602949c2107dbfb20bd40a44090a Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:15:06 +0800 Subject: [PATCH] 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) --- _features/_index.json | 2 +- .../(dashboard)/_components/app-sidebar.tsx | 2 + apps/web/app/(dashboard)/inbox/page.tsx | 25 +- apps/web/app/(dashboard)/issues/[id]/page.tsx | 294 ++++++++++++------ apps/web/app/(dashboard)/issues/page.tsx | 34 +- apps/web/app/(dashboard)/settings/page.tsx | 56 +--- 6 files changed, 272 insertions(+), 141 deletions(-) diff --git a/_features/_index.json b/_features/_index.json index 298f66c8..23a168ba 100644 --- a/_features/_index.json +++ b/_features/_index.json @@ -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" } ] diff --git a/apps/web/app/(dashboard)/_components/app-sidebar.tsx b/apps/web/app/(dashboard)/_components/app-sidebar.tsx index 0f2bbc65..9ba915be 100644 --- a/apps/web/app/(dashboard)/_components/app-sidebar.tsx +++ b/apps/web/app/(dashboard)/_components/app-sidebar.tsx @@ -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); } diff --git a/apps/web/app/(dashboard)/inbox/page.tsx b/apps/web/app/(dashboard)/inbox/page.tsx index 221ff6c7..d519e7e1 100644 --- a/apps/web/app/(dashboard)/inbox/page.tsx +++ b/apps/web/app/(dashboard)/inbox/page.tsx @@ -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 ( -
- Loading... +
+
+
+ +
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+
+
+ + + +
); } diff --git a/apps/web/app/(dashboard)/issues/[id]/page.tsx b/apps/web/app/(dashboard)/issues/[id]/page.tsx index 2d1e651c..c6ec6a65 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.tsx @@ -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 (

Acceptance Criteria

-
- {criteria.map((item, i) => ( -
- - {item} - -
- ))} -
-
{ e.preventDefault(); addItem(); }} - className="flex items-center gap-2" - > - setNewItem(e.target.value)} - placeholder="Add criteria..." - className="flex-1 text-sm bg-transparent outline-none placeholder:text-muted-foreground" - /> -
+ {criteria.length > 0 && ( +
+ {criteria.map((item, i) => ( +
+ + {item} + +
+ ))} +
+ )} + {(criteria.length > 0 || adding) ? ( +
{ e.preventDefault(); addItem(); }} + className="flex items-center gap-2" + > + 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" + /> +
+ ) : ( + + )}
); } @@ -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 (

Context References

-
- {refs.map((ref, i) => ( -
- - {isUrl(ref) ? ( - - {ref} - - ) : ( - {ref} - )} - -
- ))} -
-
{ e.preventDefault(); addRef(); }} - className="flex items-center gap-2" - > - setNewRef(e.target.value)} - placeholder="Add reference URL..." - className="flex-1 text-sm bg-transparent outline-none placeholder:text-muted-foreground" - /> -
+ {refs.length > 0 && ( +
+ {refs.map((ref, i) => ( +
+ + {isUrl(ref) ? ( + + {ref} + + ) : ( + {ref} + )} + +
+ ))} +
+ )} + {(refs.length > 0 || adding) ? ( +
{ e.preventDefault(); addRef(); }} + className="flex items-center gap-2" + > + 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" + /> +
+ ) : ( + + )}
); } @@ -291,6 +324,10 @@ export default function IssueDetailPage({ const [deleting, setDeleting] = useState(false); const [editingCommentId, setEditingCommentId] = useState(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({
{issue.id.slice(0, 8)}
-

- {issue.title} -

+ {editingTitle ? ( + 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" + /> + ) : ( +

{ setTitleDraft(issue.title); setEditingTitle(true); }} + > + {issue.title} +

+ )} - {issue.description && ( -
- {issue.description} + {editingDesc ? ( +