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:
commit
c27715dd5c
9 changed files with 98 additions and 22 deletions
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue