Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
eb1bb2f
Generated with Hive: Display sender username and avatar for non-own c…
pitoi Jun 28, 2026
d3ec4b7
Generated with Hive: Mock next-auth useSession in SidebarChat tests
pitoi Jun 28, 2026
a0b4cd1
Merge branch 'master' into feature/CMQY7BDNX-display-sender-username-…
tomsmith8 Jun 28, 2026
f396bc6
Merge branch 'master' into feature/CMQY7BDNX-display-sender-username-…
tomsmith8 Jun 28, 2026
5cb2e6e
Merge branch 'master' into feature/CMQY7BDNX-display-sender-username-…
tomsmith8 Jun 29, 2026
d3923f2
Merge branch 'master' into feature/CMQY7BDNX-display-sender-username-…
tomsmith8 Jun 29, 2026
5103f33
Merge branch 'master' into feature/CMQY7BDNX-display-sender-username-…
tomsmith8 Jun 29, 2026
24088ee
Merge branch 'master' into feature/CMQY7BDNX-display-sender-username-…
tomsmith8 Jun 29, 2026
9228bad
Merge branch 'master' into feature/CMQY7BDNX-display-sender-username-…
tomsmith8 Jun 29, 2026
a27d59a
Merge branch 'master' into feature/CMQY7BDNX-display-sender-username-…
tomsmith8 Jun 29, 2026
c624ae1
Merge branch 'master' into feature/CMQY7BDNX-display-sender-username-…
tomsmith8 Jun 29, 2026
b03affd
Merge branch 'master' into feature/CMQY7BDNX-display-sender-username-…
tomsmith8 Jun 29, 2026
5c0eb5d
Merge branch 'master' into feature/CMQY7BDNX-display-sender-username-…
tomsmith8 Jun 29, 2026
b0eeaf2
Merge branch 'master' into feature/CMQY7BDNX-display-sender-username-…
tomsmith8 Jun 29, 2026
18507ee
Merge branch 'master' into feature/CMQY7BDNX-display-sender-username-…
tomsmith8 Jun 29, 2026
d59261d
Merge branch 'master' into feature/CMQY7BDNX-display-sender-username-…
tomsmith8 Jun 29, 2026
f27f48c
Merge branch 'master' into feature/CMQY7BDNX-display-sender-username-…
tomsmith8 Jun 29, 2026
1041e61
Merge branch 'master' into feature/CMQY7BDNX-display-sender-username-…
tomsmith8 Jun 29, 2026
f0436e2
Merge branch 'master' into feature/CMQY7BDNX-display-sender-username-…
tomsmith8 Jun 29, 2026
82d50ed
Merge branch 'master' into feature/CMQY7BDNX-display-sender-username-…
tomsmith8 Jun 30, 2026
f21e241
Merge branch 'master' into feature/CMQY7BDNX-display-sender-username-…
tomsmith8 Jun 30, 2026
cf828cd
Merge branch 'master' into feature/CMQY7BDNX-display-sender-username-…
tomsmith8 Jun 30, 2026
b86d422
Merge branch 'master' into feature/CMQY7BDNX-display-sender-username-…
tomsmith8 Jun 30, 2026
7c71b26
Merge branch 'master' into feature/CMQY7BDNX-display-sender-username-…
tomsmith8 Jun 30, 2026
66b49a7
Merge branch 'master' into feature/CMQY7BDNX-display-sender-username-…
tomsmith8 Jun 30, 2026
e3d5218
Merge branch 'master' into feature/CMQY7BDNX-display-sender-username-…
tomsmith8 Jun 30, 2026
2919be6
Merge branch 'master' into feature/CMQY7BDNX-display-sender-username-…
tomsmith8 Jun 30, 2026
de0361a
Merge branch 'master' into feature/CMQY7BDNX-display-sender-username-…
tomsmith8 Jun 30, 2026
a6921d4
Merge branch 'master' into feature/CMQY7BDNX-display-sender-username-…
tomsmith8 Jun 30, 2026
ce8b839
Merge branch 'master' into feature/CMQY7BDNX-display-sender-username-…
tomsmith8 Jul 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/__tests__/unit/components/SidebarChat.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down
93 changes: 93 additions & 0 deletions src/__tests__/unit/components/SidebarChatMessage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<SidebarChatMessage
message={{ ...baseMessage }}
currentUserId="user-A"
/>,
);
// 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(
<SidebarChatMessage
message={{ ...baseMessage, senderId: "user-A" }}
currentUserId="user-A"
senderProfile={{ username: "alice", avatarUrl: "https://example.com/alice.png" }}
/>,
);
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(
<SidebarChatMessage
message={{ ...baseMessage, senderId: "user-B" }}
currentUserId="user-A"
senderProfile={{ username: "bob", avatarUrl: "https://example.com/bob.png" }}
/>,
);
// 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(
<SidebarChatMessage
message={{ ...baseMessage, senderId: "user-B" }}
currentUserId="user-A"
/>,
);
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(
<SidebarChatMessage
message={{ ...baseMessage, role: "assistant", senderId: "user-B" }}
currentUserId="user-A"
senderProfile={{ username: "bot", avatarUrl: undefined }}
/>,
);
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(
<SidebarChatMessage
message={{ ...baseMessage, senderId: "user-B" }}
currentUserId="user-A"
senderProfile={{ username: "bob" }}
/>,
);
// 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
// ═══════════════════════════════════════════════════════════════════════════
Expand Down
96 changes: 96 additions & 0 deletions src/__tests__/unit/pages/useCanvasChatAutoSave-hydrate.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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<string, { username: string; avatarUrl?: string }> = {};
for (const u of senderUsers) {
senderProfiles[u.id] = {
username: u.githubAuth?.githubUsername ?? u.id,
avatarUrl: u.image ?? undefined,
};
}

