mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user