diff --git a/DESIGN.md b/DESIGN.md index 3692c8f242..9a23d4446b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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. diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 9c3d79a07c..08986d2ef4 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -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 diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 22d611d962..517045d0e1 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -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 | 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; diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index f0873c93ac..c87ce08478 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -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, diff --git a/ui/src/components/task-chat/TaskChatBubble.test.tsx b/ui/src/components/task-chat/TaskChatBubble.test.tsx index 33a4f353ef..bdd7a16558 100644 --- a/ui/src/components/task-chat/TaskChatBubble.test.tsx +++ b/ui/src/components/task-chat/TaskChatBubble.test.tsx @@ -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( + , + )); + 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( + , + )); + 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. diff --git a/ui/src/components/task-chat/TaskChatBubble.tsx b/ui/src/components/task-chat/TaskChatBubble.tsx index 7733c04e66..3050a00b6e 100644 --- a/ui/src/components/task-chat/TaskChatBubble.tsx +++ b/ui/src/components/task-chat/TaskChatBubble.tsx @@ -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 ?
{beforeTurn}
: null} + {isHuman && !item.isCurrentUser && item.authorName ? ( + + + + ) : null} {!isHuman && item.authorName && !hideAgentIdentity ? ( { + 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"; diff --git a/ui/src/components/task-chat/task-chat-adapter.ts b/ui/src/components/task-chat/task-chat-adapter.ts index d8434c4ad3..af97618fb5 100644 --- a/ui/src/components/task-chat/task-chat-adapter.ts +++ b/ui/src/components/task-chat/task-chat-adapter.ts @@ -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; userLabelMap?: ReadonlyMap | null; + userProfileMap?: ReadonlyMap | 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, diff --git a/ui/src/components/task-chat/task-chat-model.ts b/ui/src/components/task-chat/task-chat-model.ts index e2919721e1..a62d54a367 100644 --- a/ui/src/components/task-chat/task-chat-model.ts +++ b/ui/src/components/task-chat/task-chat-model.ts @@ -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"; diff --git a/ui/src/lib/issue-chat-human-author.ts b/ui/src/lib/issue-chat-human-author.ts new file mode 100644 index 0000000000..6077844bb2 --- /dev/null +++ b/ui/src/lib/issue-chat-human-author.ts @@ -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 | 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, + }; +} diff --git a/ui/storybook/stories/task-chat-chain-of-thought.stories.tsx b/ui/storybook/stories/task-chat-chain-of-thought.stories.tsx index 3a728e19f9..4719d135fc 100644 --- a/ui/storybook/stories/task-chat-chain-of-thought.stories.tsx +++ b/ui/storybook/stories/task-chat-chain-of-thought.stories.tsx @@ -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 ( +
+ {items.map((item) => )} +
+ ); +} + function ReconnectingAlignmentReview() { const [open, setOpen] = useState(false); return ( @@ -157,6 +173,10 @@ export const RegularTimestampAfterSteering: Story = { render: () => , }; +export const SteeringSenderAvatar: Story = { + render: () => , +}; + export const ReconnectingCaretAndDotAlignment: Story = { render: () => , }; diff --git a/ui/storybook/stories/thread-human-identity/00-evidence.stories.tsx b/ui/storybook/stories/thread-human-identity/00-evidence.stories.tsx new file mode 100644 index 0000000000..13089428ae --- /dev/null +++ b/ui/storybook/stories/thread-human-identity/00-evidence.stories.tsx @@ -0,0 +1,14 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { humanMessages, profiles } from "./fixtures"; +function ReviewGuide() { + return
+

Human identity in a mixed task conversation

+

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.

+ {humanMessages.map(row => )}
Example messageAuthor
{row.body}{profiles.get(row.authorUserId)?.label}
+

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.

+

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.

+

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.

+
; +} +export default { title: "Chat & Comments/Human identity/00 Review guide", component: ReviewGuide } satisfies Meta; +export const DataFlow: StoryObj = {}; diff --git a/ui/storybook/stories/thread-human-identity/01-components.stories.tsx b/ui/storybook/stories/thread-human-identity/01-components.stories.tsx new file mode 100644 index 0000000000..db5af49ab9 --- /dev/null +++ b/ui/storybook/stories/thread-human-identity/01-components.stories.tsx @@ -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
; +} +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; +export default meta; +type Story = StoryObj; +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 } } }; diff --git a/ui/storybook/stories/thread-human-identity/02-task-journey.stories.tsx b/ui/storybook/stories/thread-human-identity/02-task-journey.stories.tsx new file mode 100644 index 0000000000..ae6dd2699b --- /dev/null +++ b/ui/storybook/stories/thread-human-identity/02-task-journey.stories.tsx @@ -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
+ [id, p.label]))} + agentMap={agentMap} enableLiveTranscriptPolling={false} + threadHeader={

Task conversation

Notification names and avatars

} + onAdd={async body => { setComments(rows => [...rows, { ...initialComments[0]!, id: `preview-${rows.length}`, authorUserId: viewer, body, createdAt: new Date(), updatedAt: new Date() }]); }} + /> +
; +} +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 }> + } /> + ; +} +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; +export default meta; +type Story = StoryObj; +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 } } }; diff --git a/ui/storybook/stories/thread-human-identity/fixtures.ts b/ui/storybook/stories/thread-human-identity/fixtures.ts new file mode 100644 index 0000000000..9a9a9b1857 --- /dev/null +++ b/ui/storybook/stories/thread-human-identity/fixtures.ts @@ -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([ + [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, +];