mirror of
https://github.com/block/buzz.git
synced 2026-11-04 16:31:38 +00:00
Polish shared instruction publishing flow
Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
|
||||
<AgentInstructionsField
|
||||
assignedRelaySkills={assignedRelaySkills}
|
||||
{...relaySkillEditor.fieldProps}
|
||||
disabled={isPending}
|
||||
onAssignedRelaySkillsChange={(coordinates) => {
|
||||
setAssignedRelaySkills(coordinates);
|
||||
setHasUserChanges(true);
|
||||
}}
|
||||
onSystemPromptChange={setSystemPrompt}
|
||||
systemPrompt={systemPrompt}
|
||||
/>
|
||||
@@ -1010,6 +1013,8 @@ export function AgentDefinitionDialog({
|
||||
</form>
|
||||
);
|
||||
|
||||
if (relaySkillEditor.editor) return relaySkillEditor.editor;
|
||||
|
||||
return (
|
||||
<AgentDefinitionDialogShell
|
||||
description={description}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import type {
|
||||
RelaySkillCover,
|
||||
ResolvedRelaySkill,
|
||||
} from "@/shared/api/tauriPersonas";
|
||||
import { useFeatureEnabled } from "@/shared/features/useFeatureEnabled";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
@@ -11,6 +15,8 @@ type AgentInstructionsFieldProps = {
|
||||
assignedRelaySkills: string[];
|
||||
disabled: boolean;
|
||||
onAssignedRelaySkillsChange: (coordinates: string[]) => 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(
|
||||
|
||||
302
desktop/src/features/agents/ui/RelaySkillEditor.tsx
Normal file
302
desktop/src/features/agents/ui/RelaySkillEditor.tsx
Normal file
@@ -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<HTMLFormElement>) {
|
||||
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 = (
|
||||
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
Anyone in your community can read this.
|
||||
</p>
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
disabled={publishState === "publishing"}
|
||||
onClick={requestClose}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
publishState === "publishing" ||
|
||||
!draft.title.trim() ||
|
||||
(!detail && !relayInstructionNameFromTitle(draft.title)) ||
|
||||
!draft.summary.trim() ||
|
||||
!draft.instructions.trim()
|
||||
}
|
||||
form="relay-skill-editor-form"
|
||||
type="submit"
|
||||
>
|
||||
{publishState === "publishing"
|
||||
? detail
|
||||
? "Saving…"
|
||||
: "Publishing…"
|
||||
: detail
|
||||
? "Save changes"
|
||||
: "Publish"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChooserDialogContent
|
||||
aria-label={
|
||||
detail ? "Edit shared instruction" : "Create shared instruction"
|
||||
}
|
||||
className={cn(
|
||||
"max-w-xl border-0",
|
||||
embedded &&
|
||||
"h-full max-h-none w-full max-w-none rounded-none shadow-none",
|
||||
)}
|
||||
contentClassName="pt-3"
|
||||
data-testid="relay-skill-editor"
|
||||
description={
|
||||
detail
|
||||
? "Update the reusable guidance agents apply to relevant tasks."
|
||||
: "Add reusable guidance agents can apply to relevant tasks."
|
||||
}
|
||||
footer={footer}
|
||||
headerClassName="pb-2"
|
||||
headerSubtitle={
|
||||
detail
|
||||
? "Update the reusable guidance agents apply to relevant tasks."
|
||||
: "Add reusable guidance agents can apply to relevant tasks."
|
||||
}
|
||||
scrollAreaTestId="relay-skill-editor-scroll-area"
|
||||
showCloseButton={!embedded}
|
||||
title={detail ? "Edit shared instruction" : "Create shared instruction"}
|
||||
>
|
||||
<form
|
||||
className="space-y-4"
|
||||
id="relay-skill-editor-form"
|
||||
onSubmit={publishSkill}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="relay-skill-title">
|
||||
Title
|
||||
</label>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-11 items-center px-3",
|
||||
PERSONA_FIELD_SHELL_CLASS,
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
className={cn(
|
||||
"h-8 px-0 py-0 leading-6",
|
||||
PERSONA_FIELD_CONTROL_CLASS,
|
||||
)}
|
||||
id="relay-skill-title"
|
||||
maxLength={280}
|
||||
onChange={(event) => updateDraft("title", event.target.value)}
|
||||
placeholder="Engineering discipline"
|
||||
value={draft.title}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="relay-skill-summary"
|
||||
>
|
||||
When to use it
|
||||
</label>
|
||||
<div className={cn("overflow-hidden", PERSONA_FIELD_SHELL_CLASS)}>
|
||||
<Textarea
|
||||
className={cn(
|
||||
"resize-none px-3 py-3 leading-5",
|
||||
PERSONA_FIELD_CONTROL_CLASS,
|
||||
)}
|
||||
id="relay-skill-summary"
|
||||
maxLength={1024}
|
||||
onChange={(event) => updateDraft("summary", event.target.value)}
|
||||
placeholder="Raises implementation quality through validation, self-review, boundary checks, coverage, and second opinions. Use when coding, refactoring, reviewing changes, testing, or preparing work for completion."
|
||||
rows={3}
|
||||
value={draft.summary}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="relay-skill-instructions"
|
||||
>
|
||||
Instructions
|
||||
</label>
|
||||
<div className={cn("overflow-hidden", PERSONA_FIELD_SHELL_CLASS)}>
|
||||
<Textarea
|
||||
className={cn(
|
||||
"min-h-40 resize-none px-3 py-3 font-mono text-sm leading-5",
|
||||
PERSONA_FIELD_CONTROL_CLASS,
|
||||
)}
|
||||
id="relay-skill-instructions"
|
||||
maxLength={32768}
|
||||
onChange={(event) =>
|
||||
updateDraft("instructions", event.target.value)
|
||||
}
|
||||
placeholder={
|
||||
"Target a 9/10 or better standard for minimalness, elegance, and correctness.\n\nValidate work in the shape the task demands.\nSelf-review for accidental changes, debug code, weak boundaries, missing coverage, and convention violations…"
|
||||
}
|
||||
value={draft.instructions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{publishError ? (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{publishError}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
</ChooserDialogContent>
|
||||
|
||||
<AlertDialog
|
||||
onOpenChange={setDiscardConfirmationOpen}
|
||||
open={discardConfirmationOpen}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Discard changes?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Your unsaved instruction changes will be lost.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel asChild>
|
||||
<Button type="button" variant="outline">
|
||||
Keep editing
|
||||
</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button onClick={onClose} type="button" variant="destructive">
|
||||
Discard changes
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,22 +2,12 @@ import * as React from "react";
|
||||
import { BookOpen, Check, Plus, X } from "lucide-react";
|
||||
|
||||
import {
|
||||
createRelaySkill,
|
||||
listMyRelaySkills,
|
||||
type RelaySkillCover,
|
||||
type ResolvedRelaySkill,
|
||||
updateRelaySkill,
|
||||
} from "@/shared/api/tauriPersonas";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
@@ -25,15 +15,9 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { relayInstructionNameFromTitle } from "../lib/relayInstructionName";
|
||||
import { RelaySkillPreviewButton } from "./RelaySkillPreviewButton";
|
||||
import { toggleRelaySkillCoordinate } from "./relaySkillPickerState";
|
||||
import {
|
||||
PERSONA_FIELD_CONTROL_CLASS,
|
||||
PERSONA_FIELD_SHELL_CLASS,
|
||||
} from "./agentConfigOptions";
|
||||
import { PERSONA_FIELD_SHELL_CLASS } from "./agentConfigOptions";
|
||||
|
||||
type RelaySkillPickerProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -42,6 +26,8 @@ type RelaySkillPickerProps = {
|
||||
labelFor: string;
|
||||
selected: readonly string[];
|
||||
onChange: (coordinates: string[]) => void;
|
||||
onEdit: (detail: ResolvedRelaySkill | null) => void;
|
||||
publishedSkills: readonly RelaySkillCover[];
|
||||
};
|
||||
|
||||
type LoadState = "loading" | "ready" | "error";
|
||||
@@ -53,22 +39,12 @@ export function RelaySkillPicker({
|
||||
labelFor,
|
||||
selected,
|
||||
onChange,
|
||||
onEdit,
|
||||
publishedSkills,
|
||||
}: RelaySkillPickerProps) {
|
||||
const [skills, setSkills] = React.useState<RelaySkillCover[]>([]);
|
||||
const [loadState, setLoadState] = React.useState<LoadState>("loading");
|
||||
const [pickerOpen, setPickerOpen] = React.useState(false);
|
||||
const [createOpen, setCreateOpen] = React.useState(false);
|
||||
const [editingDetail, setEditingDetail] =
|
||||
React.useState<ResolvedRelaySkill | null>(null);
|
||||
const [createState, setCreateState] = React.useState<
|
||||
"idle" | "publishing" | "error"
|
||||
>("idle");
|
||||
const [createError, setCreateError] = React.useState("");
|
||||
const [draft, setDraft] = React.useState({
|
||||
title: "",
|
||||
summary: "",
|
||||
instructions: "",
|
||||
});
|
||||
|
||||
const loadSkills = React.useCallback(async () => {
|
||||
setLoadState("loading");
|
||||
@@ -84,9 +60,21 @@ export function RelaySkillPicker({
|
||||
void loadSkills();
|
||||
}, [loadSkills]);
|
||||
|
||||
const visibleSkills = React.useMemo(
|
||||
() => [
|
||||
...publishedSkills,
|
||||
...skills.filter(
|
||||
(skill) =>
|
||||
!publishedSkills.some(
|
||||
(published) => published.coordinate === skill.coordinate,
|
||||
),
|
||||
),
|
||||
],
|
||||
[publishedSkills, skills],
|
||||
);
|
||||
const knownCoordinates = React.useMemo(
|
||||
() => new Set(skills.map((skill) => skill.coordinate)),
|
||||
[skills],
|
||||
() => new Set(visibleSkills.map((skill) => skill.coordinate)),
|
||||
[visibleSkills],
|
||||
);
|
||||
const unknownSelected =
|
||||
loadState === "ready"
|
||||
@@ -94,7 +82,7 @@ export function RelaySkillPicker({
|
||||
: [];
|
||||
const selectedSkills = selected
|
||||
.map((coordinate) =>
|
||||
skills.find((skill) => skill.coordinate === coordinate),
|
||||
visibleSkills.find((skill) => skill.coordinate === coordinate),
|
||||
)
|
||||
.filter((skill): skill is RelaySkillCover => skill !== undefined);
|
||||
const hasSelectedSkills =
|
||||
@@ -111,84 +99,26 @@ export function RelaySkillPicker({
|
||||
}
|
||||
}
|
||||
|
||||
function updateDraft(field: keyof typeof draft, value: string) {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
[field]: value,
|
||||
}));
|
||||
setCreateError("");
|
||||
setCreateState("idle");
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
setDraft({ title: "", summary: "", instructions: "" });
|
||||
setEditingDetail(null);
|
||||
setCreateError("");
|
||||
setCreateState("idle");
|
||||
setCreateOpen(true);
|
||||
}
|
||||
|
||||
function openEditDialog(detail: ResolvedRelaySkill) {
|
||||
function openCreateEditor() {
|
||||
setPickerOpen(false);
|
||||
setDraft({
|
||||
title: detail.title,
|
||||
summary: detail.summary ?? "",
|
||||
instructions: detail.content,
|
||||
});
|
||||
setEditingDetail(detail);
|
||||
setCreateError("");
|
||||
setCreateState("idle");
|
||||
setCreateOpen(true);
|
||||
onEdit(null);
|
||||
}
|
||||
|
||||
async function publishSkill(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setCreateState("publishing");
|
||||
setCreateError("");
|
||||
try {
|
||||
const skill = editingDetail
|
||||
? await updateRelaySkill({
|
||||
...draft,
|
||||
coordinate: editingDetail.coordinate,
|
||||
expectedEventId: editingDetail.eventId,
|
||||
})
|
||||
: await createRelaySkill({
|
||||
...draft,
|
||||
name: relayInstructionNameFromTitle(draft.title),
|
||||
});
|
||||
setSkills((current) => [
|
||||
skill,
|
||||
...current.filter((item) => item.coordinate !== skill.coordinate),
|
||||
]);
|
||||
if (!editingDetail && !selected.includes(skill.coordinate)) {
|
||||
onChange([...selected, skill.coordinate]);
|
||||
}
|
||||
setDraft({ title: "", summary: "", instructions: "" });
|
||||
setEditingDetail(null);
|
||||
setCreateState("idle");
|
||||
setCreateOpen(false);
|
||||
} catch (error) {
|
||||
setCreateError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: editingDetail
|
||||
? "This instruction couldn’t be updated."
|
||||
: "This instruction couldn’t be published.",
|
||||
);
|
||||
setCreateState("error");
|
||||
}
|
||||
function openEditEditor(detail: ResolvedRelaySkill) {
|
||||
setPickerOpen(false);
|
||||
onEdit(detail);
|
||||
}
|
||||
|
||||
function renderPickerIngress() {
|
||||
if (loadState !== "ready") return null;
|
||||
|
||||
if (skills.length === 0) {
|
||||
if (visibleSkills.length === 0) {
|
||||
return (
|
||||
<button
|
||||
aria-label="Create shared instruction"
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-muted/65 text-foreground hover:bg-muted"
|
||||
disabled={disabled}
|
||||
onClick={openCreateDialog}
|
||||
onClick={openCreateEditor}
|
||||
title="Create shared instruction"
|
||||
type="button"
|
||||
>
|
||||
@@ -238,11 +168,11 @@ export function RelaySkillPicker({
|
||||
sideOffset={5}
|
||||
>
|
||||
<div className="max-h-[min(20rem,var(--radix-dropdown-menu-content-available-height))] overflow-y-auto overscroll-contain">
|
||||
{skills.map((skill) => (
|
||||
{visibleSkills.map((skill) => (
|
||||
<RelaySkillPreviewButton
|
||||
asChild
|
||||
key={skill.coordinate}
|
||||
onEdit={openEditDialog}
|
||||
onEdit={openEditEditor}
|
||||
previewSide="left"
|
||||
skill={skill}
|
||||
>
|
||||
@@ -292,7 +222,7 @@ export function RelaySkillPicker({
|
||||
<DropdownMenuItem
|
||||
className="mx-1.5 mt-1 min-h-6 w-fit py-1"
|
||||
disabled={disabled}
|
||||
onSelect={openCreateDialog}
|
||||
onSelect={openCreateEditor}
|
||||
>
|
||||
Create new
|
||||
</DropdownMenuItem>
|
||||
@@ -338,7 +268,7 @@ export function RelaySkillPicker({
|
||||
>
|
||||
<RelaySkillPreviewButton
|
||||
className="max-w-56 truncate"
|
||||
onEdit={openEditDialog}
|
||||
onEdit={openEditEditor}
|
||||
skill={skill}
|
||||
>
|
||||
{skill.title || skill.slug}
|
||||
@@ -396,154 +326,6 @@ export function RelaySkillPicker({
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Dialog
|
||||
onOpenChange={(open) => {
|
||||
if (createState === "publishing") return;
|
||||
setCreateOpen(open);
|
||||
if (!open) {
|
||||
setCreateError("");
|
||||
setCreateState("idle");
|
||||
setEditingDetail(null);
|
||||
}
|
||||
}}
|
||||
open={createOpen}
|
||||
>
|
||||
<DialogContent className="max-h-[min(680px,calc(100vh-2rem))] max-w-xl overflow-y-auto p-5">
|
||||
<DialogHeader className="pr-8">
|
||||
<DialogTitle>
|
||||
{editingDetail
|
||||
? "Edit shared instruction"
|
||||
: "Create shared instruction"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingDetail
|
||||
? "Update the reusable guidance agents apply to relevant tasks."
|
||||
: "Add reusable guidance agents can apply to relevant tasks."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" onSubmit={publishSkill}>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="relay-skill-title"
|
||||
>
|
||||
Title
|
||||
</label>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-11 items-center px-3",
|
||||
PERSONA_FIELD_SHELL_CLASS,
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
className={cn(
|
||||
"h-8 px-0 py-0 leading-6",
|
||||
PERSONA_FIELD_CONTROL_CLASS,
|
||||
)}
|
||||
id="relay-skill-title"
|
||||
maxLength={280}
|
||||
onChange={(event) =>
|
||||
updateDraft("title", event.target.value)
|
||||
}
|
||||
placeholder="Engineering discipline"
|
||||
value={draft.title}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="relay-skill-summary"
|
||||
>
|
||||
When to use it
|
||||
</label>
|
||||
<div
|
||||
className={cn("overflow-hidden", PERSONA_FIELD_SHELL_CLASS)}
|
||||
>
|
||||
<Textarea
|
||||
className={cn(
|
||||
"resize-none px-3 py-3 leading-5",
|
||||
PERSONA_FIELD_CONTROL_CLASS,
|
||||
)}
|
||||
id="relay-skill-summary"
|
||||
maxLength={1024}
|
||||
onChange={(event) =>
|
||||
updateDraft("summary", event.target.value)
|
||||
}
|
||||
placeholder="Raises implementation quality through validation, self-review, boundary checks, coverage, and second opinions. Use when coding, refactoring, reviewing changes, testing, or preparing work for completion."
|
||||
rows={3}
|
||||
value={draft.summary}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="relay-skill-instructions"
|
||||
>
|
||||
Instructions
|
||||
</label>
|
||||
<div
|
||||
className={cn("overflow-hidden", PERSONA_FIELD_SHELL_CLASS)}
|
||||
>
|
||||
<Textarea
|
||||
className={cn(
|
||||
"min-h-40 resize-none px-3 py-3 font-mono text-sm leading-5",
|
||||
PERSONA_FIELD_CONTROL_CLASS,
|
||||
)}
|
||||
id="relay-skill-instructions"
|
||||
maxLength={32768}
|
||||
onChange={(event) =>
|
||||
updateDraft("instructions", event.target.value)
|
||||
}
|
||||
placeholder={
|
||||
"Target a 9/10 or better standard for minimalness, elegance, and correctness.\n\nValidate work in the shape the task demands.\nSelf-review for accidental changes, debug code, weak boundaries, missing coverage, and convention violations…"
|
||||
}
|
||||
value={draft.instructions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{createError ? (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{createError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
disabled={createState === "publishing"}
|
||||
onClick={() => setCreateOpen(false)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
createState === "publishing" ||
|
||||
!draft.title.trim() ||
|
||||
(!editingDetail &&
|
||||
!relayInstructionNameFromTitle(draft.title)) ||
|
||||
!draft.summary.trim() ||
|
||||
!draft.instructions.trim()
|
||||
}
|
||||
type="submit"
|
||||
>
|
||||
{createState === "publishing"
|
||||
? editingDetail
|
||||
? "Saving…"
|
||||
: "Publishing…"
|
||||
: editingDetail
|
||||
? "Save changes"
|
||||
: "Create and add"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
112
desktop/src/features/agents/ui/useRelaySkillEditor.tsx
Normal file
112
desktop/src/features/agents/ui/useRelaySkillEditor.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type {
|
||||
RelaySkillCover,
|
||||
ResolvedRelaySkill,
|
||||
} from "@/shared/api/tauriPersonas";
|
||||
import { Dialog } from "@/shared/ui/dialog";
|
||||
import { RelaySkillEditor } from "./RelaySkillEditor";
|
||||
|
||||
type UseRelaySkillEditorOptions = {
|
||||
embedded: boolean;
|
||||
open: boolean;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
assignedRelaySkills: string[];
|
||||
setAssignedRelaySkills: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
setHasUserChanges: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export function useRelaySkillEditor({
|
||||
embedded,
|
||||
open,
|
||||
onDirtyChange,
|
||||
assignedRelaySkills,
|
||||
setAssignedRelaySkills,
|
||||
setHasUserChanges,
|
||||
}: UseRelaySkillEditorOptions) {
|
||||
const [publishedRelaySkills, setPublishedRelaySkills] = React.useState<
|
||||
RelaySkillCover[]
|
||||
>([]);
|
||||
const [editorState, setEditorState] = React.useState<{
|
||||
key: number;
|
||||
detail: ResolvedRelaySkill | null;
|
||||
} | null>(null);
|
||||
const closeRequestRef = React.useRef<(() => void) | null>(null);
|
||||
|
||||
const resetRelaySkillEditor = React.useCallback(() => {
|
||||
setPublishedRelaySkills([]);
|
||||
setEditorState(null);
|
||||
closeRequestRef.current = null;
|
||||
}, []);
|
||||
const openRelaySkillEditor = React.useCallback(
|
||||
(detail: ResolvedRelaySkill | null) =>
|
||||
setEditorState({ key: Date.now(), detail }),
|
||||
[],
|
||||
);
|
||||
|
||||
function handlePublished(skill: RelaySkillCover) {
|
||||
setPublishedRelaySkills((current) => [
|
||||
skill,
|
||||
...current.filter((item) => item.coordinate !== skill.coordinate),
|
||||
]);
|
||||
if (!editorState?.detail) {
|
||||
setAssignedRelaySkills((current) =>
|
||||
current.includes(skill.coordinate)
|
||||
? current
|
||||
: [...current, skill.coordinate],
|
||||
);
|
||||
setHasUserChanges(true);
|
||||
}
|
||||
setEditorState(null);
|
||||
}
|
||||
|
||||
const fieldProps = {
|
||||
assignedRelaySkills,
|
||||
onAssignedRelaySkillsChange: (coordinates: string[]) => {
|
||||
setAssignedRelaySkills(coordinates);
|
||||
setHasUserChanges(true);
|
||||
},
|
||||
onEditRelaySkill: openRelaySkillEditor,
|
||||
publishedRelaySkills,
|
||||
};
|
||||
|
||||
if (!editorState) {
|
||||
return {
|
||||
editor: null,
|
||||
fieldProps,
|
||||
resetRelaySkillEditor,
|
||||
};
|
||||
}
|
||||
|
||||
const editorContent = (
|
||||
<RelaySkillEditor
|
||||
detail={editorState.detail}
|
||||
embedded={embedded}
|
||||
key={editorState.key}
|
||||
onClose={() => setEditorState(null)}
|
||||
onDirtyChange={onDirtyChange}
|
||||
onPublished={handlePublished}
|
||||
registerCloseRequest={(request) => {
|
||||
closeRequestRef.current = request;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const editor = embedded ? (
|
||||
editorContent
|
||||
) : (
|
||||
<Dialog
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) closeRequestRef.current?.();
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
{editorContent}
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
return {
|
||||
editor,
|
||||
fieldProps,
|
||||
resetRelaySkillEditor,
|
||||
};
|
||||
}
|
||||
@@ -554,10 +554,11 @@ test("shared instruction creation stays compact and derives safe names", async (
|
||||
.getByRole("button", { name: "Create shared instruction" })
|
||||
.click();
|
||||
|
||||
const createDialog = page.getByRole("dialog", {
|
||||
name: "Create shared instruction",
|
||||
});
|
||||
const createDialog = page.getByTestId("relay-skill-editor");
|
||||
await expect(createDialog).toBeVisible();
|
||||
await expect(createDialog).toContainText(
|
||||
"Anyone in your community can read this.",
|
||||
);
|
||||
await expect(createDialog.locator("#relay-skill-name")).toHaveCount(0);
|
||||
await expect(createDialog.locator("#relay-skill-title")).toHaveAttribute(
|
||||
"placeholder",
|
||||
@@ -572,7 +573,9 @@ test("shared instruction creation stays compact and derives safe names", async (
|
||||
).toHaveAttribute("placeholder", /Target a 9\/10 or better standard/);
|
||||
await expect
|
||||
.poll(() =>
|
||||
createDialog.evaluate((element) => getComputedStyle(element).overflowY),
|
||||
createDialog
|
||||
.getByTestId("relay-skill-editor-scroll-area")
|
||||
.evaluate((element) => getComputedStyle(element).overflowY),
|
||||
)
|
||||
.toBe("auto");
|
||||
await expect
|
||||
@@ -593,7 +596,7 @@ test("shared instruction creation stays compact and derives safe names", async (
|
||||
.locator("#relay-skill-instructions")
|
||||
.fill("Validate work in the shape the task demands.");
|
||||
|
||||
await createDialog.getByRole("button", { name: "Create and add" }).click();
|
||||
await createDialog.getByRole("button", { name: "Publish" }).click();
|
||||
await expect(createDialog).toBeHidden();
|
||||
await expect(
|
||||
personaDialog.getByRole("button", {
|
||||
@@ -620,7 +623,7 @@ test("shared instruction creation stays compact and derives safe names", async (
|
||||
await createDialog
|
||||
.locator("#relay-skill-instructions")
|
||||
.fill("Review the implementation carefully before completion.");
|
||||
await createDialog.getByRole("button", { name: "Create and add" }).click();
|
||||
await createDialog.getByRole("button", { name: "Publish" }).click();
|
||||
await expect(
|
||||
personaDialog.getByRole("button", {
|
||||
name: "設計レビュー",
|
||||
@@ -994,7 +997,7 @@ test("shared instruction editing is author-only and does not leak into creation"
|
||||
await page.getByTestId("open-agents-view").click();
|
||||
await page.getByTestId("new-agent-card").click();
|
||||
|
||||
const personaDialog = page.getByTestId("persona-dialog");
|
||||
let personaDialog = page.getByTestId("persona-dialog");
|
||||
await personaDialog
|
||||
.getByRole("button", { name: "Add shared instructions" })
|
||||
.click();
|
||||
@@ -1008,9 +1011,7 @@ test("shared instruction editing is author-only and does not leak into creation"
|
||||
await expect(editButton).toBeVisible();
|
||||
await editButton.click();
|
||||
|
||||
const editDialog = page.getByRole("dialog", {
|
||||
name: "Edit shared instruction",
|
||||
});
|
||||
const editDialog = page.getByTestId("relay-skill-editor");
|
||||
await expect(editDialog.locator("#relay-skill-title")).toHaveValue(
|
||||
"Design engineering",
|
||||
);
|
||||
@@ -1021,14 +1022,25 @@ test("shared instruction editing is author-only and does not leak into creation"
|
||||
"Review interface decisions against the product intent.",
|
||||
);
|
||||
|
||||
await editDialog.locator("#relay-skill-title").fill("Design review draft");
|
||||
await editDialog.getByRole("button", { name: "Cancel" }).click();
|
||||
const discardDialog = page.getByRole("alertdialog", {
|
||||
name: "Discard changes?",
|
||||
});
|
||||
await expect(discardDialog).toBeVisible();
|
||||
await discardDialog.getByRole("button", { name: "Keep editing" }).click();
|
||||
await expect(editDialog).toBeVisible();
|
||||
await editDialog.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(discardDialog).toBeVisible();
|
||||
await discardDialog.getByRole("button", { name: "Discard changes" }).click();
|
||||
await expect(editDialog).toBeHidden();
|
||||
personaDialog = page.getByTestId("persona-dialog");
|
||||
await expect(personaDialog).toBeVisible();
|
||||
await personaDialog
|
||||
.getByRole("button", { name: "Add shared instructions" })
|
||||
.click();
|
||||
await page.getByRole("menuitem", { name: "Create new" }).click();
|
||||
const createDialog = page.getByRole("dialog", {
|
||||
name: "Create shared instruction",
|
||||
});
|
||||
const createDialog = page.getByTestId("relay-skill-editor");
|
||||
await expect(createDialog.locator("#relay-skill-title")).toHaveValue("");
|
||||
await expect(createDialog.locator("#relay-skill-summary")).toHaveValue("");
|
||||
await expect(createDialog.locator("#relay-skill-instructions")).toHaveValue(
|
||||
|
||||
Reference in New Issue
Block a user