Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 1 addition & 2 deletions apps/web/.cross-domain-allowlist.json
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,7 @@
"avatar"
],
"src/domains/intelligence/components/identity-tab.tsx": [
"avatar",
"chat"
"avatar"
],
"src/domains/intelligence/identity-page.tsx": [
"chat"
Expand Down
49 changes: 49 additions & 0 deletions apps/web/src/assistant/identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* Runtime identity for the assistant: name, role, personality, emoji,
* home, version, and (optionally) creation timestamp.
*
* Fetched from the daemon through the wildcard proxy. Returns `null`
* when the identity cannot be retrieved (the assistant is still
* initializing, the runtime is unreachable, etc.) so the caller can
* fall back to a stub.
*/
import { client } from "@/generated/api/client.gen.js";
import { assertHasResponse } from "@/lib/api-errors.js";

// `client.get` needs a baseUrl when there's no `window` (SSR / unit tests).
const SDK_BASE_OPTIONS =
typeof window === "undefined"
? ({ baseUrl: "http://localhost" } as const)
: ({} as const);

export interface AssistantIdentity {
name: string;
role: string;
personality: string;
emoji: string;
home: string;
version: string;
createdAt?: string;
}

export async function fetchAssistantIdentity(
assistantId: string,
): Promise<AssistantIdentity | null> {
try {
const { data, error, response } = await client.get<AssistantIdentity, unknown>({
...SDK_BASE_OPTIONS,
url: "/v1/assistants/{assistant_id}/identity/",
path: { assistant_id: assistantId },
throwOnError: false,
});
assertHasResponse(response, error, "Failed to fetch assistant identity");

if (!response.ok || !data || typeof data !== "object") {
return null;
}

return data;
} catch {
return null;
}
}
100 changes: 100 additions & 0 deletions apps/web/src/domains/avatar/use-assistant-avatar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type { ReactNode } from "react";
import { afterEach, describe, expect, mock, test } from "bun:test";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cleanup, renderHook, waitFor } from "@testing-library/react";

import type { CharacterComponents, CharacterTraits } from "@/domains/avatar/types.js";

const components: CharacterComponents = {
bodyShapes: [
{
id: "brontosaurus",
viewBox: { width: 128, height: 256 },
faceCenter: { x: 64, y: 80 },
svgPath: "M 64 128 C 80 144 96 160 64 176 C 32 160 48 144 64 128 Z",
},
],
eyeStyles: [
{
id: "curious",
sourceViewBox: { width: 32, height: 32 },
eyeCenter: { x: 16, y: 16 },
paths: [{ svgPath: "M 8 16 A 8 8 0 0 1 24 16", color: "#000" }],
},
],
colors: [{ id: "cosmic-purple", hex: "#7c3aed" }],
faceCenterOverrides: [],
};

const traits: CharacterTraits = {
bodyShape: "brontosaurus",
eyeStyle: "curious",
color: "cosmic-purple",
};

const fetchCharacterComponents = mock(async () => components);
const fetchCharacterTraits = mock(async () => traits);
const fetchAvatarImageUrl = mock(async () => null as string | null);

mock.module("@/domains/avatar/api", () => ({
fetchCharacterComponents,
fetchCharacterTraits,
fetchAvatarImageUrl,
}));

const { useAssistantAvatar } = await import("@/domains/avatar/use-assistant-avatar.js");

function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});

return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
}

afterEach(() => {
cleanup();
fetchCharacterComponents.mockClear();
fetchCharacterTraits.mockClear();
fetchAvatarImageUrl.mockClear();
fetchCharacterComponents.mockResolvedValue(components);
fetchCharacterTraits.mockResolvedValue(traits);
fetchAvatarImageUrl.mockResolvedValue(null);
});

