diff --git a/ui/package.json b/ui/package.json index 7a798f7d..020e2ec2 100644 --- a/ui/package.json +++ b/ui/package.json @@ -26,6 +26,7 @@ "access": "public" }, "dependencies": { + "@assistant-ui/react": "0.12.23", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx new file mode 100644 index 00000000..b7a1271a --- /dev/null +++ b/ui/src/components/IssueChatThread.tsx @@ -0,0 +1,883 @@ +import { + AssistantRuntimeProvider, + MessagePrimitive, + ThreadPrimitive, + useAui, + useAuiState, + useMessage, +} from "@assistant-ui/react"; +import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; +import { Link, useLocation } from "@/lib/router"; +import type { + Agent, + FeedbackDataSharingPreference, + FeedbackVote, + FeedbackVoteValue, +} from "@paperclipai/shared"; +import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats"; +import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts"; +import { usePaperclipIssueRuntime, type PaperclipIssueRuntimeReassignment } from "../hooks/usePaperclipIssueRuntime"; +import { + buildIssueChatMessages, + type IssueChatComment, + type IssueChatLinkedRun, +} from "../lib/issue-chat-messages"; +import type { IssueTimelineEvent } from "../lib/issue-timeline-events"; +import { Button } from "@/components/ui/button"; +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 { StatusBadge } from "./StatusBadge"; +import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft"; +import { cn, formatDateTime } from "../lib/utils"; +import { Check, Copy, Loader2, Paperclip, Square } from "lucide-react"; + +interface CommentReassignment { + assigneeAgentId: string | null; + assigneeUserId: string | null; +} + +interface IssueChatThreadProps { + comments: IssueChatComment[]; + feedbackVotes?: FeedbackVote[]; + feedbackDataSharingPreference?: FeedbackDataSharingPreference; + feedbackTermsUrl?: string | null; + linkedRuns?: IssueChatLinkedRun[]; + timelineEvents?: IssueTimelineEvent[]; + liveRuns?: LiveRunForIssue[]; + activeRun?: ActiveRunForIssue | null; + companyId?: string | null; + projectId?: string | null; + issueStatus?: string; + agentMap?: Map; + currentUserId?: string | null; + onVote?: ( + commentId: string, + vote: FeedbackVoteValue, + options?: { allowSharing?: boolean; reason?: string }, + ) => Promise; + onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise; + onCancelRun?: () => Promise; + imageUploadHandler?: (file: File) => Promise; + onAttachImage?: (file: File) => Promise; + draftKey?: string; + enableReassign?: boolean; + reassignOptions?: InlineEntityOption[]; + currentAssigneeValue?: string; + suggestedAssigneeValue?: string; + mentions?: MentionOption[]; + composerDisabledReason?: string | null; +} + +const DRAFT_DEBOUNCE_MS = 800; + +function toIsoString(value: string | Date | null | undefined): string | null { + if (!value) return null; + return typeof value === "string" ? value : value.toISOString(); +} + +function loadDraft(draftKey: string): string { + try { + return localStorage.getItem(draftKey) ?? ""; + } catch { + return ""; + } +} + +function saveDraft(draftKey: string, value: string) { + try { + if (value.trim()) { + localStorage.setItem(draftKey, value); + } else { + localStorage.removeItem(draftKey); + } + } catch { + // Ignore localStorage failures. + } +} + +function clearDraft(draftKey: string) { + try { + localStorage.removeItem(draftKey); + } catch { + // Ignore localStorage failures. + } +} + +function parseReassignment(target: string): PaperclipIssueRuntimeReassignment | null { + if (!target || target === "__none__") { + return { assigneeAgentId: null, assigneeUserId: null }; + } + if (target.startsWith("agent:")) { + const assigneeAgentId = target.slice("agent:".length); + return assigneeAgentId ? { assigneeAgentId, assigneeUserId: null } : null; + } + if (target.startsWith("user:")) { + const assigneeUserId = target.slice("user:".length); + return assigneeUserId ? { assigneeAgentId: null, assigneeUserId } : null; + } + return null; +} + +function CopyMarkdownButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + return ( + + ); +} + +function IssueChatTextPart({ text }: { text: string }) { + return {text}; +} + +function IssueChatReasoningPart({ text }: { text: string }) { + return ( +
+ + Thinking + +
+ {text} +
+
+ ); +} + +function IssueChatToolPart({ + toolName, + argsText, + result, + isError, +}: { + toolName: string; + argsText: string; + result?: unknown; + isError?: boolean; +}) { + const [open, setOpen] = useState(false); + const resultText = + typeof result === "string" + ? result + : result === undefined + ? "" + : JSON.stringify(result, null, 2); + + return ( +
+ + + {open ? ( +
+ {argsText ? ( +
+
+ Input +
+
{argsText}
+
+ ) : null} + {result !== undefined ? ( +
+
+ Result +
+
{resultText}
+
+ ) : null} +
+ ) : null} +
+ ); +} + +function IssueChatUserMessage({ + companyId, + projectId, +}: { + companyId?: string | null; + projectId?: string | null; +}) { + 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 body = message.content + .filter((part): part is Extract<(typeof message.content)[number], { type: "text" }> => part.type === "text") + .map((part) => part.text) + .join("\n"); + const queued = custom.queueState === "queued" || custom.clientStatus === "queued"; + const pending = custom.clientStatus === "pending"; + + return ( + +
+
+
+ + {queued ? ( + + Queued + + ) : null} + {pending ? Sending... : null} +
+ + + {formatDateTime(message.createdAt)} + + + +
+ +
+ , + }} + /> +
+ + {companyId && typeof custom.commentId === "string" ? null : null} + {projectId ? null : null} +
+
+ ); +} + +function IssueChatAssistantMessage({ + feedbackVoteByTargetId, + feedbackDataSharingPreference, + feedbackTermsUrl, + onVote, +}: { + feedbackVoteByTargetId: Map; + feedbackDataSharingPreference?: FeedbackDataSharingPreference; + feedbackTermsUrl?: string | null; + onVote?: ( + commentId: string, + vote: FeedbackVoteValue, + options?: { allowSharing?: boolean; reason?: string }, + ) => Promise; +}) { + 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 + : typeof custom.runAgentName === "string" + ? custom.runAgentName + : "Agent"; + const runId = typeof custom.runId === "string" ? custom.runId : null; + const runAgentId = typeof custom.runAgentId === "string" ? custom.runAgentId : null; + 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) + : []; + const waitingText = typeof custom.waitingText === "string" ? custom.waitingText : ""; + const isRunning = message.role === "assistant" && message.status?.type === "running"; + + const handleVote = async ( + vote: FeedbackVoteValue, + options?: { allowSharing?: boolean; reason?: string }, + ) => { + if (!commentId || !onVote) return; + await onVote(commentId, vote, options); + }; + + return ( + +
+
+
+ + {isRunning ? ( + + + Running + + ) : null} +
+ + + {formatDateTime(message.createdAt)} + + +
+ +
+ , + Reasoning: ({ text }) => , + tools: { + Override: ({ toolName, argsText, result, isError }) => ( + + ), + }, + }} + /> + {message.content.length === 0 && waitingText ? ( +
+ {waitingText} +
+ ) : null} + {notices.length > 0 ? ( +
+ {notices.map((notice, index) => ( +
+ {notice} +
+ ))} +
+ ) : null} +
+ +
+ {runId ? ( + runAgentId ? ( + + run {runId.slice(0, 8)} + + ) : ( + + run {runId.slice(0, 8)} + + ) + ) : null} + {commentId && onVote ? ( + + ) : null} +
+
+
+ ); +} + +function IssueChatSystemMessage() { + const message = useMessage(); + const custom = message.metadata.custom as Record; + const text = message.content + .filter((part): part is Extract<(typeof message.content)[number], { type: "text" }> => part.type === "text") + .map((part) => part.text) + .join("\n"); + const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined; + const runId = typeof custom.runId === "string" ? custom.runId : null; + const runAgentId = typeof custom.runAgentId === "string" ? custom.runAgentId : null; + const runStatus = typeof custom.runStatus === "string" ? custom.runStatus : null; + + return ( + +
+
+ {text} + {runStatus ? : null} + {runId && runAgentId ? ( + + {runId.slice(0, 8)} + + ) : null} +
+
+
+ ); +} + +function IssueChatComposer({ + onImageUpload, + onAttachImage, + draftKey, + enableReassign = false, + reassignOptions = [], + currentAssigneeValue = "", + suggestedAssigneeValue, + mentions = [], + agentMap, + composerDisabledReason = null, + issueStatus, + onCancelRun, +}: { + onImageUpload?: (file: File) => Promise; + onAttachImage?: (file: File) => Promise; + draftKey?: string; + enableReassign?: boolean; + reassignOptions?: InlineEntityOption[]; + currentAssigneeValue?: string; + suggestedAssigneeValue?: string; + mentions?: MentionOption[]; + agentMap?: Map; + composerDisabledReason?: string | null; + issueStatus?: string; + onCancelRun?: (() => Promise) | undefined; +}) { + const api = useAui(); + const isRunning = useAuiState((state) => state.thread.isRunning); + const [body, setBody] = useState(""); + const [reopen, setReopen] = useState(issueStatus === "done" || issueStatus === "cancelled"); + const [submitting, setSubmitting] = useState(false); + const [attaching, setAttaching] = useState(false); + const [cancelling, setCancelling] = useState(false); + const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue; + const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue); + const attachInputRef = useRef(null); + const editorRef = useRef(null); + const draftTimer = useRef | null>(null); + + useEffect(() => { + if (!draftKey) return; + setBody(loadDraft(draftKey)); + }, [draftKey]); + + useEffect(() => { + if (!draftKey) return; + if (draftTimer.current) clearTimeout(draftTimer.current); + draftTimer.current = setTimeout(() => { + saveDraft(draftKey, body); + }, DRAFT_DEBOUNCE_MS); + }, [body, draftKey]); + + useEffect(() => { + return () => { + if (draftTimer.current) clearTimeout(draftTimer.current); + }; + }, []); + + useEffect(() => { + setReassignTarget(effectiveSuggestedAssigneeValue); + }, [effectiveSuggestedAssigneeValue]); + + async function handleSubmit() { + const trimmed = body.trim(); + if (!trimmed || submitting) return; + + const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue; + const reassignment = hasReassignment ? parseReassignment(reassignTarget) : undefined; + const submittedBody = trimmed; + + setSubmitting(true); + setBody(""); + try { + await api.thread().append({ + role: "user", + content: [{ type: "text", text: submittedBody }], + metadata: { custom: {} }, + attachments: [], + runConfig: { + custom: { + ...(reopen ? { reopen: true } : {}), + ...(reassignment ? { reassignment } : {}), + }, + }, + }); + if (draftKey) clearDraft(draftKey); + setReopen(issueStatus === "done" || issueStatus === "cancelled"); + setReassignTarget(effectiveSuggestedAssigneeValue); + } catch { + setBody((current) => + restoreSubmittedCommentDraft({ + currentBody: current, + submittedBody, + }), + ); + } finally { + setSubmitting(false); + } + } + + async function handleAttachFile(evt: ChangeEvent) { + const file = evt.target.files?.[0]; + if (!file) return; + setAttaching(true); + try { + if (onImageUpload) { + const url = await onImageUpload(file); + const safeName = file.name.replace(/[[\]]/g, "\\$&"); + const markdown = `![${safeName}](${url})`; + setBody((prev) => prev ? `${prev}\n\n${markdown}` : markdown); + } else if (onAttachImage) { + await onAttachImage(file); + } + } finally { + setAttaching(false); + if (attachInputRef.current) attachInputRef.current.value = ""; + } + } + + async function handleCancelRun() { + if (!onCancelRun || cancelling) return; + setCancelling(true); + try { + await onCancelRun(); + } finally { + setCancelling(false); + } + } + + const canSubmit = !submitting && !!body.trim(); + + if (composerDisabledReason) { + return ( +
+ {composerDisabledReason} +
+ ); + } + + return ( +
+ {isRunning ? ( +
+ Messages sent now queue behind the active run. + {onCancelRun ? ( + + ) : null} +
+ ) : null} + + + +
+ {(onImageUpload || onAttachImage) ? ( +
+ + +
+ ) : null} + + + + {enableReassign && reassignOptions.length > 0 ? ( + { + if (!option) return Assignee; + const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; + const agent = agentId ? agentMap?.get(agentId) : null; + return ( + <> + {agent ? ( + + ) : null} + {option.label} + + ); + }} + renderOption={(option) => { + if (!option.id) return {option.label}; + const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; + const agent = agentId ? agentMap?.get(agentId) : null; + return ( + <> + {agent ? ( + + ) : null} + {option.label} + + ); + }} + /> + ) : null} + + +
+
+ ); +} + +export function IssueChatThread({ + comments, + feedbackVotes = [], + feedbackDataSharingPreference = "prompt", + feedbackTermsUrl = null, + linkedRuns = [], + timelineEvents = [], + liveRuns = [], + activeRun = null, + companyId, + projectId, + issueStatus, + agentMap, + currentUserId, + onVote, + onAdd, + onCancelRun, + imageUploadHandler, + onAttachImage, + draftKey, + enableReassign = false, + reassignOptions = [], + currentAssigneeValue = "", + suggestedAssigneeValue, + mentions = [], + composerDisabledReason = null, +}: IssueChatThreadProps) { + const location = useLocation(); + const hasScrolledRef = useRef(false); + const displayLiveRuns = useMemo(() => { + const deduped = new Map(); + for (const run of liveRuns) { + deduped.set(run.id, run); + } + if (activeRun) { + deduped.set(activeRun.id, { + id: activeRun.id, + status: activeRun.status, + invocationSource: activeRun.invocationSource, + triggerDetail: activeRun.triggerDetail, + startedAt: toIsoString(activeRun.startedAt), + finishedAt: toIsoString(activeRun.finishedAt), + createdAt: toIsoString(activeRun.createdAt) ?? new Date().toISOString(), + agentId: activeRun.agentId, + agentName: activeRun.agentName, + adapterType: activeRun.adapterType, + }); + } + return [...deduped.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); + }, [activeRun, liveRuns]); + const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({ runs: displayLiveRuns, companyId }); + + const messages = useMemo( + () => + buildIssueChatMessages({ + comments, + timelineEvents, + linkedRuns, + liveRuns, + activeRun, + transcriptsByRunId: transcriptByRun, + hasOutputForRun, + companyId, + projectId, + agentMap, + currentUserId, + }), + [ + comments, + timelineEvents, + linkedRuns, + liveRuns, + activeRun, + transcriptByRun, + hasOutputForRun, + companyId, + projectId, + agentMap, + currentUserId, + ], + ); + + const isRunning = displayLiveRuns.some((run) => run.status === "queued" || run.status === "running"); + const feedbackVoteByTargetId = useMemo(() => { + const map = new Map(); + for (const feedbackVote of feedbackVotes) { + if (feedbackVote.targetType !== "issue_comment") continue; + map.set(feedbackVote.targetId, feedbackVote.vote); + } + return map; + }, [feedbackVotes]); + + const runtime = usePaperclipIssueRuntime({ + messages, + isRunning, + onSend: ({ body, reopen, reassignment }) => onAdd(body, reopen, reassignment), + onCancel: onCancelRun, + }); + + useEffect(() => { + const hash = location.hash; + if (!(hash.startsWith("#comment-") || hash.startsWith("#activity-") || hash.startsWith("#run-"))) return; + if (messages.length === 0 || hasScrolledRef.current) return; + const targetId = hash.slice(1); + const element = document.getElementById(targetId); + if (!element) return; + hasScrolledRef.current = true; + element.scrollIntoView({ behavior: "smooth", block: "center" }); + }, [location.hash, messages]); + + const components = useMemo( + () => ({ + UserMessage: () => , + AssistantMessage: () => ( + + ), + SystemMessage: () => , + }), + [ + companyId, + projectId, + feedbackVoteByTargetId, + feedbackDataSharingPreference, + feedbackTermsUrl, + onVote, + ], + ); + + return ( + +
+
+

Chat ({messages.length})

+ + Jump to latest + +
+ + + + +
+ This issue conversation is empty. Start with a message below. +
+
+ +
+
+ + +
+
+ ); +} diff --git a/ui/src/hooks/usePaperclipIssueRuntime.ts b/ui/src/hooks/usePaperclipIssueRuntime.ts new file mode 100644 index 00000000..328a5544 --- /dev/null +++ b/ui/src/hooks/usePaperclipIssueRuntime.ts @@ -0,0 +1,68 @@ +import { useExternalStoreRuntime, type ThreadMessage, type AppendMessage } from "@assistant-ui/react"; + +export interface PaperclipIssueRuntimeReassignment { + assigneeAgentId: string | null; + assigneeUserId: string | null; +} + +export interface PaperclipIssueRuntimeSendOptions { + body: string; + reopen?: boolean; + reassignment?: PaperclipIssueRuntimeReassignment; +} + +interface UsePaperclipIssueRuntimeOptions { + messages: readonly ThreadMessage[]; + isRunning: boolean; + onSend: (options: PaperclipIssueRuntimeSendOptions) => Promise; + onCancel?: (() => Promise) | undefined; +} + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function readTextContent(message: AppendMessage) { + return message.content + .filter((part): part is Extract<(typeof message.content)[number], { type: "text" }> => part.type === "text") + .map((part) => part.text) + .join("") + .trim(); +} + +export function usePaperclipIssueRuntime({ + messages, + isRunning, + onSend, + onCancel, +}: UsePaperclipIssueRuntimeOptions) { + return useExternalStoreRuntime({ + messages, + isRunning, + onNew: async (message) => { + const body = readTextContent(message); + if (!body) return; + + const custom = asRecord(message.runConfig?.custom); + const reassignmentRecord = asRecord(custom?.reassignment); + const reassignment = + reassignmentRecord && + ("assigneeAgentId" in reassignmentRecord || "assigneeUserId" in reassignmentRecord) + ? { + assigneeAgentId: + typeof reassignmentRecord.assigneeAgentId === "string" ? reassignmentRecord.assigneeAgentId : null, + assigneeUserId: + typeof reassignmentRecord.assigneeUserId === "string" ? reassignmentRecord.assigneeUserId : null, + } + : undefined; + + await onSend({ + body, + reopen: custom?.reopen === true ? true : undefined, + reassignment, + }); + }, + ...(onCancel ? { onCancel } : {}), + }); +} diff --git a/ui/src/lib/issue-chat-messages.test.ts b/ui/src/lib/issue-chat-messages.test.ts new file mode 100644 index 00000000..17ddcbaf --- /dev/null +++ b/ui/src/lib/issue-chat-messages.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "vitest"; +import type { Agent } from "@paperclipai/shared"; +import { + buildAssistantPartsFromTranscript, + buildIssueChatMessages, + type IssueChatComment, + type IssueChatLinkedRun, +} from "./issue-chat-messages"; +import type { IssueTimelineEvent } from "./issue-timeline-events"; +import type { LiveRunForIssue } from "../api/heartbeats"; + +function createAgent(id: string, name: string): Agent { + return { + id, + companyId: "company-1", + name, + role: "engineer", + title: null, + icon: "code", + status: "active", + reportsTo: null, + capabilities: null, + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + lastHeartbeatAt: null, + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + pauseReason: null, + pausedAt: null, + urlKey: "codexcoder", + permissions: { canCreateAgents: false }, + } as Agent; +} + +function createComment(overrides: Partial = {}): IssueChatComment { + return { + id: "comment-1", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "user-1", + body: "Hello", + createdAt: new Date("2026-04-06T12:00:00.000Z"), + updatedAt: new Date("2026-04-06T12:00:00.000Z"), + ...overrides, + }; +} + +describe("buildAssistantPartsFromTranscript", () => { + it("maps assistant text, reasoning, tool calls, and tool results", () => { + const result = buildAssistantPartsFromTranscript([ + { kind: "assistant", ts: "2026-04-06T12:00:00.000Z", text: "Working on it. " }, + { kind: "assistant", ts: "2026-04-06T12:00:01.000Z", text: "Done." }, + { kind: "thinking", ts: "2026-04-06T12:00:02.000Z", text: "Need to inspect files." }, + { + kind: "tool_call", + ts: "2026-04-06T12:00:03.000Z", + name: "read_file", + toolUseId: "tool-1", + input: { path: "ui/src/pages/IssueDetail.tsx" }, + }, + { + kind: "tool_result", + ts: "2026-04-06T12:00:04.000Z", + toolUseId: "tool-1", + content: "file contents", + isError: false, + }, + { kind: "stderr", ts: "2026-04-06T12:00:05.000Z", text: "warn: noisy setup output" }, + ]); + + expect(result.parts).toHaveLength(3); + expect(result.parts[0]).toMatchObject({ type: "text", text: "Working on it. Done." }); + expect(result.parts[1]).toMatchObject({ type: "reasoning", text: "Need to inspect files." }); + expect(result.parts[2]).toMatchObject({ + type: "tool-call", + toolCallId: "tool-1", + toolName: "read_file", + result: "file contents", + isError: false, + }); + expect(result.notices).toEqual(["warn: noisy setup output"]); + }); +}); + +describe("buildIssueChatMessages", () => { + it("orders events before comments and appends active live runs as running assistant messages", () => { + const agentMap = new Map([["agent-1", createAgent("agent-1", "CodexCoder")]]); + const comments = [ + createComment(), + createComment({ + id: "comment-2", + authorAgentId: "agent-1", + authorUserId: null, + body: "I made the change.", + createdAt: new Date("2026-04-06T12:03:00.000Z"), + updatedAt: new Date("2026-04-06T12:03:00.000Z"), + runId: "run-1", + runAgentId: "agent-1", + }), + ]; + const timelineEvents: IssueTimelineEvent[] = [ + { + id: "event-1", + createdAt: new Date("2026-04-06T11:59:00.000Z"), + actorType: "user", + actorId: "user-1", + statusChange: { + from: "done", + to: "todo", + }, + }, + ]; + const linkedRuns: IssueChatLinkedRun[] = [ + { + runId: "run-history-1", + status: "succeeded", + agentId: "agent-1", + createdAt: new Date("2026-04-06T12:01:00.000Z"), + startedAt: new Date("2026-04-06T12:01:00.000Z"), + finishedAt: new Date("2026-04-06T12:02:00.000Z"), + }, + ]; + const liveRuns: LiveRunForIssue[] = [ + { + id: "run-live-1", + status: "running", + invocationSource: "manual", + triggerDetail: null, + startedAt: "2026-04-06T12:04:00.000Z", + finishedAt: null, + createdAt: "2026-04-06T12:04:00.000Z", + agentId: "agent-1", + agentName: "CodexCoder", + adapterType: "codex_local", + }, + ]; + + const messages = buildIssueChatMessages({ + comments, + timelineEvents, + linkedRuns, + liveRuns, + transcriptsByRunId: new Map([ + [ + "run-live-1", + [{ kind: "assistant", ts: "2026-04-06T12:04:01.000Z", text: "Streaming reply" }], + ], + ]), + hasOutputForRun: () => true, + companyId: "company-1", + projectId: "project-1", + agentMap, + currentUserId: "user-1", + }); + + expect(messages.map((message) => `${message.role}:${message.id}`)).toEqual([ + "system:activity:event-1", + "user:comment-1", + "system:run:run-history-1", + "assistant:comment-2", + "assistant:live-run:run-live-1", + ]); + + const liveRunMessage = messages.at(-1); + expect(liveRunMessage).toMatchObject({ + role: "assistant", + status: { type: "running" }, + }); + expect(liveRunMessage?.content[0]).toMatchObject({ + type: "text", + text: "Streaming reply", + }); + }); +}); diff --git a/ui/src/lib/issue-chat-messages.ts b/ui/src/lib/issue-chat-messages.ts new file mode 100644 index 00000000..7bb2c818 --- /dev/null +++ b/ui/src/lib/issue-chat-messages.ts @@ -0,0 +1,502 @@ +import type { + ReasoningMessagePart, + TextMessagePart, + ThreadAssistantMessage, + ThreadMessage, + ToolCallMessagePart, + ThreadSystemMessage, + ThreadUserMessage, +} from "@assistant-ui/react"; +import type { Agent, IssueComment } from "@paperclipai/shared"; +import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats"; +import { formatAssigneeUserLabel } from "./assignees"; +import type { IssueTimelineEvent } from "./issue-timeline-events"; + +type JsonValue = null | string | number | boolean | JsonValue[] | { [key: string]: JsonValue }; +type JsonObject = { [key: string]: JsonValue }; + +export interface IssueChatComment extends IssueComment { + runId?: string | null; + runAgentId?: string | null; + interruptedRunId?: string | null; + clientId?: string; + clientStatus?: "pending" | "queued"; + queueState?: "queued"; + queueTargetRunId?: string | null; +} + +export interface IssueChatLinkedRun { + runId: string; + status: string; + agentId: string; + createdAt: Date | string; + startedAt: Date | string | null; + finishedAt?: Date | string | null; +} + +export interface IssueChatTranscriptEntry { + kind: + | "assistant" + | "thinking" + | "user" + | "tool_call" + | "tool_result" + | "init" + | "result" + | "stderr" + | "system" + | "stdout" + | "diff"; + ts: string; + text?: string; + name?: string; + input?: unknown; + toolUseId?: string; + toolName?: string; + content?: string; + isError?: boolean; + subtype?: string; + errors?: string[]; +} + +type MessageWithOrder = { + createdAtMs: number; + order: number; + message: ThreadMessage; +}; + +function toDate(value: Date | string | null | undefined) { + return value instanceof Date ? value : new Date(value ?? Date.now()); +} + +function toTimestamp(value: Date | string | null | undefined) { + return toDate(value).getTime(); +} + +function sortByCreated(items: readonly T[]) { + return [...items].sort((a, b) => { + const diff = toTimestamp(a.createdAt) - toTimestamp(b.createdAt); + if (diff !== 0) return diff; + return a.id.localeCompare(b.id); + }); +} + +function normalizeJsonValue(input: unknown): JsonValue { + if ( + input === null || + typeof input === "string" || + typeof input === "number" || + typeof input === "boolean" + ) { + return input; + } + if (Array.isArray(input)) { + return input.map((entry) => normalizeJsonValue(entry)); + } + if (typeof input === "object" && input) { + const entries = Object.entries(input as Record).map(([key, value]) => [ + key, + normalizeJsonValue(value), + ]); + return Object.fromEntries(entries) as JsonObject; + } + return String(input); +} + +function normalizeToolArgs(input: unknown): JsonObject { + if (typeof input === "object" && input && !Array.isArray(input)) { + return normalizeJsonValue(input) as JsonObject; + } + if (input === undefined) return {}; + return { value: normalizeJsonValue(input) }; +} + +function stringifyUnknown(value: unknown) { + if (typeof value === "string") return value; + if (value === null || value === undefined) return ""; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function createAssistantMetadata(custom: Record) { + return { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom, + } as const; +} + +function authorNameForComment( + comment: IssueChatComment, + agentMap?: Map, + currentUserId?: string | null, +) { + if (comment.authorAgentId) { + return agentMap?.get(comment.authorAgentId)?.name ?? comment.authorAgentId.slice(0, 8); + } + return formatAssigneeUserLabel(comment.authorUserId ?? null, currentUserId) ?? "You"; +} + +function formatStatusLabel(status: string) { + return status.replace(/_/g, " "); +} + +function createCommentMessage(args: { + comment: IssueChatComment; + agentMap?: Map; + currentUserId?: string | null; + companyId?: string | null; + projectId?: string | null; +}): ThreadMessage { + const { comment, agentMap, currentUserId, companyId, projectId } = args; + const createdAt = toDate(comment.createdAt); + const authorName = authorNameForComment(comment, agentMap, currentUserId); + const custom = { + kind: "comment", + commentId: comment.id, + anchorId: `comment-${comment.id}`, + authorName, + authorAgentId: comment.authorAgentId, + authorUserId: comment.authorUserId, + companyId: companyId ?? comment.companyId, + projectId: projectId ?? null, + runId: comment.runId ?? null, + runAgentId: comment.runAgentId ?? null, + clientStatus: comment.clientStatus ?? null, + queueState: comment.queueState ?? null, + queueTargetRunId: comment.queueTargetRunId ?? null, + interruptedRunId: comment.interruptedRunId ?? null, + }; + + if (comment.authorAgentId) { + const message: ThreadAssistantMessage = { + id: comment.id, + role: "assistant", + createdAt, + content: [{ type: "text", text: comment.body }], + status: { type: "complete", reason: "stop" }, + metadata: createAssistantMetadata(custom), + }; + return message; + } + + const message: ThreadUserMessage = { + id: comment.id, + role: "user", + createdAt, + content: [{ type: "text", text: comment.body }], + attachments: [], + metadata: { custom }, + }; + return message; +} + +function createTimelineEventMessage(args: { + event: IssueTimelineEvent; + agentMap?: Map; + currentUserId?: string | null; +}) { + const { event, agentMap, currentUserId } = args; + const actorName = event.actorType === "agent" + ? (agentMap?.get(event.actorId)?.name ?? event.actorId.slice(0, 8)) + : event.actorType === "system" + ? "System" + : (formatAssigneeUserLabel(event.actorId, currentUserId) ?? "Board"); + + const lines: string[] = [`${actorName} updated this issue`]; + if (event.statusChange) { + lines.push( + `Status: ${event.statusChange.from ?? "none"} -> ${event.statusChange.to ?? "none"}`, + ); + } + if (event.assigneeChange) { + const from = event.assigneeChange.from.agentId + ? (agentMap?.get(event.assigneeChange.from.agentId)?.name ?? event.assigneeChange.from.agentId.slice(0, 8)) + : (formatAssigneeUserLabel(event.assigneeChange.from.userId, currentUserId) ?? "Unassigned"); + const to = event.assigneeChange.to.agentId + ? (agentMap?.get(event.assigneeChange.to.agentId)?.name ?? event.assigneeChange.to.agentId.slice(0, 8)) + : (formatAssigneeUserLabel(event.assigneeChange.to.userId, currentUserId) ?? "Unassigned"); + lines.push(`Assignee: ${from} -> ${to}`); + } + + const message: ThreadSystemMessage = { + id: `activity:${event.id}`, + role: "system", + createdAt: toDate(event.createdAt), + content: [{ type: "text", text: lines.join("\n") }], + metadata: { + custom: { + kind: "event", + anchorId: `activity-${event.id}`, + eventId: event.id, + actorName, + statusChange: event.statusChange ?? null, + assigneeChange: event.assigneeChange ?? null, + }, + }, + }; + return message; +} + +function runTimestamp(run: IssueChatLinkedRun) { + return run.finishedAt ?? run.startedAt ?? run.createdAt; +} + +function createHistoricalRunMessage(run: IssueChatLinkedRun, agentMap?: Map) { + const agentName = agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8); + const message: ThreadSystemMessage = { + id: `run:${run.runId}`, + role: "system", + createdAt: toDate(runTimestamp(run)), + content: [{ type: "text", text: `${agentName} run ${run.runId.slice(0, 8)} ${formatStatusLabel(run.status)}` }], + metadata: { + custom: { + kind: "run", + anchorId: `run-${run.runId}`, + runId: run.runId, + runAgentId: run.agentId, + runAgentName: agentName, + runStatus: run.status, + }, + }, + }; + return message; +} + +function mergeAdjacentTextParts(parts: Array) { + const merged: Array = []; + for (const part of parts) { + const previous = merged.at(-1); + if (previous && previous.type === part.type && previous.parentId === part.parentId) { + merged[merged.length - 1] = { + ...previous, + text: `${previous.text}${part.text}`, + }; + continue; + } + merged.push(part); + } + return merged; +} + +export function buildAssistantPartsFromTranscript(entries: readonly IssueChatTranscriptEntry[]) { + const textLikeParts: Array = []; + const toolParts = new Map>(); + const toolOrder: string[] = []; + const notices: string[] = []; + + for (const [index, entry] of entries.entries()) { + if (entry.kind === "assistant" && entry.text) { + textLikeParts.push({ type: "text", text: entry.text }); + continue; + } + if (entry.kind === "thinking" && entry.text) { + textLikeParts.push({ type: "reasoning", text: entry.text }); + continue; + } + if (entry.kind === "tool_call") { + const toolCallId = entry.toolUseId || `tool-${index}`; + if (!toolParts.has(toolCallId)) { + toolOrder.push(toolCallId); + } + toolParts.set(toolCallId, { + type: "tool-call", + toolCallId, + toolName: entry.name || "tool", + args: normalizeToolArgs(entry.input), + argsText: stringifyUnknown(entry.input), + }); + continue; + } + if (entry.kind === "tool_result") { + const toolCallId = entry.toolUseId || `tool-result-${index}`; + const existing = toolParts.get(toolCallId); + if (!existing) { + toolOrder.push(toolCallId); + } + toolParts.set(toolCallId, { + type: "tool-call", + toolCallId, + toolName: existing?.toolName || entry.toolName || "tool", + args: existing?.args ?? {}, + argsText: existing?.argsText ?? "", + result: entry.content ?? "", + isError: entry.isError === true, + }); + continue; + } + if (entry.kind === "stderr" && entry.text) { + notices.push(entry.text); + continue; + } + if (entry.kind === "system" && entry.text) { + notices.push(entry.text); + continue; + } + if (entry.kind === "result") { + if (entry.isError && entry.errors?.length) { + notices.push(...entry.errors); + } else if (entry.text) { + notices.push(entry.text); + } + } + } + + return { + parts: [ + ...mergeAdjacentTextParts(textLikeParts), + ...toolOrder + .map((toolCallId) => toolParts.get(toolCallId)) + .filter((part): part is ToolCallMessagePart => Boolean(part)), + ], + notices, + }; +} + +function normalizeLiveRuns( + liveRuns: readonly LiveRunForIssue[], + activeRun: ActiveRunForIssue | null | undefined, + issueId?: string, +) { + const deduped = new Map(); + for (const run of liveRuns) { + deduped.set(run.id, run); + } + if (activeRun) { + deduped.set(activeRun.id, { + id: activeRun.id, + status: activeRun.status, + invocationSource: activeRun.invocationSource, + triggerDetail: activeRun.triggerDetail, + startedAt: activeRun.startedAt ? toDate(activeRun.startedAt).toISOString() : null, + finishedAt: activeRun.finishedAt ? toDate(activeRun.finishedAt).toISOString() : null, + createdAt: toDate(activeRun.createdAt).toISOString(), + agentId: activeRun.agentId, + agentName: activeRun.agentName, + adapterType: activeRun.adapterType, + issueId, + }); + } + return [...deduped.values()].sort((a, b) => toTimestamp(a.createdAt) - toTimestamp(b.createdAt)); +} + +function createLiveRunMessage(args: { + run: LiveRunForIssue; + transcript: readonly IssueChatTranscriptEntry[]; + hasOutput: boolean; +}) { + const { run, transcript, hasOutput } = args; + const { parts, notices } = buildAssistantPartsFromTranscript(transcript); + const waitingText = + run.status === "queued" + ? "Queued..." + : hasOutput + ? "" + : "Working..."; + + const content = parts.length > 0 + ? parts + : waitingText + ? [{ type: "text", text: waitingText } satisfies TextMessagePart] + : []; + + const message: ThreadAssistantMessage = { + id: `live-run:${run.id}`, + role: "assistant", + createdAt: toDate(run.startedAt ?? run.createdAt), + content, + status: { type: "running" }, + metadata: createAssistantMetadata({ + kind: "live-run", + runId: run.id, + runAgentId: run.agentId, + runAgentName: run.agentName, + runStatus: run.status, + adapterType: run.adapterType, + notices, + waitingText, + }), + }; + return message; +} + +export function buildIssueChatMessages(args: { + comments: readonly IssueChatComment[]; + timelineEvents: readonly IssueTimelineEvent[]; + linkedRuns: readonly IssueChatLinkedRun[]; + liveRuns: readonly LiveRunForIssue[]; + activeRun?: ActiveRunForIssue | null; + transcriptsByRunId?: ReadonlyMap; + hasOutputForRun?: (runId: string) => boolean; + issueId?: string; + companyId?: string | null; + projectId?: string | null; + agentMap?: Map; + currentUserId?: string | null; +}) { + const { + comments, + timelineEvents, + linkedRuns, + liveRuns, + activeRun, + transcriptsByRunId, + hasOutputForRun, + issueId, + companyId, + projectId, + agentMap, + currentUserId, + } = args; + + const orderedMessages: MessageWithOrder[] = []; + + for (const comment of sortByCreated(comments)) { + orderedMessages.push({ + createdAtMs: toTimestamp(comment.createdAt), + order: 1, + message: createCommentMessage({ comment, agentMap, currentUserId, companyId, projectId }), + }); + } + + for (const event of sortByCreated(timelineEvents)) { + orderedMessages.push({ + createdAtMs: toTimestamp(event.createdAt), + order: 0, + message: createTimelineEventMessage({ event, agentMap, currentUserId }), + }); + } + + for (const run of [...linkedRuns].sort((a, b) => toTimestamp(runTimestamp(a)) - toTimestamp(runTimestamp(b)))) { + orderedMessages.push({ + createdAtMs: toTimestamp(runTimestamp(run)), + order: 2, + message: createHistoricalRunMessage(run, agentMap), + }); + } + + for (const run of normalizeLiveRuns(liveRuns, activeRun, issueId)) { + orderedMessages.push({ + createdAtMs: toTimestamp(run.startedAt ?? run.createdAt), + order: 3, + message: createLiveRunMessage({ + run, + transcript: transcriptsByRunId?.get(run.id) ?? [], + hasOutput: hasOutputForRun?.(run.id) ?? false, + }), + }); + } + + return orderedMessages + .sort((a, b) => { + if (a.createdAtMs !== b.createdAtMs) return a.createdAtMs - b.createdAtMs; + if (a.order !== b.order) return a.order - b.order; + return a.message.id.localeCompare(b.message.id); + }) + .map((entry) => entry.message); +} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 05fb5af8..55b89994 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -40,11 +40,10 @@ import { useProjectOrder } from "../hooks/useProjectOrder"; import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils"; import { ApprovalCard } from "../components/ApprovalCard"; import { InlineEditor } from "../components/InlineEditor"; -import { CommentThread } from "../components/CommentThread"; +import { IssueChatThread } from "../components/IssueChatThread"; import { IssueDocumentsSection } from "../components/IssueDocumentsSection"; import { IssueProperties } from "../components/IssueProperties"; import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard"; -import { LiveRunWidget } from "../components/LiveRunWidget"; import type { MentionOption } from "../components/MarkdownEditor"; import { ImageGalleryModal } from "../components/ImageGalleryModal"; import { ScrollToBottom } from "../components/ScrollToBottom"; @@ -300,7 +299,7 @@ export function IssueDetail() { const [moreOpen, setMoreOpen] = useState(false); const [copied, setCopied] = useState(false); const [mobilePropsOpen, setMobilePropsOpen] = useState(false); - const [detailTab, setDetailTab] = useState("comments"); + const [detailTab, setDetailTab] = useState("chat"); const [pendingApprovalAction, setPendingApprovalAction] = useState<{ approvalId: string; action: "approve" | "reject"; @@ -610,15 +609,6 @@ export function IssueDetail() { }); }, [activity, threadComments, linkedRuns, runningIssueRun]); - const queuedComments = useMemo( - () => commentsWithRunMeta.filter((comment) => comment.queueState === "queued"), - [commentsWithRunMeta], - ); - - const timelineComments = useMemo( - () => commentsWithRunMeta.filter((comment) => comment.queueState !== "queued"), - [commentsWithRunMeta], - ); const timelineEvents = useMemo( () => extractIssueTimelineEvents(activity), [activity], @@ -1713,9 +1703,9 @@ export function IssueDetail() { - + - Comments + Chat @@ -1728,16 +1718,16 @@ export function IssueDetail() { ))} - - + { @@ -1756,10 +1746,6 @@ export function IssueDetail() { currentAssigneeValue={actualAssigneeValue} suggestedAssigneeValue={suggestedAssigneeValue} mentions={mentionOptions} - onInterruptQueued={async (runId) => { - await interruptQueuedComment.mutateAsync(runId); - }} - interruptingQueuedRunId={interruptQueuedComment.isPending ? runningIssueRun?.id ?? null : null} composerDisabledReason={commentComposerDisabledReason} onVote={async (commentId, vote, options) => { await feedbackVoteMutation.mutateAsync({ @@ -1785,7 +1771,11 @@ export function IssueDetail() { onAttachImage={async (file) => { await uploadAttachment.mutateAsync(file); }} - liveRunSlot={} + onCancelRun={runningIssueRun + ? async () => { + await interruptQueuedComment.mutateAsync(runningIssueRun.id); + } + : undefined} />