From 9131cc03551d6b63e06e7dff23bb243b2f476152 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 6 Apr 2026 16:13:46 -0500 Subject: [PATCH] Restyle issue chat comments for chat-like UX User messages: right-aligned bubbles (85% max-width) with gray background, no border. Hover reveals short date + copy icon. Agent messages: borderless with avatar, name, date and three-dots in header. Left-aligned action bar with icon-only copy, thumbs up, and thumbs down. Thumbs down opens a floating popover for reason. Co-Authored-By: Paperclip --- ui/src/components/IssueChatThread.tsx | 384 ++++++++++++++++++++++---- ui/src/lib/utils.ts | 7 + 2 files changed, 333 insertions(+), 58 deletions(-) diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 1503757d..a0b8ae25 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -27,6 +27,14 @@ import { import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events"; import { Button } from "@/components/ui/button"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, @@ -36,14 +44,16 @@ import { import { MarkdownBody } from "./MarkdownBody"; import { MarkdownEditor, type MentionOption, type MarkdownEditorRef } from "./MarkdownEditor"; import { Identity } from "./Identity"; -import { OutputFeedbackButtons } from "./OutputFeedbackButtons"; import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector"; import { AgentIcon } from "./AgentIconPicker"; import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft"; import { formatAssigneeUserLabel } from "../lib/assignees"; import { timeAgo } from "../lib/timeAgo"; -import { cn, formatDateTime } from "../lib/utils"; -import { ArrowRight, Check, ChevronDown, Copy, Loader2, MoreHorizontal, Paperclip } from "lucide-react"; +import { cn, formatDateTime, formatShortDate } from "../lib/utils"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Textarea } from "@/components/ui/textarea"; +import { ArrowRight, Check, ChevronDown, Copy, Loader2, MoreHorizontal, Paperclip, ThumbsDown, ThumbsUp } from "lucide-react"; interface IssueChatMessageContext { feedbackVoteByTargetId: Map; @@ -343,56 +353,84 @@ function IssueChatUserMessage() { const message = useMessage(); const custom = message.metadata.custom as Record; const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined; - const authorName = typeof custom.authorName === "string" ? custom.authorName : "You"; const queued = custom.queueState === "queued" || custom.clientStatus === "queued"; const pending = custom.clientStatus === "pending"; const queueTargetRunId = typeof custom.queueTargetRunId === "string" ? custom.queueTargetRunId : null; + const [copied, setCopied] = useState(false); return ( -
-
-
- - {queued ? ( +
+
+ {queued ? ( +
Queued - ) : null} - {pending ? Sending... : null} -
- - {queued && queueTargetRunId && onInterruptQueued ? ( - - ) : null} - - {formatDateTime(message.createdAt)} - - -
+ {queueTargetRunId && onInterruptQueued ? ( + + ) : null} +
+ ) : null} + {pending ?
Sending...
: null} -
- , - }} - /> +
+ , + }} + /> +
+ +
+ + + + {message.createdAt ? formatShortDate(message.createdAt) : ""} + + + + {message.createdAt ? formatDateTime(message.createdAt) : ""} + + + +
@@ -406,7 +444,6 @@ function IssueChatAssistantMessage() { feedbackTermsUrl, onVote, agentMap, - currentUserId, } = useContext(IssueChatCtx); const message = useMessage(); const custom = message.metadata.custom as Record; @@ -418,6 +455,7 @@ function IssueChatAssistantMessage() { : "Agent"; const runId = typeof custom.runId === "string" ? custom.runId : null; const runAgentId = typeof custom.runAgentId === "string" ? custom.runAgentId : null; + const runAgentIcon = runAgentId ? agentMap?.get(runAgentId)?.icon : undefined; const commentId = typeof custom.commentId === "string" ? custom.commentId : null; const notices = Array.isArray(custom.notices) ? custom.notices.filter((notice): notice is string => typeof notice === "string" && notice.length > 0) @@ -434,12 +472,23 @@ function IssueChatAssistantMessage() { await onVote(commentId, vote, options); }; + const activeVote = commentId ? feedbackVoteByTargetId.get(commentId) ?? null : null; + return ( -
+
- + {runAgentId ? ( + + {runAgentIcon ? ( + + ) : ( + {initialsForName(authorName)} + )} + + ) : null} + {authorName} {isRunning ? ( @@ -449,7 +498,7 @@ function IssueChatAssistantMessage() {
- {formatDateTime(message.createdAt)} + {message.createdAt ? formatShortDate(message.createdAt) : ""} {runHref ? ( @@ -458,8 +507,8 @@ function IssueChatAssistantMessage() { variant="ghost" size="icon-xs" className="text-muted-foreground hover:text-foreground" - title="Run actions" - aria-label="Run actions" + title="More actions" + aria-label="More actions" > @@ -500,31 +549,250 @@ function IssueChatAssistantMessage() { ) : null}
- +
- - + + {commentId && onVote ? ( - ) : null} - +
); } +function IssueChatFeedbackButtons({ + activeVote, + sharingPreference = "prompt", + termsUrl, + onVote, +}: { + activeVote: FeedbackVoteValue | null; + sharingPreference: FeedbackDataSharingPreference; + termsUrl: string | null; + onVote: (vote: FeedbackVoteValue, options?: { allowSharing?: boolean; reason?: string }) => Promise; +}) { + const [isSaving, setIsSaving] = useState(false); + const [optimisticVote, setOptimisticVote] = useState(null); + const [reasonOpen, setReasonOpen] = useState(false); + const [downvoteReason, setDownvoteReason] = useState(""); + const [pendingSharingDialog, setPendingSharingDialog] = useState<{ + vote: FeedbackVoteValue; + reason?: string; + } | null>(null); + const visibleVote = optimisticVote ?? activeVote ?? null; + + useEffect(() => { + if (optimisticVote && activeVote === optimisticVote) setOptimisticVote(null); + }, [activeVote, optimisticVote]); + + async function doVote( + vote: FeedbackVoteValue, + options?: { allowSharing?: boolean; reason?: string }, + ) { + setIsSaving(true); + try { + await onVote(vote, options); + } catch { + setOptimisticVote(null); + } finally { + setIsSaving(false); + } + } + + function handleVote(vote: FeedbackVoteValue, reason?: string) { + setOptimisticVote(vote); + if (sharingPreference === "prompt") { + setPendingSharingDialog({ vote, ...(reason ? { reason } : {}) }); + return; + } + const allowSharing = sharingPreference === "allowed"; + void doVote(vote, { + ...(allowSharing ? { allowSharing: true } : {}), + ...(reason ? { reason } : {}), + }); + } + + function handleThumbsUp() { + handleVote("up"); + } + + function handleThumbsDown() { + setOptimisticVote("down"); + setReasonOpen(true); + // Submit the initial down vote right away + handleVote("down"); + } + + function handleSubmitReason() { + if (!downvoteReason.trim()) return; + // Re-submit with reason attached + if (sharingPreference === "prompt") { + setPendingSharingDialog({ vote: "down", reason: downvoteReason }); + } else { + const allowSharing = sharingPreference === "allowed"; + void doVote("down", { + ...(allowSharing ? { allowSharing: true } : {}), + reason: downvoteReason, + }); + } + setReasonOpen(false); + setDownvoteReason(""); + } + + return ( + <> + + + + + + +
What could have been better?
+