mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-14 17:40:37 +02:00
Fix human author identity and viewer-relative alignment in task chat (#15494)
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>
This commit is contained in:
1 parent
ed2c22ea8c
commit
26d71d8def
15 files changed
+353
-35
No files matched your search
@@ -67,6 +67,13 @@ already revealed conversation and composer mounted and visible.
|
||||
Bound the initial wait to 15 seconds. If a request stalls, reveal the available
|
||||
conversation and composer with a notice that some history is still loading.
|
||||
|
||||
Only the viewer's own human comments sit on the right in blue bubbles. Agents
|
||||
and other humans sit on the left on the page surface, with their own name and
|
||||
avatar above the message. Human identity comes from the company directory;
|
||||
missing photos use the shared initials fallback. Match ownership by the stored
|
||||
author user ID and the authenticated viewer ID, never by display name or the bot
|
||||
that relayed a message from another channel.
|
||||
|
||||
## Contextual feedback
|
||||
|
||||
Task chat shows execution errors and waits only while they remain relevant.
|
||||
|
||||
@@ -102,6 +102,11 @@ API responses and MCP transports keep their existing compression behavior.
|
||||
|
||||
The board UI Storybook keeps stories and Storybook config under `ui/storybook/` so component review files stay out of the app source routes.
|
||||
|
||||
**Chat & Comments → Human identity** covers other-human names and avatars on
|
||||
left-aligned task messages alongside agents; only the viewer’s own messages are blue/right-aligned. Its review guide explains the data flow; the task journey uses representative
|
||||
fixtures in the production shell and thread with an agent and two humans, both
|
||||
human viewer perspectives, light mode, and mobile.
|
||||
|
||||
```sh
|
||||
pnpm storybook
|
||||
pnpm build-storybook
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveIssueChatHumanAuthor } from "@/lib/issue-chat-human-author";
|
||||
import { useWorkspaceBaseRefRecovery } from "./WorkspaceBaseRefRecovery";
|
||||
import { WorkspaceBaseRefRecoveryNotice } from "./WorkspaceBaseRefRecoveryNotice";
|
||||
import { DispositionRecoveryNotice, useDispositionRecoverySnapshot } from "./DispositionRecoveryNotice";
|
||||
@@ -1165,30 +1166,7 @@ function formatInteractionActorLabel(args: {
|
||||
return "System";
|
||||
}
|
||||
|
||||
export function resolveIssueChatHumanAuthor(args: {
|
||||
authorName?: string | null;
|
||||
authorUserId?: string | null;
|
||||
currentUserId?: string | null;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile> | null;
|
||||
}) {
|
||||
const { authorName, authorUserId, currentUserId, userProfileMap } = args;
|
||||
const profile = authorUserId
|
||||
? (userProfileMap?.get(authorUserId) ?? null)
|
||||
: null;
|
||||
const isCurrentUser = Boolean(
|
||||
authorUserId && currentUserId && authorUserId === currentUserId,
|
||||
);
|
||||
const resolvedAuthorName =
|
||||
profile?.label?.trim() ||
|
||||
authorName?.trim() ||
|
||||
(authorUserId === "local-board" ? "Board" : isCurrentUser ? "You" : "User");
|
||||
|
||||
return {
|
||||
isCurrentUser,
|
||||
authorName: resolvedAuthorName,
|
||||
avatarUrl: profile?.image ?? null,
|
||||
};
|
||||
}
|
||||
export { resolveIssueChatHumanAuthor } from "@/lib/issue-chat-human-author";
|
||||
|
||||
function toolCountSummary(toolParts: ToolCallMessagePart[]): string | null {
|
||||
if (toolParts.length === 0) return null;
|
||||
|
||||
@@ -864,6 +864,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
commentsToTaskChatItems(projectedComments, {
|
||||
agentMap,
|
||||
userLabelMap,
|
||||
userProfileMap,
|
||||
currentUserId,
|
||||
issueAssigneeAgentId,
|
||||
verificationCaveatsByRunId,
|
||||
@@ -872,6 +873,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
projectedComments,
|
||||
agentMap,
|
||||
userLabelMap,
|
||||
userProfileMap,
|
||||
currentUserId,
|
||||
issueAssigneeAgentId,
|
||||
verificationCaveatsByRunId,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ThemeProvider } from "@/context/ThemeContext";
|
||||
import { IssueGalleryContext } from "@/context/IssueGalleryContext";
|
||||
import { TaskChatBubble } from "./TaskChatBubble";
|
||||
import { paperclipHumanAvatarUrl } from "@/lib/issue-chat-human-author";
|
||||
import type { TaskChatMessageItem } from "./task-chat-model";
|
||||
|
||||
describe("TaskChatBubble attachment chips", () => {
|
||||
@@ -41,6 +42,46 @@ describe("TaskChatBubble attachment chips", () => {
|
||||
);
|
||||
}
|
||||
|
||||
it("renders another human on the left like an agent, and moves them right when they become the viewer", () => {
|
||||
const item: TaskChatMessageItem = {
|
||||
id: "sam-comment", kind: "message", author: "human", text: "I still see the old task title.",
|
||||
authorUserId: "sam", authorName: "Sam Rivera", isCurrentUser: false,
|
||||
};
|
||||
const render = (isCurrentUser: boolean) => flushSync(() => root!.render(
|
||||
<ThemeProvider><TaskChatBubble item={{ ...item, isCurrentUser }} /></ThemeProvider>,
|
||||
));
|
||||
render(false);
|
||||
const identity = container.querySelector('[data-testid="task-chat-human-identity"]');
|
||||
expect(identity?.textContent).toContain("Sam Rivera");
|
||||
expect(identity?.querySelector('[data-slot="avatar-fallback"]')?.textContent).toBe("SR");
|
||||
expect(identity?.getAttribute("data-author-user-id")).toBe("sam");
|
||||
expect(container.querySelector('[data-testid="task-chat-human-bubble"]')?.parentElement?.classList.contains("items-start")).toBe(true);
|
||||
const bubble = container.querySelector('[data-testid="task-chat-human-bubble"]');
|
||||
expect(bubble?.className).toContain("bg-transparent");
|
||||
expect(bubble?.className).not.toContain("bg-(--liveness-blue)");
|
||||
expect(bubble?.querySelector(".paperclip-markdown")?.className).not.toContain("paperclip-markdown-on-accent");
|
||||
render(true);
|
||||
expect(bubble?.parentElement?.classList.contains("items-end")).toBe(true);
|
||||
expect(bubble?.className).toContain("bg-(--liveness-blue)");
|
||||
expect(container.querySelector('[data-testid="task-chat-human-identity"]')).toBeNull();
|
||||
expect(container.textContent).toContain("I still see the old task title.");
|
||||
});
|
||||
|
||||
it("uses initials instead of loading an external human avatar", () => {
|
||||
flushSync(() => root!.render(
|
||||
<ThemeProvider><TaskChatBubble item={{
|
||||
id: "external-avatar", kind: "message", author: "human", text: "Please check this.",
|
||||
authorName: "Sam Rivera", authorAvatarUrl: "https://example.com/track.png", isCurrentUser: false,
|
||||
}} /></ThemeProvider>,
|
||||
));
|
||||
const identity = container.querySelector('[data-testid="task-chat-human-identity"]');
|
||||
expect(identity?.querySelector("img")).toBeNull();
|
||||
expect(identity?.querySelector('[data-slot="avatar-fallback"]')?.textContent).toBe("SR");
|
||||
expect(paperclipHumanAvatarUrl("/api/assets/sam/content")).toBe("/api/assets/sam/content");
|
||||
expect(paperclipHumanAvatarUrl("https://example.com/track.png")).toBeNull();
|
||||
expect(paperclipHumanAvatarUrl("//example.com/track.png")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows persistent iMessage attribution only on inbound human bubbles", () => {
|
||||
for (const author of ["human", "agent"] as const) {
|
||||
flushSync(() => root!.render(
|
||||
@@ -295,8 +336,8 @@ describe("TaskChatBubble accent-bubble text color", () => {
|
||||
);
|
||||
}
|
||||
|
||||
it("marks the human bubble's markdown as on-accent so prose text follows text-white", () => {
|
||||
render({ id: "m1", kind: "message", author: "human", text: "when a new task is created…" });
|
||||
it("marks the own-message markdown as on-accent so prose text follows text-white", () => {
|
||||
render({ id: "m1", kind: "message", author: "human", isCurrentUser: true, text: "when a new task is created…" });
|
||||
const body = container.querySelector(".paperclip-markdown");
|
||||
expect(body).not.toBeNull();
|
||||
// Without this class the light-mode prose body color reads as black on blue.
|
||||
|
||||
@@ -3,6 +3,8 @@ import { isTextAttachment } from "@/lib/issue-attachments";
|
||||
import { ArtifactPreview } from "@/components/artifacts/ArtifactCard";
|
||||
import { isVideoLikeOutput } from "@/lib/issue-output";
|
||||
import { AgentAvatar, type AvatarAgent } from "../AgentAvatar";
|
||||
import { Identity } from "../Identity";
|
||||
import { paperclipHumanAvatarUrl } from "@/lib/issue-chat-human-author";
|
||||
import { useCallback, useContext, useState, type ReactNode } from "react";
|
||||
import { useEmailComment } from "@/components/EmailMessageCard";
|
||||
import type { IssueAttachment } from "@paperclipai/shared";
|
||||
@@ -95,9 +97,9 @@ export function TaskChatAgentIdentity({
|
||||
}
|
||||
|
||||
/**
|
||||
* Author-typed message row — the primary legibility signal. Human messages sit
|
||||
* right in a solid accent bubble; agent messages sit directly on the page
|
||||
* surface with an avatar author header (the agent's assigned icon + name);
|
||||
* Viewer-relative message row — the primary legibility signal. Own messages sit
|
||||
* right in a solid accent bubble; agents and other humans sit on the left
|
||||
* directly on the page surface with their own avatar and name;
|
||||
* system notices are centered and recede.
|
||||
*/
|
||||
function galleryItemForMedia(
|
||||
@@ -171,6 +173,7 @@ function TaskChatBubbleContent({
|
||||
}
|
||||
|
||||
const isHuman = item.author === "human";
|
||||
const isOwnMessage = isHuman && item.isCurrentUser === true;
|
||||
const sentFromIMessage = isHuman && item.sourceChannel === "imessage-photon";
|
||||
// Non-image file references ("[name](/api/attachments/…/content)") render as
|
||||
// attachment chips under the bubble; link-only lines leave the body text.
|
||||
@@ -219,10 +222,15 @@ function TaskChatBubbleContent({
|
||||
className={cn(
|
||||
"flex w-full flex-col gap-1",
|
||||
animateEntry && "tc-enter-bubble",
|
||||
isHuman ? "items-end" : "items-start",
|
||||
isOwnMessage ? "items-end" : "items-start",
|
||||
)}
|
||||
>
|
||||
{beforeTurn ? <div className="w-full pb-1">{beforeTurn}</div> : null}
|
||||
{isHuman && !item.isCurrentUser && item.authorName ? (
|
||||
<span className="px-1 text-sm font-semibold text-foreground" data-testid="task-chat-human-identity" data-author-user-id={item.authorUserId}>
|
||||
<Identity name={item.authorName} avatarUrl={paperclipHumanAvatarUrl(item.authorAvatarUrl)} size="default" className="gap-2" />
|
||||
</span>
|
||||
) : null}
|
||||
{!isHuman && item.authorName && !hideAgentIdentity ? (
|
||||
<TaskChatAgentIdentity
|
||||
agentName={item.authorName}
|
||||
@@ -240,19 +248,19 @@ function TaskChatBubbleContent({
|
||||
}
|
||||
className={cn(
|
||||
"break-words py-2 text-sm",
|
||||
isHuman
|
||||
isOwnMessage
|
||||
? "max-w-(--pct-85) rounded-2xl rounded-br-sm bg-(--liveness-blue) px-3.5 text-white"
|
||||
: "w-full bg-transparent px-1 text-foreground",
|
||||
)}
|
||||
>
|
||||
<MarkdownBody
|
||||
// The human bubble sits on the solid --liveness-blue accent, so the
|
||||
// The own-message bubble sits on the solid --liveness-blue accent, so the
|
||||
// prose body text must follow the bubble's `text-white` rather than
|
||||
// the default light-mode prose color (which reads as black on blue).
|
||||
// `paperclip-markdown-on-accent` flips prose tokens to currentColor
|
||||
// (== inherited white) in both themes; dark mode was already correct
|
||||
// only because `prose-invert` happened to lighten the text.
|
||||
className={isHuman ? "paperclip-markdown-on-accent" : undefined}
|
||||
className={isOwnMessage ? "paperclip-markdown-on-accent" : undefined}
|
||||
softBreaks
|
||||
linkIssueReferences
|
||||
onImageClick={openImage}
|
||||
|
||||
@@ -6,6 +6,40 @@ import {
|
||||
} from "./task-chat-adapter";
|
||||
|
||||
describe("commentsToTaskChatItems", () => {
|
||||
it("preserves distinct human IDs, photos and viewer ownership, including after a profile refresh", () => {
|
||||
const comments = ["alex", "sam"].map(authorUserId => ({
|
||||
id: `comment-${authorUserId}`, authorType: "user", authorUserId,
|
||||
authorAgentId: null, body: "A human message", createdAt: "2026-10-09T20:42:00Z",
|
||||
} as unknown as IssueChatComment));
|
||||
const userProfileMap = new Map([
|
||||
["alex", { label: "Alex Morgan", image: "/alex.png" }],
|
||||
["sam", { label: "Sam Rivera", image: "/sam.png" }],
|
||||
]);
|
||||
expect(commentsToTaskChatItems(comments, { currentUserId: "alex", userProfileMap })).toMatchObject([
|
||||
{ authorUserId: "alex", authorName: "Alex Morgan", authorAvatarUrl: "/alex.png", isCurrentUser: true },
|
||||
{ authorUserId: "sam", authorName: "Sam Rivera", authorAvatarUrl: "/sam.png", isCurrentUser: false },
|
||||
]);
|
||||
expect(commentsToTaskChatItems(comments, { currentUserId: "sam", userProfileMap })).toMatchObject([
|
||||
{ authorUserId: "alex", isCurrentUser: false },
|
||||
{ authorUserId: "sam", isCurrentUser: true },
|
||||
]);
|
||||
expect(commentsToTaskChatItems(comments, { currentUserId: "alex" })).toMatchObject([
|
||||
{ authorName: "You", isCurrentUser: true, authorAvatarUrl: null },
|
||||
{ authorName: "User", isCurrentUser: false, authorAvatarUrl: null },
|
||||
]);
|
||||
// Directory hydration changes presentation without changing canonical message IDs.
|
||||
expect(commentsToTaskChatItems(comments, { currentUserId: "alex", userProfileMap })[1]).toMatchObject({
|
||||
id: "comment-sam", authorName: "Sam Rivera", authorAvatarUrl: "/sam.png",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not infer ownership from matching names or an unresolved viewer", () => {
|
||||
const comment = { id: "other", authorType: "user", authorUserId: "other", body: "Hello" } as IssueChatComment;
|
||||
expect(commentsToTaskChatItems([comment], {
|
||||
currentUserId: "me", userLabelMap: new Map([["me", "Alex"], ["other", "Alex"]]),
|
||||
})[0]).toMatchObject({ authorName: "Alex", isCurrentUser: false });
|
||||
expect(commentsToTaskChatItems([comment])[0]).toMatchObject({ authorName: "User", isCurrentUser: false });
|
||||
});
|
||||
it("carries inbound channel attribution into the human bubble", () => {
|
||||
expect(commentsToTaskChatItems([{
|
||||
id: "photon-comment",
|
||||
@@ -165,6 +199,26 @@ describe("commentsToTaskChatItems", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves human identity for steered and ordinary messages", () => {
|
||||
const comment = {
|
||||
id: "steered-by-colleague", body: "Check the latest result.",
|
||||
authorType: "user", authorUserId: "colleague", steeredIntoRunId: "run-1",
|
||||
createdAt: "2026-09-04T14:09:33.000Z",
|
||||
} as unknown as IssueChatComment;
|
||||
const ctx = {
|
||||
currentUserId: "viewer",
|
||||
userProfileMap: new Map([["colleague", { label: "Sam Rivera", image: "/api/assets/sam/content" }]]),
|
||||
};
|
||||
for (const steeredIntoRunId of ["run-1", null]) {
|
||||
expect(commentsToTaskChatItems([{ ...comment, steeredIntoRunId }], ctx)[0]).toMatchObject({
|
||||
authorName: "Sam Rivera", authorAvatarUrl: "/api/assets/sam/content", isCurrentUser: false,
|
||||
});
|
||||
}
|
||||
expect(commentsToTaskChatItems([{ ...comment, authorUserId: "viewer" }], ctx)[0]).toMatchObject({
|
||||
isCurrentUser: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the regular comment time for a successor-run follow-up", () => {
|
||||
const createdAt = "2026-09-04T14:09:33.000Z";
|
||||
const conversationAnchorAt = "2026-09-04T14:10:35.000Z";
|
||||
|
||||
@@ -10,11 +10,14 @@
|
||||
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
|
||||
@@ -78,6 +81,12 @@ export function commentsToTaskChatItems(
|
||||
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";
|
||||
@@ -89,8 +98,7 @@ export function commentsToTaskChatItems(
|
||||
resolveUserLabel: (userId) => ctx.userLabelMap?.get(userId),
|
||||
})?.userName;
|
||||
} else if (kind === "human") {
|
||||
authorName =
|
||||
(comment.authorUserId && ctx.userLabelMap?.get(comment.authorUserId)) || undefined;
|
||||
authorName = humanAuthor!.authorName;
|
||||
}
|
||||
const queued = comment.queueState === "queued" || comment.clientStatus === "queued";
|
||||
const optimistic =
|
||||
@@ -120,6 +128,11 @@ export function commentsToTaskChatItems(
|
||||
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,
|
||||
|
||||
@@ -107,6 +107,10 @@ export interface TaskChatMessageItem {
|
||||
kind: "message";
|
||||
author: TaskChatAuthorKind;
|
||||
authorName?: string;
|
||||
/** Human identity comes from the company directory, matched by stored user ID. */
|
||||
authorUserId?: string | null;
|
||||
authorAvatarUrl?: string | null;
|
||||
isCurrentUser?: boolean;
|
||||
text: string;
|
||||
/** Runner-authored output channel. Legacy adapters leave this unset. */
|
||||
channel?: "progress" | "final" | "unknown";
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { CompanyUserProfile } from "./company-members";
|
||||
|
||||
/** Task messages load uploaded Paperclip avatars automatically, never remote URLs. */
|
||||
export function paperclipHumanAvatarUrl(value: string | null | undefined): string | null {
|
||||
return value && /^\/api\/assets\/[^/?#]+\/content$/.test(value) ? value : null;
|
||||
}
|
||||
|
||||
export function resolveIssueChatHumanAuthor(args: {
|
||||
authorName?: string | null;
|
||||
authorUserId?: string | null;
|
||||
currentUserId?: string | null;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile> | null;
|
||||
}) {
|
||||
const { authorName, authorUserId, currentUserId, userProfileMap } = args;
|
||||
const profile = authorUserId ? userProfileMap?.get(authorUserId) : null;
|
||||
const isCurrentUser = Boolean(authorUserId && currentUserId && authorUserId === currentUserId);
|
||||
return {
|
||||
isCurrentUser,
|
||||
authorName: profile?.label?.trim() || authorName?.trim() ||
|
||||
(authorUserId === "local-board" ? "Board" : isCurrentUser ? "You" : "User"),
|
||||
avatarUrl: profile?.image ?? null,
|
||||
};
|
||||
}
|
||||
@@ -123,6 +123,22 @@ function TimestampReview() {
|
||||
);
|
||||
}
|
||||
|
||||
function SteeringSenderAvatarReview() {
|
||||
const comments = [
|
||||
{ ...steeredComment, id: "my-steer", authorUserId: "viewer", body: "Use the current design tokens." },
|
||||
{ ...steeredComment, id: "colleague-steer", authorUserId: "colleague", body: "Please show who sent this steering message." },
|
||||
];
|
||||
const items = commentsToTaskChatItems(comments, {
|
||||
currentUserId: "viewer",
|
||||
userProfileMap: new Map([["colleague", { label: "Alex Rivera", image: null }]]),
|
||||
});
|
||||
return (
|
||||
<div className="flex max-w-xl flex-col gap-6 rounded-lg border border-border bg-background p-4">
|
||||
{items.map((item) => <TaskChatBubble key={item.id} item={item as TaskChatMessageItem} animateEntry={false} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReconnectingAlignmentReview() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
@@ -157,6 +173,10 @@ export const RegularTimestampAfterSteering: Story = {
|
||||
render: () => <TimestampReview />,
|
||||
};
|
||||
|
||||
export const SteeringSenderAvatar: Story = {
|
||||
render: () => <SteeringSenderAvatarReview />,
|
||||
};
|
||||
|
||||
export const ReconnectingCaretAndDotAlignment: Story = {
|
||||
render: () => <ReconnectingAlignmentReview />,
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { humanMessages, profiles } from "./fixtures";
|
||||
function ReviewGuide() {
|
||||
return <main className="mx-auto max-w-5xl space-y-6 p-6 text-foreground">
|
||||
<h1 className="text-xl font-semibold">Human identity in a mixed task conversation</h1>
|
||||
<p>Two human users and an agent can contribute to the same task. Previously every human comment used the same blue, right-aligned style without a name or avatar. Other people’s messages looked like the viewer’s own messages.</p>
|
||||
<table className="w-full text-left text-sm"><thead><tr><th className="p-2">Example message</th><th className="p-2">Author</th></tr></thead><tbody>{humanMessages.map(row => <tr key={row.id} className="border-t border-border"><td className="p-2">{row.body}</td><td className="p-2">{profiles.get(row.authorUserId)?.label}</td></tr>)}</tbody></table>
|
||||
<p>IssueDetail builds companyUserProfileMap from user-directory records and obtains currentUserId from the session. TaskChatThread received both, but only passed userLabelMap to the adapter. The normalized message discarded authorUserId, profile.image, and viewer ownership. TaskChatBubble rendered identity only for agents.</p>
|
||||
<p>The change carries those fields through the production adapter and displays the shared Identity component for other humans. Agents and other humans sit on the left on the page surface with an avatar and name. Only the viewer’s own messages remain blue and right-aligned. Unknown profiles say User. Missing or externally hosted photos show initials. Profile refreshes are included in the adapter memo dependencies.</p>
|
||||
<p>These are representative fixtures, not a copy of private task data. The mixed journey uses the production shell, adapter, thread, and composer. Swap the viewer to verify that ownership follows authorUserId. Full Conversation includes all fixture messages. Sending is local to the preview.</p>
|
||||
</main>;
|
||||
}
|
||||
export default { title: "Chat & Comments/Human identity/00 Review guide", component: ReviewGuide } satisfies Meta<typeof ReviewGuide>;
|
||||
export const DataFlow: StoryObj<typeof ReviewGuide> = {};
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, within } from "storybook/test";
|
||||
import { TaskChatBubble } from "@/components/task-chat/TaskChatBubble";
|
||||
import { commentsToTaskChatItems } from "@/components/task-chat/task-chat-adapter";
|
||||
import type { TaskChatMessageItem } from "@/components/task-chat/task-chat-model";
|
||||
import { comments, profiles, alexId, samId } from "./fixtures";
|
||||
|
||||
function Bubble({ viewer = alexId, photo = null, unresolved = false }: { viewer?: string; photo?: string | null; unresolved?: boolean }) {
|
||||
const userProfileMap = new Map(profiles);
|
||||
userProfileMap.set(samId, { label: "Sam Rivera", image: photo });
|
||||
const item = commentsToTaskChatItems([comments[5]!], { currentUserId: viewer, userProfileMap: unresolved ? undefined : userProfileMap })[0] as TaskChatMessageItem;
|
||||
return <div className="mx-auto max-w-2xl p-6"><TaskChatBubble item={item} animateEntry={false} /></div>;
|
||||
}
|
||||
const meta = {
|
||||
title: "Chat & Comments/Human identity/01 Components", component: Bubble,
|
||||
args: { viewer: alexId, photo: null },
|
||||
parameters: { layout: "padded", docs: { description: { component: "Production adapter → model → TaskChatBubble → Identity. Other humans sit on the left on the same page surface as agents with their own name/photo; no-photo profiles use the shared initials fallback. Edit photo to exercise an uploaded Paperclip asset URL. External URLs use initials. The production avatar URL is taken from the company directory, never from the current viewer or the relay bot." } } },
|
||||
} satisfies Meta<typeof Bubble>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const OtherHuman: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByTestId("task-chat-human-identity")).toHaveTextContent("Sam Rivera");
|
||||
await expect(canvas.getByTestId("task-chat-human-bubble").parentElement).toHaveClass("items-start");
|
||||
},
|
||||
};
|
||||
export const OwnMessage: Story = { args: { viewer: samId }, play: async ({ canvasElement }) => {
|
||||
await expect(within(canvasElement).queryByTestId("task-chat-human-identity")).not.toBeInTheDocument();
|
||||
} };
|
||||
export const UnknownProfile: Story = { args: { unresolved: true } };
|
||||
export const Light: Story = { globals: { theme: "light" } };
|
||||
export const Mobile: Story = { globals: { viewport: { value: "mobile", isRotated: false } } };
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, within } from "storybook/test";
|
||||
import { QueryClient, QueryClientProvider, useQueryClient } from "@tanstack/react-query";
|
||||
import { Routes, Route } from "@/lib/router";
|
||||
import { Layout } from "@/components/Layout";
|
||||
import { TaskChatThread } from "@/components/TaskChatThread";
|
||||
import { PluginLauncherProvider } from "@/plugins/launchers";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { storybookAuthSession } from "../../fixtures/paperclipData";
|
||||
import { mixedComments as initialComments, profiles, alexId, samId, issueId, agentMap } from "./fixtures";
|
||||
|
||||
function Conversation({ viewer = alexId, fullHistory = false }: { viewer?: string; fullHistory?: boolean }) {
|
||||
const [comments, setComments] = useState(() => fullHistory ? initialComments : [...initialComments.slice(0, 2), ...initialComments.slice(-2)]);
|
||||
const [client] = useState(() => {
|
||||
const cache = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } });
|
||||
cache.setQueryData([...queryKeys.issues.documents(issueId), "plan"], null);
|
||||
return cache;
|
||||
});
|
||||
return <QueryClientProvider client={client}><div className="flex h-full min-h-0 flex-col bg-background text-foreground">
|
||||
<TaskChatThread comments={comments} issueId={issueId} issueStatus="todo" currentUserId={viewer}
|
||||
userProfileMap={profiles} userLabelMap={new Map([...profiles].map(([id, p]) => [id, p.label]))}
|
||||
agentMap={agentMap} enableLiveTranscriptPolling={false}
|
||||
threadHeader={<div className="px-4 pt-4"><p className="text-xs text-muted-foreground">Task conversation</p><h1 className="text-lg font-semibold">Notification names and avatars</h1></div>}
|
||||
onAdd={async body => { setComments(rows => [...rows, { ...initialComments[0]!, id: `preview-${rows.length}`, authorUserId: viewer, body, createdAt: new Date(), updatedAt: new Date() }]); }}
|
||||
/>
|
||||
</div></QueryClientProvider>;
|
||||
}
|
||||
function Journey(props: { viewer?: string; fullHistory?: boolean }) {
|
||||
const client = useQueryClient();
|
||||
const viewer = props.viewer ?? alexId;
|
||||
useLayoutEffect(() => {
|
||||
const previous = client.getQueryData(queryKeys.auth.session);
|
||||
client.setQueryData(queryKeys.auth.session, {
|
||||
...storybookAuthSession,
|
||||
session: { ...storybookAuthSession.session, userId: viewer },
|
||||
user: { ...storybookAuthSession.user, id: viewer, name: profiles.get(viewer)?.label },
|
||||
});
|
||||
return () => { client.setQueryData(queryKeys.auth.session, previous); };
|
||||
}, [client, viewer]);
|
||||
return <PluginLauncherProvider><Routes><Route path="/:companyPrefix" element={<Layout />}>
|
||||
<Route path="issues/:issueId" element={<Conversation {...props} />} />
|
||||
</Route></Routes></PluginLauncherProvider>;
|
||||
}
|
||||
const meta = {
|
||||
title: "Chat & Comments/Human identity/02 Mixed-author journey", component: Journey,
|
||||
args: { viewer: alexId, fullHistory: false },
|
||||
parameters: { layout: "fullscreen", initialEntries: ["/PAP/issues/human-identity-example"], docs: { story: { inline: false }, description: { component: "Representative turns from two humans and an agent in the production app shell and TaskChatThread, including its composer. Photos remain null so the shared initials fallback is visible. Swap the viewer: identity follows authorUserId. The default excerpt fits all three speakers in one view; Full Conversation includes all fixture messages. Sending is local to this preview." } } },
|
||||
} satisfies Meta<typeof Journey>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const ViewingAsAlex: Story = { play: async ({ canvasElement }) => {
|
||||
const identities = await within(canvasElement).findAllByTestId("task-chat-human-identity");
|
||||
await expect(identities).toHaveLength(1);
|
||||
for (const identity of identities) await expect(identity).toHaveTextContent("Sam Rivera");
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByTestId("task-chat-agent-identity")).toHaveTextContent("Fable");
|
||||
await expect(canvas.getByTestId("task-chat-agent-bubble").parentElement).toHaveClass("items-start");
|
||||
const humanBubbles = canvas.getAllByTestId("task-chat-human-bubble");
|
||||
await expect(humanBubbles.filter(bubble => bubble.parentElement?.classList.contains("items-end"))).toHaveLength(2);
|
||||
await expect(humanBubbles.filter(bubble => bubble.parentElement?.classList.contains("items-start"))).toHaveLength(1);
|
||||
} };
|
||||
export const ViewingAsSam: Story = { args: { viewer: samId }, play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const identities = await canvas.findAllByTestId("task-chat-human-identity");
|
||||
await expect(identities).toHaveLength(2);
|
||||
for (const identity of identities) await expect(identity).toHaveTextContent("Alex Morgan");
|
||||
await expect(canvas.getByTestId("task-chat-agent-bubble").parentElement).toHaveClass("items-start");
|
||||
} };
|
||||
export const FullConversation: Story = { args: { fullHistory: true } };
|
||||
export const Light: Story = { globals: { theme: "light" } };
|
||||
export const Mobile: Story = { globals: { viewport: { value: "mobile", isRotated: false } } };
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { IssueComment } from "@paperclipai/shared";
|
||||
import { storybookAgents } from "../../fixtures/paperclipData";
|
||||
import type { CompanyUserProfile } from "@/lib/company-members";
|
||||
|
||||
// Representative fixtures. No private task content or tenant identities.
|
||||
export const alexId = "fixture-alex";
|
||||
export const samId = "fixture-sam";
|
||||
export const issueId = "fixture-human-identity-task";
|
||||
export const profiles = new Map<string, CompanyUserProfile>([
|
||||
[alexId, { label: "Alex Morgan", image: null }],
|
||||
[samId, { label: "Sam Rivera", image: null }],
|
||||
]);
|
||||
export const humanMessages = [
|
||||
{ id: "comment-1", authorUserId: samId, body: "Can you check the task title?", time: "15:33:00" },
|
||||
{ id: "comment-2", authorUserId: samId, body: "The notification still shows the previous title.", time: "15:35:14" },
|
||||
{ id: "comment-3", authorUserId: samId, body: "I updated it from the task page.", time: "15:36:10" },
|
||||
{ id: "comment-4", authorUserId: alexId, body: "Thanks, I will check the next notification.", time: "15:36:18" },
|
||||
{ id: "comment-5", authorUserId: samId, body: "Please check the sender avatar too.", time: "15:40:00" },
|
||||
{ id: "comment-6", authorUserId: samId, body: "I still see the old task title.", time: "15:42:11" },
|
||||
{ id: "comment-7", authorUserId: alexId, body: "Thanks — I am working on it.", time: "15:42:29" },
|
||||
];
|
||||
export const comments: IssueComment[] = humanMessages.map(row => ({
|
||||
id: row.id, companyId: "company-paperclip", issueId,
|
||||
authorType: "user", authorUserId: row.authorUserId, authorAgentId: null,
|
||||
body: row.body, presentation: null, metadata: null,
|
||||
createdAt: new Date(`2026-10-09T${row.time}-05:00`),
|
||||
updatedAt: new Date(`2026-10-09T${row.time}-05:00`),
|
||||
}));
|
||||
export const agentId = "fixture-chat-assistant";
|
||||
export const assistant = { ...storybookAgents[0]!, id: agentId, name: "Fable" };
|
||||
export const agentMap = new Map([[agentId, assistant]]);
|
||||
export const mixedComments: IssueComment[] = [
|
||||
{
|
||||
...comments[0]!, id: "comment-review-request", authorUserId: alexId,
|
||||
body: "Please review the task title and notification.",
|
||||
createdAt: new Date("2026-10-09T12:31:00-05:00"), updatedAt: new Date("2026-10-09T12:31:00-05:00"),
|
||||
},
|
||||
{
|
||||
...comments[0]!, id: "comment-agent-reply", authorType: "agent", authorUserId: null, authorAgentId: agentId,
|
||||
body: "I checked the notification. The sender name and avatar are visible.",
|
||||
createdAt: new Date("2026-10-09T12:37:00-05:00"), updatedAt: new Date("2026-10-09T12:37:00-05:00"),
|
||||
},
|
||||
...comments,
|
||||
];
|
||||
Reference in new issue
Block a user