From eb1bb2f8ea62d1f88b481c38027b0f261c0d958a Mon Sep 17 00:00:00 2001 From: pitoi Date: Sun, 28 Jun 2026 21:33:38 +0000 Subject: [PATCH 1/2] Generated with Hive: Display sender username and avatar for non-own canvas chat messages --- .../components/SidebarChatMessage.test.tsx | 93 ++++++++++++++++++ .../useCanvasChatAutoSave-hydrate.test.ts | 96 +++++++++++++++++++ .../conversations/[conversationId]/route.ts | 39 +++++++- .../_components/CanvasHistoryPopover.tsx | 7 ++ .../_components/OrgCanvasView.tsx | 11 +++ .../[githubLogin]/_components/SidebarChat.tsx | 15 +++ .../_components/SidebarChatMessage.tsx | 45 ++++++++- .../[githubLogin]/_state/canvasChatStore.ts | 41 ++++++++ .../_state/useCanvasChatAutoSave.ts | 13 ++- src/services/canvas-turn-persistence.ts | 2 + src/services/org-canvas-conversation.ts | 1 + 11 files changed, 359 insertions(+), 4 deletions(-) create mode 100644 src/__tests__/unit/pages/useCanvasChatAutoSave-hydrate.test.ts diff --git a/src/__tests__/unit/components/SidebarChatMessage.test.tsx b/src/__tests__/unit/components/SidebarChatMessage.test.tsx index 3de21d3af5..9d73eefccb 100644 --- a/src/__tests__/unit/components/SidebarChatMessage.test.tsx +++ b/src/__tests__/unit/components/SidebarChatMessage.test.tsx @@ -250,6 +250,99 @@ describe("SidebarChatMessage — attachment rendering", () => { }); }); +// ═══════════════════════════════════════════════════════════════════════════ +// Sender attribution tests +// ═══════════════════════════════════════════════════════════════════════════ + +describe("SidebarChatMessage — sender attribution", () => { + it("no senderId → right-aligned bubble, no avatar", () => { + const { container } = render( + , + ); + // items-end means right-aligned + const wrapper = container.querySelector(".items-end"); + expect(wrapper).toBeInTheDocument(); + // No avatar rendered + expect(container.querySelector("[data-testid=avatar]")).toBeNull(); + expect(container.querySelector("img[alt]")).toBeNull(); + }); + + it("senderId === currentUserId → right-aligned, no attribution label", () => { + const { container } = render( + , + ); + const wrapper = container.querySelector(".items-end"); + expect(wrapper).toBeInTheDocument(); + // No username label + expect(container.querySelector(".text-muted-foreground")).toBeNull(); + }); + + it("senderId !== currentUserId + senderProfile → left-aligned, avatar + username shown", () => { + const { container } = render( + , + ); + // items-start means left-aligned + const wrapper = container.querySelector(".items-start"); + expect(wrapper).toBeInTheDocument(); + // Username label present + expect(container.querySelector(".text-muted-foreground")?.textContent).toBe("bob"); + // Avatar element present (Radix Avatar renders a span[data-slot="avatar"] in jsdom) + const avatarEl = container.querySelector("[data-slot='avatar']"); + expect(avatarEl).toBeInTheDocument(); + }); + + it("senderId !== currentUserId but no senderProfile → left-aligned, no attribution header", () => { + const { container } = render( + , + ); + const wrapper = container.querySelector(".items-start"); + expect(wrapper).toBeInTheDocument(); + // No username label + expect(container.querySelector(".text-muted-foreground")).toBeNull(); + }); + + it("assistant message → left-aligned, no attribution regardless of senderId", () => { + const { container } = render( + , + ); + const wrapper = container.querySelector(".items-start"); + expect(wrapper).toBeInTheDocument(); + // No attribution header for assistant messages + expect(container.querySelector(".text-muted-foreground")).toBeNull(); + }); + + it("AvatarFallback shows first letter of username when no avatarUrl", () => { + const { container } = render( + , + ); + // The fallback span contains the first letter + const fallback = container.querySelector(".text-\\[10px\\]"); + expect(fallback?.textContent).toBe("B"); + }); +}); + // ═══════════════════════════════════════════════════════════════════════════ // Link renderer tests — deeplink chips and in-page routing // ═══════════════════════════════════════════════════════════════════════════ diff --git a/src/__tests__/unit/pages/useCanvasChatAutoSave-hydrate.test.ts b/src/__tests__/unit/pages/useCanvasChatAutoSave-hydrate.test.ts new file mode 100644 index 0000000000..53560f7994 --- /dev/null +++ b/src/__tests__/unit/pages/useCanvasChatAutoSave-hydrate.test.ts @@ -0,0 +1,96 @@ +/** + * Unit tests for `hydrateServerMessages` in `useCanvasChatAutoSave`. + * + * Verifies that the `senderId` field is threaded correctly when + * converting raw SharedConversation.messages JSON into store-shaped + * `CanvasChatMessage[]`. + */ + +import { describe, it, expect } from "vitest"; +import { hydrateServerMessages } from "@/app/org/[githubLogin]/_state/useCanvasChatAutoSave"; + +describe("hydrateServerMessages — senderId propagation", () => { + it("preserves senderId on a user message that has one", () => { + const raw = [ + { + id: "turn-1-u", + role: "user", + content: "Hello", + timestamp: new Date().toISOString(), + senderId: "user-abc", + }, + ]; + + const result = hydrateServerMessages(raw); + expect(result).toHaveLength(1); + expect(result[0].senderId).toBe("user-abc"); + }); + + it("leaves senderId undefined when the raw message has no senderId", () => { + const raw = [ + { + id: "turn-2-u", + role: "user", + content: "No sender", + timestamp: new Date().toISOString(), + }, + ]; + + const result = hydrateServerMessages(raw); + expect(result).toHaveLength(1); + expect(result[0].senderId).toBeUndefined(); + }); + + it("does not copy senderId onto assistant messages (field simply absent)", () => { + const raw = [ + { + id: "turn-1-a0", + role: "assistant", + content: "I can help with that.", + timestamp: new Date().toISOString(), + // senderId would never be on an assistant row, but defensive check + }, + ]; + + const result = hydrateServerMessages(raw); + expect(result).toHaveLength(1); + expect(result[0].senderId).toBeUndefined(); + }); + + it("handles a mixed conversation with and without senderId", () => { + const now = new Date().toISOString(); + const raw = [ + { id: "m-1", role: "user", content: "Hey", timestamp: now, senderId: "user-1" }, + { id: "m-2", role: "assistant", content: "Hello!", timestamp: now }, + { id: "m-3", role: "user", content: "No sender here", timestamp: now }, + { id: "m-4", role: "user", content: "From user 2", timestamp: now, senderId: "user-2" }, + ]; + + const result = hydrateServerMessages(raw); + expect(result).toHaveLength(4); + expect(result[0].senderId).toBe("user-1"); + expect(result[1].senderId).toBeUndefined(); + expect(result[2].senderId).toBeUndefined(); + expect(result[3].senderId).toBe("user-2"); + }); + + it("filters out rows with invalid roles", () => { + const raw = [ + { id: "m-1", role: "user", content: "Valid", timestamp: new Date().toISOString() }, + { id: "m-2", role: "system", content: "Should be filtered", timestamp: new Date().toISOString() }, + ]; + + const result = hydrateServerMessages(raw); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("m-1"); + }); + + it("coerces timestamp string to a Date instance", () => { + const ts = "2025-01-15T10:30:00.000Z"; + const raw = [{ id: "m-1", role: "user", content: "Hi", timestamp: ts, senderId: "u-1" }]; + + const result = hydrateServerMessages(raw); + expect(result[0].timestamp).toBeInstanceOf(Date); + expect(result[0].timestamp.toISOString()).toBe(ts); + }); +}); diff --git a/src/app/api/orgs/[githubLogin]/chat/conversations/[conversationId]/route.ts b/src/app/api/orgs/[githubLogin]/chat/conversations/[conversationId]/route.ts index b6bd0a73c3..48849e36fd 100644 --- a/src/app/api/orgs/[githubLogin]/chat/conversations/[conversationId]/route.ts +++ b/src/app/api/orgs/[githubLogin]/chat/conversations/[conversationId]/route.ts @@ -9,6 +9,7 @@ import { } from "@/lib/ai/conversationHelpers"; import { notifyCanvasConversationUpdated } from "@/lib/pusher"; import type { ConversationDetail, UpdateConversationRequest } from "@/types/shared-conversation"; +import type { StoredMessage } from "@/services/canvas-turn-persistence"; async function resolveOrg(githubLogin: string) { return db.sourceControlOrg.findUnique({ @@ -79,7 +80,42 @@ export async function GET( return NextResponse.json({ error: "Conversation not found" }, { status: 404 }); } - const response: ConversationDetail = { + // Batch-resolve sender display info for all unique senderIds in the + // conversation. One query covers all senders — keeps the write path + // lean (only userId is stored) while ensuring names/avatars are fresh. + const senderIds = [ + ...new Set( + (Array.isArray(conversation.messages) + ? (conversation.messages as unknown as StoredMessage[]) + : [] + ) + .map((m) => m.senderId) + .filter((id): id is string => !!id), + ), + ]; + + const senderUsers = senderIds.length + ? await db.user.findMany({ + where: { id: { in: senderIds } }, + select: { + id: true, + image: true, + githubAuth: { select: { githubUsername: true } }, + }, + }) + : []; + + const senderProfiles: Record = {}; + for (const u of senderUsers) { + senderProfiles[u.id] = { + username: u.githubAuth?.githubUsername ?? u.id, + avatarUrl: u.image ?? undefined, + }; + } + + const response: ConversationDetail & { + senderProfiles: Record; + } = { id: conversation.id, workspaceId: null as any, // org-scoped; no workspace userId: conversation.userId, @@ -100,6 +136,7 @@ export async function GET( email: conversation.user.email, } : null, + senderProfiles, }; return NextResponse.json(response); diff --git a/src/app/org/[githubLogin]/_components/CanvasHistoryPopover.tsx b/src/app/org/[githubLogin]/_components/CanvasHistoryPopover.tsx index 1601cfa04a..9162b6bafb 100644 --- a/src/app/org/[githubLogin]/_components/CanvasHistoryPopover.tsx +++ b/src/app/org/[githubLogin]/_components/CanvasHistoryPopover.tsx @@ -91,6 +91,7 @@ export function CanvasHistoryPopover({ githubLogin }: CanvasHistoryPopoverProps) rejection: m.rejection, approvalResult: m.approvalResult, source: m.source, + senderId: m.senderId as string | undefined, })); const store = useCanvasChatStore.getState(); @@ -119,6 +120,12 @@ export function CanvasHistoryPopover({ githubLogin }: CanvasHistoryPopoverProps) ); store.setServerConversationId(newId, item.id); + // Store the batch-resolved sender profiles so SidebarChat can render + // attribution on non-own user messages in this historical conversation. + if (conv.senderProfiles && typeof conv.senderProfiles === "object") { + store.setSenderProfiles(newId, conv.senderProfiles); + } + // Opening the chat clears its unread flag — optimistically locally, // and persisted so the next list load agrees. Fire-and-forget. setItems((prev) => diff --git a/src/app/org/[githubLogin]/_components/OrgCanvasView.tsx b/src/app/org/[githubLogin]/_components/OrgCanvasView.tsx index 347bb2a4f6..6c39c1b2d7 100644 --- a/src/app/org/[githubLogin]/_components/OrgCanvasView.tsx +++ b/src/app/org/[githubLogin]/_components/OrgCanvasView.tsx @@ -218,6 +218,8 @@ export function OrgCanvasView({ githubLogin, orgId, orgName }: OrgCanvasViewProp const sharedChatId = searchParams.get("chat"); const [chatInitialMessages, setChatInitialMessages] = useState(null); + const [chatInitialSenderProfiles, setChatInitialSenderProfiles] = + useState>({}); const [chatLoadComplete, setChatLoadComplete] = useState(false); // Synthetic "My Activity" intro — fetched from /api/profile/activity. @@ -315,6 +317,9 @@ export function OrgCanvasView({ githubLogin, orgId, orgName }: OrgCanvasViewProp })); setChatInitialMessages(seeded); } + if (data?.senderProfiles && typeof data.senderProfiles === "object") { + setChatInitialSenderProfiles(data.senderProfiles); + } }) .catch(() => {}) .finally(() => { @@ -894,6 +899,12 @@ export function OrgCanvasView({ githubLogin, orgId, orgName }: OrgCanvasViewProp } } + // Seed sender profiles from the preloaded ?chat= conversation so + // SidebarChat can render attribution on non-own user messages. + if (Object.keys(chatInitialSenderProfiles).length > 0) { + useCanvasChatStore.getState().setSenderProfiles(conversationId, chatInitialSenderProfiles); + } + setConversationStarted(true); // Mount-once on chat-ready. Subsequent context changes go through // the patch effect below, not by restarting the conversation. diff --git a/src/app/org/[githubLogin]/_components/SidebarChat.tsx b/src/app/org/[githubLogin]/_components/SidebarChat.tsx index f496bebac8..677ffa1844 100644 --- a/src/app/org/[githubLogin]/_components/SidebarChat.tsx +++ b/src/app/org/[githubLogin]/_components/SidebarChat.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useSession } from "next-auth/react"; import { motion } from "framer-motion"; import { FileIcon, Loader2, Mic, MicOff, Paperclip, Plus, RefreshCw, Send, Share2, X } from "lucide-react"; import { useSpeechRecognition } from "@/hooks/useSpeechRecognition"; @@ -109,6 +110,13 @@ export function SidebarChat({ githubLogin }: SidebarChatProps) { null, ); + const senderProfiles = useCanvasChatStore( + (s) => (activeId ? s.conversations[activeId]?.senderProfiles : undefined) ?? EMPTY_SENDER_PROFILES, + ); + + const { data: session } = useSession(); + const currentUserId = session?.user?.id; + const { id: workspaceId } = useWorkspace(); const { isActive } = useCanvasAgentActivity(activeId, workspaceId); @@ -470,6 +478,12 @@ export function SidebarChat({ githubLogin }: SidebarChatProps) { )} {proposals.length > 0 && ( @@ -575,6 +589,7 @@ export function SidebarChat({ githubLogin }: SidebarChatProps) { */ const EMPTY_MESSAGES: CanvasChatMessage[] = []; const EMPTY_TOOL_CALLS: ToolCall[] = []; +const EMPTY_SENDER_PROFILES: Record = {}; /** * Dispatch point for rich agent artifacts. Selects `state.artifacts` diff --git a/src/app/org/[githubLogin]/_components/SidebarChatMessage.tsx b/src/app/org/[githubLogin]/_components/SidebarChatMessage.tsx index 5151738e7a..1e5032dcb1 100644 --- a/src/app/org/[githubLogin]/_components/SidebarChatMessage.tsx +++ b/src/app/org/[githubLogin]/_components/SidebarChatMessage.tsx @@ -8,6 +8,11 @@ import { MarkdownRenderer } from "@/components/MarkdownRenderer"; import { FileIcon } from "lucide-react"; import type { CanvasAttachment } from "../_state/canvasChatStore"; import { CanvasDeeplinkChip } from "./CanvasDeeplinkChip"; +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@/components/ui/avatar"; /** * Sidebar-flavored chat bubble. Forked from @@ -48,15 +53,36 @@ interface SidebarChatMessageProps { content: string; timestamp: Date; attachments?: CanvasAttachment[]; + senderId?: string; }; isStreaming?: boolean; + /** The currently logged-in user's id. Used to distinguish own vs others' messages. */ + currentUserId?: string; + /** Resolved display info for the sender (looked up by senderId in SidebarChat). */ + senderProfile?: { username: string; avatarUrl?: string }; } export function SidebarChatMessage({ message, isStreaming = false, + currentUserId, + senderProfile, }: SidebarChatMessageProps) { - const isUser = message.role === "user"; + /** + * Own message: no senderId (optimistic local message), or senderId matches + * current user → right-aligned, no attribution label. + * Other user's message: senderId set and different from current user + * → left-aligned with avatar + username rendered above the bubble. + */ + const isMine = + message.role === "user" && + (!message.senderId || message.senderId === currentUserId); + const isOtherUser = + message.role === "user" && + !!message.senderId && + message.senderId !== currentUserId; + // For alignment/styling purposes "isUser" means "treat as own user bubble" + const isUser = isMine; const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); @@ -168,6 +194,23 @@ export function SidebarChatMessage({ transition={{ duration: 0.2 }} className={`flex w-full flex-col gap-1.5 ${isUser ? "items-end" : "items-start"}`} > + {/* Sender attribution — shown above the bubble for other users' messages */} + {isOtherUser && senderProfile && ( +
+ + + + {senderProfile.username[0]?.toUpperCase()} + + + + {senderProfile.username} + +
+ )} {/* Text bubble */} {hasContent && (
diff --git a/src/app/org/[githubLogin]/_state/canvasChatStore.ts b/src/app/org/[githubLogin]/_state/canvasChatStore.ts index 56207ed0e1..098f1840b0 100644 --- a/src/app/org/[githubLogin]/_state/canvasChatStore.ts +++ b/src/app/org/[githubLogin]/_state/canvasChatStore.ts @@ -229,6 +229,13 @@ export interface CanvasChatMessage { fireAt: string; // ISO timestamp status: "PENDING" | "FIRED" | "CANCELLED" | "FAILED"; }; + /** + * userId of the human who sent this user message. Absent on assistant rows + * and on optimistic local messages (treated as "own" by the renderer). + * Stamped server-side in `persistCanvasUserMessage`; never accepted from + * the client (IDOR-safe). + */ + senderId?: string; } /** @@ -282,6 +289,14 @@ export interface CanvasConversation { */ forkedFromShareId: string | null; messages: CanvasChatMessage[]; + /** + * Batch-resolved display info for all human senders in this conversation. + * Keyed by userId (`senderId` on `CanvasChatMessage`). Populated when the + * conversation is loaded from the server (history popover, `?chat=` preload, + * live-sync). Used by `SidebarChat` to render avatar + username above + * other-user bubbles. + */ + senderProfiles: Record; isLoading: boolean; /** * `true` for the full lifetime of a streaming response — from the @@ -479,6 +494,15 @@ interface CanvasChatState { conversationId: string, serverId: string, ) => void; + /** + * Store the batch-resolved sender profiles for a conversation. Called after + * loading a conversation from the server so `SidebarChat` can render + * attribution on non-own user messages. + */ + setSenderProfiles: ( + conversationId: string, + profiles: Record, + ) => void; // ─── Message actions ───────────────────────────────────────────────── appendUserMessage: ( @@ -598,6 +622,7 @@ export const useCanvasChatStore = create()( serverConversationId: serverConversationId ?? null, forkedFromShareId: forkedFromShareId ?? null, messages: seedMessages ?? [], + senderProfiles: {}, isLoading: false, isStreaming: false, activeToolCalls: [], @@ -713,6 +738,22 @@ export const useCanvasChatStore = create()( "setServerConversationId", ), + setSenderProfiles: (conversationId, profiles) => + set( + (s) => { + const conv = s.conversations[conversationId]; + if (!conv) return s; + return { + conversations: { + ...s.conversations, + [conversationId]: { ...conv, senderProfiles: profiles }, + }, + }; + }, + false, + "setSenderProfiles", + ), + appendUserMessage: (conversationId, message) => set( (s) => { diff --git a/src/app/org/[githubLogin]/_state/useCanvasChatAutoSave.ts b/src/app/org/[githubLogin]/_state/useCanvasChatAutoSave.ts index 29b9a4a260..25688b93c1 100644 --- a/src/app/org/[githubLogin]/_state/useCanvasChatAutoSave.ts +++ b/src/app/org/[githubLogin]/_state/useCanvasChatAutoSave.ts @@ -68,7 +68,8 @@ interface AutoSaveArgs { * `CanvasChatMessage[]` (timestamps → `Date`). Mirrors the hydration in * `CanvasHistoryPopover.handleItemClick`. */ -function hydrateServerMessages(raw: unknown[]): CanvasChatMessage[] { +/** @internal exported for unit-testing only */ +export function hydrateServerMessages(raw: unknown[]): CanvasChatMessage[] { return raw .filter( (m): m is Record => @@ -91,6 +92,7 @@ function hydrateServerMessages(raw: unknown[]): CanvasChatMessage[] { approvalResult: m.approvalResult as CanvasChatMessage["approvalResult"], deferredCheck: m.deferredCheck as CanvasChatMessage["deferredCheck"], source: m.source as CanvasChatMessage["source"], + senderId: m.senderId as string | undefined, })); } @@ -197,8 +199,15 @@ export function useCanvasChatAutoSave({ githubLogin }: AutoSaveArgs) { // rows, so the no-message-loss invariant holds. const reconciled = reconcilePlannerSources(merged.messages, mapped); - if (merged.added.length === 0 && !reconciled.changed) return; // in sync const store = useCanvasChatStore.getState(); + + // Always refresh sender profiles from the latest server response so + // avatars/usernames are fresh even when no new messages arrived. + if (body.senderProfiles && typeof body.senderProfiles === "object") { + store.setSenderProfiles(conversationId, body.senderProfiles); + } + + if (merged.added.length === 0 && !reconciled.changed) return; // in sync store.setConversationMessages(conversationId, reconciled.messages); // The user is looking at this chat (only the active conv is diff --git a/src/services/canvas-turn-persistence.ts b/src/services/canvas-turn-persistence.ts index 3042822368..f1d79f1c37 100644 --- a/src/services/canvas-turn-persistence.ts +++ b/src/services/canvas-turn-persistence.ts @@ -97,6 +97,8 @@ export interface StoredMessage { toolCalls?: StoredToolCall[]; attachments?: StoredAttachment[]; source?: { kind: string; featureId?: string; plannerMessageId?: string }; + /** userId of the human who sent this message; absent on assistant rows. */ + senderId?: string; // Approval-flow metadata round-tripping through the JSON. Untyped here // (the canonical types live in `src/lib/proposals/types.ts`); the // render-side store re-narrows them. diff --git a/src/services/org-canvas-conversation.ts b/src/services/org-canvas-conversation.ts index d90cc2965e..2763a64b61 100644 --- a/src/services/org-canvas-conversation.ts +++ b/src/services/org-canvas-conversation.ts @@ -110,6 +110,7 @@ export async function persistCanvasUserMessage(args: { role: "user", content, timestamp: new Date().toISOString(), + senderId: userId, ...(attachments && attachments.length > 0 ? { attachments } : {}), }; From d3ec4b719c3bddaa491bfbdb83371ef1370f3469 Mon Sep 17 00:00:00 2001 From: pitoi Date: Sun, 28 Jun 2026 21:42:53 +0000 Subject: [PATCH 2/2] Generated with Hive: Mock next-auth useSession in SidebarChat tests --- src/__tests__/unit/components/SidebarChat.test.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/__tests__/unit/components/SidebarChat.test.tsx b/src/__tests__/unit/components/SidebarChat.test.tsx index b6b7f742a5..e755e057a3 100644 --- a/src/__tests__/unit/components/SidebarChat.test.tsx +++ b/src/__tests__/unit/components/SidebarChat.test.tsx @@ -18,6 +18,11 @@ beforeAll(() => { window.HTMLElement.prototype.scrollIntoView = vi.fn(); }); +// ── next-auth mock ──────────────────────────────────────────────────────────── +vi.mock("next-auth/react", () => ({ + useSession: () => ({ data: { user: { id: "user-test" } } }), +})); + // ── Activity indicator hook mock ────────────────────────────────────────────── let mockIsActive = false; vi.mock("@/hooks/useCanvasAgentActivity", () => ({