diff --git a/apps/web/public/provider-logos/anthropic.svg b/apps/web/public/provider-logos/anthropic.svg
new file mode 100644
index 00000000..5f09f7f7
--- /dev/null
+++ b/apps/web/public/provider-logos/anthropic.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/public/provider-logos/azure.svg b/apps/web/public/provider-logos/azure.svg
new file mode 100644
index 00000000..09c243df
--- /dev/null
+++ b/apps/web/public/provider-logos/azure.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/public/provider-logos/bedrock.svg b/apps/web/public/provider-logos/bedrock.svg
new file mode 100644
index 00000000..3e9ee146
--- /dev/null
+++ b/apps/web/public/provider-logos/bedrock.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/public/provider-logos/claude-code.svg b/apps/web/public/provider-logos/claude-code.svg
new file mode 100644
index 00000000..40cfedbd
--- /dev/null
+++ b/apps/web/public/provider-logos/claude-code.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/public/provider-logos/codex.svg b/apps/web/public/provider-logos/codex.svg
new file mode 100644
index 00000000..0a384aec
--- /dev/null
+++ b/apps/web/public/provider-logos/codex.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/public/provider-logos/cohere.svg b/apps/web/public/provider-logos/cohere.svg
new file mode 100644
index 00000000..bc3ff83f
--- /dev/null
+++ b/apps/web/public/provider-logos/cohere.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/public/provider-logos/deepseek.svg b/apps/web/public/provider-logos/deepseek.svg
new file mode 100644
index 00000000..64b054c8
--- /dev/null
+++ b/apps/web/public/provider-logos/deepseek.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/public/provider-logos/gemini-cli.svg b/apps/web/public/provider-logos/gemini-cli.svg
new file mode 100644
index 00000000..0601b6ae
--- /dev/null
+++ b/apps/web/public/provider-logos/gemini-cli.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/public/provider-logos/gemini.svg b/apps/web/public/provider-logos/gemini.svg
new file mode 100644
index 00000000..508d95e6
--- /dev/null
+++ b/apps/web/public/provider-logos/gemini.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/public/provider-logos/groq.svg b/apps/web/public/provider-logos/groq.svg
new file mode 100644
index 00000000..a6f245c5
--- /dev/null
+++ b/apps/web/public/provider-logos/groq.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/public/provider-logos/meta_llama.svg b/apps/web/public/provider-logos/meta_llama.svg
new file mode 100644
index 00000000..d2c6bf98
--- /dev/null
+++ b/apps/web/public/provider-logos/meta_llama.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/public/provider-logos/mistral.svg b/apps/web/public/provider-logos/mistral.svg
new file mode 100644
index 00000000..e36a8ce3
--- /dev/null
+++ b/apps/web/public/provider-logos/mistral.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/public/provider-logos/ollama.svg b/apps/web/public/provider-logos/ollama.svg
new file mode 100644
index 00000000..e7e4e5da
--- /dev/null
+++ b/apps/web/public/provider-logos/ollama.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/public/provider-logos/openai.svg b/apps/web/public/provider-logos/openai.svg
new file mode 100644
index 00000000..0e3364a0
--- /dev/null
+++ b/apps/web/public/provider-logos/openai.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/public/provider-logos/openrouter.svg b/apps/web/public/provider-logos/openrouter.svg
new file mode 100644
index 00000000..a5b59c8f
--- /dev/null
+++ b/apps/web/public/provider-logos/openrouter.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/public/provider-logos/perplexity.svg b/apps/web/public/provider-logos/perplexity.svg
new file mode 100644
index 00000000..86ea5853
--- /dev/null
+++ b/apps/web/public/provider-logos/perplexity.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/public/provider-logos/vertex_ai.svg b/apps/web/public/provider-logos/vertex_ai.svg
new file mode 100644
index 00000000..cf030ef5
--- /dev/null
+++ b/apps/web/public/provider-logos/vertex_ai.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/public/provider-logos/xai.svg b/apps/web/public/provider-logos/xai.svg
new file mode 100644
index 00000000..b350ce48
--- /dev/null
+++ b/apps/web/public/provider-logos/xai.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/src/components/app-shell/app-sidebar.tsx b/apps/web/src/components/app-shell/app-sidebar.tsx
index 07e16927..2aef3393 100644
--- a/apps/web/src/components/app-shell/app-sidebar.tsx
+++ b/apps/web/src/components/app-shell/app-sidebar.tsx
@@ -44,6 +44,7 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
+import { EntityAvatarContent } from "@/components/shared/entity-avatar";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
@@ -93,7 +94,11 @@ import type { PluginNavRegistration } from "@/lib/plugin-api";
import { ExtensionPoint } from "@/lib/plugins/extension-point";
import { resolvePluginIcon } from "@/lib/plugins/icon-resolver";
import { usePluginRegistry } from "@/lib/plugins/registry";
-import { projectQueryOptions, projectsQueryOptions } from "@/lib/project-api";
+import {
+ getProjectInitials,
+ projectQueryOptions,
+ projectsQueryOptions,
+} from "@/lib/project-api";
import { cn } from "@/lib/utils";
import { UserMenu } from "./user-menu";
@@ -729,7 +734,7 @@ function ProjectSwitcher({
const projects = projectsResult?.items ?? [];
const label = currentProject?.name ?? t("projectSwitcher.projects");
const initials = currentProject?.name
- ? currentProject.name.slice(0, 2).toUpperCase()
+ ? getProjectInitials(currentProject.name)
: null;
const { data: user } = useQuery(currentUserOptionalQueryOptions);
@@ -738,7 +743,9 @@ function ProjectSwitcher({
return (
- {initials ?? }
+
+ {initials ?? }
+
{label}
@@ -756,7 +763,9 @@ function ProjectSwitcher({
)}
>
{p.name}
{p.id === currentProjectId && (
diff --git a/apps/web/src/components/app-shell/user-menu.tsx b/apps/web/src/components/app-shell/user-menu.tsx
index 5b81c0ad..267f43f6 100644
--- a/apps/web/src/components/app-shell/user-menu.tsx
+++ b/apps/web/src/components/app-shell/user-menu.tsx
@@ -12,7 +12,7 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { LocaleRadioGroup } from "@/components/LocaleRadioGroup";
-import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
@@ -105,6 +105,9 @@ export function UserMenu() {
}
>
+ {user.avatar_thumb_url ? (
+
+ ) : null}
{initials}
diff --git a/apps/web/src/components/projects/agents/agent-card.tsx b/apps/web/src/components/projects/agents/agent-card.tsx
index 96825256..e460d4bf 100644
--- a/apps/web/src/components/projects/agents/agent-card.tsx
+++ b/apps/web/src/components/projects/agents/agent-card.tsx
@@ -4,7 +4,7 @@ import { Loader2, MoreHorizontal, Settings, Trash2, Zap } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
-import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -30,6 +30,7 @@ import {
globalAcpBridgeStatusQueryOptions,
globalAgentsQueryOptions,
} from "@/lib/agent-api";
+import { resolveAgentAvatarUrl } from "@/lib/provider-logos";
import { cn } from "@/lib/utils";
// Shared between the project Agents page
@@ -91,6 +92,7 @@ export function AgentCard({
.join("")
.toUpperCase()
.slice(0, 2);
+ const avatarUrl = resolveAgentAvatarUrl(agent);
const detailHref = projectId
? `/projects/${projectId}/agents/${agent.id}`
@@ -112,6 +114,7 @@ export function AgentCard({
+ {avatarUrl ? : null}
{initials}
diff --git a/apps/web/src/components/projects/agents/agent-detail.tsx b/apps/web/src/components/projects/agents/agent-detail.tsx
index 7581142c..f65d3773 100644
--- a/apps/web/src/components/projects/agents/agent-detail.tsx
+++ b/apps/web/src/components/projects/agents/agent-detail.tsx
@@ -14,7 +14,7 @@ import {
} from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
-import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+import { AvatarUpload } from "@/components/shared/avatar-upload";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -74,6 +74,7 @@ import {
updateMCPServer,
updateSkill,
} from "@/lib/agent-api";
+import { resolveAgentAvatarUrl } from "@/lib/provider-logos";
import { splitShellCommand } from "@/lib/shell-command";
import { AcpBridgeSetup } from "./acp-bridge-setup";
import { AgentActivityTab } from "./agent-activity-tab";
@@ -1337,6 +1338,7 @@ export function AgentDetailView({
agentId: string;
}) {
const { t } = useTranslation("projects");
+ const qc = useQueryClient();
// Both permission hooks are always called (never conditionally, per the
// rules of hooks) — useProjectPermissions no-ops its own query when
@@ -1438,11 +1440,56 @@ export function AgentDetailView({
{/* Agent header */}
-
-
- {initials}
-
-
+ {
+ qc.setQueryData(
+ (projectId
+ ? agentQueryOptions(projectId, agent.id)
+ : globalAgentQueryOptions(agent.id)
+ ).queryKey,
+ (old) => (old ? { ...old, ...result } : old),
+ );
+ // The single-agent cache above only fixes this page. Every
+ // other place that shows this agent's avatar — the agent
+ // list/cards, the chat agent picker, the conversations list,
+ // and (via project members) the team page and task
+ // assignee/reporter chips — reads from separate query caches
+ // that won't pick up the change until invalidated. A global
+ // agent can also belong to several projects at once, so the
+ // members invalidation matches every project's members query,
+ // not just this one.
+ qc.invalidateQueries({
+ queryKey: projectId
+ ? ["projects", projectId, "agents"]
+ : ["global-agents"],
+ });
+ qc.invalidateQueries({
+ predicate: (query) =>
+ query.queryKey[0] === "projects" &&
+ query.queryKey[2] === "members",
+ });
+ }}
+ />
{agent.name}
diff --git a/apps/web/src/components/projects/agents/conversations-layout.tsx b/apps/web/src/components/projects/agents/conversations-layout.tsx
index 85775ca4..520ddd49 100644
--- a/apps/web/src/components/projects/agents/conversations-layout.tsx
+++ b/apps/web/src/components/projects/agents/conversations-layout.tsx
@@ -3,7 +3,7 @@ import { Link, Outlet, useParams } from "@tanstack/react-router";
import { Clock, MessageSquare, Plus, Zap } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
-import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
@@ -20,6 +20,7 @@ import {
conversationsQueryOptions,
globalConversationsQueryOptions,
} from "@/lib/agent-api";
+import { resolveAgentAvatarUrl } from "@/lib/provider-logos";
import { cn } from "@/lib/utils";
import { ConversationFilters } from "./conversation-filters";
@@ -71,6 +72,7 @@ function ConversationListItem({
.join("")
.toUpperCase()
.slice(0, 2);
+ const avatarUrl = agent ? resolveAgentAvatarUrl(agent) : undefined;
const href = projectId
? `/projects/${projectId}/conversations/${conv.id}`
@@ -88,6 +90,7 @@ function ConversationListItem({
>
}
/>
diff --git a/apps/web/src/components/projects/settings/GeneralSettings.tsx b/apps/web/src/components/projects/settings/GeneralSettings.tsx
index 2222180d..1104243d 100644
--- a/apps/web/src/components/projects/settings/GeneralSettings.tsx
+++ b/apps/web/src/components/projects/settings/GeneralSettings.tsx
@@ -2,13 +2,19 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Globe, Loader2, Lock } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
+import { AvatarUpload } from "@/components/shared/avatar-upload";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
+import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { ApiErrorCode, getApiErrorCode } from "@/lib/api-error";
-import { projectQueryOptions, updateProject } from "@/lib/project-api";
+import {
+ getProjectInitials,
+ projectQueryOptions,
+ updateProject,
+} from "@/lib/project-api";
export function GeneralSettings({
projectId,
@@ -78,11 +84,56 @@ export function GeneralSettings({
prefix.trim() !== (project?.task_id_prefix ?? "") ||
isPublic !== (project?.is_public ?? false);
+ const initials = getProjectInitials(project?.name ?? "");
+
return (
{t("settings.general.title")}
+
+ {/* Identity header — avatar next to the project's name/prefix, same
+ layout as the profile page's own avatar setting. */}
+
+ {
+ queryClient.setQueryData(
+ projectQueryOptions(projectId).queryKey,
+ (old) => (old ? { ...old, ...result } : old),
+ );
+ // Other places reading the project list (sidebar switcher,
+ // home page cards) hold a separate cache entry that
+ // setQueryData above doesn't touch.
+ queryClient.invalidateQueries({ queryKey: ["projects"] });
+ }}
+ />
+
+
{project?.name}
+ {project?.task_id_prefix ? (
+
+ {project.task_id_prefix}
+
+ ) : null}
+
+
+
+
+
{isComment ? (
diff --git a/apps/web/src/components/shared/avatar-upload.tsx b/apps/web/src/components/shared/avatar-upload.tsx
new file mode 100644
index 00000000..46d14486
--- /dev/null
+++ b/apps/web/src/components/shared/avatar-upload.tsx
@@ -0,0 +1,167 @@
+import { Camera, Loader2, X } from "lucide-react";
+import { type ReactNode, useRef, useState } from "react";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import {
+ ACCEPTED_AVATAR_CONTENT_TYPES,
+ type AvatarResult,
+ removeAvatar,
+ uploadAvatar,
+ validateAvatarFile,
+} from "@/lib/avatar-api";
+import { cn } from "@/lib/utils";
+
+export interface AvatarUploadLabels {
+ change: string;
+ remove: string;
+ uploading: string;
+ invalidType: string;
+ tooLarge: string;
+ uploadFailed: string;
+ removeFailed: string;
+}
+
+interface AvatarUploadProps {
+ /** Owner path — "/users/me", "/projects/{id}/agents/{id}", or "/admin/agents/{id}". */
+ basePath: string;
+ avatarUrl?: string | null;
+ /** Shown when there's no avatar (initials or an icon). */
+ fallback: ReactNode;
+ onChange: (result: AvatarResult) => void;
+ labels: AvatarUploadLabels;
+ /** Sizing/shape for the avatar itself, e.g. "size-14 rounded-xl". */
+ className?: string;
+ fallbackClassName?: string;
+ disabled?: boolean;
+ /** Whether the remove button can show at all — pass `false` when
+ * `avatarUrl` is a placeholder (e.g. a provider-logo default) rather than
+ * a real upload, since there's nothing to remove. Defaults to `true`. */
+ canRemove?: boolean;
+}
+
+export function AvatarUpload({
+ basePath,
+ avatarUrl,
+ fallback,
+ onChange,
+ labels,
+ className,
+ fallbackClassName,
+ disabled,
+ canRemove = true,
+}: AvatarUploadProps) {
+ const inputRef = useRef(null);
+ const [uploading, setUploading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const handleFileChange = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ e.target.value = "";
+ if (!file) return;
+
+ const validationError = validateAvatarFile(file);
+ if (validationError) {
+ setError(
+ validationError === "tooLarge" ? labels.tooLarge : labels.invalidType,
+ );
+ return;
+ }
+
+ setError(null);
+ setUploading(true);
+ try {
+ const result = await uploadAvatar(basePath, file);
+ onChange(result);
+ } catch {
+ setError(labels.uploadFailed);
+ } finally {
+ setUploading(false);
+ }
+ };
+
+ const handleRemove = async () => {
+ setError(null);
+ setUploading(true);
+ try {
+ const result = await removeAvatar(basePath);
+ onChange(result);
+ } catch {
+ setError(labels.removeFailed);
+ } finally {
+ setUploading(false);
+ }
+ };
+
+ return (
+
+ {/* The shape (e.g. "rounded-xl") lives here, on the outer frame, so the
+ photo, the initials fallback, and the hover overlay can all inherit
+ the exact same radius via `rounded-[inherit]` instead of each
+ needing their own copy of it. */}
+
+
+ {/* Always mounted (never conditionally omitted): base-ui's Avatar
+ tracks image-load status internally and only shows Fallback
+ once it observes a missing/failed src — omitting this element
+ from the tree instead leaves that internal state stuck at
+ whatever it last was, so removing an avatar left the whole
+ thing blank (no photo, no fallback) until a full reload. */}
+
+
+ {fallback}
+
+
+
+
+
+ {avatarUrl && canRemove && !uploading ? (
+
+ ) : null}
+
+
+
+ {error ?
{error}
: null}
+
+ );
+}
diff --git a/apps/web/src/components/shared/entity-avatar.tsx b/apps/web/src/components/shared/entity-avatar.tsx
new file mode 100644
index 00000000..93c832b7
--- /dev/null
+++ b/apps/web/src/components/shared/entity-avatar.tsx
@@ -0,0 +1,27 @@
+import type { ReactNode } from "react";
+
+/**
+ * Display-only content for the many places that hand-roll their own sized
+ * initials `
` instead of the `Avatar` primitive (task assignee stacks,
+ * activity feed, team list, etc.) — drops into that existing wrapper without
+ * touching its size/ring/gradient classes. Renders an image when avatarUrl
+ * is set, otherwise the given fallback (initials text or an icon).
+ */
+export function EntityAvatarContent({
+ avatarUrl,
+ children,
+}: {
+ avatarUrl?: string | null;
+ children: ReactNode;
+}) {
+ if (avatarUrl) {
+ return (
+
+ );
+ }
+ return <>{children}>;
+}
diff --git a/apps/web/src/components/shared/mention-suggestion-menus.tsx b/apps/web/src/components/shared/mention-suggestion-menus.tsx
index 2c97b31e..b8d52201 100644
--- a/apps/web/src/components/shared/mention-suggestion-menus.tsx
+++ b/apps/web/src/components/shared/mention-suggestion-menus.tsx
@@ -4,6 +4,7 @@ import {
type DefaultReactSuggestionItem,
SuggestionMenuController,
} from "@blocknote/react";
+import { EntityAvatarContent } from "@/components/shared/entity-avatar";
import { useDebouncedAsyncCallback } from "@/hooks/use-debounced-callback";
import { searchMentionableTasks } from "@/lib/mention-api";
import type { customSchema } from "./blocknote-schema";
@@ -30,7 +31,7 @@ interface MentionSuggestionMenuProps {
id: string;
name: string;
username: string;
- avatar?: string | null | undefined;
+ avatarThumbUrl?: string | null | undefined;
}>;
/** Needed to search tasks live as the user types after "#" — the project's
* task list has no fixed upper bound, so it's queried on demand instead
@@ -54,6 +55,16 @@ export function MentionSuggestionMenus({
return teamMembers.map((member) => ({
title: member.name,
subtext: `@${member.username}`,
+ // Resolved fresh from the live teamMembers list every time the
+ // dropdown opens — safe, unlike the mention chip's own props (see
+ // the avatar: "" comment below), since this is never persisted.
+ icon: (
+
+
+ {member.name.slice(0, 1).toUpperCase()}
+
+
+ ),
onItemClick: () => {
editor.insertInlineContent([
{
@@ -61,7 +72,12 @@ export function MentionSuggestionMenus({
props: {
id: member.id,
name: member.name,
- avatar: member.avatar ?? "",
+ // Never populated with a real URL: mention props are baked
+ // into permanently-stored document content, and a presigned
+ // avatar URL would expire long before the mention does. The
+ // live suggestion dropdown (below) shows the real avatar
+ // instead, since it's resolved fresh every time it opens.
+ avatar: "",
},
},
" ",
diff --git a/apps/web/src/i18n/locales/en/profile.json b/apps/web/src/i18n/locales/en/profile.json
index 0f54340a..b8e987fb 100644
--- a/apps/web/src/i18n/locales/en/profile.json
+++ b/apps/web/src/i18n/locales/en/profile.json
@@ -18,6 +18,17 @@
"errors": {
"updateFailed": "Failed to update profile. Please try again."
},
+ "avatar": {
+ "change": "Change avatar",
+ "remove": "Remove avatar",
+ "uploading": "Uploading…",
+ "errors": {
+ "invalidType": "Please choose a PNG, JPEG, WEBP, or GIF image.",
+ "tooLarge": "Image must be 5 MB or smaller.",
+ "uploadFailed": "Failed to upload avatar. Please try again.",
+ "removeFailed": "Failed to remove avatar. Please try again."
+ }
+ },
"joinedOn": "Joined {{date}}",
"changePassword": {
"title": "Change Password",
diff --git a/apps/web/src/i18n/locales/en/projects.json b/apps/web/src/i18n/locales/en/projects.json
index 22f150c6..cc8f6526 100644
--- a/apps/web/src/i18n/locales/en/projects.json
+++ b/apps/web/src/i18n/locales/en/projects.json
@@ -138,6 +138,17 @@
"envVars": "Environment",
"activity": "Activity"
},
+ "avatar": {
+ "change": "Change avatar",
+ "remove": "Remove avatar",
+ "uploading": "Uploading…",
+ "errors": {
+ "invalidType": "Please choose a PNG, JPEG, WEBP, or GIF image.",
+ "tooLarge": "Image must be 5 MB or smaller.",
+ "uploadFailed": "Failed to upload avatar. Please try again.",
+ "removeFailed": "Failed to remove avatar. Please try again."
+ }
+ },
"overview": {
"nameLabel": "Name",
"llmConfiguration": "LLM Configuration",
@@ -457,6 +468,17 @@
"settings": {
"general": {
"title": "General",
+ "avatar": {
+ "change": "Change avatar",
+ "remove": "Remove avatar",
+ "uploading": "Uploading…",
+ "errors": {
+ "invalidType": "Please choose a PNG, JPEG, WEBP, or GIF image.",
+ "tooLarge": "Image must be 5 MB or smaller.",
+ "uploadFailed": "Failed to upload avatar. Please try again.",
+ "removeFailed": "Failed to remove avatar. Please try again."
+ }
+ },
"projectNameLabel": "Project name",
"projectNamePlaceholder": "My awesome project",
"taskIdPrefixLabel": "Task ID prefix",
diff --git a/apps/web/src/i18n/locales/es/profile.json b/apps/web/src/i18n/locales/es/profile.json
index b2ad8fe8..00c6e5b3 100644
--- a/apps/web/src/i18n/locales/es/profile.json
+++ b/apps/web/src/i18n/locales/es/profile.json
@@ -18,6 +18,17 @@
"errors": {
"updateFailed": "No se pudo actualizar el perfil. Inténtalo de nuevo."
},
+ "avatar": {
+ "change": "Cambiar avatar",
+ "remove": "Eliminar avatar",
+ "uploading": "Subiendo…",
+ "errors": {
+ "invalidType": "Elige una imagen PNG, JPEG, WEBP o GIF.",
+ "tooLarge": "La imagen debe pesar 5 MB o menos.",
+ "uploadFailed": "No se pudo subir el avatar. Inténtalo de nuevo.",
+ "removeFailed": "No se pudo eliminar el avatar. Inténtalo de nuevo."
+ }
+ },
"joinedOn": "Se unió el {{date}}",
"changePassword": {
"title": "Cambiar contraseña",
diff --git a/apps/web/src/i18n/locales/es/projects.json b/apps/web/src/i18n/locales/es/projects.json
index 85b178ff..1bc70c3b 100644
--- a/apps/web/src/i18n/locales/es/projects.json
+++ b/apps/web/src/i18n/locales/es/projects.json
@@ -138,6 +138,17 @@
"envVars": "Entorno",
"activity": "Actividad"
},
+ "avatar": {
+ "change": "Cambiar avatar",
+ "remove": "Eliminar avatar",
+ "uploading": "Subiendo…",
+ "errors": {
+ "invalidType": "Elige una imagen PNG, JPEG, WEBP o GIF.",
+ "tooLarge": "La imagen debe pesar 5 MB o menos.",
+ "uploadFailed": "No se pudo subir el avatar. Inténtalo de nuevo.",
+ "removeFailed": "No se pudo eliminar el avatar. Inténtalo de nuevo."
+ }
+ },
"overview": {
"nameLabel": "Nombre",
"llmConfiguration": "Configuración del LLM",
@@ -457,6 +468,17 @@
"settings": {
"general": {
"title": "General",
+ "avatar": {
+ "change": "Cambiar avatar",
+ "remove": "Eliminar avatar",
+ "uploading": "Subiendo…",
+ "errors": {
+ "invalidType": "Elige una imagen PNG, JPEG, WEBP o GIF.",
+ "tooLarge": "La imagen debe pesar 5 MB o menos.",
+ "uploadFailed": "No se pudo subir el avatar. Inténtalo de nuevo.",
+ "removeFailed": "No se pudo eliminar el avatar. Inténtalo de nuevo."
+ }
+ },
"projectNameLabel": "Nombre del proyecto",
"projectNamePlaceholder": "Mi proyecto increíble",
"taskIdPrefixLabel": "Prefijo de ID de tarea",
diff --git a/apps/web/src/i18n/locales/fr/profile.json b/apps/web/src/i18n/locales/fr/profile.json
index c586d689..2cfa4240 100644
--- a/apps/web/src/i18n/locales/fr/profile.json
+++ b/apps/web/src/i18n/locales/fr/profile.json
@@ -18,6 +18,17 @@
"errors": {
"updateFailed": "Échec de la mise à jour du profil. Veuillez réessayer."
},
+ "avatar": {
+ "change": "Changer l’avatar",
+ "remove": "Supprimer l’avatar",
+ "uploading": "Envoi en cours…",
+ "errors": {
+ "invalidType": "Veuillez choisir une image PNG, JPEG, WEBP ou GIF.",
+ "tooLarge": "L’image doit faire 5 Mo maximum.",
+ "uploadFailed": "Échec de l’envoi de l’avatar. Veuillez réessayer.",
+ "removeFailed": "Échec de la suppression de l’avatar. Veuillez réessayer."
+ }
+ },
"joinedOn": "Inscrit le {{date}}",
"changePassword": {
"title": "Changer le mot de passe",
diff --git a/apps/web/src/i18n/locales/fr/projects.json b/apps/web/src/i18n/locales/fr/projects.json
index 9fdf44ed..e10f4ade 100644
--- a/apps/web/src/i18n/locales/fr/projects.json
+++ b/apps/web/src/i18n/locales/fr/projects.json
@@ -138,6 +138,17 @@
"envVars": "Environnement",
"activity": "Activité"
},
+ "avatar": {
+ "change": "Changer l’avatar",
+ "remove": "Supprimer l’avatar",
+ "uploading": "Envoi en cours…",
+ "errors": {
+ "invalidType": "Veuillez choisir une image PNG, JPEG, WEBP ou GIF.",
+ "tooLarge": "L’image doit faire 5 Mo maximum.",
+ "uploadFailed": "Échec de l’envoi de l’avatar. Veuillez réessayer.",
+ "removeFailed": "Échec de la suppression de l’avatar. Veuillez réessayer."
+ }
+ },
"overview": {
"nameLabel": "Nom",
"llmConfiguration": "Configuration du LLM",
@@ -457,6 +468,17 @@
"settings": {
"general": {
"title": "Général",
+ "avatar": {
+ "change": "Changer l’avatar",
+ "remove": "Supprimer l’avatar",
+ "uploading": "Envoi en cours…",
+ "errors": {
+ "invalidType": "Veuillez choisir une image PNG, JPEG, WEBP ou GIF.",
+ "tooLarge": "L’image doit faire 5 Mo maximum.",
+ "uploadFailed": "Échec de l’envoi de l’avatar. Veuillez réessayer.",
+ "removeFailed": "Échec de la suppression de l’avatar. Veuillez réessayer."
+ }
+ },
"projectNameLabel": "Nom du projet",
"projectNamePlaceholder": "Mon super projet",
"taskIdPrefixLabel": "Préfixe d'ID de tâche",
diff --git a/apps/web/src/i18n/locales/ja/profile.json b/apps/web/src/i18n/locales/ja/profile.json
index ab15df34..2f519549 100644
--- a/apps/web/src/i18n/locales/ja/profile.json
+++ b/apps/web/src/i18n/locales/ja/profile.json
@@ -18,6 +18,17 @@
"errors": {
"updateFailed": "プロフィールの更新に失敗しました。もう一度お試しください。"
},
+ "avatar": {
+ "change": "アバターを変更",
+ "remove": "アバターを削除",
+ "uploading": "アップロード中…",
+ "errors": {
+ "invalidType": "PNG、JPEG、WEBP、GIF形式の画像を選択してください。",
+ "tooLarge": "画像は5MB以下にしてください。",
+ "uploadFailed": "アバターのアップロードに失敗しました。もう一度お試しください。",
+ "removeFailed": "アバターの削除に失敗しました。もう一度お試しください。"
+ }
+ },
"joinedOn": "{{date}}に参加",
"changePassword": {
"title": "パスワードを変更",
diff --git a/apps/web/src/i18n/locales/ja/projects.json b/apps/web/src/i18n/locales/ja/projects.json
index 4ab1c38b..5034d58a 100644
--- a/apps/web/src/i18n/locales/ja/projects.json
+++ b/apps/web/src/i18n/locales/ja/projects.json
@@ -138,6 +138,17 @@
"envVars": "環境変数",
"activity": "アクティビティ"
},
+ "avatar": {
+ "change": "アバターを変更",
+ "remove": "アバターを削除",
+ "uploading": "アップロード中…",
+ "errors": {
+ "invalidType": "PNG、JPEG、WEBP、GIF形式の画像を選択してください。",
+ "tooLarge": "画像は5MB以下にしてください。",
+ "uploadFailed": "アバターのアップロードに失敗しました。もう一度お試しください。",
+ "removeFailed": "アバターの削除に失敗しました。もう一度お試しください。"
+ }
+ },
"overview": {
"nameLabel": "名前",
"llmConfiguration": "LLM設定",
@@ -457,6 +468,17 @@
"settings": {
"general": {
"title": "一般",
+ "avatar": {
+ "change": "アバターを変更",
+ "remove": "アバターを削除",
+ "uploading": "アップロード中…",
+ "errors": {
+ "invalidType": "PNG、JPEG、WEBP、GIF形式の画像を選択してください。",
+ "tooLarge": "画像は5MB以下にしてください。",
+ "uploadFailed": "アバターのアップロードに失敗しました。もう一度お試しください。",
+ "removeFailed": "アバターの削除に失敗しました。もう一度お試しください。"
+ }
+ },
"projectNameLabel": "プロジェクト名",
"projectNamePlaceholder": "My awesome project",
"taskIdPrefixLabel": "タスクIDプレフィックス",
diff --git a/apps/web/src/i18n/locales/ko/profile.json b/apps/web/src/i18n/locales/ko/profile.json
index 371a2174..ca0a3022 100644
--- a/apps/web/src/i18n/locales/ko/profile.json
+++ b/apps/web/src/i18n/locales/ko/profile.json
@@ -18,6 +18,17 @@
"errors": {
"updateFailed": "프로필 업데이트에 실패했습니다. 다시 시도해 주세요."
},
+ "avatar": {
+ "change": "아바타 변경",
+ "remove": "아바타 제거",
+ "uploading": "업로드 중…",
+ "errors": {
+ "invalidType": "PNG, JPEG, WEBP 또는 GIF 이미지를 선택해 주세요.",
+ "tooLarge": "이미지는 5MB 이하여야 합니다.",
+ "uploadFailed": "아바타 업로드에 실패했습니다. 다시 시도해 주세요.",
+ "removeFailed": "아바타 제거에 실패했습니다. 다시 시도해 주세요."
+ }
+ },
"joinedOn": "{{date}}에 가입",
"changePassword": {
"title": "비밀번호 변경",
diff --git a/apps/web/src/i18n/locales/ko/projects.json b/apps/web/src/i18n/locales/ko/projects.json
index 6ca79e2f..4a37e66c 100644
--- a/apps/web/src/i18n/locales/ko/projects.json
+++ b/apps/web/src/i18n/locales/ko/projects.json
@@ -138,6 +138,17 @@
"envVars": "환경 변수",
"activity": "활동"
},
+ "avatar": {
+ "change": "아바타 변경",
+ "remove": "아바타 제거",
+ "uploading": "업로드 중…",
+ "errors": {
+ "invalidType": "PNG, JPEG, WEBP 또는 GIF 이미지를 선택해 주세요.",
+ "tooLarge": "이미지는 5MB 이하여야 합니다.",
+ "uploadFailed": "아바타 업로드에 실패했습니다. 다시 시도해 주세요.",
+ "removeFailed": "아바타 제거에 실패했습니다. 다시 시도해 주세요."
+ }
+ },
"overview": {
"nameLabel": "이름",
"llmConfiguration": "LLM 구성",
@@ -457,6 +468,17 @@
"settings": {
"general": {
"title": "일반",
+ "avatar": {
+ "change": "아바타 변경",
+ "remove": "아바타 제거",
+ "uploading": "업로드 중…",
+ "errors": {
+ "invalidType": "PNG, JPEG, WEBP 또는 GIF 이미지를 선택해 주세요.",
+ "tooLarge": "이미지는 5MB 이하여야 합니다.",
+ "uploadFailed": "아바타 업로드에 실패했습니다. 다시 시도해 주세요.",
+ "removeFailed": "아바타 제거에 실패했습니다. 다시 시도해 주세요."
+ }
+ },
"projectNameLabel": "프로젝트 이름",
"projectNamePlaceholder": "내 멋진 프로젝트",
"taskIdPrefixLabel": "작업 ID 접두사",
diff --git a/apps/web/src/i18n/locales/pt-BR/profile.json b/apps/web/src/i18n/locales/pt-BR/profile.json
index 2e54ea7c..ae652f1b 100644
--- a/apps/web/src/i18n/locales/pt-BR/profile.json
+++ b/apps/web/src/i18n/locales/pt-BR/profile.json
@@ -18,6 +18,17 @@
"errors": {
"updateFailed": "Falha ao atualizar o perfil. Tente novamente."
},
+ "avatar": {
+ "change": "Alterar avatar",
+ "remove": "Remover avatar",
+ "uploading": "Enviando…",
+ "errors": {
+ "invalidType": "Escolha uma imagem PNG, JPEG, WEBP ou GIF.",
+ "tooLarge": "A imagem deve ter no máximo 5 MB.",
+ "uploadFailed": "Falha ao enviar o avatar. Tente novamente.",
+ "removeFailed": "Falha ao remover o avatar. Tente novamente."
+ }
+ },
"joinedOn": "Ingressou em {{date}}",
"changePassword": {
"title": "Alterar Senha",
diff --git a/apps/web/src/i18n/locales/pt-BR/projects.json b/apps/web/src/i18n/locales/pt-BR/projects.json
index d2e6a830..f2679bfd 100644
--- a/apps/web/src/i18n/locales/pt-BR/projects.json
+++ b/apps/web/src/i18n/locales/pt-BR/projects.json
@@ -138,6 +138,17 @@
"envVars": "Ambiente",
"activity": "Atividade"
},
+ "avatar": {
+ "change": "Alterar avatar",
+ "remove": "Remover avatar",
+ "uploading": "Enviando…",
+ "errors": {
+ "invalidType": "Escolha uma imagem PNG, JPEG, WEBP ou GIF.",
+ "tooLarge": "A imagem deve ter no máximo 5 MB.",
+ "uploadFailed": "Falha ao enviar o avatar. Tente novamente.",
+ "removeFailed": "Falha ao remover o avatar. Tente novamente."
+ }
+ },
"overview": {
"nameLabel": "Nome",
"llmConfiguration": "Configuração do LLM",
@@ -457,6 +468,17 @@
"settings": {
"general": {
"title": "Geral",
+ "avatar": {
+ "change": "Alterar avatar",
+ "remove": "Remover avatar",
+ "uploading": "Enviando…",
+ "errors": {
+ "invalidType": "Escolha uma imagem PNG, JPEG, WEBP ou GIF.",
+ "tooLarge": "A imagem deve ter no máximo 5 MB.",
+ "uploadFailed": "Falha ao enviar o avatar. Tente novamente.",
+ "removeFailed": "Falha ao remover o avatar. Tente novamente."
+ }
+ },
"projectNameLabel": "Nome do projeto",
"projectNamePlaceholder": "Meu projeto incrível",
"taskIdPrefixLabel": "Prefixo de ID de tarefa",
diff --git a/apps/web/src/i18n/locales/ru/profile.json b/apps/web/src/i18n/locales/ru/profile.json
index 428b76c0..a885b46e 100644
--- a/apps/web/src/i18n/locales/ru/profile.json
+++ b/apps/web/src/i18n/locales/ru/profile.json
@@ -18,6 +18,17 @@
"errors": {
"updateFailed": "Не удалось обновить профиль. Попробуйте ещё раз."
},
+ "avatar": {
+ "change": "Изменить аватар",
+ "remove": "Удалить аватар",
+ "uploading": "Загрузка…",
+ "errors": {
+ "invalidType": "Выберите изображение в формате PNG, JPEG, WEBP или GIF.",
+ "tooLarge": "Размер изображения не должен превышать 5 МБ.",
+ "uploadFailed": "Не удалось загрузить аватар. Попробуйте снова.",
+ "removeFailed": "Не удалось удалить аватар. Попробуйте снова."
+ }
+ },
"joinedOn": "Присоединился {{date}}",
"changePassword": {
"title": "Сменить пароль",
diff --git a/apps/web/src/i18n/locales/ru/projects.json b/apps/web/src/i18n/locales/ru/projects.json
index b3d38acc..bbf20c49 100644
--- a/apps/web/src/i18n/locales/ru/projects.json
+++ b/apps/web/src/i18n/locales/ru/projects.json
@@ -138,6 +138,17 @@
"envVars": "Окружение",
"activity": "Активность"
},
+ "avatar": {
+ "change": "Изменить аватар",
+ "remove": "Удалить аватар",
+ "uploading": "Загрузка…",
+ "errors": {
+ "invalidType": "Выберите изображение в формате PNG, JPEG, WEBP или GIF.",
+ "tooLarge": "Размер изображения не должен превышать 5 МБ.",
+ "uploadFailed": "Не удалось загрузить аватар. Попробуйте снова.",
+ "removeFailed": "Не удалось удалить аватар. Попробуйте снова."
+ }
+ },
"overview": {
"nameLabel": "Имя",
"llmConfiguration": "Конфигурация LLM",
@@ -469,6 +480,17 @@
"settings": {
"general": {
"title": "Общие",
+ "avatar": {
+ "change": "Изменить аватар",
+ "remove": "Удалить аватар",
+ "uploading": "Загрузка…",
+ "errors": {
+ "invalidType": "Выберите изображение в формате PNG, JPEG, WEBP или GIF.",
+ "tooLarge": "Размер изображения не должен превышать 5 МБ.",
+ "uploadFailed": "Не удалось загрузить аватар. Попробуйте снова.",
+ "removeFailed": "Не удалось удалить аватар. Попробуйте снова."
+ }
+ },
"projectNameLabel": "Название проекта",
"projectNamePlaceholder": "Мой отличный проект",
"taskIdPrefixLabel": "Префикс ID задач",
diff --git a/apps/web/src/i18n/locales/vi/profile.json b/apps/web/src/i18n/locales/vi/profile.json
index b1cc5c3c..59eeebff 100644
--- a/apps/web/src/i18n/locales/vi/profile.json
+++ b/apps/web/src/i18n/locales/vi/profile.json
@@ -18,6 +18,17 @@
"errors": {
"updateFailed": "Cập nhật hồ sơ thất bại. Vui lòng thử lại."
},
+ "avatar": {
+ "change": "Đổi ảnh đại diện",
+ "remove": "Xóa ảnh đại diện",
+ "uploading": "Đang tải lên…",
+ "errors": {
+ "invalidType": "Vui lòng chọn ảnh định dạng PNG, JPEG, WEBP hoặc GIF.",
+ "tooLarge": "Ảnh phải nhỏ hơn hoặc bằng 5 MB.",
+ "uploadFailed": "Tải ảnh đại diện lên thất bại. Vui lòng thử lại.",
+ "removeFailed": "Xóa ảnh đại diện thất bại. Vui lòng thử lại."
+ }
+ },
"joinedOn": "Tham gia {{date}}",
"changePassword": {
"title": "Đổi mật khẩu",
diff --git a/apps/web/src/i18n/locales/vi/projects.json b/apps/web/src/i18n/locales/vi/projects.json
index 52688bf1..a99d1e92 100644
--- a/apps/web/src/i18n/locales/vi/projects.json
+++ b/apps/web/src/i18n/locales/vi/projects.json
@@ -138,6 +138,17 @@
"envVars": "Môi trường",
"activity": "Hoạt động"
},
+ "avatar": {
+ "change": "Đổi ảnh đại diện",
+ "remove": "Xóa ảnh đại diện",
+ "uploading": "Đang tải lên…",
+ "errors": {
+ "invalidType": "Vui lòng chọn ảnh định dạng PNG, JPEG, WEBP hoặc GIF.",
+ "tooLarge": "Ảnh phải nhỏ hơn hoặc bằng 5 MB.",
+ "uploadFailed": "Tải ảnh đại diện lên thất bại. Vui lòng thử lại.",
+ "removeFailed": "Xóa ảnh đại diện thất bại. Vui lòng thử lại."
+ }
+ },
"overview": {
"nameLabel": "Tên",
"llmConfiguration": "Cấu hình LLM",
@@ -457,6 +468,17 @@
"settings": {
"general": {
"title": "Chung",
+ "avatar": {
+ "change": "Đổi ảnh đại diện",
+ "remove": "Xóa ảnh đại diện",
+ "uploading": "Đang tải lên…",
+ "errors": {
+ "invalidType": "Vui lòng chọn ảnh định dạng PNG, JPEG, WEBP hoặc GIF.",
+ "tooLarge": "Ảnh phải nhỏ hơn hoặc bằng 5 MB.",
+ "uploadFailed": "Tải ảnh đại diện lên thất bại. Vui lòng thử lại.",
+ "removeFailed": "Xóa ảnh đại diện thất bại. Vui lòng thử lại."
+ }
+ },
"projectNameLabel": "Tên dự án",
"projectNamePlaceholder": "Dự án tuyệt vời của tôi",
"taskIdPrefixLabel": "Tiền tố mã nhiệm vụ",
diff --git a/apps/web/src/i18n/locales/zh-CN/profile.json b/apps/web/src/i18n/locales/zh-CN/profile.json
index bed1d7d2..0590c7f5 100644
--- a/apps/web/src/i18n/locales/zh-CN/profile.json
+++ b/apps/web/src/i18n/locales/zh-CN/profile.json
@@ -18,6 +18,17 @@
"errors": {
"updateFailed": "更新资料失败,请重试。"
},
+ "avatar": {
+ "change": "更换头像",
+ "remove": "移除头像",
+ "uploading": "上传中…",
+ "errors": {
+ "invalidType": "请选择 PNG、JPEG、WEBP 或 GIF 格式的图片。",
+ "tooLarge": "图片大小不能超过 5 MB。",
+ "uploadFailed": "头像上传失败,请重试。",
+ "removeFailed": "头像移除失败,请重试。"
+ }
+ },
"joinedOn": "加入于 {{date}}",
"changePassword": {
"title": "修改密码",
diff --git a/apps/web/src/i18n/locales/zh-CN/projects.json b/apps/web/src/i18n/locales/zh-CN/projects.json
index 22d9ffc9..8ab100b2 100644
--- a/apps/web/src/i18n/locales/zh-CN/projects.json
+++ b/apps/web/src/i18n/locales/zh-CN/projects.json
@@ -138,6 +138,17 @@
"envVars": "环境变量",
"activity": "动态"
},
+ "avatar": {
+ "change": "更换头像",
+ "remove": "移除头像",
+ "uploading": "上传中…",
+ "errors": {
+ "invalidType": "请选择 PNG、JPEG、WEBP 或 GIF 格式的图片。",
+ "tooLarge": "图片大小不能超过 5 MB。",
+ "uploadFailed": "头像上传失败,请重试。",
+ "removeFailed": "头像移除失败,请重试。"
+ }
+ },
"overview": {
"nameLabel": "名称",
"llmConfiguration": "LLM 配置",
@@ -457,6 +468,17 @@
"settings": {
"general": {
"title": "通用",
+ "avatar": {
+ "change": "更换头像",
+ "remove": "移除头像",
+ "uploading": "上传中…",
+ "errors": {
+ "invalidType": "请选择 PNG、JPEG、WEBP 或 GIF 格式的图片。",
+ "tooLarge": "图片大小不能超过 5 MB。",
+ "uploadFailed": "头像上传失败,请重试。",
+ "removeFailed": "头像移除失败,请重试。"
+ }
+ },
"projectNameLabel": "项目名称",
"projectNamePlaceholder": "我的精彩项目",
"taskIdPrefixLabel": "任务编号前缀",
diff --git a/apps/web/src/lib/admin-api.ts b/apps/web/src/lib/admin-api.ts
index a05848a1..8077dbd4 100644
--- a/apps/web/src/lib/admin-api.ts
+++ b/apps/web/src/lib/admin-api.ts
@@ -73,6 +73,8 @@ export interface User {
full_name: string;
role: string;
must_change_password: boolean;
+ avatar_url?: string | null;
+ avatar_thumb_url?: string | null;
created_at: string;
}
diff --git a/apps/web/src/lib/agent-api.ts b/apps/web/src/lib/agent-api.ts
index 0ab63b33..d1601afd 100644
--- a/apps/web/src/lib/agent-api.ts
+++ b/apps/web/src/lib/agent-api.ts
@@ -127,6 +127,7 @@ export interface Agent {
name: string;
handle: string;
avatar_url?: string | null;
+ avatar_thumb_url?: string | null;
agent_type: AgentType;
llm_provider: string;
llm_model: string;
diff --git a/apps/web/src/lib/auth-api.ts b/apps/web/src/lib/auth-api.ts
index 9f2890e0..d044c4b5 100644
--- a/apps/web/src/lib/auth-api.ts
+++ b/apps/web/src/lib/auth-api.ts
@@ -11,6 +11,8 @@ export interface User {
full_name: string;
role: string;
must_change_password: boolean;
+ avatar_url?: string | null;
+ avatar_thumb_url?: string | null;
created_at: string;
}
diff --git a/apps/web/src/lib/avatar-api.ts b/apps/web/src/lib/avatar-api.ts
new file mode 100644
index 00000000..8fadb594
--- /dev/null
+++ b/apps/web/src/lib/avatar-api.ts
@@ -0,0 +1,127 @@
+import { apiClient } from "./api-client";
+import type { SuccessEnvelope } from "./api-error";
+
+// ── Shared constants ─────────────────────────────────────────────────────────
+// Mirrors attachmentdom.MaxAvatarUploadSize / AvatarContentTypes on the server
+// — checking client-side first gives instant feedback, but the server always
+// re-validates.
+
+export const MAX_AVATAR_UPLOAD_SIZE = 5 * 1024 * 1024; // 5 MiB
+export const ACCEPTED_AVATAR_CONTENT_TYPES = [
+ "image/png",
+ "image/jpeg",
+ "image/webp",
+ "image/gif",
+];
+
+/** Returns an error message if file isn't an acceptable avatar upload, else null. */
+export function validateAvatarFile(file: File): string | null {
+ if (!ACCEPTED_AVATAR_CONTENT_TYPES.includes(file.type)) {
+ return "invalidType";
+ }
+ if (file.size > MAX_AVATAR_UPLOAD_SIZE) {
+ return "tooLarge";
+ }
+ return null;
+}
+
+// ── API shapes ────────────────────────────────────────────────────────────────
+
+interface AvatarUploadSession {
+ file_id: string;
+ upload_url?: string;
+}
+
+export interface AvatarResult {
+ avatar_url?: string | null;
+ avatar_thumb_url?: string | null;
+}
+
+// ── API calls ─────────────────────────────────────────────────────────────────
+
+async function initiateAvatarUpload(
+ basePath: string,
+ payload: { file_name: string; content_type: string; file_size: number },
+): Promise {
+ const { data } = await apiClient.instance.post<
+ SuccessEnvelope
+ >(`${basePath}/avatar/initiate-upload`, payload);
+ return data.data;
+}
+
+// The server omits avatar_url/avatar_thumb_url entirely (rather than sending
+// them as null) when an owner has no avatar, since the DTO fields are
+// `*string` with `json:",omitempty"`. Callers merge this result onto cached
+// query data with `{ ...old, ...result }` — spreading an object that's
+// missing a key leaves the stale value from `old` in place, so a removal
+// would silently fail to clear the avatar until the next full refetch.
+// Normalizing both keys to be always-present (real URL or explicit null)
+// here means every caller's spread-merge just works.
+function normalizeAvatarResult(result: AvatarResult): Required {
+ return {
+ avatar_url: result.avatar_url ?? null,
+ avatar_thumb_url: result.avatar_thumb_url ?? null,
+ };
+}
+
+async function completeAvatarUpload(
+ basePath: string,
+ fileId: string,
+): Promise {
+ const { data } = await apiClient.instance.post>(
+ `${basePath}/avatar/complete-upload`,
+ { file_id: fileId },
+ );
+ return normalizeAvatarResult(data.data);
+}
+
+/**
+ * Uploads a single image file directly to the object store via a presigned
+ * URL, then confirms with the API so the server can derive the "full" and
+ * "thumb" variants. `basePath` selects the owner — `/users/me`,
+ * `/projects/{projectId}/agents/{agentId}`, or `/admin/agents/{agentId}`.
+ */
+export async function uploadAvatar(
+ basePath: string,
+ file: File,
+ onProgress?: (loaded: number, total: number) => void,
+): Promise {
+ const session = await initiateAvatarUpload(basePath, {
+ file_name: file.name,
+ content_type: file.type || "application/octet-stream",
+ file_size: file.size,
+ });
+ if (!session.upload_url) {
+ throw new Error("Server returned no upload URL");
+ }
+ const uploadUrl = session.upload_url;
+
+ await new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+ xhr.open("PUT", uploadUrl);
+ xhr.setRequestHeader(
+ "Content-Type",
+ file.type || "application/octet-stream",
+ );
+ xhr.upload.addEventListener("progress", (e) => {
+ if (e.lengthComputable) onProgress?.(e.loaded, e.total);
+ });
+ xhr.addEventListener("load", () =>
+ xhr.status >= 200 && xhr.status < 300
+ ? resolve()
+ : reject(new Error(`Upload failed: ${xhr.status}`)),
+ );
+ xhr.addEventListener("error", () => reject(new Error("Upload error")));
+ xhr.send(file);
+ });
+
+ return completeAvatarUpload(basePath, session.file_id);
+}
+
+/** Removes the avatar at basePath (see uploadAvatar for basePath shapes). */
+export async function removeAvatar(basePath: string): Promise {
+ const { data } = await apiClient.instance.delete<
+ SuccessEnvelope
+ >(`${basePath}/avatar`);
+ return normalizeAvatarResult(data.data);
+}
diff --git a/apps/web/src/lib/doc-api.ts b/apps/web/src/lib/doc-api.ts
index e256daca..4b474dfc 100644
--- a/apps/web/src/lib/doc-api.ts
+++ b/apps/web/src/lib/doc-api.ts
@@ -79,6 +79,8 @@ export interface DocActivity {
actor_id: string | null;
actor_name: string;
actor_username: string;
+ actor_avatar_url?: string | null;
+ actor_avatar_thumb_url?: string | null;
activity_type: DocActivityType;
content: string | DocActivityContent | null;
created_at: string;
diff --git a/apps/web/src/lib/interaction-api.ts b/apps/web/src/lib/interaction-api.ts
index 3ee97832..5a712e62 100644
--- a/apps/web/src/lib/interaction-api.ts
+++ b/apps/web/src/lib/interaction-api.ts
@@ -872,6 +872,8 @@ export interface Activity {
actor_id?: string | null;
actor_name: string;
actor_username: string;
+ actor_avatar_url?: string | null;
+ actor_avatar_thumb_url?: string | null;
activity_type: string;
content: Record | unknown[];
created_at: string;
diff --git a/apps/web/src/lib/mention-api.ts b/apps/web/src/lib/mention-api.ts
index 875c0996..ea1d02c3 100644
--- a/apps/web/src/lib/mention-api.ts
+++ b/apps/web/src/lib/mention-api.ts
@@ -7,7 +7,8 @@ export interface TeamMember {
id: string;
name: string;
username: string;
- avatar?: string | null | undefined;
+ avatarUrl?: string | null | undefined;
+ avatarThumbUrl?: string | null | undefined;
}
export interface MentionableTask {
@@ -80,7 +81,8 @@ export function useMentionData(projectId?: string | null) {
: member.user_id,
name: member.full_name,
username: member.username,
- avatar: member.full_name.slice(0, 2).toUpperCase() || undefined,
+ avatarUrl: member.avatar_url,
+ avatarThumbUrl: member.avatar_thumb_url,
}));
const mentionDocs: MentionableDocument[] = documents.map((doc) => ({
diff --git a/apps/web/src/lib/project-api.ts b/apps/web/src/lib/project-api.ts
index 4d43eb06..04c610df 100644
--- a/apps/web/src/lib/project-api.ts
+++ b/apps/web/src/lib/project-api.ts
@@ -12,10 +12,24 @@ export interface Project {
is_public: boolean;
task_id_prefix: string;
settings: Record;
+ avatar_url?: string | null;
+ avatar_thumb_url?: string | null;
created_by?: string;
created_at: string;
}
+/** Text-avatar fallback for a project — one letter per word, up to two
+ * words (e.g. "Test Project" -> "TP"). Shared so every surface (sidebar,
+ * project cards, settings) renders the same initials for the same name. */
+export function getProjectInitials(name: string): string {
+ return name
+ .split(/\s+/)
+ .filter(Boolean)
+ .slice(0, 2)
+ .map((w) => w[0].toUpperCase())
+ .join("");
+}
+
export interface ProjectListResult {
items: Project[];
total: number;
@@ -41,6 +55,13 @@ export interface ProjectMember {
agent_id?: string;
agent_name?: string;
agent_handle?: string;
+ avatar_url?: string | null;
+ avatar_thumb_url?: string | null;
+ // Only meaningful when member_type is "agent" — used to pick a default
+ // provider-logo avatar when this member has no avatar_url of its own.
+ agent_type?: string; // "llm" | "acp"
+ agent_llm_provider?: string;
+ agent_acp_provider?: string | null;
}
export interface ProjectRole {
diff --git a/apps/web/src/lib/provider-logos.ts b/apps/web/src/lib/provider-logos.ts
new file mode 100644
index 00000000..4a733643
--- /dev/null
+++ b/apps/web/src/lib/provider-logos.ts
@@ -0,0 +1,139 @@
+import type { Agent } from "./agent-api";
+import type { ProjectMember } from "./project-api";
+
+// Default agent avatar placeholders, shown when an agent has no custom
+// uploaded avatar — one recognizable brand mark per LLM/ACP provider instead
+// of bare initials. Assets are static SVGs served from public/provider-logos
+// (sourced from @lobehub/icons-static-svg, MIT licensed), not part of the
+// upload/S3 pipeline: they're a pure display fallback, same tier as the
+// initials fallback they replace.
+//
+// Deliberately covers only the mainstream providers users are realistically
+// likely to pick — not the full ~115-entry raw LLM catalog (most of which
+// are non-chat or extremely niche). Anything unmapped here falls back to
+// initials exactly as before.
+const LLM_PROVIDER_LOGOS: Record = {
+ anthropic: "/provider-logos/anthropic.svg",
+ openai: "/provider-logos/openai.svg",
+ gemini: "/provider-logos/gemini.svg",
+ mistral: "/provider-logos/mistral.svg",
+ cohere: "/provider-logos/cohere.svg",
+ xai: "/provider-logos/xai.svg",
+ deepseek: "/provider-logos/deepseek.svg",
+ groq: "/provider-logos/groq.svg",
+ perplexity: "/provider-logos/perplexity.svg",
+ openrouter: "/provider-logos/openrouter.svg",
+ ollama: "/provider-logos/ollama.svg",
+ azure: "/provider-logos/azure.svg",
+ bedrock: "/provider-logos/bedrock.svg",
+ meta_llama: "/provider-logos/meta_llama.svg",
+ vertex_ai: "/provider-logos/vertex_ai.svg",
+};
+
+// "custom" has no fixed provider identity, so it's intentionally unmapped —
+// falls back to initials like any other unmapped provider.
+const ACP_PROVIDER_LOGOS: Record = {
+ "claude-code": "/provider-logos/claude-code.svg",
+ codex: "/provider-logos/codex.svg",
+ "gemini-cli": "/provider-logos/gemini-cli.svg",
+};
+
+function lookupProviderLogo(
+ agentType: string | undefined,
+ llmProvider: string | undefined,
+ acpProvider: string | null | undefined,
+): string | undefined {
+ if (agentType === "acp") {
+ return acpProvider ? ACP_PROVIDER_LOGOS[acpProvider] : undefined;
+ }
+ return llmProvider
+ ? LLM_PROVIDER_LOGOS[llmProvider.toLowerCase()]
+ : undefined;
+}
+
+/**
+ * Returns the default avatar for an agent's configured provider, or
+ * undefined if the provider isn't mapped (caller should fall back to
+ * initials in that case, same as when the agent has no avatar at all).
+ */
+function getDefaultAgentAvatar(
+ agent: Pick,
+): string | undefined {
+ return lookupProviderLogo(
+ agent.agent_type,
+ agent.llm_provider,
+ agent.acp_provider,
+ );
+}
+
+/**
+ * getDefaultAgentAvatar's sibling for the lighter-weight ProjectMember
+ * projection (team page, assignee/reporter pickers, task chips) — same
+ * lookup, different field names since those come from a JOIN rather than
+ * the full Agent record. Returns undefined for human members.
+ */
+function getDefaultMemberAvatar(
+ member: Pick<
+ ProjectMember,
+ "member_type" | "agent_type" | "agent_llm_provider" | "agent_acp_provider"
+ >,
+): string | undefined {
+ if (member.member_type !== "agent") return undefined;
+ return lookupProviderLogo(
+ member.agent_type,
+ member.agent_llm_provider,
+ member.agent_acp_provider,
+ );
+}
+
+// ── Resolved avatar URL — the single "avatar -> default avatar" step ───────────
+//
+// Every display site ultimately wants one thing: the URL to render, with a
+// real upload always winning over the provider-logo default. Previously each
+// call site re-derived that itself via `x.avatar_thumb_url ?? getDefaultXAvatar(x)`
+// — easy to typo the field, forget the `??`, or omit the default entirely (all
+// three happened across different components over time). These two functions
+// are the only place that priority is expressed; every caller goes through
+// one of them instead of inlining the merge. The remaining, final tier —
+// falling back to initials/an icon when this returns undefined — stays with
+// the rendering components (EntityAvatarContent / AvatarUpload), since that
+// part is display markup, not a resolution rule.
+
+/**
+ * Resolves an agent's own record to the avatar URL to render: its real
+ * upload if present, else its provider-logo default, else undefined. `size`
+ * picks which uploaded variant to prefer — "full" for large headers, "thumb"
+ * (default) for every small chip/list use.
+ */
+export function resolveAgentAvatarUrl(
+ agent: Pick<
+ Agent,
+ | "avatar_url"
+ | "avatar_thumb_url"
+ | "agent_type"
+ | "llm_provider"
+ | "acp_provider"
+ >,
+ size: "full" | "thumb" = "thumb",
+): string | undefined {
+ const uploaded = size === "full" ? agent.avatar_url : agent.avatar_thumb_url;
+ return uploaded ?? getDefaultAgentAvatar(agent);
+}
+
+/**
+ * resolveAgentAvatarUrl's sibling for the lighter-weight ProjectMember
+ * projection (team page, assignee/reporter pickers, task chips). Always
+ * thumb-sized — ProjectMember-based surfaces never need the full variant.
+ */
+export function resolveMemberAvatarUrl(
+ member: Pick<
+ ProjectMember,
+ | "avatar_thumb_url"
+ | "member_type"
+ | "agent_type"
+ | "agent_llm_provider"
+ | "agent_acp_provider"
+ >,
+): string | undefined {
+ return member.avatar_thumb_url ?? getDefaultMemberAvatar(member);
+}
diff --git a/apps/web/src/routes/_authenticated/home/index.tsx b/apps/web/src/routes/_authenticated/home/index.tsx
index 1280659a..55ba3b65 100644
--- a/apps/web/src/routes/_authenticated/home/index.tsx
+++ b/apps/web/src/routes/_authenticated/home/index.tsx
@@ -18,6 +18,7 @@ import { type ComponentType, useState } from "react";
import { useTranslation } from "react-i18next";
import { AssignedTasksList } from "@/components/home/assigned-tasks-list";
import { UpdateBanner } from "@/components/home/UpdateBanner";
+import { EntityAvatarContent } from "@/components/shared/entity-avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -41,6 +42,7 @@ import { currentUserQueryOptions } from "@/lib/auth-api";
import { assignedTasksQueryOptions } from "@/lib/interaction-api";
import {
createProject,
+ getProjectInitials,
type Project,
projectsQueryOptions,
workspaceStatsQueryOptions,
@@ -328,12 +330,7 @@ function CreateProjectDialog({
function ProjectCard({ project }: { project: Project }) {
const { t } = useTranslation("shared");
- const initials = project.name
- .split(/\s+/)
- .filter(Boolean)
- .slice(0, 2)
- .map((w) => w[0].toUpperCase())
- .join("");
+ const initials = getProjectInitials(project.name);
const formattedDate = new Date(project.created_at).toLocaleDateString(
"en-US",
@@ -349,7 +346,9 @@ function ProjectCard({ project }: { project: Project }) {
- {initials || }
+
+ {initials || }
+
diff --git a/apps/web/src/routes/_authenticated/profile/index.tsx b/apps/web/src/routes/_authenticated/profile/index.tsx
index 3d88fa81..049227ae 100644
--- a/apps/web/src/routes/_authenticated/profile/index.tsx
+++ b/apps/web/src/routes/_authenticated/profile/index.tsx
@@ -4,7 +4,7 @@ import { CalendarDays, User } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ChangePasswordCard } from "@/components/profile/ChangePasswordCard";
-import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+import { AvatarUpload } from "@/components/shared/avatar-upload";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -22,19 +22,21 @@ import { Skeleton } from "@/components/ui/skeleton";
import { apiClient } from "@/lib/api-client";
import type { SuccessEnvelope } from "@/lib/api-error";
import type { User as UserType } from "@/lib/auth-api";
-import { currentUserQueryOptions } from "@/lib/auth-api";
+import {
+ currentUserOptionalQueryOptions,
+ currentUserQueryOptions,
+} from "@/lib/auth-api";
import { formatDate } from "@/lib/format-date";
export const Route = createFileRoute("/_authenticated/profile/")({
component: ProfilePage,
});
-async function updateProfile(
- userId: string,
- payload: { full_name: string },
-): Promise {
+async function updateProfile(payload: {
+ full_name: string;
+}): Promise {
const { data } = await apiClient.instance.patch>(
- `/users/${userId}`,
+ "/users/me",
payload,
);
return data.data;
@@ -60,12 +62,7 @@ function ProfilePage() {
const [serverError, setServerError] = useState(null);
const mutation = useMutation({
- mutationFn: () => {
- if (!user) {
- throw new Error("User is not loaded");
- }
- return updateProfile(user.id, { full_name: fullName.trim() });
- },
+ mutationFn: () => updateProfile({ full_name: fullName.trim() }),
onSuccess: (updated) => {
queryClient.setQueryData(currentUserQueryOptions.queryKey, updated);
setEditing(false);
@@ -181,11 +178,36 @@ function ProfilePage() {
-
-
- {initials}
-
-
+ {
+ // Two separate caches back "who am I" (this page's own query,
+ // and user-menu.tsx's optional variant used in the top-nav) —
+ // both need the update or one keeps showing the pre-upload
+ // avatar until its staleTime lapses.
+ queryClient.setQueryData(
+ currentUserQueryOptions.queryKey,
+ (old) => (old ? { ...old, ...result } : old),
+ );
+ queryClient.setQueryData(
+ currentUserOptionalQueryOptions.queryKey,
+ (old) => (old ? { ...old, ...result } : old),
+ );
+ }}
+ />