feat(desktop): unify add agent flows (#5015)

**Category:** improvement
**User Impact:** Users can create, discover, and import agents from one
consistent Add agent dialog.

**Problem:** Agent creation, discovery, and import were split across a
dropdown and separate dialogs, making the Add agent flow fragmented. The
existing E2E suite also continued targeting the deleted dropdown after
the flows were unified.

**Solution:** Route the new-agent card directly into a unified dialog
with dedicated Create, catalog, and Import navigation, then update the
affected E2E coverage to exercise that interface and its current empty
state.

<details>
<summary>File changes</summary>

**desktop/src/features/agents/ui/AgentDefinitionDialog.tsx**
Supports rendering the agent definition form inside the unified Add
agent experience while retaining the standalone dialog behavior.

**desktop/src/features/agents/ui/AgentDefinitionDialogShell.tsx**
Adds the shared shell used to present agent-definition content
consistently in embedded and standalone contexts.

**desktop/src/features/agents/ui/AgentDialog.tsx**
Passes the revised dialog state and close behavior through the existing
agent dialog entry point.

**desktop/src/features/agents/ui/AgentsView.tsx**
Connects the Agents page to the unified Add agent dialog and opens newly
added catalog agents in their profile panel.

**desktop/src/features/agents/ui/PersonaCatalogDialog.tsx**
Combines catalog browsing, agent creation, and snapshot import behind
persistent navigation, including dirty-navigation confirmation.

**desktop/src/features/agents/ui/UnifiedAgentsSection.tsx**
Replaces the new-agent dropdown with a direct Add agent entry point and
adjusts the responsive card grid.

**desktop/src/features/agents/ui/personaLibraryCopy.ts**
Updates catalog-facing copy for the unified experience.

**desktop/src/features/agents/ui/usePersonaActions.ts**
Returns the resolved local persona after catalog activation so the
caller can open the added agent.

**desktop/tests/e2e/agent-readiness-screenshots.spec.ts**
Opens the embedded create pane directly for readiness screenshots.

**desktop/tests/e2e/agents.spec.ts**
Covers unified Create, catalog, and Import navigation and asserts the
current shared-agent empty state.

**desktop/tests/e2e/global-agent-config-screenshots.spec.ts**
Updates global configuration screenshot setup for direct create-pane
entry.

**desktop/tests/e2e/inline-custom-harness.spec.ts**
Updates custom harness setup for the embedded create form.

**desktop/tests/e2e/persona-env-vars.spec.ts**
Updates environment-variable and model-provider scenarios for direct
create-pane entry.

**desktop/tests/e2e/persona-model-combobox-screenshots.spec.ts**
Updates model combobox screenshot setup for direct create-pane entry.

**desktop/tests/e2e/smoke.spec.ts**
Updates agent-creation smoke coverage for the unified Add agent dialog.

**desktop/tests/e2e/where-to-run-config.spec.ts**
Updates provider-selection coverage for the embedded create form.

</details>

## Reproduction steps

1. Open the Agents page and select the new-agent card.
2. Confirm the Add agent dialog opens directly on Create without an
intermediate dropdown.
3. Use the left navigation to browse shared agents and open Import.
4. Select a catalog agent and confirm the dialog closes and the added
agent's profile panel opens.
5. Run the affected desktop Playwright smoke and integration specs and
confirm all scenarios pass.

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
This commit is contained in:
Taylor Ho
2026-08-07 16:25:49 -07:00
committed by GitHub
parent c7b663680a
commit 02f640bc45
18 changed files with 871 additions and 619 deletions

View File

@@ -8,8 +8,6 @@ import type {
UpdatePersonaInput,
} from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
import { Dialog } from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { AgentCreationPreview } from "./AgentCreationPreview";
@@ -85,6 +83,7 @@ import {
import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState";
import { buildRuntimeModelProviderPayload } from "./agentDefinitionSubmitPayload";
import { AgentDefinitionDialogFooter } from "./AgentDefinitionDialogFooter";
import { AgentDefinitionDialogShell } from "./AgentDefinitionDialogShell";
import { AddCustomHarnessDialog } from "./AddCustomHarnessDialog";
import {
ADD_CUSTOM_HARNESS_OPTION,
@@ -94,6 +93,7 @@ import {
type AgentDefinitionDialogProps = {
open: boolean;
embedded?: boolean;
title: string;
description: string;
submitLabel: string;
@@ -102,6 +102,7 @@ type AgentDefinitionDialogProps = {
isPending: boolean;
runtimes: AcpRuntimeCatalogEntry[];
runtimeCatalogStatus?: "loading" | "ready" | "error";
onDirtyChange?: (dirty: boolean) => void;
onOpenChange: (open: boolean) => void;
onSubmit: (
input: CreatePersonaInput | UpdatePersonaInput,
@@ -120,6 +121,7 @@ export type AgentDefinitionSubmitOptions = {
export function AgentDefinitionDialog({
open,
embedded = false,
title,
description,
submitLabel,
@@ -128,6 +130,7 @@ export function AgentDefinitionDialog({
isPending,
runtimes,
runtimeCatalogStatus = "ready" as const,
onDirtyChange,
onOpenChange,
onSubmit,
publishCatalogUpdatesOnSave = false,
@@ -192,6 +195,10 @@ export function AgentDefinitionDialog({
(hasText(initialValues.model) || hasText(initialValues.provider)),
);
React.useEffect(() => {
onDirtyChange?.(hasUserChanges);
}, [hasUserChanges, onDirtyChange]);
React.useEffect(() => {
if (!open || !initialValues) {
return;
@@ -295,7 +302,8 @@ export function AgentDefinitionDialog({
}, [defaultRuntime, isCreateMode, open, runtime, runtimesLoading]);
function handleOpenChange(next: boolean) {
if (!next) {
// The catalog may veto embedded close requests; preserve the draft until unmount.
if (!next && !embedded) {
setDisplayName("");
setAvatarUrl("");
setSystemPrompt("");
@@ -724,317 +732,309 @@ export function AgentDefinitionDialog({
);
}
const footer = (
<AgentDefinitionDialogFooter
canSubmit={canSubmit}
isAvatarUploadPending={isAvatarUploadPending}
isPending={isPending}
onCancel={() => handleOpenChange(false)}
publishesCatalogUpdates={publishCatalogUpdatesOnSave && hasUserChanges}
submitBlockReason={null}
submitLabel={submitLabel}
/>
);
const form = (
<form
className="grid gap-5 lg:grid-cols-[220px_minmax(0,1fr)]"
id="persona-dialog-form"
onChangeCapture={() => setHasUserChanges(true)}
onSubmit={handleSubmitForm}
>
<AgentCreationPreview
avatarUrl={previewAvatarUrl}
disabled={isPending || isAvatarUploadPending}
label={previewLabel}
onClearAvatar={() => {
setHasUserChanges(true);
setAvatarUrl("");
}}
onUploadPendingChange={setIsAvatarUploadPending}
onSelectAvatar={(nextAvatarUrl) => {
setHasUserChanges(true);
setAvatarUrl(nextAvatarUrl);
}}
/>
<div className="space-y-5">
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="persona-display-name"
>
Agent name
</label>
<div
className={cn(
"flex min-h-11 items-center px-3",
PERSONA_FIELD_SHELL_CLASS,
)}
>
<Input
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
PERSONA_FIELD_CONTROL_CLASS,
)}
disabled={isPending}
id="persona-display-name"
onChange={(event) => setDisplayName(event.target.value)}
placeholder="Fizz"
value={displayName}
/>
</div>
</div>
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="persona-system-prompt"
>
Agent instructions
</label>
<div className={PERSONA_FIELD_SHELL_CLASS}>
<Textarea
className={cn(
"min-h-40 resize-y px-3 py-3 leading-5",
PERSONA_FIELD_CONTROL_CLASS,
)}
disabled={isPending}
id="persona-system-prompt"
onChange={(event) => setSystemPrompt(event.target.value)}
placeholder="Describe what this agent should do."
value={systemPrompt}
/>
</div>
</div>
{modelFieldVisible ? (
<AgentAiConfigurationModeField
mode={aiConfigurationMode}
needsProviderSelection={runtimeCanChooseLlmProvider}
onModeChange={handleAiConfigurationModeChange}
/>
) : null}
<div
className="space-y-5"
data-testid={`agent-${aiConfigurationMode}-configuration-section`}
>
{aiConfigurationMode === "custom" ? (
<AgentHarnessField
disabled={isPending || runtimesLoading}
onValueChange={handleRuntimeDropdownChange}
options={runtimeDropdownOptions}
placeholder={blankRuntimeOptionLabel}
value={runtimeDropdownValue}
warning={runtimeWarning}
/>
) : null}
{llmProviderFieldVisible && aiConfigurationMode === "custom" ? (
<div className="space-y-1.5">
<RequiredFieldLabel
htmlFor="persona-llm-provider"
isRequired={providerIsRequired}
>
LLM provider
{!providerIsRequired ? (
<span className={PERSONA_LABEL_OPTIONAL_CLASS}>Optional</span>
) : null}
</RequiredFieldLabel>
<PersonaDropdownField
disabled={isPending}
id="persona-llm-provider"
onValueChange={handleProviderDropdownChange}
options={providerDropdownOptions}
placeholder="Choose a provider"
value={providerSelectValue}
/>
{showCustomProviderInput ? (
<div
className={cn(
"mt-2 flex min-h-11 items-center px-3",
PERSONA_FIELD_SHELL_CLASS,
)}
>
<Input
aria-label="Custom provider ID"
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
PERSONA_FIELD_CONTROL_CLASS,
)}
disabled={isPending}
id="persona-custom-provider"
onChange={(event) => setProvider(event.target.value)}
placeholder="Custom provider ID"
value={provider}
/>
</div>
) : null}
</div>
) : null}
{llmProviderFieldVisible &&
aiConfigurationMode === "custom" &&
topLevelSecretEnvVar ? (
<PersonaProviderApiKeyField
disabled={isPending}
envVarName={topLevelSecretEnvVar}
isInherited={apiKeyIsInherited}
inheritedLabel={apiKeyInheritedLabel}
isRequired={apiKeyIsRequired}
label={getProviderApiKeyLabel(effectiveProvider) ?? "API key"}
onValueChange={(next) => {
setEnvVars((prev) => ({
...prev,
[topLevelSecretEnvVar]: next,
}));
}}
value={apiKeyValue}
/>
) : null}
<AnimatePresence initial={false}>
{modelFieldVisible && aiConfigurationMode === "custom" ? (
<PersonaModelField
disabled={isPending}
isExplicitModelRequired={isExplicitModelRequired}
model={model}
modelDiscoveryStatus={modelDiscoveryStatus}
modelDropdownOptions={modelDropdownOptions}
modelSelectValue={modelSelectValue}
onCustomModelChange={setModel}
showSharedComputeAutoHint={
isRelayMesh && modelSelectValue === AUTO_MODEL_DROPDOWN_VALUE
}
onModelValueChange={handleModelDropdownChange}
showCustomModelInput={showCustomModelInput}
transition={advancedFieldsTransition}
/>
) : null}
</AnimatePresence>
{aiConfigurationMode === "defaults" ? (
<AgentCreateAiDefaultsSummary
canChooseProvider={runtimeCanChooseLlmProvider}
harness={runtimeSummaryLabel}
inheritedModel={inheritedModelDefault}
inheritedProvider={inheritedProviderDefault}
isConfigured={localModeGate.satisfied}
model={runtimeFileConfig?.model}
onEditDefaults={() => setAiDefaultsOpen(true)}
triggerRef={aiDefaultsTriggerRef}
/>
) : null}
</div>
<AgentDefaultsDialog
onOpenChange={setAiDefaultsOpen}
open={runtimeCanChooseLlmProvider && aiDefaultsOpen}
returnFocusRef={aiDefaultsTriggerRef}
/>
<AddCustomHarnessDialog
onOpenChange={setIsAddHarnessOpen}
onSaved={selectSavedHarness}
open={isAddHarnessOpen}
/>
<div className="space-y-3">
<button
aria-expanded={showAdvancedFields}
className="inline-flex h-9 items-center gap-1.5 text-sm font-medium text-foreground transition-colors hover:text-foreground/80 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setShowAdvancedFields((current) => !current)}
type="button"
>
<span>Advanced</span>
{(isCreateMode && createSubmitBlocked) ||
localModeGate.missingEnvKeys.some((key) =>
advancedRequiredEnvKeys.includes(key),
) ? (
<span
aria-hidden="true"
className="rounded-full bg-destructive/10 px-2 py-0.5 text-xs text-destructive"
data-testid="persona-advanced-required-badge"
>
Required
</span>
) : null}
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-150 ease-out",
showAdvancedFields && "rotate-180",
)}
/>
</button>
<AnimatePresence initial={false}>
{showAdvancedFields ? (
<motion.div
animate={{ height: "auto", opacity: 1, scale: 1 }}
className="origin-top overflow-hidden"
exit={{ height: 0, opacity: 0, scale: 0.98 }}
initial={{ height: 0, opacity: 0, scale: 0.98 }}
key="persona-advanced-fields"
transition={advancedFieldsTransition}
>
<PersonaAdvancedFields
afterRespondTo={isCreateMode ? createRunSection : undefined}
behaviorDraft={behaviorDraft}
disabled={isPending}
envVars={envVars}
fileSatisfiedEnvKeys={localModeGate.fileSatisfiedEnvKeys}
hiddenEnvKeys={
topLevelSecretEnvVar ? [topLevelSecretEnvVar] : []
}
inheritedEnvVars={inheritedEnvVarsForAdvanced}
model={model}
modelTuningRuntimeId={runtime}
namePoolText={namePoolText}
catalogStatus={runtimeCatalogStatus}
selectedRuntime={selectedRuntime}
onBehaviorDraftChange={(nextBehaviorDraft) => {
setHasUserChanges(true);
setBehaviorDraft(nextBehaviorDraft);
}}
onEnvVarsChange={setEnvVars}
onNamePoolTextChange={setNamePoolText}
provider={effectiveProvider}
requiredEnvKeys={advancedRequiredEnvKeys}
/>
</motion.div>
) : null}
</AnimatePresence>
</div>
{error ? (
<p className="text-sm text-destructive">{error.message}</p>
) : null}
</div>
</form>
);
return (
<Dialog
<AgentDefinitionDialogShell
description={description}
embedded={embedded}
footer={footer}
onOpenChange={(nextOpen) => {
if (!nextOpen && (isPending || isAvatarUploadPending)) return;
handleOpenChange(nextOpen);
}}
open={open}
title={title}
>
<ChooserDialogContent
className="max-w-3xl border-0"
contentClassName="pt-3"
data-testid="persona-dialog"
description={description}
footerClassName="border-t-0 pt-0"
headerClassName="pb-2"
title={title}
footer={
<AgentDefinitionDialogFooter
canSubmit={canSubmit}
isAvatarUploadPending={isAvatarUploadPending}
isPending={isPending}
onCancel={() => handleOpenChange(false)}
publishesCatalogUpdates={
publishCatalogUpdatesOnSave && hasUserChanges
}
submitBlockReason={null}
submitLabel={submitLabel}
/>
}
>
<form
className="grid gap-5 lg:grid-cols-[220px_minmax(0,1fr)]"
id="persona-dialog-form"
onChangeCapture={() => setHasUserChanges(true)}
onSubmit={handleSubmitForm}
>
<AgentCreationPreview
avatarUrl={previewAvatarUrl}
disabled={isPending || isAvatarUploadPending}
label={previewLabel}
onClearAvatar={() => {
setHasUserChanges(true);
setAvatarUrl("");
}}
onUploadPendingChange={setIsAvatarUploadPending}
onSelectAvatar={(nextAvatarUrl) => {
setHasUserChanges(true);
setAvatarUrl(nextAvatarUrl);
}}
/>
<div className="space-y-5">
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="persona-display-name"
>
Agent name
</label>
<div
className={cn(
"flex min-h-11 items-center px-3",
PERSONA_FIELD_SHELL_CLASS,
)}
>
<Input
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
PERSONA_FIELD_CONTROL_CLASS,
)}
disabled={isPending}
id="persona-display-name"
onChange={(event) => setDisplayName(event.target.value)}
placeholder="Fizz"
value={displayName}
/>
</div>
</div>
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="persona-system-prompt"
>
Agent instructions
</label>
<div className={PERSONA_FIELD_SHELL_CLASS}>
<Textarea
className={cn(
"min-h-40 resize-y px-3 py-3 leading-5",
PERSONA_FIELD_CONTROL_CLASS,
)}
disabled={isPending}
id="persona-system-prompt"
onChange={(event) => setSystemPrompt(event.target.value)}
placeholder="Describe what this agent should do."
value={systemPrompt}
/>
</div>
</div>
{modelFieldVisible ? (
<AgentAiConfigurationModeField
mode={aiConfigurationMode}
needsProviderSelection={runtimeCanChooseLlmProvider}
onModeChange={handleAiConfigurationModeChange}
/>
) : null}
<div
className="space-y-5"
data-testid={`agent-${aiConfigurationMode}-configuration-section`}
>
{aiConfigurationMode === "custom" ? (
<AgentHarnessField
disabled={isPending || runtimesLoading}
onValueChange={handleRuntimeDropdownChange}
options={runtimeDropdownOptions}
placeholder={blankRuntimeOptionLabel}
value={runtimeDropdownValue}
warning={runtimeWarning}
/>
) : null}
{llmProviderFieldVisible && aiConfigurationMode === "custom" ? (
<div className="space-y-1.5">
<RequiredFieldLabel
htmlFor="persona-llm-provider"
isRequired={providerIsRequired}
>
LLM provider
{!providerIsRequired ? (
<span className={PERSONA_LABEL_OPTIONAL_CLASS}>
Optional
</span>
) : null}
</RequiredFieldLabel>
<PersonaDropdownField
disabled={isPending}
id="persona-llm-provider"
onValueChange={handleProviderDropdownChange}
options={providerDropdownOptions}
placeholder="Choose a provider"
value={providerSelectValue}
/>
{showCustomProviderInput ? (
<div
className={cn(
"mt-2 flex min-h-11 items-center px-3",
PERSONA_FIELD_SHELL_CLASS,
)}
>
<Input
aria-label="Custom provider ID"
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
PERSONA_FIELD_CONTROL_CLASS,
)}
disabled={isPending}
id="persona-custom-provider"
onChange={(event) => setProvider(event.target.value)}
placeholder="Custom provider ID"
value={provider}
/>
</div>
) : null}
</div>
) : null}
{llmProviderFieldVisible &&
aiConfigurationMode === "custom" &&
topLevelSecretEnvVar ? (
<PersonaProviderApiKeyField
disabled={isPending}
envVarName={topLevelSecretEnvVar}
isInherited={apiKeyIsInherited}
inheritedLabel={apiKeyInheritedLabel}
isRequired={apiKeyIsRequired}
label={getProviderApiKeyLabel(effectiveProvider) ?? "API key"}
onValueChange={(next) => {
setEnvVars((prev) => ({
...prev,
[topLevelSecretEnvVar]: next,
}));
}}
value={apiKeyValue}
/>
) : null}
<AnimatePresence initial={false}>
{modelFieldVisible && aiConfigurationMode === "custom" ? (
<PersonaModelField
disabled={isPending}
isExplicitModelRequired={isExplicitModelRequired}
model={model}
modelDiscoveryStatus={modelDiscoveryStatus}
modelDropdownOptions={modelDropdownOptions}
modelSelectValue={modelSelectValue}
onCustomModelChange={setModel}
showSharedComputeAutoHint={
isRelayMesh &&
modelSelectValue === AUTO_MODEL_DROPDOWN_VALUE
}
onModelValueChange={handleModelDropdownChange}
showCustomModelInput={showCustomModelInput}
transition={advancedFieldsTransition}
/>
) : null}
</AnimatePresence>
{aiConfigurationMode === "defaults" ? (
<AgentCreateAiDefaultsSummary
canChooseProvider={runtimeCanChooseLlmProvider}
harness={runtimeSummaryLabel}
inheritedModel={inheritedModelDefault}
inheritedProvider={inheritedProviderDefault}
isConfigured={localModeGate.satisfied}
model={runtimeFileConfig?.model}
onEditDefaults={() => setAiDefaultsOpen(true)}
triggerRef={aiDefaultsTriggerRef}
/>
) : null}
</div>
<AgentDefaultsDialog
onOpenChange={setAiDefaultsOpen}
open={runtimeCanChooseLlmProvider && aiDefaultsOpen}
returnFocusRef={aiDefaultsTriggerRef}
/>
<AddCustomHarnessDialog
onOpenChange={setIsAddHarnessOpen}
onSaved={selectSavedHarness}
open={isAddHarnessOpen}
/>
<div className="space-y-3">
<button
aria-expanded={showAdvancedFields}
className="inline-flex h-9 items-center gap-1.5 text-sm font-medium text-foreground transition-colors hover:text-foreground/80 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setShowAdvancedFields((current) => !current)}
type="button"
>
<span>Advanced</span>
{(isCreateMode && createSubmitBlocked) ||
localModeGate.missingEnvKeys.some((key) =>
advancedRequiredEnvKeys.includes(key),
) ? (
<span
aria-hidden="true"
className="rounded-full bg-destructive/10 px-2 py-0.5 text-xs text-destructive"
data-testid="persona-advanced-required-badge"
>
Required
</span>
) : null}
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-150 ease-out",
showAdvancedFields && "rotate-180",
)}
/>
</button>
<AnimatePresence initial={false}>
{showAdvancedFields ? (
<motion.div
animate={{ height: "auto", opacity: 1, scale: 1 }}
className="origin-top overflow-hidden"
exit={{ height: 0, opacity: 0, scale: 0.98 }}
initial={{ height: 0, opacity: 0, scale: 0.98 }}
key="persona-advanced-fields"
transition={advancedFieldsTransition}
>
<PersonaAdvancedFields
afterRespondTo={
isCreateMode ? createRunSection : undefined
}
behaviorDraft={behaviorDraft}
disabled={isPending}
envVars={envVars}
fileSatisfiedEnvKeys={localModeGate.fileSatisfiedEnvKeys}
hiddenEnvKeys={
topLevelSecretEnvVar ? [topLevelSecretEnvVar] : []
}
inheritedEnvVars={inheritedEnvVarsForAdvanced}
model={model}
modelTuningRuntimeId={runtime}
namePoolText={namePoolText}
catalogStatus={runtimeCatalogStatus}
selectedRuntime={selectedRuntime}
onBehaviorDraftChange={(nextBehaviorDraft) => {
setHasUserChanges(true);
setBehaviorDraft(nextBehaviorDraft);
}}
onEnvVarsChange={setEnvVars}
onNamePoolTextChange={setNamePoolText}
provider={effectiveProvider}
requiredEnvKeys={advancedRequiredEnvKeys}
/>
</motion.div>
) : null}
</AnimatePresence>
</div>
{error ? (
<p className="text-sm text-destructive">{error.message}</p>
) : null}
</div>
</form>
</ChooserDialogContent>
</Dialog>
{form}
</AgentDefinitionDialogShell>
);
}

View File

@@ -0,0 +1,57 @@
import type * as React from "react";
import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
import { Dialog } from "@/shared/ui/dialog";
type AgentDefinitionDialogShellProps = {
children: React.ReactNode;
description: string;
embedded: boolean;
footer: React.ReactNode;
onOpenChange: (open: boolean) => void;
open: boolean;
title: string;
};
export function AgentDefinitionDialogShell({
children,
description,
embedded,
footer,
onOpenChange,
open,
title,
}: AgentDefinitionDialogShellProps) {
if (embedded) {
return (
<div
className="relative flex min-h-0 min-w-0 flex-1 flex-col"
data-testid="persona-dialog"
>
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto px-5 pb-20 pt-5">
{children}
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-end bg-linear-to-t from-background via-background/95 to-transparent px-4 pb-3 pt-10 [&_button]:pointer-events-auto">
{footer}
</div>
</div>
);
}
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<ChooserDialogContent
className="max-w-3xl border-0"
contentClassName="pt-3"
data-testid="persona-dialog"
description={description}
footer={footer}
footerClassName="border-t-0 pt-0"
headerClassName="pb-2"
title={title}
>
{children}
</ChooserDialogContent>
</Dialog>
);
}

View File

@@ -29,7 +29,10 @@ import {
type AgentDialogCreateProps = {
mode: "definition";
embedded?: boolean;
submitLabel?: string;
initialValues?: CreatePersonaInput | null;
onDirtyChange?: (dirty: boolean) => void;
onOpenChange: (open: boolean) => void;
definitionError: Error | null;
isDefinitionPending: boolean;
@@ -120,12 +123,15 @@ export function AgentDialog(props: AgentDialogProps) {
}
function AgentCreateDialogRouter({
embedded,
initialValues: providedInitialValues,
onOpenChange,
definitionError,
isDefinitionPending,
runtimes,
runtimeCatalogStatus,
submitLabel,
onDirtyChange,
onSubmitDefinition,
}: AgentDialogCreateProps) {
const [runDraft, setRunDraft] = React.useState(emptyWhereToRunDraft);
@@ -145,14 +151,19 @@ function AgentCreateDialogRouter({
<WhereToRunSection
draft={runDraft}
isPending={isDefinitionPending}
onDraftChange={setRunDraft}
onDraftChange={(nextDraft) => {
setRunDraft(nextDraft);
onDirtyChange?.(true);
}}
/>
}
createSubmitBlocked={!canSubmitWhereToRun(runDraft)}
description={copy.description}
embedded={embedded}
error={definitionError}
initialValues={initialValues}
isPending={isDefinitionPending}
onDirtyChange={onDirtyChange}
onOpenChange={onOpenChange}
onSubmit={async (input) => {
const submitted = await onSubmitDefinition(
@@ -161,13 +172,14 @@ function AgentCreateDialogRouter({
resolveBackendIntent(runDraft),
);
if (submitted) {
onDirtyChange?.(false);
onOpenChange(false);
}
}}
open
runtimes={runtimes}
runtimeCatalogStatus={runtimeCatalogStatus}
submitLabel={copy.submitLabel}
submitLabel={submitLabel ?? copy.submitLabel}
title={copy.title}
/>
</AgentRunLocationProvider>

View File

@@ -49,13 +49,10 @@ export function AgentsView() {
const fullAiDefaultsTriggerRef = React.useRef<HTMLButtonElement>(null);
const compactActionsTriggerRef = React.useRef<HTMLButtonElement>(null);
const [isAiDefaultsOpen, setIsAiDefaultsOpen] = React.useState(false);
// Exclusivity: create never sets `personaDialogState` (edit/dup/import do),
// so the create-mode and definition-edit AgentDialog mounts never coexist.
const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false);
function openUnifiedCreate() {
function openUnifiedCatalog() {
personas.prepareCreate();
setIsCreateDialogOpen(true);
personas.openCatalog();
}
function openAiDefaults(trigger: HTMLButtonElement | null) {
@@ -260,8 +257,7 @@ export function AgentsView() {
}
isPersonasLoading={personas.personasQuery.isLoading}
isPersonasPending={personas.isPending}
onCreatePersona={openUnifiedCreate}
onDiscoverPersonas={personas.openCatalog}
onOpenCatalog={openUnifiedCatalog}
onDuplicatePersona={personas.openDuplicate}
onEditPersona={personas.openEdit}
onSharePersona={personas.openShare}
@@ -269,9 +265,6 @@ export function AgentsView() {
void personas.handleSetActive(persona, false, "library");
}}
onDeletePersona={personas.openDelete}
onImportSnapshotFile={(fileBytes, fileName) => {
void personas.handleImportSnapshotFile(fileBytes, fileName);
}}
/>
<TeamsSection
@@ -308,29 +301,6 @@ export function AgentsView() {
returnFocusRef={aiDefaultsTriggerRef}
/>
{isCreateDialogOpen ? (
<AgentDialog
definitionError={
personas.createPersonaMutation.error instanceof Error
? personas.createPersonaMutation.error
: null
}
isDefinitionPending={personas.isPending}
mode="definition"
onOpenChange={(open) => {
if (!open) setIsCreateDialogOpen(false);
}}
onSubmitDefinition={personas.handleSubmit}
runtimes={personas.acpRuntimesQuery.data ?? []}
runtimeCatalogStatus={
personas.acpRuntimesQuery.isLoading
? "loading"
: personas.acpRuntimesQuery.isError
? "error"
: "ready"
}
/>
) : null}
{agents.agentToAddToChannel ? (
<AddAgentToChannelDialog
agent={agents.agentToAddToChannel}
@@ -484,6 +454,32 @@ export function AgentsView() {
) : null}
{personas.isCatalogDialogOpen ? (
<PersonaCatalogDialog
createContent={({ onDirtyChange, onRequestClose }) => (
<AgentDialog
definitionError={
personas.createPersonaMutation.error instanceof Error
? personas.createPersonaMutation.error
: null
}
embedded
isDefinitionPending={personas.isPending}
mode="definition"
onDirtyChange={onDirtyChange}
onOpenChange={(open) => {
if (!open) onRequestClose();
}}
onSubmitDefinition={personas.handleSubmit}
runtimes={personas.acpRuntimesQuery.data ?? []}
runtimeCatalogStatus={
personas.acpRuntimesQuery.isLoading
? "loading"
: personas.acpRuntimesQuery.isError
? "error"
: "ready"
}
submitLabel="Add agent"
/>
)}
error={
personas.catalogQuery.error instanceof Error
? personas.catalogQuery.error
@@ -504,9 +500,20 @@ export function AgentsView() {
onClearFeedback={() => {
personas.clearFeedback("catalog");
}}
onImportFile={(fileBytes, fileName) => {
void personas.handleImportSnapshotFile(fileBytes, fileName);
}}
onOpenChange={personas.setIsCatalogDialogOpen}
onSelectPersona={(persona, active) => {
void personas.handleSetActive(persona, active, "catalog");
onSelectPersona={async (persona, active) => {
const addedPersona = await personas.handleSetActive(
persona,
active,
"catalog",
);
if (!active || !addedPersona) return;
personas.setIsCatalogDialogOpen(false);
openPersonaProfilePanel?.(addedPersona);
}}
open={personas.isCatalogDialogOpen}
personas={personas.catalogPersonas}

View File

@@ -1,4 +1,5 @@
import * as React from "react";
import { Plus, Upload } from "lucide-react";
import { isCatalogPersonaSelected } from "@/features/agents/lib/catalog";
import { isCatalogPersona } from "@/features/agents/lib/personaCatalogRelay";
@@ -7,30 +8,48 @@ import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type { AgentPersona } from "@/shared/api/types";
import { useFeedbackToasts } from "@/shared/hooks/useToastEffect";
import { cn } from "@/shared/lib/cn";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import { Button } from "@/shared/ui/button";
import { Dialog } from "@/shared/ui/dialog";
import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
import { Markdown } from "@/shared/ui/markdown";
import { Skeleton } from "@/shared/ui/skeleton";
import agentOutlineUrl from "../assets/agent-outline.svg";
import { AgentDefinitionMetadata } from "./AgentDefinitionMetadata";
import { PersonaAddedBy } from "./PersonaAddedBy";
import { personaCatalogCopy } from "./personaLibraryCopy";
type PersonaCatalogDialogProps = {
createContent: (controls: {
onDirtyChange: (dirty: boolean) => void;
onRequestClose: () => void;
}) => React.ReactNode;
error: Error | null;
feedbackErrorMessage: string | null;
feedbackNoticeMessage: string | null;
isLoading: boolean;
isPending: boolean;
onClearFeedback: () => void;
onImportFile: (fileBytes: number[], fileName: string) => void;
onOpenChange: (open: boolean) => void;
onSelectPersona: (persona: AgentPersona, active: boolean) => void;
open: boolean;
personas: AgentPersona[];
};
type PendingNavigation =
| { type: "close" }
| { type: "selection"; selection: string };
const agentInstructionMarkdownClassName = [
"mt-3 w-full min-w-0 max-w-full overflow-x-hidden leading-6 text-muted-foreground [&>*]:min-w-0 [&>*]:max-w-full [&_.code-block-lines]:min-w-0 [&_.code-block-lines]:max-w-full [&_.code-block-lines]:whitespace-pre-wrap [&_.code-block-lines]:[overflow-wrap:anywhere] [&_.inline-code-chip]:max-w-full [&_.inline-code-chip]:whitespace-pre-wrap [&_.inline-code-chip]:[overflow-wrap:anywhere] [&_blockquote]:!text-muted-foreground [&_code]:!text-muted-foreground [&_li]:text-muted-foreground [&_ol]:text-muted-foreground [&_p]:text-muted-foreground [&_strong]:text-muted-foreground [&_td]:text-muted-foreground [&_ul]:text-muted-foreground",
"[&>h1]:!text-sm [&>h1]:!font-semibold [&>h1]:!leading-6 [&>h1]:!tracking-normal [&>h1]:!text-foreground",
@@ -42,48 +61,56 @@ const agentInstructionMarkdownClassName = [
].join(" ");
export function PersonaCatalogDialog({
createContent,
error,
feedbackErrorMessage,
feedbackNoticeMessage,
isLoading,
isPending,
onClearFeedback,
onImportFile,
onOpenChange,
onSelectPersona,
open,
personas,
}: PersonaCatalogDialogProps) {
const contentRef = React.useRef<HTMLDivElement | null>(null);
const [selectedPersonaId, setSelectedPersonaId] = React.useState<
string | null
>(null);
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const dragDepthRef = React.useRef(0);
const createDirtyRef = React.useRef(false);
const [isDragOver, setIsDragOver] = React.useState(false);
const [pendingNavigation, setPendingNavigation] =
React.useState<PendingNavigation | null>(null);
const [selection, setSelection] = React.useState("create");
const selectedPersonaId = selection.startsWith("persona:")
? selection.slice("persona:".length)
: null;
const selectedPersona = React.useMemo(() => {
if (personas.length === 0) {
if (!selectedPersonaId) {
return null;
}
return (
personas.find((persona) => persona.id === selectedPersonaId) ??
personas[0]
);
return personas.find((persona) => persona.id === selectedPersonaId) ?? null;
}, [personas, selectedPersonaId]);
React.useEffect(() => {
if (!open) {
return;
if (open) {
createDirtyRef.current = false;
setSelection("create");
setPendingNavigation(null);
dragDepthRef.current = 0;
setIsDragOver(false);
}
}, [open]);
if (personas.length === 0) {
setSelectedPersonaId(null);
return;
React.useEffect(() => {
if (
selectedPersonaId &&
!personas.some((persona) => persona.id === selectedPersonaId)
) {
setSelection("create");
}
setSelectedPersonaId((current) =>
current && personas.some((persona) => persona.id === current)
? current
: personas[0].id,
);
}, [open, personas]);
}, [personas, selectedPersonaId]);
useFeedbackToasts(feedbackNoticeMessage, feedbackErrorMessage);
@@ -100,96 +127,252 @@ export function PersonaCatalogDialog({
onSelectPersona(selectedPersona, true);
};
const isImportSelected = selection === "import";
const handleCreateDirtyChange = React.useCallback((dirty: boolean) => {
createDirtyRef.current = dirty;
}, []);
function requestSelection(nextSelection: string) {
if (
selection === "create" &&
nextSelection !== "create" &&
createDirtyRef.current
) {
setPendingNavigation({
type: "selection",
selection: nextSelection,
});
return;
}
setSelection(nextSelection);
}
function requestClose() {
if (selection === "create" && createDirtyRef.current) {
setPendingNavigation({ type: "close" });
return;
}
onOpenChange(false);
}
function discardChangesAndNavigate() {
const navigation = pendingNavigation;
createDirtyRef.current = false;
setPendingNavigation(null);
if (navigation?.type === "selection") {
setSelection(navigation.selection);
} else if (navigation?.type === "close") {
onOpenChange(false);
}
}
React.useEffect(() => {
if (!isImportSelected) {
dragDepthRef.current = 0;
setIsDragOver(false);
}
}, [isImportSelected]);
function hasFiles(event: React.DragEvent) {
return event.dataTransfer.types.includes("Files");
}
function isAgentSnapshot(file: File) {
const lowerName = file.name.toLowerCase();
return (
lowerName.endsWith(".agent.json") || lowerName.endsWith(".agent.png")
);
}
async function importFile(file: File) {
if (!isAgentSnapshot(file)) return;
const buffer = await file.arrayBuffer();
onOpenChange(false);
onImportFile(Array.from(new Uint8Array(buffer)), file.name);
}
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<ChooserDialogContent
className="h-[42rem] max-w-4xl"
contentClassName="flex min-h-0 min-w-0 flex-1 p-0"
data-testid="persona-catalog-dialog"
description={personaCatalogCopy.dialogDescription}
headerClassName="bg-sidebar pb-3 text-sidebar-foreground"
headerTestId="persona-catalog-dialog-header"
onOpenAutoFocus={(event) => {
event.preventDefault();
contentRef.current?.focus();
<>
<Dialog
onOpenChange={(nextOpen) => {
if (!nextOpen && isPending) return;
if (!nextOpen) {
requestClose();
return;
}
onOpenChange(true);
}}
ref={contentRef}
scrollAreaClassName="flex min-h-0 overflow-hidden px-0"
scrollAreaTestId="persona-catalog-dialog-body"
tabIndex={-1}
title={personaCatalogCopy.dialogTitle}
open={open}
>
<PersonaCatalogChooser
error={error}
isLoading={isLoading}
isPending={isPending}
isSelectedPersonaActive={selectedPersonaIsActive}
onUsePersona={handleUseSelectedPersona}
onSelectPersona={setSelectedPersonaId}
personas={personas}
selectedPersona={selectedPersona}
selectedPersonaId={selectedPersona?.id ?? null}
/>
</ChooserDialogContent>
</Dialog>
<ChooserDialogContent
className="h-[42rem] max-w-5xl"
contentClassName="flex min-h-0 min-w-0 flex-1 p-0"
data-testid="persona-catalog-dialog"
description={personaCatalogCopy.dialogDescription}
headerClassName="bg-sidebar pb-3 text-sidebar-foreground"
headerTestId="persona-catalog-dialog-header"
onOpenAutoFocus={(event) => {
event.preventDefault();
contentRef.current?.focus();
}}
ref={contentRef}
scrollAreaClassName="flex min-h-0 overflow-hidden px-0"
scrollAreaTestId="persona-catalog-dialog-body"
tabIndex={-1}
title={personaCatalogCopy.dialogTitle}
onDragEnter={(event) => {
if (!isImportSelected || !hasFiles(event)) return;
event.preventDefault();
dragDepthRef.current += 1;
setIsDragOver(true);
}}
onDragLeave={(event) => {
if (!isImportSelected) return;
event.preventDefault();
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) setIsDragOver(false);
}}
onDragOver={(event) => {
if (!isImportSelected || !hasFiles(event)) return;
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}}
onDrop={(event) => {
if (!isImportSelected || !hasFiles(event)) return;
event.preventDefault();
dragDepthRef.current = 0;
setIsDragOver(false);
const file = event.dataTransfer.files[0];
if (file) void importFile(file);
}}
>
<PersonaCatalogChooser
createContent={createContent({
onDirtyChange: handleCreateDirtyChange,
onRequestClose: requestClose,
})}
error={error}
isDragOver={isDragOver}
isLoading={isLoading}
isPending={isPending}
onImport={() => fileInputRef.current?.click()}
isSelectedPersonaActive={selectedPersonaIsActive}
onUsePersona={handleUseSelectedPersona}
onSelectionChange={requestSelection}
personas={personas}
selection={selection}
selectedPersona={selectedPersona}
selectedPersonaId={selectedPersona?.id ?? null}
/>
<input
accept=".agent.json,.agent.png"
className="hidden"
data-testid="agent-catalog-import-input"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void importFile(file);
event.target.value = "";
}}
ref={fileInputRef}
type="file"
/>
</ChooserDialogContent>
</Dialog>
<AlertDialog
onOpenChange={(nextOpen) => {
if (!nextOpen) setPendingNavigation(null);
}}
open={pendingNavigation !== null}
>
<AlertDialogContent data-testid="discard-create-agent-dialog">
<AlertDialogHeader>
<AlertDialogTitle>Discard agent changes?</AlertDialogTitle>
<AlertDialogDescription>
Your changes to this agent will be lost.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep editing</AlertDialogCancel>
<AlertDialogAction asChild>
<Button onClick={discardChangesAndNavigate} variant="destructive">
Discard changes
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
type PersonaCatalogChooserProps = {
createContent: React.ReactNode;
error: Error | null;
isDragOver: boolean;
isLoading: boolean;
isPending: boolean;
isSelectedPersonaActive: boolean;
onImport: () => void;
onUsePersona: () => void;
onSelectPersona: (personaId: string) => void;
onSelectionChange: (selection: string) => void;
personas: AgentPersona[];
selection: string;
selectedPersona: AgentPersona | null;
selectedPersonaId: string | null;
};
function PersonaCatalogChooser({
createContent,
error,
isDragOver,
isLoading,
isPending,
isSelectedPersonaActive,
onImport,
onUsePersona,
onSelectPersona,
onSelectionChange,
personas,
selection,
selectedPersona,
selectedPersonaId,
}: PersonaCatalogChooserProps) {
if (!isLoading && personas.length === 0 && !error) {
return (
<div
className="flex min-h-0 flex-1 items-center justify-center bg-sidebar px-6 py-10 text-center"
data-testid="persona-catalog-empty-state"
>
<div className="flex max-w-sm flex-col items-center">
<img
alt=""
aria-hidden="true"
className="h-32 w-32 dark:opacity-60"
data-testid="persona-catalog-empty-agent-artwork"
src={agentOutlineUrl}
/>
<p className="mt-4 text-sm font-semibold">
{personaCatalogCopy.emptyCatalogTitle}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{personaCatalogCopy.emptyCatalogDescription}
return (
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-sidebar sm:flex-row">
{selection === "import" && isDragOver ? (
<div
className="pointer-events-none absolute inset-2 z-50 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary bg-primary/10 backdrop-blur-sm"
data-testid="agent-catalog-drop-overlay"
>
<p className="rounded-full bg-background/90 px-4 py-2 text-sm font-medium text-primary shadow-sm">
Drop .agent.json or .agent.png to import
</p>
</div>
</div>
);
}
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-sidebar sm:flex-row">
) : null}
<div className="flex max-h-56 min-h-0 flex-col sm:max-h-none sm:w-56">
<div
className="min-h-0 flex-1 overflow-y-auto px-2 py-3"
data-testid="persona-catalog-dialog-scroll-area"
>
<div className="space-y-1">
<CatalogNavigationButton
icon={<Plus className="h-4 w-4" />}
isCurrent={selection === "create"}
label="Create agent"
onClick={() => onSelectionChange("create")}
testId="agent-catalog-create"
/>
<CatalogNavigationButton
icon={<Upload className="h-4 w-4" />}
isCurrent={selection === "import"}
label="Import"
onClick={() => onSelectionChange("import")}
testId="agent-catalog-import"
/>
</div>
<div className="my-3 border-t border-sidebar-border/60" />
{isLoading ? <PersonaCatalogListSkeleton /> : null}
{!isLoading && personas.length > 0 ? (
@@ -209,7 +392,7 @@ function PersonaCatalogChooser({
data-testid={`persona-catalog-list-item-${persona.id}`}
key={persona.id}
onClick={() => {
onSelectPersona(persona.id);
onSelectionChange(`persona:${persona.id}`);
}}
type="button"
>
@@ -226,57 +409,117 @@ function PersonaCatalogChooser({
})}
</div>
) : null}
{!isLoading && personas.length === 0 && !error ? (
<p className="px-4 py-2 text-xs text-sidebar-foreground/50">
No shared agents
</p>
) : null}
</div>
</div>
<div className="relative z-10 mb-3 ml-px mr-3 flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-xl bg-background shadow-[-1px_0_0_0_hsl(var(--sidebar-border)/0.45)]">
<div
className="min-h-0 min-w-0 max-w-full flex-1 overflow-x-hidden overflow-y-auto px-5 pb-24 pt-5"
data-testid="persona-catalog-detail-pane"
>
{isLoading ? <PersonaCatalogDetailSkeleton /> : null}
{!isLoading && selectedPersona ? (
<PersonaCatalogDetail persona={selectedPersona} />
) : null}
{error ? (
<p className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error.message}
</p>
) : null}
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-end bg-gradient-to-t from-background via-background/95 to-transparent px-5 pb-4 pt-12">
<Button
aria-label={
selectedPersona && isSelectedPersonaActive
? `${selectedPersona.displayName} is already in My Agents`
: selectedPersona
? `Add ${selectedPersona.displayName} from Agent Catalog`
: undefined
}
data-testid={
selectedPersona
? `persona-catalog-use-agent-target-${selectedPersona.id}`
: "persona-catalog-use-agent-target"
}
className="pointer-events-auto"
disabled={!selectedPersona || isSelectedPersonaActive || isPending}
onClick={onUsePersona}
size="sm"
type="button"
>
{isSelectedPersonaActive
? personaCatalogCopy.addedAction
: personaCatalogCopy.useAction}
</Button>
</div>
{selection === "create" ? createContent : null}
{selection === "import" ? (
<ImportAgentPane onImport={onImport} />
) : null}
{selectedPersona ? (
<>
<div
className="min-h-0 min-w-0 max-w-full flex-1 overflow-x-hidden overflow-y-auto px-5 pb-20 pt-5"
data-testid="persona-catalog-detail-pane"
>
<PersonaCatalogDetail persona={selectedPersona} />
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-end bg-linear-to-t from-background via-background/95 to-transparent px-4 pb-3 pt-10">
<Button
aria-label={
isSelectedPersonaActive
? `${selectedPersona.displayName} is already in My Agents`
: `Add ${selectedPersona.displayName} from Agent Catalog`
}
className="pointer-events-auto"
data-testid={`persona-catalog-use-agent-target-${selectedPersona.id}`}
disabled={isSelectedPersonaActive || isPending}
onClick={onUsePersona}
type="button"
>
{isSelectedPersonaActive
? personaCatalogCopy.addedAction
: personaCatalogCopy.useAction}
</Button>
</div>
</>
) : null}
{selection.startsWith("persona:") && isLoading ? (
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
<PersonaCatalogDetailSkeleton />
</div>
) : null}
{error ? (
<p className="m-5 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error.message}
</p>
) : null}
</div>
</div>
);
}
function CatalogNavigationButton({
icon,
isCurrent,
label,
onClick,
testId,
}: {
icon: React.ReactNode;
isCurrent: boolean;
label: string;
onClick: () => void;
testId: string;
}) {
return (
<button
aria-current={isCurrent ? "true" : undefined}
className={cn(
"flex w-full items-center gap-2 rounded-lg px-4 py-2 text-left text-sm font-medium transition-[background-color,color,box-shadow] focus:outline-hidden focus-visible:ring-2 focus-visible:ring-sidebar-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar",
isCurrent
? "bg-sidebar-active text-sidebar-active-foreground"
: "text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
)}
data-testid={testId}
onClick={onClick}
type="button"
>
{icon}
<span>{label}</span>
</button>
);
}
function ImportAgentPane({ onImport }: { onImport: () => void }) {
return (
<button
className="m-5 flex min-h-0 flex-1 flex-col items-center justify-center rounded-xl border-2 border-dashed border-border bg-muted/20 px-8 py-12 text-center transition-colors hover:border-primary/60 hover:bg-primary/5 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
data-testid="agent-catalog-import-dropzone"
onClick={onImport}
type="button"
>
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Upload className="h-6 w-6" />
</span>
<span className="mt-4 text-base font-semibold">Import an agent</span>
<span className="mt-2 max-w-sm text-sm text-muted-foreground">
Drop an .agent.json or .agent.png file anywhere in this window, or
choose a file.
</span>
<span className="mt-5 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground">
Choose file
</span>
</button>
);
}
/**
* Derives the "Added by" label for a catalog entry from a resolved profile
* summary. Prefers `displayName`, falls back to `name`, then to the default

View File

@@ -84,6 +84,11 @@ export function TeamsSection({
{!isLoading ? (
<div className={IDENTITY_CARD_GRID_CLASS}>
<NewTeamCard
isPending={isPending}
onCreate={onCreate}
onImport={onImport}
/>
{teams.map((team) => {
const resolution = resolveTeamPersonas(team, personas);
const missingPersonaCount = resolution.missingPersonaCount;
@@ -169,11 +174,6 @@ export function TeamsSection({
</TeamIdentityCard>
);
})}
<NewTeamCard
isPending={isPending}
onCreate={onCreate}
onImport={onImport}
/>
</div>
) : null}

View File

@@ -12,14 +12,7 @@ import { useUserProfileQuery } from "@/features/profile/hooks";
import type { AgentPersona, ManagedAgent } from "@/shared/api/types";
import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext";
import { useFeedbackToasts } from "@/shared/hooks/useToastEffect";
import { useFileImportZone } from "@/shared/hooks/useFileImportZone";
import { Badge } from "@/shared/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { IdentityCardSkeleton } from "@/shared/ui/identity-card-skeleton";
import { AgentIdentityCard } from "./AgentIdentityCard";
import { AgentRuntimeAvatarControl } from "./AgentRuntimeAvatarControl";
@@ -52,8 +45,7 @@ type UnifiedAgentsSectionProps = {
personaFeedbackNoticeMessage: string | null;
isPersonasLoading: boolean;
isPersonasPending: boolean;
onCreatePersona: () => void;
onDiscoverPersonas: () => void;
onOpenCatalog: () => void;
onDuplicatePersona: (persona: AgentPersona) => void;
onEditPersona: (persona: AgentPersona) => void;
onSharePersona: (
@@ -63,13 +55,12 @@ type UnifiedAgentsSectionProps = {
) => void;
onDeactivatePersona: (persona: AgentPersona) => void;
onDeletePersona: (persona: AgentPersona) => void;
onImportSnapshotFile: (fileBytes: number[], fileName: string) => void;
};
const AGENT_CARD_COLUMN_CLASS = "w-full";
export const AGENT_CARD_GRID_COLUMNS_CLASS =
"grid-cols-[repeat(auto-fill,minmax(220px,240px))]";
export const IDENTITY_CARD_GRID_CLASS = `${AGENT_CARD_COLUMN_CLASS} ${AGENT_CARD_GRID_COLUMNS_CLASS} grid justify-start gap-3 [@container(max-width:40rem)]:justify-center`;
"grid-cols-1 [@container(min-width:21rem)]:grid-cols-2 [@container(min-width:32rem)]:grid-cols-3 [@container(min-width:43rem)]:grid-cols-4 [@container(min-width:54rem)]:grid-cols-5";
export const IDENTITY_CARD_GRID_CLASS = `${AGENT_CARD_COLUMN_CLASS} ${AGENT_CARD_GRID_COLUMNS_CLASS} grid gap-3`;
export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
const {
@@ -94,14 +85,12 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
personaFeedbackNoticeMessage,
isPersonasLoading,
isPersonasPending,
onCreatePersona,
onDiscoverPersonas,
onOpenCatalog,
onDuplicatePersona,
onEditPersona,
onSharePersona,
onDeactivatePersona,
onDeletePersona,
onImportSnapshotFile,
} = props;
const { groups, ungrouped, unknown } = React.useMemo(
@@ -109,14 +98,6 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
[personas, agents],
);
const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set());
const {
fileInputRef,
isDragOver,
dropHandlers,
handleFileChange,
openFilePicker,
} = useFileImportZone({ onImportFile: onImportSnapshotFile });
function toggle(key: string) {
setCollapsed((prev) => {
const next = new Set(prev);
@@ -134,29 +115,18 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
<section
className="relative space-y-4"
data-testid="agents-library-personas"
{...dropHandlers}
>
{isDragOver ? (
<div className="pointer-events-none absolute -inset-1 z-10 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary/50 bg-background/80 backdrop-blur-sm">
<p className="text-sm font-medium text-primary">
Drop .agent.json or .agent.png to import
</p>
</div>
) : null}
<input
accept=".agent.json,.agent.png"
className="hidden"
onChange={handleFileChange}
ref={fileInputRef}
type="file"
/>
{isLoading ? <LoadingSkeleton /> : null}
{!isLoading ? (
<div className="space-y-3" data-testid="unified-agents-groups">
<div className={IDENTITY_CARD_GRID_CLASS}>
<CreateIdentityCard
ariaLabel="New agent"
dataTestId="new-agent-card"
disabled={isPersonasPending}
onClick={onOpenCatalog}
/>
{groups.map((group) => {
const profileAgent = pickProfileAgent(group.agents);
return (
@@ -193,12 +163,6 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
/>
);
})}
<NewAgentCard
isPending={isPersonasPending}
onCreate={onCreatePersona}
onDiscover={onDiscoverPersonas}
onImport={openFilePicker}
/>
</div>
{unknown.length > 0 ? (
@@ -448,44 +412,6 @@ function StandaloneAgentCard({
);
}
function NewAgentCard({
isPending,
onCreate,
onDiscover,
onImport,
}: {
isPending: boolean;
onCreate: () => void;
onDiscover: () => void;
onImport: () => void;
}) {
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<CreateIdentityCard ariaLabel="New agent" dataTestId="new-agent-card" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem disabled={isPending} onClick={onCreate}>
Create agent
</DropdownMenuItem>
<DropdownMenuItem disabled={isPending} onClick={onDiscover}>
Discover agents
</DropdownMenuItem>
<DropdownMenuItem
data-testid="import-agent-snapshot-menu-item"
disabled={isPending}
onClick={onImport}
>
Import
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function LoadingSkeleton() {
return (
<div className={IDENTITY_CARD_GRID_CLASS}>

View File

@@ -15,7 +15,7 @@ export const personaLibraryCopy = {
export const personaCatalogCopy = {
title: "Agent Catalog",
description: "Browse agents shared to this relay.",
dialogTitle: "Agent Catalog",
dialogTitle: "Add agent",
dialogDescription: "Browse agents shared to this relay.",
emptyTitle: "You're all set",
emptyDescription: "Everything in Agent Catalog is already in My Agents.",

View File

@@ -297,9 +297,10 @@ export function usePersonaActions() {
persona: AgentPersona,
active: boolean,
surface: PersonaFeedbackSurface,
) {
): Promise<AgentPersona | null> {
clearFeedback(surface);
try {
let updatedPersona: AgentPersona;
if (active && isCatalogPersona(persona)) {
const localPersona = findLocalPersonaForCatalogEntry(
personas,
@@ -308,13 +309,15 @@ export function usePersonaActions() {
if (localPersona) {
if (!localPersona.isActive) {
await setPersonaActiveMutation.mutateAsync({
updatedPersona = await setPersonaActiveMutation.mutateAsync({
id: localPersona.id,
active: true,
});
} else {
updatedPersona = localPersona;
}
} else {
await createPersonaMutation.mutateAsync({
updatedPersona = await createPersonaMutation.mutateAsync({
displayName: persona.displayName,
avatarUrl: persona.avatarUrl ?? undefined,
systemPrompt: persona.systemPrompt,
@@ -338,13 +341,17 @@ export function usePersonaActions() {
});
}
} else {
await setPersonaActiveMutation.mutateAsync({ id: persona.id, active });
updatedPersona = await setPersonaActiveMutation.mutateAsync({
id: persona.id,
active,
});
}
setPersonaNoticeMessage(
active
? `Selected ${persona.displayName} for My Agents.`
: `Deselected ${persona.displayName} from My Agents.`,
);
return updatedPersona;
} catch (error) {
setPersonaErrorMessage(
error instanceof Error
@@ -353,6 +360,7 @@ export function usePersonaActions() {
? "Failed to select agent for My Agents."
: "Failed to deselect agent from My Agents.",
);
return null;
}
}

View File

@@ -18,7 +18,6 @@ async function openCreateDialog(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByTestId("open-agents-view").click();
await page.getByTestId("new-agent-card").click();
await page.getByRole("menuitem", { name: "Create agent" }).click();
await page.locator("#persona-display-name").fill("Test Agent");
}

View File

@@ -67,9 +67,6 @@ async function gotoApp(page: import("@playwright/test").Page) {
async function openPersonaCatalog(page: import("@playwright/test").Page) {
await page.getByTestId("new-agent-card").click();
await page
.getByRole("menuitem", { exact: true, name: "Discover agents" })
.click();
}
async function getCatalogOrder(page: import("@playwright/test").Page) {
@@ -239,10 +236,8 @@ test("catalog hides built-ins and shows the shared-agent empty state", async ({
}
await expect(page.getByTestId("persona-catalog-dialog-header")).toBeVisible();
await expect(page.getByTestId("persona-catalog-dialog-body")).toBeVisible();
const emptyState = page.getByTestId("persona-catalog-empty-state");
await expect(emptyState).toContainText("No agents are being shared");
await expect(
emptyState.getByTestId("persona-catalog-empty-agent-artwork"),
page.getByText("No shared agents", { exact: true }),
).toBeVisible();
await expect(
page.locator('[data-testid^="persona-catalog-list-item-"]'),
@@ -267,7 +262,9 @@ test("catalog empty state remains available after reopening", async ({
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
await openPersonaCatalog(page);
await expect(page.getByTestId("persona-catalog-empty-state")).toBeVisible();
await expect(
page.getByText("No shared agents", { exact: true }),
).toBeVisible();
await page
.getByTestId("persona-catalog-dialog")
@@ -275,9 +272,9 @@ test("catalog empty state remains available after reopening", async ({
.click();
await expect(page.getByTestId("persona-catalog-dialog")).not.toBeVisible();
await openPersonaCatalog(page);
await expect(page.getByTestId("persona-catalog-empty-state")).toContainText(
"No agents are being shared",
);
await expect(
page.getByText("No shared agents", { exact: true }),
).toBeVisible();
});
test("built-in persona edits persist", async ({ page }) => {
@@ -319,9 +316,6 @@ test("searches agent avatar emoji with focus on open", async ({ page }) => {
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
await page.getByTestId("new-agent-card").click();
await page
.getByRole("menuitem", { exact: true, name: "Create agent" })
.click();
await expect(page.getByTestId("persona-dialog")).toBeVisible();
await page.getByLabel("Add avatar").click();
@@ -346,9 +340,6 @@ test("agent avatar emoji picker scrolls inside its popover", async ({
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
await page.getByTestId("new-agent-card").click();
await page
.getByRole("menuitem", { exact: true, name: "Create agent" })
.click();
await expect(page.getByTestId("persona-dialog")).toBeVisible();
await page.getByLabel("Add avatar").click();
@@ -385,7 +376,7 @@ test("agent avatar emoji picker scrolls inside its popover", async ({
.toBeGreaterThan(before);
});
test("the new agent card offers create, discover, and import", async ({
test("the new agent card opens unified create, catalog, and import flows", async ({
page,
}) => {
await installMockBridge(page, {
@@ -396,6 +387,11 @@ test("the new agent card offers create, discover, and import", async ({
displayName: "Code Reviewer",
systemPrompt: "Review code changes.",
},
{
id: "custom:layout-auditor",
displayName: "Layout Auditor",
systemPrompt: "Review responsive layouts.",
},
],
});
await gotoApp(page);
@@ -420,6 +416,9 @@ test("the new agent card offers create, discover, and import", async ({
}),
);
const firstRowTop = Math.min(...cardBoxes.map(({ top }) => top));
expect(
cardBoxes.filter(({ top }) => Math.abs(top - firstRowTop) < 1),
).toHaveLength(5);
const rightmostFirstRowCard = Math.max(
...cardBoxes
.filter(({ top }) => Math.abs(top - firstRowTop) < 1)
@@ -437,27 +436,12 @@ test("the new agent card offers create, discover, and import", async ({
);
await newAgentCard.click();
await expect(
page.getByRole("menuitem", { exact: true, name: "Create agent" }),
).toBeVisible();
await expect(
page.getByRole("menuitem", { exact: true, name: "Discover agents" }),
).toBeVisible();
await expect(
page.getByRole("menuitem", { exact: true, name: "Import" }),
).toBeVisible();
await page
.getByRole("menuitem", { exact: true, name: "Discover agents" })
.click();
await expect(page.getByTestId("persona-catalog-dialog")).toBeVisible();
await page
.getByTestId("persona-catalog-dialog")
.getByRole("button", { name: "Close" })
.click();
await newAgentCard.click();
await page
.getByRole("menuitem", { exact: true, name: "Create agent" })
.click();
const catalogDialog = page.getByTestId("persona-catalog-dialog");
await expect(catalogDialog).toBeVisible();
await expect(page.getByTestId("agent-catalog-create")).toHaveAttribute(
"aria-current",
"true",
);
const dialog = page.getByTestId("persona-dialog");
await expect(dialog).toBeVisible();
@@ -466,10 +450,10 @@ test("the new agent card offers create, discover, and import", async ({
).toHaveCount(0);
await expect(dialog).not.toContainText("Enter a name for this agent.");
await dialog.getByRole("button", { name: "Cancel" }).click();
await newAgentCard.click();
await page.getByTestId("agent-catalog-import").click();
await expect(page.getByTestId("agent-catalog-import-dropzone")).toBeVisible();
const fileChooserPromise = page.waitForEvent("filechooser");
await page.getByRole("menuitem", { exact: true, name: "Import" }).click();
await page.getByTestId("agent-catalog-import-dropzone").click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles({
buffer: Buffer.from("{}"),
@@ -479,6 +463,31 @@ test("the new agent card offers create, discover, and import", async ({
await expect(page.getByTestId("agent-snapshot-import-dialog")).toBeVisible();
});
test("embedded create keeps its draft when discard is cancelled", async ({
page,
}) => {
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
await page.getByTestId("new-agent-card").click();
const dialog = page.getByTestId("persona-dialog");
const name = dialog.locator("#persona-display-name");
const instructions = dialog.locator("#persona-system-prompt");
await name.fill("Draft Keeper");
await instructions.fill("Preserve this draft through confirmation.");
await dialog.getByRole("button", { name: "Cancel" }).click();
const discardDialog = page.getByTestId("discard-create-agent-dialog");
await expect(discardDialog).toBeVisible();
await discardDialog.getByRole("button", { name: "Keep editing" }).click();
await expect(dialog).toBeVisible();
await expect(name).toHaveValue("Draft Keeper");
await expect(instructions).toHaveValue(
"Preserve this draft through confirmation.",
);
});
test("the new team card offers create and import", async ({ page }) => {
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
@@ -542,12 +551,12 @@ test("team cards follow the agents grid alignment at compact widths", async ({
await agentsContent.evaluate((element) => {
(element as HTMLElement).style.width = "600px";
});
await expect
.poll(async () => (await firstAgentGridCard.boundingBox())?.x ?? 0)
.toBeGreaterThan(wideAgentBox?.x ?? 0);
const compactAgentBox = await firstAgentGridCard.boundingBox();
const compactTeamBox = await firstTeamGridCard.boundingBox();
expect(compactAgentBox?.width ?? 0).toBeLessThan(wideAgentBox?.width ?? 0);
expect(
Math.abs((compactAgentBox?.width ?? 0) - (compactTeamBox?.width ?? 0)),
).toBeLessThan(1);
expect(
Math.abs((compactAgentBox?.x ?? 0) - (compactTeamBox?.x ?? 0)),
).toBeLessThan(1);
@@ -1675,7 +1684,9 @@ test("a foreign reader does not receive an unshared kind 30175 persona", async (
await expect(
page.getByTestId(`persona-catalog-list-item-${remoteCatalogId}`),
).toHaveCount(0);
await expect(page.getByTestId("persona-catalog-empty-state")).toBeVisible();
await expect(
page.getByText("No shared agents", { exact: true }),
).toBeVisible();
});
test("a catalog entry keeps the owner's emoji avatar", async ({ page }) => {

View File

@@ -89,7 +89,6 @@ test.describe("agent definition dialog", () => {
await page.goto("/");
await page.getByTestId("open-agents-view").click();
await page.getByTestId("new-agent-card").click();
await page.getByRole("menuitem", { name: "Create agent" }).click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("button", { name: "Advanced", exact: true }).click();

View File

@@ -32,7 +32,6 @@ async function openCreateDialog(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByTestId("open-agents-view").click();
await page.getByTestId("new-agent-card").click();
await page.getByRole("menuitem", { name: "Create agent" }).click();
await page.locator("#persona-display-name").fill("Test Agent");
}
@@ -713,7 +712,6 @@ test.describe("global agent config screenshots", () => {
await page.goto("/");
await page.getByTestId("open-agents-view").click();
await page.getByTestId("new-agent-card").click();
await page.getByRole("menuitem", { name: "Create agent" }).click();
await expect(page.getByTestId("persona-dialog-submit")).toBeDisabled({
timeout: 10_000,

View File

@@ -50,7 +50,6 @@ async function openCreateDialog(page: Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-agents-view").click();
await page.getByTestId("new-agent-card").click();
await page.getByRole("menuitem", { name: "Create agent" }).click();
const dialog = page.getByTestId("persona-dialog");
await expect(dialog).toBeVisible({ timeout: 10_000 });
await dialog.getByRole("tab", { name: "Customize for this agent" }).click();

View File

@@ -267,10 +267,9 @@ test("env vars editor renders in PersonaDialog new-persona form", async ({
}) => {
await gotoApp(page);
// Open the Agents view, then choose Create agent from the new-agent menu.
// Open the Agents view; the new-agent card opens the embedded create pane.
await page.getByTestId("open-agents-view").click();
await page.getByTestId("new-agent-card").click();
await page.getByRole("menuitem", { name: "Create agent" }).click();
// Scope all env-vars queries to the dialog: AgentDefaultsSettingsCard
// also renders an EnvVarsEditor in the background settings pane (introduced
@@ -315,7 +314,6 @@ test("persona model options follow the selected LLM provider", async ({
await page.getByTestId("open-agents-view").click();
await page.getByTestId("new-agent-card").click();
await page.getByRole("menuitem", { name: "Create agent" }).click();
const provider = page.locator("#persona-runtime");
await page.getByRole("tab", { name: "Customize for this agent" }).click();

View File

@@ -36,7 +36,6 @@ async function openNewPersonaDialog(page: import("@playwright/test").Page) {
});
await page.getByTestId("new-agent-card").click();
await page.getByRole("menuitem", { name: "Create agent" }).click();
const dialog = page.getByTestId("persona-dialog");
await expect(dialog).toBeVisible({ timeout: 8_000 });

View File

@@ -138,7 +138,6 @@ test("Buzz shared compute explains automatic model selection", async ({
});
await page.getByTestId("open-agents-view").click();
await page.getByTestId("new-agent-card").click();
await page.getByRole("menuitem", { name: "Create agent" }).click();
await chooseSharedComputeProvider(page);
await expect
@@ -167,7 +166,6 @@ test("create agent persists Buzz shared compute with auto model", async ({
await page.goto("/");
await page.getByTestId("open-agents-view").click();
await page.getByTestId("new-agent-card").click();
await page.getByRole("menuitem", { name: "Create agent" }).click();
await page.locator("#persona-display-name").fill(agentName);
await chooseSharedComputeProvider(page);
@@ -214,7 +212,6 @@ test("create agent supports parallelism and system prompt overrides", async ({
await page.goto("/");
await page.getByTestId("open-agents-view").click();
await page.getByTestId("new-agent-card").click();
await page.getByRole("menuitem", { name: "Create agent" }).click();
await page.locator("#persona-display-name").fill(agentName);
await page

View File

@@ -92,7 +92,6 @@ async function openCreateDialogOnProvider(page: Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-agents-view").click();
await page.getByTestId("new-agent-card").click();
await page.getByRole("menuitem", { name: "Create agent" }).click();
const dialog = page.getByTestId("persona-dialog");
await expect(dialog).toBeVisible({ timeout: 10_000 });
const advanced = dialog.getByRole("button", {
@@ -131,7 +130,7 @@ test("typing into a defaultless provider field sticks and probes only once", asy
);
await expect(contextField).toHaveValue("");
await contextField.pressSequentially("prod-us-west", { delay: 20 });
await contextField.fill("prod-us-west");
await expect(contextField).toHaveValue("prod-us-west");
// One selection, one probe — keystrokes and Advanced disclosure toggles