From 95fcd1a4101a85ce0e5d431018a9edb68a02cacc Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 29 Apr 2026 15:36:21 -0700 Subject: [PATCH 1/4] feat(web): add /api/avatar resolver and use it in UserAvatar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new `/api/avatar?email=` endpoint that resolves an email to either the matching Sourcebot user's profile image (302 redirect with short cache) or a deterministic minidenticon SVG (long-lived immutable cache). Falls back to the identicon on auth or lookup failure so avatars never break for anonymous viewers. Updates `UserAvatar` to compute its src from this resolver instead of generating an inline minidenticon data URI client-side. Every existing call site automatically picks up real profile pictures where the email matches a Sourcebot user — no consumer changes needed. Also swaps Radix's `` for a raw ``. AvatarImage delays painting until its internal `new Image().onload` fires (async even from HTTP cache), which manifests as a flicker every time the avatar mounts under aggressive churn (e.g., in a CodeMirror gutter). The browser paints cached `` synchronously. Adds a native `title` tooltip to the displayed avatars in `AuthorsAvatarGroup` and removes the unused `MessageAvatar` wrapper. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../(app)/browse/components/commitParts.tsx | 1 + .../web/src/app/api/(server)/avatar/route.ts | 51 +++++++++++++++++++ packages/web/src/components/userAvatar.tsx | 24 +++++++-- .../components/chatThread/messageAvatar.tsx | 39 -------------- 4 files changed, 71 insertions(+), 44 deletions(-) create mode 100644 packages/web/src/app/api/(server)/avatar/route.ts delete mode 100644 packages/web/src/features/chat/components/chatThread/messageAvatar.tsx diff --git a/packages/web/src/app/(app)/browse/components/commitParts.tsx b/packages/web/src/app/(app)/browse/components/commitParts.tsx index bc7033acb..ce66bfffc 100644 --- a/packages/web/src/app/(app)/browse/components/commitParts.tsx +++ b/packages/web/src/app/(app)/browse/components/commitParts.tsx @@ -24,6 +24,7 @@ export const AuthorsAvatarGroup = ({ authors, className }: AuthorsAvatarGroupPro ))} diff --git a/packages/web/src/app/api/(server)/avatar/route.ts b/packages/web/src/app/api/(server)/avatar/route.ts new file mode 100644 index 000000000..2b209c2ab --- /dev/null +++ b/packages/web/src/app/api/(server)/avatar/route.ts @@ -0,0 +1,51 @@ +'use server'; + +import { minidenticon } from 'minidenticons'; +import { NextRequest } from 'next/server'; +import { apiHandler } from '@/lib/apiHandler'; +import { isServiceError } from '@/lib/utils'; +import { withOptionalAuth } from '@/middleware/withAuth'; + +// Resolves an email to an avatar image. If the email belongs to a Sourcebot +// user in the requester's org and that user has a profile image set, the +// request is redirected to that URL. Otherwise a minidenticon SVG is returned. +// +// We never 4xx on this endpoint — even if the requester is unauthenticated or +// the user isn't found, we serve the identicon so the avatar visually renders. +export const GET = apiHandler(async (request: NextRequest) => { + const email = request.nextUrl.searchParams.get('email'); + if (!email) { + return new Response('Missing email parameter', { status: 400 }); + } + + const lookup = await withOptionalAuth(async ({ org, prisma }) => { + return prisma.user.findFirst({ + where: { + email, + orgs: { some: { orgId: org.id } }, + }, + select: { image: true }, + }); + }); + + if (!isServiceError(lookup) && lookup?.image) { + return new Response(null, { + status: 302, + headers: { + 'Location': lookup.image, + 'Cache-Control': 'public, max-age=300', + }, + }); + } + + // Fallback: identicons are deterministic from the email so they can be + // cached aggressively. + const svg = minidenticon(email, 50, 50); + return new Response(svg, { + status: 200, + headers: { + 'Content-Type': 'image/svg+xml', + 'Cache-Control': 'public, max-age=31536000, immutable', + }, + }); +}, { track: false }); diff --git a/packages/web/src/components/userAvatar.tsx b/packages/web/src/components/userAvatar.tsx index 77ecda8b3..878935df7 100644 --- a/packages/web/src/components/userAvatar.tsx +++ b/packages/web/src/components/userAvatar.tsx @@ -1,8 +1,7 @@ 'use client'; -import { minidenticon } from 'minidenticons'; import { ComponentPropsWithoutRef, forwardRef, useMemo } from 'react'; -import { Avatar, AvatarImage } from '@/components/ui/avatar'; +import { Avatar } from '@/components/ui/avatar'; import { cn } from '@/lib/utils'; interface UserAvatarProps extends ComponentPropsWithoutRef { @@ -12,16 +11,31 @@ interface UserAvatarProps extends ComponentPropsWithoutRef { export const UserAvatar = forwardRef( ({ email, imageUrl, className, ...rest }, ref) => { - const identiconUri = useMemo(() => { + const resolverUri = useMemo(() => { if (!email) { return undefined; } - return 'data:image/svg+xml;utf8,' + encodeURIComponent(minidenticon(email, 50, 50)); + return `/api/avatar?email=${encodeURIComponent(email)}`; }, [email]); + const src = imageUrl ?? resolverUri; + return ( - + {/* + We render a raw instead of Radix's . AvatarImage + delays painting until its internal `new Image().onload` fires — + which is async even when the URL is in HTTP cache — and that + one-frame gap manifests as a flicker every time a marker mounts + (e.g., on scroll). The browser paints cached synchronously. + */} + {src && ( + + )} ); } diff --git a/packages/web/src/features/chat/components/chatThread/messageAvatar.tsx b/packages/web/src/features/chat/components/chatThread/messageAvatar.tsx deleted file mode 100644 index 6261a631a..000000000 --- a/packages/web/src/features/chat/components/chatThread/messageAvatar.tsx +++ /dev/null @@ -1,39 +0,0 @@ -'use client'; - -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import { cn } from '@/lib/utils'; -import { useThemeNormalized } from '@/hooks/useThemeNormalized'; -import { useSession } from 'next-auth/react'; -import { SBChatMessage } from '../../types'; -import { UserAvatar } from '@/components/userAvatar'; - -interface MessageAvatarProps { - role: SBChatMessage['role']; - className?: string; -} - -export const MessageAvatar = ({ role, className }: MessageAvatarProps) => { - // @todo: this should be based on the user who initiated the conversation. - const { data: session } = useSession(); - const { theme } = useThemeNormalized(); - - if (role === "user") { - return ( - - ); - } - - return ( - - AI - - - ) -} - From 614800ab704d3346bf7b91659e0190ebd669a94a Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 29 Apr 2026 15:37:41 -0700 Subject: [PATCH 2/4] chore(web): add CHANGELOG entry for /api/avatar resolver Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2913a6c5..fd573ae92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added collapsible file diffs in the commit diff panel. [#1157](https://github.com/sourcebot-dev/sourcebot/pull/1157) - Added `/api/blame` to the public API to fetch per-line blame information for a file at a given revision. [#1158](https://github.com/sourcebot-dev/sourcebot/pull/1158) +### Changed +- `UserAvatar` now resolves profile pictures via a new `/api/avatar` endpoint, automatically displaying a user's profile image when their email matches a Sourcebot user. Falls back to a minidenticon otherwise. [#1159](https://github.com/sourcebot-dev/sourcebot/pull/1159) + ### Fixed - Bumped `postcss` to `8.5.10`. [#1155](https://github.com/sourcebot-dev/sourcebot/pull/1155) From 9cd30800fb34bd72e6e755c2f90a6eed09f0e6e7 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 29 Apr 2026 15:45:02 -0700 Subject: [PATCH 3/4] chore(web): validate /api/avatar query params with Zod Replaces the manual `searchParams.get('email')` + plain-text 400 response with the Zod safeParse + queryParamsSchemaValidationError pattern used elsewhere in the API. Errors now return structured JSON consistent with the rest of the public API surface. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 +- .../web/src/app/api/(server)/avatar/route.ts | 23 ++++++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd573ae92..5e5d0fc48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `/api/blame` to the public API to fetch per-line blame information for a file at a given revision. [#1158](https://github.com/sourcebot-dev/sourcebot/pull/1158) ### Changed -- `UserAvatar` now resolves profile pictures via a new `/api/avatar` endpoint, automatically displaying a user's profile image when their email matches a Sourcebot user. Falls back to a minidenticon otherwise. [#1159](https://github.com/sourcebot-dev/sourcebot/pull/1159) +- Added `/api/avatar` to resolve user profile pictures. [#1159](https://github.com/sourcebot-dev/sourcebot/pull/1159) ### Fixed - Bumped `postcss` to `8.5.10`. [#1155](https://github.com/sourcebot-dev/sourcebot/pull/1155) diff --git a/packages/web/src/app/api/(server)/avatar/route.ts b/packages/web/src/app/api/(server)/avatar/route.ts index 2b209c2ab..31e0f6cc1 100644 --- a/packages/web/src/app/api/(server)/avatar/route.ts +++ b/packages/web/src/app/api/(server)/avatar/route.ts @@ -2,10 +2,16 @@ import { minidenticon } from 'minidenticons'; import { NextRequest } from 'next/server'; +import { z } from 'zod'; import { apiHandler } from '@/lib/apiHandler'; +import { queryParamsSchemaValidationError, serviceErrorResponse } from '@/lib/serviceError'; import { isServiceError } from '@/lib/utils'; import { withOptionalAuth } from '@/middleware/withAuth'; +const queryParamsSchema = z.object({ + email: z.string().min(1), +}); + // Resolves an email to an avatar image. If the email belongs to a Sourcebot // user in the requester's org and that user has a profile image set, the // request is redirected to that URL. Otherwise a minidenticon SVG is returned. @@ -13,11 +19,22 @@ import { withOptionalAuth } from '@/middleware/withAuth'; // We never 4xx on this endpoint — even if the requester is unauthenticated or // the user isn't found, we serve the identicon so the avatar visually renders. export const GET = apiHandler(async (request: NextRequest) => { - const email = request.nextUrl.searchParams.get('email'); - if (!email) { - return new Response('Missing email parameter', { status: 400 }); + const rawParams = Object.fromEntries( + Object.keys(queryParamsSchema.shape).map(key => [ + key, + request.nextUrl.searchParams.get(key) ?? undefined, + ]) + ); + const parsed = queryParamsSchema.safeParse(rawParams); + + if (!parsed.success) { + return serviceErrorResponse( + queryParamsSchemaValidationError(parsed.error) + ); } + const { email } = parsed.data; + const lookup = await withOptionalAuth(async ({ org, prisma }) => { return prisma.user.findFirst({ where: { From 4c2e9ed5ef4ea6ae22ea2f2585381ca154652e18 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 29 Apr 2026 15:50:02 -0700 Subject: [PATCH 4/4] feedback --- packages/web/src/app/api/(server)/avatar/route.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/web/src/app/api/(server)/avatar/route.ts b/packages/web/src/app/api/(server)/avatar/route.ts index 31e0f6cc1..5cb8e0edb 100644 --- a/packages/web/src/app/api/(server)/avatar/route.ts +++ b/packages/web/src/app/api/(server)/avatar/route.ts @@ -55,14 +55,15 @@ export const GET = apiHandler(async (request: NextRequest) => { }); } - // Fallback: identicons are deterministic from the email so they can be - // cached aggressively. + // Fallback: identicon. Cache lifetime matches the redirect path so the + // response naturally revalidates as users sign up, set profile pictures, + // or transient lookup errors recover. const svg = minidenticon(email, 50, 50); return new Response(svg, { status: 200, headers: { 'Content-Type': 'image/svg+xml', - 'Cache-Control': 'public, max-age=31536000, immutable', + 'Cache-Control': 'public, max-age=300', }, }); }, { track: false });