From 944d758f0c3aee3f8d6af6256801effdf1051889 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 25 Aug 2026 08:36:32 -0700 Subject: [PATCH] Polish shared instruction publishing flow Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> Signed-off-by: Taylor Ho --- desktop/src/features/agents/AGENTS.md | 11 +- .../agents/ui/AgentDefinitionDialog.tsx | 23 +- .../agents/ui/AgentInstructionsField.tsx | 10 + .../features/agents/ui/RelaySkillEditor.tsx | 302 ++++++++++++++++++ .../features/agents/ui/RelaySkillPicker.tsx | 280 ++-------------- .../agents/ui/useRelaySkillEditor.tsx | 112 +++++++ desktop/tests/e2e/agents.spec.ts | 38 ++- 7 files changed, 504 insertions(+), 272 deletions(-) create mode 100644 desktop/src/features/agents/ui/RelaySkillEditor.tsx create mode 100644 desktop/src/features/agents/ui/useRelaySkillEditor.tsx diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 7822211541..595e0836c2 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -184,7 +184,16 @@ with a TypeScript lookup table or an id comparison in a component. import boundary. Do not silently strip them: rejection keeps the reviewed string identical to the executed string. New sharing paths must reuse the same validation before they persist or activate a definition. -13. **Profile runtime sections render only reported agent data.** Missing +13. **Shared-instruction editing replaces the agent form; it never nests a + dialog.** Create and author-owned Edit open `RelaySkillEditor` in the + existing agent-dialog surface while preserving the parent agent draft. + Publishing a new instruction returns to that draft and assigns the accepted + relay coordinate immediately; updating an existing instruction preserves + its assignment. Dirty Title, When to use it, or Instructions fields must + confirm before Cancel or the enclosing dialog close discards them. The + primary create action is **Publish**, and the footer discloses that anyone + in the community can read the instruction. +14. **Profile runtime sections render only reported agent data.** Missing runtime, model, status, command, MCP, advanced, or diagnostics values stay absent in every build mode. Do not fill profile or agent-panel gaps with development/staging examples, preview controls, or synthetic configuration; diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index 25aecbf4c9..cd82c18b80 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -16,6 +16,7 @@ import type { EnvVarsValue } from "./EnvVarsEditor"; import { PersonaAdvancedFields } from "./PersonaAdvancedFields"; import { PersonaModelField } from "./PersonaModelField"; import { runtimeAvailabilityWarning } from "./runtimeAvailabilityWarning"; +import { useRelaySkillEditor } from "./useRelaySkillEditor"; import { PersonaProviderApiKeyField } from "./PersonaProviderApiKeyField"; import { canSubmitPersonaDialog, @@ -159,15 +160,12 @@ export function AgentDefinitionDialog({ const [behaviorDraft, setBehaviorDraft] = React.useState( emptyPersonaBehaviorDraft, ); - // The seed the draft is diffed against at submit: an untouched quad // submits no behavior group, keeping unrelated edits hash-quiet. const behaviorSeedRef = React.useRef(emptyPersonaBehaviorDraft); - // Tracks when the runtime was auto-seeded by the default-runtime effect in // edit mode (i.e. the user never explicitly chose a runtime). Used to omit // the seeded runtime from the submit payload for builtin definitions whose // canonical runtime is null — the sync would revert it anyway. const isRuntimeAutoSeededRef = React.useRef(false); - // Guards the seeding effect so it fires at most once per dialog-open. // Without this, clearing runtime back to "" via "No preference" would re- // trigger the effect (the `runtime` dep would pass the length guard) and // snap the dropdown back to the default — an edit-mode regression. @@ -176,6 +174,14 @@ export function AgentDefinitionDialog({ const [isAvatarUploadPending, setIsAvatarUploadPending] = React.useState(false); const [hasUserChanges, setHasUserChanges] = React.useState(false); + const relaySkillEditor = useRelaySkillEditor({ + assignedRelaySkills, + embedded, + open, + onDirtyChange, + setAssignedRelaySkills, + setHasUserChanges, + }); const [isAddHarnessOpen, setIsAddHarnessOpen] = React.useState(false); const { globalConfig, @@ -211,6 +217,7 @@ export function AgentDefinitionDialog({ setAvatarUrl(initialValues.avatarUrl ?? ""); setSystemPrompt(initialValues.systemPrompt); setAssignedRelaySkills(initialValues.assignedRelaySkills ?? []); + relaySkillEditor.resetRelaySkillEditor(); setRuntime(initialValues.runtime ?? ""); setModel(initialValues.model ?? ""); setIsCustomModelEditing(false); @@ -239,7 +246,7 @@ export function AgentDefinitionDialog({ setHasUserChanges(false); isRuntimeAutoSeededRef.current = false; hasSeededForOpenRef.current = false; - }, [initialValues, open]); + }, [initialValues, open, relaySkillEditor.resetRelaySkillEditor]); React.useEffect(() => { if ( @@ -794,12 +801,8 @@ export function AgentDefinitionDialog({ { - setAssignedRelaySkills(coordinates); - setHasUserChanges(true); - }} onSystemPromptChange={setSystemPrompt} systemPrompt={systemPrompt} /> @@ -1010,6 +1013,8 @@ export function AgentDefinitionDialog({ ); + if (relaySkillEditor.editor) return relaySkillEditor.editor; + return ( void; + onEditRelaySkill: (detail: ResolvedRelaySkill | null) => void; + publishedRelaySkills: readonly RelaySkillCover[]; onSystemPromptChange: (value: string) => void; systemPrompt: string; }; @@ -35,6 +41,8 @@ export function AgentInstructionsField({ assignedRelaySkills, disabled, onAssignedRelaySkillsChange, + onEditRelaySkill, + publishedRelaySkills, onSystemPromptChange, systemPrompt, }: AgentInstructionsFieldProps) { @@ -47,6 +55,8 @@ export function AgentInstructionsField({ label="Agent instructions" labelFor="persona-system-prompt" onChange={onAssignedRelaySkillsChange} + onEdit={onEditRelaySkill} + publishedSkills={publishedRelaySkills} selected={assignedRelaySkills} > {textarea( diff --git a/desktop/src/features/agents/ui/RelaySkillEditor.tsx b/desktop/src/features/agents/ui/RelaySkillEditor.tsx new file mode 100644 index 0000000000..a48bf387c5 --- /dev/null +++ b/desktop/src/features/agents/ui/RelaySkillEditor.tsx @@ -0,0 +1,302 @@ +import * as React from "react"; + +import { + createRelaySkill, + type RelaySkillCover, + type ResolvedRelaySkill, + updateRelaySkill, +} from "@/shared/api/tauriPersonas"; +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 { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; +import { Input } from "@/shared/ui/input"; +import { Textarea } from "@/shared/ui/textarea"; +import { relayInstructionNameFromTitle } from "../lib/relayInstructionName"; +import { + PERSONA_FIELD_CONTROL_CLASS, + PERSONA_FIELD_SHELL_CLASS, +} from "./agentConfigOptions"; + +type RelaySkillEditorProps = { + detail: ResolvedRelaySkill | null; + embedded?: boolean; + onClose: () => void; + onDirtyChange?: (dirty: boolean) => void; + onPublished: (skill: RelaySkillCover) => void; + registerCloseRequest: (request: (() => void) | null) => void; +}; + +const EMPTY_DRAFT = { title: "", summary: "", instructions: "" }; + +export function RelaySkillEditor({ + detail, + embedded = false, + onClose, + onDirtyChange, + onPublished, + registerCloseRequest, +}: RelaySkillEditorProps) { + const seed = React.useMemo( + () => + detail + ? { + title: detail.title, + summary: detail.summary ?? "", + instructions: detail.content, + } + : EMPTY_DRAFT, + [detail], + ); + const [draft, setDraft] = React.useState(seed); + const [publishState, setPublishState] = React.useState< + "idle" | "publishing" | "error" + >("idle"); + const [publishError, setPublishError] = React.useState(""); + const [discardConfirmationOpen, setDiscardConfirmationOpen] = + React.useState(false); + const dirty = + draft.title !== seed.title || + draft.summary !== seed.summary || + draft.instructions !== seed.instructions; + + const requestClose = React.useCallback(() => { + if (publishState === "publishing") return; + if (dirty) { + setDiscardConfirmationOpen(true); + return; + } + onClose(); + }, [dirty, onClose, publishState]); + + React.useEffect(() => { + onDirtyChange?.(dirty); + return () => onDirtyChange?.(false); + }, [dirty, onDirtyChange]); + + React.useEffect(() => { + registerCloseRequest(requestClose); + return () => registerCloseRequest(null); + }, [registerCloseRequest, requestClose]); + + function updateDraft(field: keyof typeof draft, value: string) { + setDraft((current) => ({ ...current, [field]: value })); + setPublishError(""); + setPublishState("idle"); + } + + async function publishSkill(event: React.FormEvent) { + event.preventDefault(); + setPublishState("publishing"); + setPublishError(""); + try { + const skill = detail + ? await updateRelaySkill({ + ...draft, + coordinate: detail.coordinate, + expectedEventId: detail.eventId, + }) + : await createRelaySkill({ + ...draft, + name: relayInstructionNameFromTitle(draft.title), + }); + onPublished(skill); + } catch (error) { + setPublishError( + error instanceof Error + ? error.message + : detail + ? "This instruction couldn’t be updated." + : "This instruction couldn’t be published.", + ); + setPublishState("error"); + } + } + + const footer = ( +
+

+ Anyone in your community can read this. +

+
+ + +
+
+ ); + + return ( + <> + +
+
+ +
+ updateDraft("title", event.target.value)} + placeholder="Engineering discipline" + value={draft.title} + /> +
+
+
+ +
+