Merge pull request #184 from multica-ai/NevilleQingNY/ui-fixes-batch

fix(ui): batch UI fixes — spacing, comments, activity coalescing
This commit is contained in:
Naiyuan Qing 2026-03-30 14:31:38 +08:00 committed by GitHub
commit c27715dd5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 98 additions and 22 deletions

View file

@ -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);
}
};

View file

@ -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();
});
});

View file

@ -91,7 +91,7 @@ export function BoardCardContent({
{/* Title */}
<p
className={`text-sm font-medium leading-snug line-clamp-2 text-muted-foreground transition-colors group-hover:text-foreground ${showPriority ? "mt-2" : ""}`}
className={`text-sm font-medium leading-snug line-clamp-2 ${showPriority ? "mt-2" : ""}`}
>
{issue.title}
</p>

View file

@ -168,7 +168,7 @@ function CommentCard({
collectReplies(entry.id);
return (
<Card className={`!py-0 overflow-hidden${entry.id.startsWith("temp-") ? " opacity-60" : ""}`}>
<Card className={`!py-0 !gap-0 overflow-hidden${entry.id.startsWith("temp-") ? " opacity-60" : ""}`}>
{/* Parent comment */}
<div className="px-4">
<CommentRow

View file

@ -15,7 +15,7 @@ function CommentInput({ onSubmit }: CommentInputProps) {
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 {
@ -28,8 +28,8 @@ function CommentInput({ onSubmit }: CommentInputProps) {
};
return (
<div className="rounded-lg bg-card ring-1 ring-border">
<div className="min-h-20 max-h-48 overflow-y-auto px-3 py-2">
<div className="relative rounded-lg bg-card ring-1 ring-border">
<div className="min-h-20 max-h-48 overflow-y-auto px-3 py-2 pb-8">
<RichTextEditor
ref={editorRef}
placeholder="Leave a comment..."
@ -38,7 +38,7 @@ function CommentInput({ onSubmit }: CommentInputProps) {
debounceMs={100}
/>
</div>
<div className="flex items-center justify-end border-t border-border/50 px-2 py-1.5">
<div className="absolute bottom-1.5 right-1.5">
<Button
size="icon-sm"
disabled={isEmpty || submitting}

View file

@ -209,16 +209,23 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
// Watch the global issue store for real-time updates from other users/agents
const storeIssue = useIssueStore((s) => s.issues.find((i) => i.id === id));
const wasLoadedRef = useRef(false);
useEffect(() => {
if (storeIssue) {
wasLoadedRef.current = true;
setIssue(storeIssue);
if (!titleFocusedRef.current) {
setTitleDraft(storeIssue.title);
}
} else if (wasLoadedRef.current && !loading) {
// Issue was in the store but is now gone (deleted by another user)
setIssue(null);
}
}, [storeIssue]);
}, [storeIssue, loading]);
useEffect(() => {
wasLoadedRef.current = false;
setIssue(null);
setTitleDraft("");
setTimeline([]);
@ -461,8 +468,14 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
if (!issue) {
return (
<div className="flex flex-1 min-h-0 items-center justify-center text-sm text-muted-foreground">
Issue not found
<div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
<p>This issue does not exist or has been deleted in this workspace.</p>
{!onDelete && (
<Button variant="outline" size="sm" onClick={() => router.push("/issues")}>
<ChevronLeft className="mr-1 h-3.5 w-3.5" />
Back to Issues
</Button>
)}
</div>
);
}
@ -844,7 +857,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
</div>
{/* Timeline entries */}
<div className="mt-4 space-y-2">
<div className="mt-4 flex flex-col gap-3">
{(() => {
const topLevel = timeline.filter((e) => e.type === "activity" || !e.parent_id);
const repliesByParent = new Map<string, TimelineEntry[]>();
@ -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") {
@ -888,7 +922,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
}
return (
<div key={group.entries[0]!.id} className="px-4">
<div key={group.entries[0]!.id} className="px-4 flex flex-col gap-3">
{group.entries.map((entry, idx) => {
const details = (entry.details ?? {}) as Record<string, string>;
const isStatusChange = entry.action === "status_changed";
@ -908,12 +942,14 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
}
return (
<div key={entry.id} className="flex text-xs text-muted-foreground">
<div className="mr-2.5 flex w-3.5 shrink-0 flex-col items-center">
<div key={entry.id} className="relative flex text-xs text-muted-foreground">
<div className="mr-2.5 flex w-3.5 shrink-0 justify-center">
<div className="flex h-5 items-center">{leadIcon}</div>
{!isLast && <div className="w-px flex-1 bg-border" />}
</div>
<div className={`flex flex-1 items-baseline gap-1 ${!isLast ? "pb-3" : ""}`}>
{!isLast && (
<div className="absolute left-[7px] top-5 h-3 w-px bg-border" />
)}
<div className="flex flex-1 items-baseline gap-1">
<span className="font-medium">{getActorName(entry.actor_type, entry.actor_id)}</span>
<span>{formatActivity(entry, getActorName)}</span>
<Tooltip>

View file

@ -124,7 +124,7 @@ export function ListView({
</Tooltip>
</div>
</Accordion.Header>
<Accordion.Panel>
<Accordion.Panel className="pt-1">
{statusIssues.length > 0 ? (
statusIssues.map((issue) => (
<ListRow key={issue.id} issue={issue} />

View file

@ -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 {

View file

@ -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<WorkspaceStore>((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);
},