paperclip/ui/src/components/task-chat/task-chat-adapter.ts

158 lines
6.7 KiB
TypeScript

/**
* 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<string, Agent>;
userLabelMap?: ReadonlyMap<string, string> | 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<string, TaskChatMessageItem["verificationCaveats"]>;
}
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;
}