fix(issues): UI polish batch — comment input, card gap, board title, activity coalescing

- 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) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-03-30 14:19:32 +08:00
parent 9e2cb4040f
commit cd34b82454
5 changed files with 45 additions and 11 deletions

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

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 {