describe("useAssistantAvatar", () => {
test("skips character traits when a custom avatar image is available", async () => {
fetchAvatarImageUrl.mockResolvedValueOnce("blob:avatar-image");

const { result } = renderHook(() => useAssistantAvatar("asst-1"), {
wrapper: createWrapper(),
});

await waitFor(() => {
expect(result.current.customImageUrl).toBe("blob:avatar-image");
});

expect(result.current.components).toEqual(components);
expect(result.current.traits).toBeNull();
expect(fetchCharacterComponents).toHaveBeenCalledTimes(1);
expect(fetchAvatarImageUrl).toHaveBeenCalledTimes(1);
expect(fetchCharacterTraits).not.toHaveBeenCalled();
});

test("fetches character traits when no avatar image is available", async () => {
const { result } = renderHook(() => useAssistantAvatar("asst-1"), {
wrapper: createWrapper(),
});

await waitFor(() => {
expect(result.current.traits).toEqual(traits);
});

expect(result.current.customImageUrl).toBeNull();
expect(fetchCharacterComponents).toHaveBeenCalledTimes(1);
expect(fetchAvatarImageUrl).toHaveBeenCalledTimes(1);
expect(fetchCharacterTraits).toHaveBeenCalledTimes(1);
});
});
9 changes: 7 additions & 2 deletions apps/web/src/domains/avatar/use-assistant-avatar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,16 @@ export function useAssistantAvatar(assistantId: string | null) {
queryKey: avatarQueryKey(assistantId ?? ""),
queryFn: async () => {
const id = assistantId!;
const [components, traits, imageUrl] = await Promise.all([
const [components, imageUrl] = await Promise.all([
fetchCharacterComponents(id),
fetchCharacterTraits(id),
fetchAvatarImageUrl(id),
]);
// Skip the traits fetch when a custom image exists — the traits
// file is intentionally deleted on the daemon side in that case,
// so requesting it just generates 404s on every SSE-driven
// reconnect invalidation. `AvatarRenderer` only reads `traits`
// when there is no `customImageUrl`.
const traits = imageUrl ? null : await fetchCharacterTraits(id);

const prev = activeBlobUrls.get(id);
if (prev && prev !== imageUrl) {
Expand Down
48 changes: 6 additions & 42 deletions apps/web/src/domains/chat/api/assistant.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
/**
* Assistant-level operations: discovery, chat context bootstrapping,
* and runtime identity retrieval.
* Chat context bootstrapping: pick an assistant and build the
* initial conversation context the chat UI lands on.
*
* Runtime assistant identity (name, personality, emoji, etc.)
* lives at `@/assistant/identity.js` — it's a property of the
* assistant itself, not a chat concern.
*/

import {
Expand Down Expand Up @@ -89,43 +93,3 @@ export async function getChatContext(): Promise<ChatContext | null> {
return { assistantId, conversations, conversationKey };
}

// ---------------------------------------------------------------------------
// Runtime identity
// ---------------------------------------------------------------------------

export interface AssistantIdentity {
name: string;
role: string;
personality: string;
emoji: string;
home: string;
version: string;
createdAt?: string;
}

/**
* Fetch the runtime identity for an assistant via the wildcard proxy.
* Returns `null` when the identity cannot be retrieved (e.g. assistant
* is still initializing or the runtime is unreachable).
*/
export async function fetchAssistantIdentity(
assistantId: string,
): Promise<AssistantIdentity | null> {
try {
const { data, error, response } = await client.get<AssistantIdentity, unknown>({
...SDK_BASE_OPTIONS,
url: "/v1/assistants/{assistant_id}/identity/",
path: { assistant_id: assistantId },
throwOnError: false,
});
assertHasResponse(response, error, "Failed to fetch assistant identity");

if (!response.ok || !data || typeof data !== "object") {
return null;
}

return data;
} catch {
return null;
}
}
4 changes: 2 additions & 2 deletions apps/web/src/domains/chat/chat-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ import { useEventStream } from "@/domains/chat/hooks/use-event-stream.js";
import { useActiveAppPinSync } from "@/domains/chat/hooks/use-active-app-pin-sync.js";

import { createWebSyncRouter } from "@/lib/sync/web-sync-router.js";
import { fetchAssistantIdentity } from "@/domains/chat/api/assistant.js";
import { fetchAssistantIdentity } from "@/assistant/identity.js";
import { shouldSuppressGenericChatErrorNotice } from "@/domains/chat/utils/error-classification.js";
import { hasPendingAssistantResponse } from "@/domains/chat/utils/chat-utils.js";
import { isSurfaceInteractive } from "@/domains/chat/types/types.js";
Expand All @@ -88,7 +88,7 @@ import { MobileSubagentDetailOverlay } from "@/domains/chat/components/mobile-su
import { getEditChatKey, setEditChatKey } from "@/domains/chat/utils/edit-chat-session.js";
import { routes } from "@/utils/routes.js";
import { haptic } from "@/utils/haptics.js";
import type { AssistantIdentity } from "@/domains/chat/api/assistant.js";
import type { AssistantIdentity } from "@/assistant/identity.js";
import type { ChatEventStream } from "@/domains/chat/api/stream.js";
import {
ChatRouteContent,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ import type { QuestionResponseEntry, AllowlistOption, ScopeOption, DirectoryScop
import type { CharacterComponents, CharacterTraits } from "@/domains/avatar/types.js";
import { DiskPressureBanner, type DiskPressureBannerMode } from "@/domains/chat/components/disk-pressure-banner.js";
import type { VoiceInputButtonHandle } from "@/domains/chat/components/voice-input-button.js";
import type { AssistantIdentity } from "@/domains/chat/api/assistant.js";
import type { AssistantIdentity } from "@/assistant/identity.js";
import type { Conversation } from "@/domains/chat/api/conversations.js";
import { submitQuestionResponse } from "@/domains/chat/api/interactions.js";
import type { ChatEventStream } from "@/domains/chat/api/stream.js";
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/domains/chat/utils/chat-utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { DisplayMessage } from "@/domains/chat/types/types.js";
import type { AssistantIdentity } from "@/domains/chat/api/assistant.js";
import type { AssistantIdentity } from "@/assistant/identity.js";
import type { Conversation } from "@/domains/chat/api/conversations.js";
import type { AllowlistOption, AssistantEvent, DirectoryScopeOption, PendingToolConfirmation, ScopeOption } from "@/domains/chat/api/event-types.js";

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/domains/contacts/contacts-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import type {
ContactSelection,
} from "@/domains/contacts/types.js";
import { useAssistantContext } from "@/domains/chat/assistant-context.js";
import { fetchAssistantIdentity } from "@/domains/chat/api/assistant.js";
import { fetchAssistantIdentity } from "@/assistant/identity.js";
import { useFeatureFlagStore } from "@/lib/feature-flags/feature-flag-store.js";
import { routes } from "@/utils/routes.js";

Expand Down
54 changes: 39 additions & 15 deletions apps/web/src/domains/home/detail-panel/home-detail-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Circle, CircleCheck, X } from "lucide-react";
import { CircleX, Mail, MailOpen, MoreVertical, X } from "lucide-react";

import { Button, Typography } from "@vellum/design-library";
import { Button, Menu, Typography } from "@vellum/design-library";
import { CATEGORY_STYLES } from "../home-feed-filter-bar.js";
import { HomeGenericDetail } from "./home-generic-detail.js";
import { HomeToolPermissionCard } from "./home-tool-permission-card.js";
Expand All @@ -18,15 +18,19 @@ export interface HomeDetailPanelProps {
onClose: () => void;
onGoToThread: (conversationId: string) => void;
onUpdateStatus: (itemId: string, status: FeedItemStatus) => void;
onDismiss: (itemId: string) => void;
}

export function HomeDetailPanel({
item,
onClose,
onGoToThread,
onUpdateStatus,
onDismiss,
}: HomeDetailPanelProps) {
if (!item) return null;
if (!item) {
return null;
}

const panelKind = item.detailPanel?.kind;
const categoryStyle = resolveCategoryStyle(item.category);
Expand Down Expand Up @@ -60,27 +64,47 @@ export function HomeDetailPanel({
{item.title}
</Typography>

<Button
variant="outlined"
size="compact"
iconOnly={isUnread ? <CircleCheck /> : <Circle />}
onClick={() =>
onUpdateStatus(item.id, isUnread ? "seen" : "new")
}
aria-label={isUnread ? "Mark as read" : "Mark as unread"}
title={isUnread ? "Mark as read" : "Mark as unread"}
/>

{/* Go to Convo button — only when conversationId exists */}
{item.conversationId ? (
<Button
variant="outlined"
size="compact"
onClick={() => onGoToThread(item.conversationId!)}
>
Go to Thread
Go to Convo
</Button>
) : null}

{/* Overflow menu — mark-as-read toggle + dismiss */}
<Menu.Root>
<Menu.Trigger>
<Button
variant="outlined"
size="compact"
iconOnly={<MoreVertical />}
aria-label="More actions"
/>
</Menu.Trigger>
<Menu.Content align="end">
<Menu.Item
onSelect={() =>
onUpdateStatus(item.id, isUnread ? "seen" : "new")
}
>
{isUnread ? (
<MailOpen className="size-4" />
) : (
<Mail className="size-4" />
)}
{isUnread ? "Mark as read" : "Mark as unread"}
</Menu.Item>
<Menu.Item onSelect={() => onDismiss(item.id)}>
<CircleX className="size-4" />
Dismiss
</Menu.Item>
</Menu.Content>
</Menu.Root>

<Button
variant="outlined"
size="compact"
Expand Down
Loading