From cc19cfb15a04015ed1df002fcedb552c65884ab1 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:42:01 +0800 Subject: [PATCH 1/5] fix(issues): unify activity/comment timeline spacing with flex-col gap Replace mixed spacing approach (space-y + pb-3) with consistent flex-col gap-3. Activity connector lines now use absolute positioning to bridge between icons. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../features/issues/components/issue-detail.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/web/features/issues/components/issue-detail.tsx b/apps/web/features/issues/components/issue-detail.tsx index 34638ae7..ff8e2b09 100644 --- a/apps/web/features/issues/components/issue-detail.tsx +++ b/apps/web/features/issues/components/issue-detail.tsx @@ -844,7 +844,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo {/* Timeline entries */} -
+
{(() => { const topLevel = timeline.filter((e) => e.type === "activity" || !e.parent_id); const repliesByParent = new Map(); @@ -888,7 +888,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo } return ( -
+
{group.entries.map((entry, idx) => { const details = (entry.details ?? {}) as Record; const isStatusChange = entry.action === "status_changed"; @@ -908,12 +908,14 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo } return ( -
-
+
+
{leadIcon}
- {!isLast &&
}
-
+ {!isLast && ( +
+ )} +
{getActorName(entry.actor_type, entry.actor_id)} {formatActivity(entry, getActorName)} From 9e2cb4040fc0aa2425d9e7f7d0b394e918205dfa Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:44:02 +0800 Subject: [PATCH 2/5] fix(agents): show error toast on agent creation failure Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/app/(dashboard)/agents/page.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/app/(dashboard)/agents/page.tsx b/apps/web/app/(dashboard)/agents/page.tsx index 502fc7ec..70625205 100644 --- a/apps/web/app/(dashboard)/agents/page.tsx +++ b/apps/web/app/(dashboard)/agents/page.tsx @@ -62,6 +62,7 @@ import { import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { toast } from "sonner"; import { api } from "@/shared/api"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore } from "@/features/workspace"; @@ -145,7 +146,8 @@ function CreateAgentDialog({ triggers: [{ id: generateId(), type: "on_assign", enabled: true, config: {} }], }); onClose(); - } catch { + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to create agent"); setCreating(false); } }; From cd34b8245475fda07df419a833c4798d9759a434 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:19:32 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix(issues):=20UI=20polish=20batch=20?= =?UTF-8?q?=E2=80=94=20comment=20input,=20card=20gap,=20board=20title,=20a?= =?UTF-8?q?ctivity=20coalescing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CommentInput: remove border-t divider, position submit button inside editor area (bottom-right) for cleaner look - CommentCard: add !gap-0 to override Card's default gap-4 - CommentInput/ReplyInput: strip trailing empty lines from markdown before submit to prevent extra blank lines in rendered comments - BoardCard: use normal text color for title instead of muted+hover - Timeline: coalesce same actor + same action within 2 min window, keeping only the final result (e.g. 5 status changes → 1) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../features/issues/components/board-card.tsx | 2 +- .../issues/components/comment-card.tsx | 2 +- .../issues/components/comment-input.tsx | 8 ++-- .../issues/components/issue-detail.tsx | 42 +++++++++++++++++-- .../issues/components/reply-input.tsx | 2 +- 5 files changed, 45 insertions(+), 11 deletions(-) diff --git a/apps/web/features/issues/components/board-card.tsx b/apps/web/features/issues/components/board-card.tsx index d418b103..a6eed5e0 100644 --- a/apps/web/features/issues/components/board-card.tsx +++ b/apps/web/features/issues/components/board-card.tsx @@ -91,7 +91,7 @@ export function BoardCardContent({ {/* Title */}

{issue.title}

diff --git a/apps/web/features/issues/components/comment-card.tsx b/apps/web/features/issues/components/comment-card.tsx index a8ea065a..5db099fb 100644 --- a/apps/web/features/issues/components/comment-card.tsx +++ b/apps/web/features/issues/components/comment-card.tsx @@ -168,7 +168,7 @@ function CommentCard({ collectReplies(entry.id); return ( - + {/* Parent comment */}
{ - const content = editorRef.current?.getMarkdown()?.trim(); + const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim(); if (!content || submitting) return; setSubmitting(true); try { @@ -28,8 +28,8 @@ function CommentInput({ onSubmit }: CommentInputProps) { }; return ( -
-
+
+
-
+
+ )}
); } @@ -856,9 +869,30 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo } } + // Coalesce: same actor + same action within 2 min → keep last only + const COALESCE_MS = 2 * 60 * 1000; + const coalesced: TimelineEntry[] = []; + for (const entry of topLevel) { + if (entry.type === "activity") { + const prev = coalesced[coalesced.length - 1]; + if ( + prev?.type === "activity" && + prev.action === entry.action && + prev.actor_type === entry.actor_type && + prev.actor_id === entry.actor_id && + Math.abs(new Date(entry.created_at).getTime() - new Date(prev.created_at).getTime()) <= COALESCE_MS + ) { + // Replace previous with this one (keep the later result) + coalesced[coalesced.length - 1] = entry; + continue; + } + } + coalesced.push(entry); + } + // Group consecutive activities together so the connector line works const groups: { type: "activities" | "comment"; entries: TimelineEntry[] }[] = []; - for (const entry of topLevel) { + for (const entry of coalesced) { if (entry.type === "activity") { const last = groups[groups.length - 1]; if (last?.type === "activities") { diff --git a/apps/web/features/issues/components/reply-input.tsx b/apps/web/features/issues/components/reply-input.tsx index dc355509..b95662c4 100644 --- a/apps/web/features/issues/components/reply-input.tsx +++ b/apps/web/features/issues/components/reply-input.tsx @@ -34,7 +34,7 @@ function ReplyInput({ const [submitting, setSubmitting] = useState(false); const handleSubmit = async () => { - const content = editorRef.current?.getMarkdown()?.trim(); + const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim(); if (!content || submitting) return; setSubmitting(true); try { From 632ff1f8ae8694c875066e673377448dc9c1087a Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:20:02 +0800 Subject: [PATCH 4/5] fix(issues): update test mocks and workspace switch ordering - Add stable issue store mock to prevent infinite effect loop in tests - Update expected error message in not-found test case - Clear runtime store on workspace switch and set workspace before hydration Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/(dashboard)/issues/[id]/page.test.tsx | 31 ++++++++++++++++++- apps/web/features/workspace/store.ts | 13 ++++++-- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx index 86847a30..b6b6b151 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx @@ -61,6 +61,35 @@ vi.mock("@/features/workspace", () => ({ }), })); +// Mock issue store — supply a stable full issue object so storeIssue +// doesn't create a new reference each render (avoids infinite effect loop) +// and has all required fields for rendering. +const stableStoreIssues = vi.hoisted(() => [ + { + id: "issue-1", + workspace_id: "ws-1", + number: 1, + identifier: "TES-1", + title: "Implement authentication", + description: "Add JWT auth to the backend", + status: "in_progress", + priority: "high", + assignee_type: "member", + assignee_id: "user-1", + creator_type: "member", + creator_id: "user-1", + parent_issue_id: null, + position: 0, + due_date: "2026-06-01T00:00:00Z", + created_at: "2026-01-15T00:00:00Z", + updated_at: "2026-01-20T00:00:00Z", + }, +]); +vi.mock("@/features/issues", () => ({ + useIssueStore: (selector: (s: any) => any) => + selector({ issues: stableStoreIssues }), +})); + // Mock ws-context vi.mock("@/features/realtime", () => ({ useWSEvent: () => {}, @@ -246,7 +275,7 @@ describe("IssueDetailPage", () => { await renderPage("nonexistent-id"); await waitFor(() => { - expect(screen.getByText("Issue not found")).toBeInTheDocument(); + expect(screen.getByText("This issue does not exist or has been deleted in this workspace.")).toBeInTheDocument(); }); }); diff --git a/apps/web/features/workspace/store.ts b/apps/web/features/workspace/store.ts index 5b3cbee6..75fe7fef 100644 --- a/apps/web/features/workspace/store.ts +++ b/apps/web/features/workspace/store.ts @@ -4,6 +4,7 @@ import { create } from "zustand"; import type { Workspace, MemberWithUser, Agent, Skill } from "@/shared/types"; import { useIssueStore } from "@/features/issues"; import { useInboxStore } from "@/features/inbox"; +import { useRuntimeStore } from "@/features/runtimes"; import { api } from "@/shared/api"; import { createLogger } from "@/shared/logger"; @@ -93,10 +94,18 @@ export const useWorkspaceStore = create((set, get) => ({ const ws = workspaces.find((item) => item.id === workspaceId); if (!ws) return; - // Clear stale data from other stores before switching + // Switch identity FIRST — api client, localStorage, and the + // workspace object in this store — so that any in-flight refetch + // (e.g. triggered by a WS event during the async gap) already + // targets the new workspace. + api.setWorkspaceId(ws.id); + localStorage.setItem("multica_workspace_id", ws.id); + + // Clear ALL stale data across every store before hydrating. useIssueStore.getState().setIssues([]); useInboxStore.getState().setItems([]); - set({ members: [], agents: [], skills: [] }); + useRuntimeStore.getState().setRuntimes([]); + set({ workspace: ws, members: [], agents: [], skills: [] }); await hydrateWorkspace(workspaces, ws.id); }, From 168d9ab5a24faaa75f043b622269be36e22eb86d Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:29:29 +0800 Subject: [PATCH 5/5] fix(issues): add consistent spacing between list header and items Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/features/issues/components/list-view.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/features/issues/components/list-view.tsx b/apps/web/features/issues/components/list-view.tsx index 36a0f143..a2938a14 100644 --- a/apps/web/features/issues/components/list-view.tsx +++ b/apps/web/features/issues/components/list-view.tsx @@ -124,7 +124,7 @@ export function ListView({
- + {statusIssues.length > 0 ? ( statusIssues.map((issue) => (