/** * Live adapter: normalize the existing IssueChatComment stream (including * optimistic echoes) into the redesign's TaskChatItem[] model. This is the * seam that lets the new render layer show live task data without touching the * comment data pipeline. Rich agent-run streaming (thinking/tool/diff) is * demonstrated in the harness and layered onto this adapter as a later step; * the baseline live thread renders the author-typed message history + optimistic * echo, which is the core legibility win. */ import type { Agent } from "@paperclipai/shared"; import type { IssueChatComment } from "@/lib/issue-chat-messages"; import { resolveCommentAttribution } from "@/lib/comment-attribution"; import type { TaskChatAuthorKind, TaskChatItem, TaskChatMessageItem } from "./task-chat-model"; export interface TaskChatAdapterContext { agentMap?: Map; userLabelMap?: ReadonlyMap | null; currentUserId?: string | null; /** * Task's current assignee. Agent comments from anyone else are cross-issue * writes and get a "for {user}" attribution chip (the open cross-task write design (attribution)). */ issueAssigneeAgentId?: string | null; verificationCaveatsByRunId?: ReadonlyMap; } function effectiveAgentId(comment: IssueChatComment): string | null { return comment.authorAgentId ?? comment.derivedAuthorAgentId ?? null; } function authorKind(comment: IssueChatComment): TaskChatAuthorKind { // The server-authored presentation contract wins over attribution. Some // control-plane notices keep the run agent as their author for audit and // authorization, but they must still use the system-notice renderer. // System authorship also wins over any derivable run→agent linkage // (PAP-443): recovery notices carry a derivedAuthorAgentId but must not // render as agent bubbles. if ( comment.presentation?.kind === "system_notice" || comment.authorType === "system" ) { return "system"; } if (effectiveAgentId(comment)) return "agent"; if (comment.authorType === "user") return "human"; return "agent"; } /** Shared bubble-footer time format ("2:34 PM") — also used by the description bubble (PAP-375). */ export function formatTaskChatTimestamp(value: unknown): string | undefined { if (!value) return undefined; const d = value instanceof Date ? value : new Date(value as string); if (Number.isNaN(d.getTime())) return undefined; return d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); } /** * Follow-up inputs render at the causal slot where a runner consumed them. * Keep their original submission time visible as well so the reordered bubble * cannot look like it travelled backwards in the conversation. */ export function formatTaskChatCommentTimestamp( comment: IssueChatComment, kind: TaskChatAuthorKind, ): string | undefined { const queuedAt = formatTaskChatTimestamp(comment.createdAt); const deliveredAt = formatTaskChatTimestamp(comment.conversationAnchorAt); const isDeliveredFollowUp = Boolean( kind === "human" && comment.conversationAnchorAt && comment.consumedByRunId && (comment.followUpRequested || comment.steeredIntoRunId), ); if (!isDeliveredFollowUp) return queuedAt; if (!queuedAt || !deliveredAt) return queuedAt ?? deliveredAt; const action = comment.steeredIntoRunId ? "Steered" : "Delivered"; return `Queued ${queuedAt} · ${action} ${deliveredAt}`; } export function commentsToTaskChatItems( comments: IssueChatComment[], ctx: TaskChatAdapterContext = {}, ): TaskChatItem[] { const items: TaskChatItem[] = []; for (const comment of comments) { if (comment.deletedAt) continue; if (comment.conversationSessionGeneration != null) { items.push({ id: comment.id, kind: "marker", variant: "session_start", label: "New session", detail: "Earlier messages and files are still available.", createdAtIso: new Date(comment.createdAt).toISOString() }); continue; } const kind = authorKind(comment); let authorName: string | undefined; let agentIcon: string | null | undefined; let onBehalfOfUserName: string | undefined; if (kind === "agent") { const agentId = effectiveAgentId(comment); authorName = (agentId && ctx.agentMap?.get(agentId)?.name) || "Agent"; agentIcon = agentId ? ctx.agentMap?.get(agentId)?.icon : undefined; onBehalfOfUserName = resolveCommentAttribution({ authorAgentId: agentId, onBehalfOfUserId: comment.onBehalfOfUserId ?? null, issueAssigneeAgentId: ctx.issueAssigneeAgentId, resolveUserLabel: (userId) => ctx.userLabelMap?.get(userId), })?.userName; } else if (kind === "human") { authorName = (comment.authorUserId && ctx.userLabelMap?.get(comment.authorUserId)) || undefined; } const queued = comment.queueState === "queued" || comment.clientStatus === "queued"; const optimistic = queued ? "queued" : comment.clientStatus === "pending" ? "pending" : undefined; const createdAtIso = comment.createdAt instanceof Date ? comment.createdAt.toISOString() : comment.createdAt ? String(comment.createdAt) : undefined; // Durable run-authored comments already carry their source run directly. // Activity-derived `runId` is a useful fallback for older rows, but it may // arrive later (or be omitted entirely for server-materialized final // replies). Prefer the stored provenance so settled-response decorations // such as verification caveats are never lost. const sourceRunId = comment.createdByRunId ?? comment.runId ?? comment.derivedCreatedByRunId ?? null; items.push({ id: comment.id || comment.clientId || `${comment.createdAt}`, renderKey: comment.clientId ?? comment.id, kind: "message", author: kind, authorName, text: comment.body, timestamp: formatTaskChatCommentTimestamp(comment, kind), optimistic, queueTargetRunId: queued ? comment.queueTargetRunId ?? null : null, verificationCaveats: sourceRunId ? ctx.verificationCaveatsByRunId?.get(sourceRunId) : undefined, agentIcon, onBehalfOfUserName, // System notices carry their structured hints through to the render // layer (PAP-443); other authors keep the item lean. presentation: kind === "system" ? comment.presentation ?? null : undefined, metadata: kind === "system" ? comment.metadata ?? null : undefined, runAgentId: kind === "system" ? comment.runAgentId ?? null : undefined, createdAtIso: kind === "system" ? createdAtIso : undefined, }); } return items; }