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:
Taylor Ho
2026-08-25 08:36:32 -07:00
committed by Rizz
parent ba45022416
commit 944d758f0c
7 changed files with 504 additions and 272 deletions

View File

@@ -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;

View File

@@ -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}

View File

@@ -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(

View 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>
</>
);
}

View File

@@ -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>
</>
);
}

View 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,
};
}

View File

@@ -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(