const response: ConversationDetail & {
senderProfiles: Record<string, { username: string; avatarUrl?: string }>;
} = {
id: conversation.id,
workspaceId: null as any, // org-scoped; no workspace
userId: conversation.userId,
Expand All @@ -100,6 +136,7 @@ export async function GET(
email: conversation.user.email,
}
: null,
senderProfiles,
};

return NextResponse.json(response);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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) =>
Expand Down
11 changes: 11 additions & 0 deletions src/app/org/[githubLogin]/_components/OrgCanvasView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,8 @@ export function OrgCanvasView({ githubLogin, orgId, orgName }: OrgCanvasViewProp
const sharedChatId = searchParams.get("chat");
const [chatInitialMessages, setChatInitialMessages] =
useState<CanvasChatMessage[] | null>(null);
const [chatInitialSenderProfiles, setChatInitialSenderProfiles] =
useState<Record<string, { username: string; avatarUrl?: string }>>({});
const [chatLoadComplete, setChatLoadComplete] = useState(false);

// Synthetic "My Activity" intro — fetched from /api/profile/activity.
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions src/app/org/[githubLogin]/_components/SidebarChat.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -111,6 +112,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);

Expand Down Expand Up @@ -472,6 +480,12 @@ export function SidebarChat({ githubLogin }: SidebarChatProps) {
<SidebarChatMessage
message={message}
isStreaming={isMessageStreaming}
currentUserId={currentUserId}
senderProfile={
message.senderId
? senderProfiles[message.senderId]
: undefined
}
/>
)}
{proposals.length > 0 && (
Expand Down Expand Up @@ -577,6 +591,7 @@ export function SidebarChat({ githubLogin }: SidebarChatProps) {
*/
const EMPTY_MESSAGES: CanvasChatMessage[] = [];
const EMPTY_TOOL_CALLS: ToolCall[] = [];
const EMPTY_SENDER_PROFILES: Record<string, { username: string; avatarUrl?: string }> = {};

/**
* Dispatch point for rich agent artifacts. Selects `state.artifacts`
Expand Down
Loading
Loading