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
11 changes: 11 additions & 0 deletions src/features/agents/lib/agentBuilderIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,17 @@ export function isPlaceholderAgentName(name: string): boolean {
);
}

// Re-exported here so callers dealing with agent/draft identity (name,
// description, body placeholders) have one place to import from, alongside
// isPlaceholderAgentName above. The check itself lives in
// @/shared/api/agents, which needs it for create/update/export — this module
// only imports a *type* from there (erased at build time), so re-exporting
// its values here doesn't create a real circular dependency.
export {
hasRealAgentDescription,
isPlaceholderAgentDescription,
} from "@/shared/api/agents";

export function isEmptyPlaceholderDraft(source: AgentSourceEntry): boolean {
const builderSessionId =
typeof source.properties?.builderSessionId === "string"
Expand Down
35 changes: 35 additions & 0 deletions src/features/agents/lib/personaPresentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
canDeletePersona,
canEditPersona,
getPersonaSource,
getRealPersonaDescription,
isPersonaReadOnly,
} from "./personaPresentation";

Expand Down Expand Up @@ -34,4 +35,38 @@ describe("personaPresentation", () => {
expect(getPersonaSource(persona({ writable: true }))).toBe("file");
expect(getPersonaSource(persona({ writable: false }))).toBe("builtin");
});

it("returns a real, user-authored description as-is", () => {
expect(
getRealPersonaDescription(
persona({ sourceDescription: "Reviews your code and catches bugs." }),
),
).toBe("Reviews your code and catches bugs.");
});

it("treats the legacy 'Agent' placeholder as no description", () => {
expect(
getRealPersonaDescription(persona({ sourceDescription: "Agent" })),
).toBeUndefined();
// Case-insensitive and trims whitespace, since the placeholder could
// come back from the API in either form.
expect(
getRealPersonaDescription(persona({ sourceDescription: " AGENT " })),
).toBeUndefined();
});

it("treats the builder-draft 'Draft' placeholder as no description", () => {
expect(
getRealPersonaDescription(persona({ sourceDescription: "Draft" })),
).toBeUndefined();
});

it("treats a missing or empty description as no description", () => {
expect(
getRealPersonaDescription(persona({ sourceDescription: undefined })),
).toBeUndefined();
expect(
getRealPersonaDescription(persona({ sourceDescription: " " })),
).toBeUndefined();
});
});
9 changes: 9 additions & 0 deletions src/features/agents/lib/personaPresentation.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import type { Persona } from "@/shared/types/agents";
import { hasRealAgentDescription } from "@/features/agents/lib/agentBuilderIdentity";

export type PersonaSource = "builtin" | "file";

export function getRealPersonaDescription(
persona: Persona,
): string | undefined {
return hasRealAgentDescription(persona.sourceDescription)
? persona.sourceDescription?.trim()
: undefined;
}

