diff --git a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx
index e2c13db9..7344ff45 100644
--- a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx
+++ b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx
@@ -50,16 +50,42 @@ vi.mock("../../../../lib/auth-context", () => ({
}),
}));
+// Mock ws-context
+vi.mock("../../../../lib/ws-context", () => ({
+ useWSEvent: () => {},
+}));
+
+// Mock @multica/ui calendar (react-day-picker needs browser APIs)
+vi.mock("@multica/ui/components/ui/calendar", () => ({
+ Calendar: () => null,
+}));
+
+// Mock tab-store
+vi.mock("../../../../lib/tab-store", () => ({
+ useTabStore: () => ({
+ updateTabTitle: vi.fn(),
+ activeTabId: "tab-1",
+ }),
+}));
+
// Mock api
const mockGetIssue = vi.hoisted(() => vi.fn());
const mockListComments = vi.hoisted(() => vi.fn());
const mockCreateComment = vi.hoisted(() => vi.fn());
+const mockUpdateComment = vi.hoisted(() => vi.fn());
+const mockDeleteComment = vi.hoisted(() => vi.fn());
+const mockDeleteIssue = vi.hoisted(() => vi.fn());
+const mockUpdateIssue = vi.hoisted(() => vi.fn());
vi.mock("../../../../lib/api", () => ({
api: {
getIssue: (...args: any[]) => mockGetIssue(...args),
listComments: (...args: any[]) => mockListComments(...args),
createComment: (...args: any[]) => mockCreateComment(...args),
+ updateComment: (...args: any[]) => mockUpdateComment(...args),
+ deleteComment: (...args: any[]) => mockDeleteComment(...args),
+ deleteIssue: (...args: any[]) => mockDeleteIssue(...args),
+ updateIssue: (...args: any[]) => mockUpdateIssue(...args),
},
}));
diff --git a/apps/web/app/(dashboard)/issues/[id]/page.tsx b/apps/web/app/(dashboard)/issues/[id]/page.tsx
index a10f1267..c6789ed1 100644
--- a/apps/web/app/(dashboard)/issues/[id]/page.tsx
+++ b/apps/web/app/(dashboard)/issues/[id]/page.tsx
@@ -1,19 +1,43 @@
"use client";
-import { use, useState, useEffect, useRef } from "react";
+import { use, useState, useEffect, useCallback } from "react";
import Link from "next/link";
+import { useRouter } from "next/navigation";
import {
Bot,
ChevronRight,
+ GitBranch,
+ Link2,
+ Pencil,
Send,
- UserCircle,
+ Trash2,
X,
} from "lucide-react";
-import type { Issue, Comment, IssueAssigneeType } from "@multica/types";
-import { STATUS_CONFIG, PRIORITY_CONFIG } from "../_data/config";
-import { StatusIcon, PriorityIcon } from "../page";
+import { toast } from "sonner";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@multica/ui/components/ui/alert-dialog";
+import { Calendar } from "@multica/ui/components/ui/calendar";
+import {
+ Popover,
+ PopoverTrigger,
+ PopoverContent,
+} from "@multica/ui/components/ui/popover";
+import type { Issue, Comment, UpdateIssueRequest } from "@multica/types";
+import { StatusPicker, PriorityPicker, AssigneePicker } from "../_components";
import { api } from "../../../../lib/api";
import { useAuth } from "../../../../lib/auth-context";
+import { useWSEvent } from "../../../../lib/ws-context";
+import { useTabStore } from "../../../../lib/tab-store";
+import type { CommentCreatedPayload, CommentUpdatedPayload, CommentDeletedPayload } from "@multica/types";
// ---------------------------------------------------------------------------
// Helpers
@@ -81,19 +105,12 @@ function ActorAvatar({
function PropRow({
label,
children,
- onClick,
}: {
label: string;
children: React.ReactNode;
- onClick?: () => void;
}) {
return (
-
+
{label}
{children}
@@ -103,150 +120,285 @@ function PropRow({
}
// ---------------------------------------------------------------------------
-// Assignee Picker
+// Due Date Picker
// ---------------------------------------------------------------------------
-function AssigneePicker({
- issue,
- onSelect,
- onClose,
+function DueDatePicker({
+ dueDate,
+ onUpdate,
}: {
- issue: Issue;
- onSelect: (type: IssueAssigneeType | null, id: string | null) => void;
- onClose: () => void;
+ dueDate: string | null;
+ onUpdate: (updates: Partial
) => void;
}) {
- const { members, agents } = useAuth();
- const [search, setSearch] = useState("");
- const ref = useRef(null);
- const inputRef = useRef(null);
-
- useEffect(() => {
- inputRef.current?.focus();
- }, []);
-
- useEffect(() => {
- function handleClickOutside(e: MouseEvent) {
- if (ref.current && !ref.current.contains(e.target as Node)) {
- onClose();
- }
- }
- document.addEventListener("mousedown", handleClickOutside);
- return () => document.removeEventListener("mousedown", handleClickOutside);
- }, [onClose]);
-
- const q = search.toLowerCase();
- const filteredMembers = members.filter((m) =>
- m.name.toLowerCase().includes(q) || m.email.toLowerCase().includes(q),
- );
- const filteredAgents = agents.filter((a) =>
- a.name.toLowerCase().includes(q),
- );
-
- const isSelected = (type: string, id: string) =>
- issue.assignee_type === type && issue.assignee_id === id;
+ const [open, setOpen] = useState(false);
+ const date = dueDate ? new Date(dueDate) : undefined;
+ const isOverdue = date ? date < new Date() : false;
return (
-
-
-
-
- {/* Unassign option */}
- {issue.assignee_id && (
- <>
+ {date && (
+
-
- >
- )}
-
- {/* Members */}
- {filteredMembers.length > 0 && (
- <>
-
- Members
-
- {filteredMembers.map((m) => (
-
- ))}
- >
- )}
-
- {/* Agents */}
- {filteredAgents.length > 0 && (
- <>
-
- Agents
-
- {filteredAgents.map((a) => (
-
- ))}
- >
- )}
-
- {filteredMembers.length === 0 && filteredAgents.length === 0 && (
-
- No results found
)}
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Acceptance Criteria Editor
+// ---------------------------------------------------------------------------
+
+function AcceptanceCriteriaEditor({
+ criteria,
+ onUpdate,
+}: {
+ criteria: string[];
+ onUpdate: (updates: Partial
) => void;
+}) {
+ const [newItem, setNewItem] = useState("");
+
+ const addItem = () => {
+ if (!newItem.trim()) return;
+ onUpdate({ acceptance_criteria: [...criteria, newItem.trim()] });
+ setNewItem("");
+ };
+
+ const removeItem = (index: number) => {
+ onUpdate({ acceptance_criteria: criteria.filter((_, i) => i !== index) });
+ };
+
+ if (criteria.length === 0 && !newItem) {
+ return null;
+ }
+
+ return (
+
+
Acceptance Criteria
+
+ {criteria.map((item, i) => (
+
+ •
+ {item}
+
+
+ ))}
+
);
}
+// ---------------------------------------------------------------------------
+// Context Refs Editor
+// ---------------------------------------------------------------------------
+
+function ContextRefsEditor({
+ refs,
+ onUpdate,
+}: {
+ refs: string[];
+ onUpdate: (updates: Partial) => void;
+}) {
+ const [newRef, setNewRef] = useState("");
+
+ const addRef = () => {
+ if (!newRef.trim()) return;
+ onUpdate({ context_refs: [...refs, newRef.trim()] });
+ setNewRef("");
+ };
+
+ const removeRef = (index: number) => {
+ onUpdate({ context_refs: refs.filter((_, i) => i !== index) });
+ };
+
+ if (refs.length === 0 && !newRef) {
+ return null;
+ }
+
+ const isUrl = (s: string) => s.startsWith("http://") || s.startsWith("https://");
+
+ return (
+
+
Context References
+
+ {refs.map((ref, i) => (
+
+
+ {isUrl(ref) ? (
+
+ {ref}
+
+ ) : (
+
{ref}
+ )}
+
+
+ ))}
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Repository Editor
+// ---------------------------------------------------------------------------
+
+function RepositoryEditor({
+ repository,
+ onUpdate,
+}: {
+ repository: { url: string; branch?: string; path?: string } | null;
+ onUpdate: (updates: Partial) => void;
+}) {
+ const [open, setOpen] = useState(false);
+ const [url, setUrl] = useState("");
+ const [branch, setBranch] = useState("");
+ const [path, setPath] = useState("");
+
+ const handleOpen = (v: boolean) => {
+ if (v) {
+ setUrl(repository?.url ?? "");
+ setBranch(repository?.branch ?? "");
+ setPath(repository?.path ?? "");
+ }
+ setOpen(v);
+ };
+
+ const save = () => {
+ if (!url.trim()) {
+ onUpdate({ repository: null });
+ } else {
+ onUpdate({
+ repository: {
+ url: url.trim(),
+ branch: branch.trim() || undefined,
+ path: path.trim() || undefined,
+ },
+ });
+ }
+ setOpen(false);
+ };
+
+ const clear = () => {
+ onUpdate({ repository: null });
+ setOpen(false);
+ };
+
+ return (
+
+
+ {repository ? (
+ <>
+
+ {repository.branch ?? "main"}
+ >
+ ) : (
+ None
+ )}
+
+
+ Repository
+
+ setUrl(e.target.value)}
+ placeholder="https://github.com/org/repo"
+ className="w-full rounded-md border bg-background px-2.5 py-1.5 text-xs outline-none focus:ring-1 focus:ring-ring"
+ autoFocus
+ />
+ setBranch(e.target.value)}
+ placeholder="Branch"
+ className="w-full rounded-md border bg-background px-2.5 py-1.5 text-xs outline-none focus:ring-1 focus:ring-ring"
+ />
+ setPath(e.target.value)}
+ placeholder="Path"
+ className="w-full rounded-md border bg-background px-2.5 py-1.5 text-xs outline-none focus:ring-1 focus:ring-ring"
+ />
+
+
+ {repository && (
+
+ )}
+
+
+
+
+ );
+}
+
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
@@ -257,13 +409,17 @@ export default function IssueDetailPage({
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
- const { getActorName } = useAuth();
+ const router = useRouter();
+ const { user, getActorName } = useAuth();
+ const { updateTabTitle, activeTabId, closeTabByPath } = useTabStore();
const [issue, setIssue] = useState(null);
const [comments, setComments] = useState([]);
const [loading, setLoading] = useState(true);
const [commentText, setCommentText] = useState("");
const [submitting, setSubmitting] = useState(false);
- const [showAssigneePicker, setShowAssigneePicker] = useState(false);
+ const [deleting, setDeleting] = useState(false);
+ const [editingCommentId, setEditingCommentId] = useState(null);
+ const [editContent, setEditContent] = useState("");
useEffect(() => {
setIssue(null);
@@ -278,6 +434,13 @@ export default function IssueDetailPage({
.finally(() => setLoading(false));
}, [id]);
+ // Sync tab title with loaded issue title
+ useEffect(() => {
+ if (issue?.title && activeTabId) {
+ updateTabTitle(activeTabId, issue.title);
+ }
+ }, [issue?.title, activeTabId, updateTabTitle]);
+
const handleSubmitComment = async (e: React.FormEvent) => {
e.preventDefault();
if (!commentText.trim() || submitting) return;
@@ -293,31 +456,92 @@ export default function IssueDetailPage({
}
};
- const handleAssigneeChange = async (
- type: IssueAssigneeType | null,
- assigneeId: string | null,
- ) => {
- if (!issue) return;
- setShowAssigneePicker(false);
- // Optimistic update
- setIssue({
- ...issue,
- assignee_type: type,
- assignee_id: assigneeId,
- });
- try {
- const updated = await api.updateIssue(id, {
- assignee_type: type,
- assignee_id: assigneeId,
+ const handleUpdateField = useCallback(
+ (updates: Partial) => {
+ if (!issue) return;
+ const prev = issue;
+ setIssue((curr) => (curr ? ({ ...curr, ...updates } as Issue) : curr));
+ api.updateIssue(id, updates).catch(() => {
+ setIssue(prev);
+ toast.error("Failed to update issue");
});
- setIssue(updated);
- } catch (err) {
- console.error("Failed to update assignee:", err);
- // Revert on error
- setIssue(issue);
+ },
+ [issue, id],
+ );
+
+ const handleDelete = async () => {
+ setDeleting(true);
+ try {
+ await api.deleteIssue(issue!.id);
+ toast.success("Issue deleted");
+ closeTabByPath(`/issues/${id}`);
+ router.push("/issues");
+ } catch {
+ toast.error("Failed to delete issue");
+ setDeleting(false);
}
};
+ const startEditComment = (c: Comment) => {
+ setEditingCommentId(c.id);
+ setEditContent(c.content);
+ };
+
+ const handleSaveEditComment = async () => {
+ if (!editingCommentId || !editContent.trim()) return;
+ try {
+ const updated = await api.updateComment(editingCommentId, editContent.trim());
+ setComments((prev) => prev.map((c) => (c.id === updated.id ? updated : c)));
+ setEditingCommentId(null);
+ } catch {
+ toast.error("Failed to update comment");
+ }
+ };
+
+ const handleDeleteComment = async (commentId: string) => {
+ try {
+ await api.deleteComment(commentId);
+ setComments((prev) => prev.filter((c) => c.id !== commentId));
+ } catch {
+ toast.error("Failed to delete comment");
+ }
+ };
+
+ // Real-time comment updates
+ useWSEvent(
+ "comment:created",
+ useCallback((payload: unknown) => {
+ const { comment } = payload as CommentCreatedPayload;
+ if (comment.issue_id !== id) return;
+ // Skip own comments — already added locally via API response
+ if (comment.author_type === "member" && comment.author_id === user?.id) return;
+ setComments((prev) => {
+ if (prev.some((c) => c.id === comment.id)) return prev;
+ return [...prev, comment];
+ });
+ }, [id, user?.id]),
+ );
+
+ useWSEvent(
+ "comment:updated",
+ useCallback((payload: unknown) => {
+ const { comment } = payload as CommentUpdatedPayload;
+ if (comment.issue_id === id) {
+ setComments((prev) => prev.map((c) => (c.id === comment.id ? comment : c)));
+ }
+ }, [id]),
+ );
+
+ useWSEvent(
+ "comment:deleted",
+ useCallback((payload: unknown) => {
+ const { comment_id, issue_id } = payload as CommentDeletedPayload;
+ if (issue_id === id) {
+ setComments((prev) => prev.filter((c) => c.id !== comment_id));
+ }
+ }, [id]),
+ );
+
if (loading) {
return (
@@ -334,25 +558,47 @@ export default function IssueDetailPage({
);
}
- const statusCfg = STATUS_CONFIG[issue.status];
- const priorityCfg = PRIORITY_CONFIG[issue.priority];
- const isOverdue =
- issue.due_date && new Date(issue.due_date) < new Date() && issue.status !== "done";
-
return (
{/* LEFT: Content area */}
{/* Header bar */}
-
-
- Issues
-
-
-
{issue.id.slice(0, 8)}
+
+
+
+ Issues
+
+
+ {issue.id.slice(0, 8)}
+
+
+ }
+ >
+
+
+
+
+ Delete issue
+
+ This will permanently delete this issue and all its comments. This action cannot be undone.
+
+
+
+ Cancel
+
+ {deleting ? "Deleting..." : "Delete"}
+
+
+
+
{/* Content */}
@@ -369,6 +615,19 @@ export default function IssueDetailPage({
)}
+ {(issue.acceptance_criteria.length > 0 || issue.context_refs.length > 0) && (
+
+ )}
+
{/* Activity / Comments */}
@@ -376,26 +635,57 @@ export default function IssueDetailPage({
Activity
- {comments.map((comment) => (
-
-
-
-
- {getActorName(comment.author_type, comment.author_id)}
-
-
- {timeAgo(comment.created_at)}
-
+ {comments.map((comment) => {
+ const isOwn = comment.author_type === "member" && comment.author_id === user?.id;
+ return (
+
+
+
+
+ {getActorName(comment.author_type, comment.author_id)}
+
+
+ {timeAgo(comment.created_at)}
+
+ {isOwn && (
+
+
+
+
+ )}
+
+ {editingCommentId === comment.id ? (
+
+ ) : (
+
+ {comment.content}
+
+ )}
-
- {comment.content}
-
-
- ))}
+ );
+ })}
{/* Comment input */}
@@ -430,51 +720,27 @@ export default function IssueDetailPage({
-
- {statusCfg.label}
+
-
- {priorityCfg.label}
+
-
-
setShowAssigneePicker(!showAssigneePicker)}
- >
- {issue.assignee_type && issue.assignee_id ? (
- <>
-
- {getActorName(issue.assignee_type, issue.assignee_id)}
- >
- ) : (
- Unassigned
- )}
-
-
- {showAssigneePicker && (
-
setShowAssigneePicker(false)}
- />
- )}
-
+
+
+
- {issue.due_date ? (
-
- {shortDate(issue.due_date)}
-
- ) : (
- None
- )}
+
+
+
+
+