fix(issues): board polish — blocked column, drag fix, comment perms, task cleanup

- Add "blocked" to STATUS_ORDER/ALL_STATUSES and board visible columns
- Add min-h-[200px] to droppable columns for reliable empty-column drops
- Fix card click-vs-drag conflict with pointer-events-none on Link
- List view uses STATUS_ORDER from config instead of hardcoded order
- Create Issue dialog: add AssigneePicker for assigning on creation
- Issue detail page syncs from global useIssueStore for real-time updates
- Comment UpdateComment/DeleteComment: add author-or-admin permission check
- DeleteIssue: cancel running agent tasks before deletion

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-03-25 10:32:25 +08:00
parent 9127e543d5
commit 2c02aa357d
7 changed files with 125 additions and 62 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": "designing", "name": "Issue Board & Detail Polish" },
{ "id": "issue-board-polish", "status": "in_progress", "name": "Issue Board & Detail Polish" },
{ "id": "workspace-permissions", "status": "designing", "name": "Workspace & Permissions" },
{ "id": "inbox-notifications", "status": "designing", "name": "Inbox & Notifications" }
]

View file

@ -1,102 +1,99 @@
{
"id": "issue-board-polish",
"name": "Issue Board & Detail Polish",
"status": "designing",
"status": "in_progress",
"createdAt": "2026-03-25",
"completedAt": null,
"description": "Fix drag-drop data consistency bugs, complete issue detail page interactions, and polish board/list views to Linear MVP quality with consistent shadcn UI.",
"currentState": "Board view has 5 columns (missing blocked), drag-drop has 3 data consistency bugs, issue detail title/description not editable, create dialog missing assignee/due date fields, comments lack optimistic updates and author-only edit/delete.",
"currentState": "Core tasks done: board has 6 columns with blocked, drag/click conflict fixed, list view uses STATUS_ORDER, create dialog has assignee picker, detail page syncs from global store, comment permissions enforced, delete cancels tasks. Remaining: inline edit title/description, comment optimistic create, comment timestamp tooltip, acceptance criteria UI, loading/empty states — all deferred as UI polish.",
"decisions": [
"Board shows 6 columns: backlog, todo, in_progress, in_review, done, blocked. Cancelled issues visible via filter only.",
"Drag-drop revert respects current filter params",
"WS issue events check against current filter before adding to view",
"AbortController used for filter change requests to prevent race conditions",
"Issue title: click-to-edit inline input, blur/Enter saves, Escape cancels",
"Issue description: click-to-edit textarea, blur saves",
"Comment creation uses optimistic update (show immediately, confirm on API response)",
"3 filter/WS bugs auto-fixed by infra migration (global store + useMemo): no AbortController or per-page WS handlers needed",
"Issue detail syncs from useIssueStore via useEffect (Option A: minimal change, keeps local state)",
"Comment edit/delete only by author or workspace admin (backend enforced)",
"All loading/empty/error states use shadcn Skeleton and consistent patterns"
"Inline edit title/description deferred to UI polish phase",
"Comment optimistic create deferred to UI polish phase"
],
"tasks": [
{
"task": "Fix: Board drag-drop revert respects active filters",
"done": false,
"scope": "When drag-drop API call fails, revert calls listIssues with current filterStatus and filterPriority params instead of bare { limit: 200 }."
"done": true,
"scope": "Auto-fixed by infra migration: global store + useMemo client-side filtering."
},
{
"task": "Fix: WS issue events respect current filter",
"done": false,
"scope": "issue:created handler checks if new issue matches filterStatus/filterPriority before adding. issue:updated handler removes issue from view if it no longer matches filter."
"done": true,
"scope": "Auto-fixed by infra migration: global store + useMemo client-side filtering."
},
{
"task": "Fix: Filter change race condition with AbortController",
"done": false,
"scope": "useEffect for filter changes creates AbortController, passes signal to fetch, aborts previous request on new filter change. Stale responses ignored."
"done": true,
"scope": "Auto-fixed by infra migration: single initial fetch, client-side filtering via useMemo."
},
{
"task": "Fix: Board add blocked column, fix empty column drop target",
"done": false,
"scope": "visibleStatuses includes 'blocked'. All columns have min-h-[200px] for reliable drop target. Cancelled issues not shown as column but accessible via filter."
"done": true,
"scope": "visibleStatuses includes 'blocked'. All columns have min-h-[200px] for reliable drop target. 'blocked' added to STATUS_ORDER and ALL_STATUSES in config."
},
{
"task": "Fix: Board card click vs drag conflict",
"done": false,
"scope": "When isDragging is true, Link inside card has pointer-events-none to prevent navigation during drag."
"done": true,
"scope": "Link has pointer-events-none when isDragging. Removed unreliable onClickCapture handler."
},
{
"task": "Fix: List view status group order matches STATUS_ORDER",
"done": false,
"scope": "List view groups issues using STATUS_ORDER constant instead of hardcoded different order."
"done": true,
"scope": "List view uses STATUS_ORDER.filter(s => s !== 'cancelled') instead of hardcoded array."
},
{
"task": "Create Issue dialog: add Assignee and Due Date fields",
"done": false,
"scope": "Dialog includes AssigneePicker and date picker (Calendar popover). Fields passed to api.createIssue(). Existing StatusPicker and PriorityPicker remain."
"done": true,
"scope": "Dialog includes AssigneePicker. assignee_type and assignee_id passed to api.createIssue(). Due date deferred (not in CreateIssueRequest)."
},
{
"task": "Issue detail: inline editable title",
"done": false,
"scope": "Title renders as h1. Click transforms to Input. Blur or Enter calls api.updateIssue with optimistic update. Escape reverts. Empty title rejected."
"scope": "Deferred: UI polish. Title renders as h1. Click transforms to Input. Blur or Enter saves."
},
{
"task": "Issue detail: inline editable description",
"done": false,
"scope": "Description renders as paragraph. Click transforms to Textarea. Blur saves with optimistic update. Empty description allowed (clears field)."
"scope": "Deferred: UI polish. Description renders as paragraph. Click transforms to Textarea."
},
{
"task": "Issue detail: listen to issue:updated WS event",
"done": false,
"scope": "Detail page subscribes to issue:updated. When event.issue.id matches current issue, merges update into local state (unless user is actively editing that field)."
"done": true,
"scope": "Detail page syncs from useIssueStore via useEffect. Store updated by useRealtimeSync on WS events."
},
{
"task": "Issue detail: acceptance criteria and context refs always addable",
"done": false,
"scope": "Show 'Add acceptance criteria' and 'Add context reference' buttons even when arrays are empty. Click reveals input field."
"scope": "Deferred: UI polish."
},
{
"task": "Comment: optimistic create",
"done": false,
"scope": "On submit, immediately append comment with temp ID and muted styling. On API success, replace temp with real. On API error, remove temp and show toast."
"scope": "Deferred: UI polish."
},
{
"task": "Comment: backend author-only edit/delete",
"done": false,
"scope": "UpdateComment and DeleteComment in comment.go load comment first, verify author_id matches request user OR user is workspace admin. Return 403 otherwise."
"done": true,
"scope": "UpdateComment and DeleteComment load comment, verify workspace membership, check author_id matches user OR user is owner/admin. Return 403 otherwise."
},
{
"task": "Comment: hover timestamp shows full date tooltip",
"done": false,
"scope": "Relative time ('2h ago') wrapped in shadcn Tooltip showing full ISO-formatted date on hover."
"scope": "Deferred: UI polish."
},
{
"task": "Issue delete: cancel running tasks first",
"done": false,
"scope": "DeleteIssue handler calls TaskService.CancelTasksForIssue before deleting issue to prevent FK constraint errors."
"done": true,
"scope": "DeleteIssue calls TaskService.CancelTasksForIssue before h.Queries.DeleteIssue."
},
{
"task": "UI: consistent loading/empty/error states across issues pages",
"done": false,
"scope": "Board empty columns show 'No issues' muted text. List empty groups hidden. Initial load uses Skeleton. Error shows toast + retry. Filter with no results shows 'No matching issues' with clear filter link."
"scope": "Deferred: UI polish."
}
]
}

