mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-19 20:44:16 +02:00
Preserve human author identity in task chat and align only the current viewer on the right. Show other humans and agents on the left with their own identity. Include mixed-author Storybook journeys and regression coverage. Co-Authored-By: Paperclip <noreply@paperclip.ing>
157 lines
6.8 KiB
TypeScript
157 lines
6.8 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 { resolveIssueChatHumanAuthor } from "@/lib/issue-chat-human-author";
|
|
import type { CompanyUserProfile } from "@/lib/company-members";
|
|
import type { TaskChatAuthorKind, TaskChatItem, TaskChatMessageItem } from "./task-chat-model";
|
|
|
|
export interface TaskChatAdapterContext {
|
|
agentMap?: Map<string, Agent>;
|
|
userLabelMap?: ReadonlyMap<string, string> | null;
|
|
userProfileMap?: ReadonlyMap<string, CompanyUserProfile> | 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" });
|
|
}
|
|
|
|
/** Keep every comment footer on the same compact, user-visible timestamp. */
|
|
export function formatTaskChatCommentTimestamp(
|
|
comment: IssueChatComment,
|
|
_kind: TaskChatAuthorKind,
|
|
): string | undefined {
|
|
return formatTaskChatTimestamp(comment.createdAt);
|
|
}
|
|
|
|
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;
|
|
const humanAuthor = kind === "human" ? resolveIssueChatHumanAuthor({
|
|
authorUserId: comment.authorUserId,
|
|
authorName: comment.authorUserId ? ctx.userLabelMap?.get(comment.authorUserId) : null,
|
|
currentUserId: ctx.currentUserId,
|
|
userProfileMap: ctx.userProfileMap,
|
|
}) : 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 = humanAuthor!.authorName;
|
|
}
|
|
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,
|
|
...(humanAuthor ? {
|
|
authorUserId: comment.authorUserId,
|
|
authorAvatarUrl: humanAuthor.avatarUrl,
|
|
isCurrentUser: humanAuthor.isCurrentUser,
|
|
} : {}),
|
|
agent: effectiveAgentId(comment) ? ctx.agentMap?.get(effectiveAgentId(comment)!) ?? { id: effectiveAgentId(comment)! } : undefined,
|
|
text: comment.body,
|
|
sourceChannel: kind === "human" ? comment.metadata?.sourceChannel : undefined,
|
|
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;
|
|
}
|