type ProviderLabel = {
id: string;
label: string;
Expand Down
18 changes: 18 additions & 0 deletions src/features/agents/ui/AgentBuilderRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
fileStem,
isPlaceholderAgentName,
PLACEHOLDER_AGENT_BODY,
PLACEHOLDER_AGENT_DESCRIPTION,
promoteDraft,
} from "@/features/agents/lib/agentBuilderSession";
import { useExperiment } from "@/features/experiments/experimentPreferences";
Expand Down Expand Up @@ -302,6 +303,10 @@ export function AgentBuilderRail({
: null;
const nameFieldValue =
data && !isPlaceholderAgentName(data.name) ? data.name : "";
const descriptionFieldValue =
data && data.description !== PLACEHOLDER_AGENT_DESCRIPTION
? data.description
: "";
const contentFieldValue = data?.content ?? "";
const isPlaceholderContent = contentFieldValue === PLACEHOLDER_AGENT_BODY;
const instructionsFieldValue = isPlaceholderContent ? "" : contentFieldValue;
Expand Down Expand Up @@ -712,6 +717,19 @@ export function AgentBuilderRail({
/>
</label>

<label className="block text-sm" htmlFor="builder-rail-description">
<span className={FIELD_LABEL_CLASS}>
{t("builderRail.descriptionLabel")}
</span>
<Input
id="builder-rail-description"
value={descriptionFieldValue}
placeholder={t("builderRail.descriptionPlaceholder")}
onChange={(event) => update({ description: event.target.value })}
className={FIELD_CLASS}
/>
</label>

<ProviderModelFields
provider={provider}
modelProviderId={modelProviderId}
Expand Down
17 changes: 15 additions & 2 deletions src/features/agents/ui/AgentDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
canDeletePersona,
canEditPersona,
getPersonaProviderLabel,
getRealPersonaDescription,
} from "@/features/agents/lib/personaPresentation";
import {
AGENT_PROFILE_FIELDS_TRANSITION_NAME,
Expand Down Expand Up @@ -143,6 +144,7 @@ export function AgentDetailPage({
!isBundledAvatarRef(normalizedAvatarValue ?? "") &&
normalizedAvatarValue !== personaAvatarValue &&
!avatarSavePending;
const descriptionValue = getRealPersonaDescription(persona);
const providerLabel = getPersonaProviderLabel(
persona.provider,
acpProviders,
Expand All @@ -154,11 +156,22 @@ export function AgentDetailPage({
const avatarTransitionName = getAgentAvatarTransitionName(persona.id);
const fallbackAvatarSrc = resolveAgentIcon(persona.id);
const metadata = [
descriptionValue
? {
label: t("view.description"),
value: descriptionValue,
multiline: true,
}
: null,
{ label: t("editor.provider"), value: providerLabel },
{ label: t("editor.model"), value: modelLabel },
createdLabel ? { label: t("view.created"), value: createdLabel } : null,
updatedLabel ? { label: t("view.updated"), value: updatedLabel } : null,
].filter(Boolean) as Array<{ label: string; value: string }>;
].filter(Boolean) as Array<{
label: string;
value: string;
multiline?: boolean;
}>;

if (previousPersonaAvatarValue !== personaAvatarValue) {
setPreviousPersonaAvatarValue(personaAvatarValue);
Expand Down Expand Up @@ -274,7 +287,7 @@ export function AgentDetailPage({
className={AVATAR_CUSTOMIZE_LABEL_CLASS}
aria-hidden="true"
>
{t("editor.changeAvatar")}
{t("builderRail.changeAvatar")}
</Badge>
</>
) : null}
Expand Down
9 changes: 8 additions & 1 deletion src/features/agents/ui/AgentIdentityRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { cn } from "@/shared/lib/cn";
interface AgentIdentityMetadataItem {
label: string;
value: string;
/** Breaks anywhere, for unbroken strings like file paths. */
wrap?: boolean;
/** Wraps at word boundaries, for real prose like a description. */
multiline?: boolean;
}

interface AgentIdentityRailProps {
Expand Down Expand Up @@ -62,7 +65,11 @@ export function AgentIdentityRail({
<dd
className={cn(
"text-[14px] leading-5 text-surface-agent-profile-fg-80",
item.wrap ? "break-all" : "truncate",
item.multiline
? "whitespace-pre-line break-words"
: item.wrap
? "break-all"
: "truncate",
)}
>
{item.value}
Expand Down
3 changes: 2 additions & 1 deletion src/features/agents/ui/PersonaCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type { Persona } from "@/shared/types/agents";
import {
canDeletePersona,
canEditPersona,
getRealPersonaDescription,
} from "@/features/agents/lib/personaPresentation";
import { resolveAgentIcon } from "@/features/agents/lib/resolveAgentIcon";
import { getAgentAvatarTransitionName } from "@/features/agents/lib/agentViewTransitions";
Expand Down Expand Up @@ -254,7 +255,7 @@ export const PersonaCard = memo(function PersonaCard({
</div>

<p className="line-clamp-3 max-w-[28ch] text-xs font-normal leading-4 text-muted-foreground">
{persona.systemPrompt}
{getRealPersonaDescription(persona) ?? persona.systemPrompt}
</p>
</div>
</div>
Expand Down
30 changes: 30 additions & 0 deletions src/features/agents/ui/__tests__/AgentBuilderRail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ vi.mock("@/features/agents/lib/agentBuilderSession", () => ({
name === "Untitled agent" || name.startsWith("Untitled agent "),
PLACEHOLDER_AGENT_NAME: "Untitled agent",
PLACEHOLDER_AGENT_BODY: "Draft in progress.",
PLACEHOLDER_AGENT_DESCRIPTION: "Draft",
}));

vi.mock("@/features/agents/hooks/useAvatarLibrary", () => ({
Expand Down Expand Up @@ -211,6 +212,35 @@ describe("AgentBuilderRail", () => {
expect(update).toHaveBeenCalledWith({ content: "Be snarky." });
});

it("calls update() when the description field changes", () => {
const { update } = mockHook();
renderWithProviders(
<AgentBuilderRail
sessionId="s1"
targetAgentPath={baseSource.path}
targetAgentSlug="draft-1"
/>,
);
fireEvent.change(screen.getByLabelText(/description/i), {
target: { value: "Catches bugs before you ship them." },
});
expect(update).toHaveBeenCalledWith({
description: "Catches bugs before you ship them.",
});
});

it("treats the placeholder draft description as empty in the field", () => {
mockHook({ data: { ...baseSource, description: "Draft" } });
renderWithProviders(
<AgentBuilderRail
sessionId="s1"
targetAgentPath={baseSource.path}
targetAgentSlug="draft-1"
/>,
);
expect(screen.getByLabelText(/description/i)).toHaveValue("");
});

it("renders the placeholder draft body as muted placeholder text", () => {
mockHook();
renderWithProviders(
Expand Down
33 changes: 32 additions & 1 deletion src/features/agents/ui/__tests__/AgentsView.entry.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ vi.mock("@/shared/api/agents", () => ({
readAgentSourceFile: vi.fn().mockResolvedValue(mockDraftSource),
updatePersonaSource: vi.fn().mockResolvedValue(mockDraftSource),
deletePersonaSource: vi.fn().mockResolvedValue(undefined),
isPlaceholderAgentDescription: (description: string | undefined | null) => {
const trimmed = description?.trim().toLowerCase();
return !trimmed || trimmed === "agent" || trimmed === "draft";
},
hasRealAgentDescription: (description: string | undefined | null) => {
const trimmed = description?.trim().toLowerCase();
return Boolean(trimmed) && trimmed !== "agent" && trimmed !== "draft";
},
}));

vi.mock("@/shared/api/system", () => ({
Expand Down Expand Up @@ -373,6 +381,29 @@ describe("AgentsView entry points", () => {
expect(onActivePersonaIdChange).toHaveBeenCalledWith(null, undefined);
});

it("shows the agent's description on the detail page, next to provider and model", () => {
useAgentStore.setState({
personas: [
{ ...persona, sourceDescription: "Reviews your code carefully." },
],
});

render(<AgentsView activePersonaId={persona.id} />);

expect(screen.getByText("view.description")).toBeInTheDocument();
expect(
screen.getByText("Reviews your code carefully."),
).toBeInTheDocument();
});

it("shows no description row on the detail page when there's no real description", () => {
useAgentStore.setState({ personas: [persona] });

render(<AgentsView activePersonaId={persona.id} />);

expect(screen.queryByText("view.description")).not.toBeInTheDocument();
});

it("shows and activates the avatar customization affordance", async () => {
useAgentStore.setState({ personas: [persona] });
const user = userEvent.setup();
Expand All @@ -382,7 +413,7 @@ describe("AgentsView entry points", () => {
const customizeAvatar = screen.getByRole("button", {
name: "editor.customizeAvatar",
});
expect(screen.getByText("editor.changeAvatar")).toBeInTheDocument();
expect(screen.getByText("builderRail.changeAvatar")).toBeInTheDocument();
customizeAvatar.focus();
expect(customizeAvatar).toHaveFocus();
await user.keyboard("{Enter}");
Expand Down
36 changes: 34 additions & 2 deletions src/features/agents/ui/__tests__/PersonaCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,10 +146,42 @@ describe("PersonaCard", () => {
expect(screen.queryByText(/claude-sonnet/i)).not.toBeInTheDocument();
});

it("shows system prompt preview", () => {
it("shows the agent's description, not its instructions", () => {
render(
<PersonaCard
persona={makePersona({ systemPrompt: "You are a coding assistant." })}
persona={makePersona({
systemPrompt: "You are a coding assistant.",
sourceDescription: "Reviews your code and catches bugs.",
})}
/>,
);
expect(
screen.getByText("Reviews your code and catches bugs."),
).toBeInTheDocument();
expect(
screen.queryByText("You are a coding assistant."),
).not.toBeInTheDocument();
});

it("falls back to instructions when there's no real description", () => {
render(
<PersonaCard
persona={makePersona({
systemPrompt: "You are a coding assistant.",
sourceDescription: undefined,
})}
/>,
);
expect(screen.getByText("You are a coding assistant.")).toBeInTheDocument();
});

it("falls back to instructions when the description is a placeholder", () => {
render(
<PersonaCard
persona={makePersona({
systemPrompt: "You are a coding assistant.",
sourceDescription: "Agent",
})}
/>,
);
expect(screen.getByText("You are a coding assistant.")).toBeInTheDocument();
Expand Down
9 changes: 2 additions & 7 deletions src/features/agents/ui/share-card/agentShareCard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import cardFoil from "@/features/agents/assets/share-card/card-foil.png";
import berdCardLogo from "@/features/agents/assets/share-card/berd-card-logo.svg";
import type { ResolvedAvatarMedia } from "@/shared/avatars/catalog";
import type { Persona } from "@/shared/types/agents";
import { getRealPersonaDescription } from "@/features/agents/lib/personaPresentation";
import {
fallbackAgentCardColor,
sampleAgentAvatarColor,
Expand All @@ -24,13 +25,7 @@ export function getAgentShareCardBase(_personaId: string): string {
}

export function getAgentShareDescription(persona: Persona): string {
const sourceDescription = persona.sourceDescription?.trim();
const candidate =
sourceDescription && sourceDescription.toLowerCase() !== "agent"
? sourceDescription
: persona.systemPrompt.trim();

return candidate;
return getRealPersonaDescription(persona) ?? persona.systemPrompt.trim();
}

export function getAgentShareFilename(name: string): string {
Expand Down
Loading