View file

@ -38,6 +38,7 @@ import { api } from "@/shared/api";
import { useAuthStore } from "@/features/auth";
import { useActorName } from "@/features/workspace";
import { useWSEvent } from "@/features/realtime";
import { useIssueStore } from "@multica/store";
import type { CommentCreatedPayload, CommentUpdatedPayload, CommentDeletedPayload } from "@multica/types";
// ---------------------------------------------------------------------------
@ -291,6 +292,15 @@ export default function IssueDetailPage({
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
const [editContent, setEditContent] = 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));
useEffect(() => {
if (storeIssue) {
setIssue(storeIssue);
}
}, [storeIssue]);
useEffect(() => {
setIssue(null);
setComments([]);

View file

@ -22,7 +22,7 @@ import {
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { Issue, IssueStatus, IssuePriority } from "@multica/types";
import { STATUS_CONFIG, PRIORITY_CONFIG, ALL_STATUSES, PRIORITY_ORDER } from "@/features/issues/config";
import { STATUS_CONFIG, PRIORITY_CONFIG, ALL_STATUSES, PRIORITY_ORDER, STATUS_ORDER } from "@/features/issues/config";
import {
Dialog,
DialogContent,
@ -43,7 +43,7 @@ import {
SelectGroup,
} from "@/components/ui/select";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
import { StatusIcon, PriorityIcon, AssigneePicker } from "@/features/issues/components";
import { api } from "@/shared/api";
import { useActorName } from "@/features/workspace";
@ -118,13 +118,10 @@ function DraggableBoardCard({ issue }: { issue: Issue }) {
{...attributes}
{...listeners}
className={isDragging ? "opacity-30" : ""}
onClickCapture={(e) => {
if (isDragging) e.stopPropagation();
}}
>
<Link
href={`/issues/${issue.id}`}
className="block transition-colors hover:opacity-80"
className={`block transition-colors hover:opacity-80 ${isDragging ? "pointer-events-none" : ""}`}
>
<BoardCardContent issue={issue} />
</Link>
@ -155,7 +152,7 @@ function DroppableColumn({
</div>
<div
ref={setNodeRef}
className={`flex-1 space-y-1.5 overflow-y-auto rounded-lg p-1 transition-colors ${
className={`min-h-[200px] flex-1 space-y-1.5 overflow-y-auto rounded-lg p-1 transition-colors ${
isOver ? "bg-accent/40" : ""
}`}
>
@ -192,6 +189,7 @@ function BoardView({
"in_progress",
"in_review",
"done",
"blocked",
];
const handleDragStart = useCallback(
@ -292,13 +290,7 @@ function ListRow({ issue }: { issue: Issue }) {
}
function ListView({ issues }: { issues: Issue[] }) {
const groupOrder: IssueStatus[] = [
"in_review",
"in_progress",
"todo",
"backlog",
"done",
];
const groupOrder = STATUS_ORDER.filter((s) => s !== "cancelled");
return (
<div className="overflow-y-auto">
@ -334,12 +326,16 @@ function CreateIssueDialog({ onCreated }: { onCreated: (issue: Issue) => void })
const [status, setStatus] = useState<IssueStatus>("todo");
const [priority, setPriority] = useState<IssuePriority>("none");
const [submitting, setSubmitting] = useState(false);
const [assigneeType, setAssigneeType] = useState<string | undefined>();
const [assigneeId, setAssigneeId] = useState<string | undefined>();
const reset = () => {
setTitle("");
setDescription("");
setStatus("todo");
setPriority("none");
setAssigneeType(undefined);
setAssigneeId(undefined);
};
const handleSubmit = async () => {
@ -351,6 +347,8 @@ function CreateIssueDialog({ onCreated }: { onCreated: (issue: Issue) => void })
description: description.trim() || undefined,
status,
priority,
assignee_type: assigneeType,
assignee_id: assigneeId,
});
onCreated(issue);
reset();
@ -422,6 +420,15 @@ function CreateIssueDialog({ onCreated }: { onCreated: (issue: Issue) => void })
))}
</SelectContent>
</Select>
{/* Assignee picker */}
<AssigneePicker
assigneeType={assigneeType ?? null}
assigneeId={assigneeId ?? null}
onUpdate={(updates) => {
setAssigneeType(updates.assignee_type ?? undefined);
setAssigneeId(updates.assignee_id ?? undefined);
}}
/>
</div>
</div>
<DialogFooter>

View file

@ -6,6 +6,7 @@ export const STATUS_ORDER: IssueStatus[] = [
"in_progress",
"in_review",
"done",
"blocked",
"cancelled",
];
@ -15,6 +16,7 @@ export const ALL_STATUSES: IssueStatus[] = [
"in_progress",
"in_review",
"done",
"blocked",
"cancelled",
];

View file

@ -105,6 +105,37 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
commentId := chi.URLParam(r, "commentId")
userID, ok := requireUserID(w, r)
if !ok {
return
}
// Load comment to check ownership
existing, err := h.Queries.GetComment(r.Context(), parseUUID(commentId))
if err != nil {
writeError(w, http.StatusNotFound, "comment not found")
return
}
// Load issue to get workspace
issue, err := h.Queries.GetIssue(r.Context(), existing.IssueID)
if err != nil {
writeError(w, http.StatusNotFound, "comment not found")
return
}
member, ok := h.requireWorkspaceMember(w, r, uuidToString(issue.WorkspaceID), "comment not found")
if !ok {
return
}
isAuthor := existing.AuthorType == "member" && uuidToString(existing.AuthorID) == userID
isAdmin := roleAllowed(member.Role, "owner", "admin")
if !isAuthor && !isAdmin {
writeError(w, http.StatusForbidden, "only comment author or admin can edit")
return
}
var req struct {
Content string `json:"content"`
}
@ -127,18 +158,18 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
}
resp := commentToResponse(comment)
userID := requestUserID(r)
workspaceID := ""
if issue, err := h.Queries.GetIssue(r.Context(), comment.IssueID); err == nil {
workspaceID = uuidToString(issue.WorkspaceID)
}
h.publish(protocol.EventCommentUpdated, workspaceID, "member", userID, map[string]any{"comment": resp})
h.publish(protocol.EventCommentUpdated, uuidToString(issue.WorkspaceID), "member", userID, map[string]any{"comment": resp})
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) DeleteComment(w http.ResponseWriter, r *http.Request) {
commentId := chi.URLParam(r, "commentId")
userID, ok := requireUserID(w, r)
if !ok {
return
}
// Get the comment first to know the issue_id for the broadcast
comment, err := h.Queries.GetComment(r.Context(), parseUUID(commentId))
if err != nil {
@ -146,17 +177,31 @@ func (h *Handler) DeleteComment(w http.ResponseWriter, r *http.Request) {
return
}
// Load issue to get workspace
issue, err := h.Queries.GetIssue(r.Context(), comment.IssueID)
if err != nil {
writeError(w, http.StatusNotFound, "comment not found")
return
}
member, ok := h.requireWorkspaceMember(w, r, uuidToString(issue.WorkspaceID), "comment not found")
if !ok {
return
}
isAuthor := comment.AuthorType == "member" && uuidToString(comment.AuthorID) == userID
isAdmin := roleAllowed(member.Role, "owner", "admin")
if !isAuthor && !isAdmin {
writeError(w, http.StatusForbidden, "only comment author or admin can delete")
return
}
if err := h.Queries.DeleteComment(r.Context(), parseUUID(commentId)); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete comment")
return
}
userID := requestUserID(r)
workspaceID := ""
if issue, err := h.Queries.GetIssue(r.Context(), comment.IssueID); err == nil {
workspaceID = uuidToString(issue.WorkspaceID)
}
h.publish(protocol.EventCommentDeleted, workspaceID, "member", userID, map[string]any{
h.publish(protocol.EventCommentDeleted, uuidToString(issue.WorkspaceID), "member", userID, map[string]any{
"comment_id": commentId,
"issue_id": uuidToString(comment.IssueID),
})

View file

@ -458,6 +458,8 @@ func (h *Handler) DeleteIssue(w http.ResponseWriter, r *http.Request) {
return
}
h.TaskService.CancelTasksForIssue(r.Context(), issue.ID)
err := h.Queries.DeleteIssue(r.Context(), parseUUID(id))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete issue")