= {};
/**
* 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 af5728b30f..126d2c9dd6 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 } : {}),
};