diff --git a/ui/src/lib/agent-skills-state.test.ts b/ui/src/lib/agent-skills-state.test.ts new file mode 100644 index 00000000..883ef860 --- /dev/null +++ b/ui/src/lib/agent-skills-state.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { applyAgentSkillSnapshot } from "./agent-skills-state"; + +describe("applyAgentSkillSnapshot", () => { + it("hydrates the initial snapshot without arming autosave", () => { + const result = applyAgentSkillSnapshot( + { + draft: [], + lastSaved: [], + hasHydratedSnapshot: false, + }, + ["paperclip", "para-memory-files"], + ); + + expect(result).toEqual({ + draft: ["paperclip", "para-memory-files"], + lastSaved: ["paperclip", "para-memory-files"], + hasHydratedSnapshot: true, + shouldSkipAutosave: true, + }); + }); + + it("keeps unsaved local edits when a fresh snapshot arrives", () => { + const result = applyAgentSkillSnapshot( + { + draft: ["paperclip", "custom-skill"], + lastSaved: ["paperclip"], + hasHydratedSnapshot: true, + }, + ["paperclip"], + ); + + expect(result).toEqual({ + draft: ["paperclip", "custom-skill"], + lastSaved: ["paperclip"], + hasHydratedSnapshot: true, + shouldSkipAutosave: false, + }); + }); + + it("adopts server state after a successful save and skips the follow-up autosave pass", () => { + const result = applyAgentSkillSnapshot( + { + draft: ["paperclip", "custom-skill"], + lastSaved: ["paperclip", "custom-skill"], + hasHydratedSnapshot: true, + }, + ["paperclip", "custom-skill"], + ); + + expect(result).toEqual({ + draft: ["paperclip", "custom-skill"], + lastSaved: ["paperclip", "custom-skill"], + hasHydratedSnapshot: true, + shouldSkipAutosave: true, + }); + }); +}); diff --git a/ui/src/lib/agent-skills-state.ts b/ui/src/lib/agent-skills-state.ts new file mode 100644 index 00000000..640b3819 --- /dev/null +++ b/ui/src/lib/agent-skills-state.ts @@ -0,0 +1,29 @@ +export interface AgentSkillDraftState { + draft: string[]; + lastSaved: string[]; + hasHydratedSnapshot: boolean; +} + +export interface AgentSkillSnapshotApplyResult extends AgentSkillDraftState { + shouldSkipAutosave: boolean; +} + +export function arraysEqual(a: string[], b: string[]): boolean { + if (a === b) return true; + if (a.length !== b.length) return false; + return a.every((value, index) => value === b[index]); +} + +export function applyAgentSkillSnapshot( + state: AgentSkillDraftState, + desiredSkills: string[], +): AgentSkillSnapshotApplyResult { + const shouldReplaceDraft = !state.hasHydratedSnapshot || arraysEqual(state.draft, state.lastSaved); + + return { + draft: shouldReplaceDraft ? desiredSkills : state.draft, + lastSaved: desiredSkills, + hasHydratedSnapshot: true, + shouldSkipAutosave: shouldReplaceDraft, + }; +} diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index c891d6c3..1d020136 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -74,6 +74,7 @@ import { } from "@paperclipai/shared"; import { redactHomePathUserSegments, redactHomePathUserSegmentsInValue } from "@paperclipai/adapter-utils"; import { agentRouteRef } from "../lib/utils"; +import { applyAgentSkillSnapshot, arraysEqual } from "../lib/agent-skills-state"; const runStatusIcons: Record = { succeeded: { icon: CheckCircle2, color: "text-green-600 dark:text-green-400" }, @@ -137,12 +138,6 @@ const sourceLabels: Record = { const LIVE_SCROLL_BOTTOM_TOLERANCE_PX = 32; type ScrollContainer = Window | HTMLElement; -function arraysEqual(a: string[], b: string[]): boolean { - if (a === b) return true; - if (a.length !== b.length) return false; - return a.every((value, index) => value === b[index]); -} - function isWindowContainer(container: ScrollContainer): container is Window { return container === window; } @@ -1250,6 +1245,8 @@ function AgentSkillsTab({ const [skillDraft, setSkillDraft] = useState([]); const [lastSavedSkills, setLastSavedSkills] = useState([]); const lastSavedSkillsRef = useRef([]); + const hasHydratedSkillSnapshotRef = useRef(false); + const skipNextSkillAutosaveRef = useRef(true); const { data: skillSnapshot, isLoading } = useQuery({ queryKey: queryKeys.agents.skills(agent.id), @@ -1277,16 +1274,36 @@ function AgentSkillsTab({ }); useEffect(() => { - if (!skillSnapshot) return; - setSkillDraft((current) => - arraysEqual(current, lastSavedSkillsRef.current) ? skillSnapshot.desiredSkills : current, - ); - lastSavedSkillsRef.current = skillSnapshot.desiredSkills; - setLastSavedSkills(skillSnapshot.desiredSkills); - }, [skillSnapshot]); + setSkillDraft([]); + setLastSavedSkills([]); + lastSavedSkillsRef.current = []; + hasHydratedSkillSnapshotRef.current = false; + skipNextSkillAutosaveRef.current = true; + }, [agent.id]); useEffect(() => { if (!skillSnapshot) return; + const nextState = applyAgentSkillSnapshot( + { + draft: skillDraft, + lastSaved: lastSavedSkillsRef.current, + hasHydratedSnapshot: hasHydratedSkillSnapshotRef.current, + }, + skillSnapshot.desiredSkills, + ); + skipNextSkillAutosaveRef.current = nextState.shouldSkipAutosave; + hasHydratedSkillSnapshotRef.current = nextState.hasHydratedSnapshot; + setSkillDraft(nextState.draft); + lastSavedSkillsRef.current = nextState.lastSaved; + setLastSavedSkills(nextState.lastSaved); + }, [skillDraft, skillSnapshot]); + + useEffect(() => { + if (!skillSnapshot) return; + if (skipNextSkillAutosaveRef.current) { + skipNextSkillAutosaveRef.current = false; + return; + } if (syncSkills.isPending) return; if (arraysEqual(skillDraft, lastSavedSkillsRef.current)) return; @@ -1409,7 +1426,7 @@ function AgentSkillsTab({ const renderSkillRow = (skill: SkillRow) => { const adapterEntry = skill.adapterEntry ?? adapterEntryByName.get(skill.slug); const required = Boolean(adapterEntry?.required); - const checked = required || Boolean(adapterEntry?.desired) || skillDraft.includes(skill.slug); + const checked = required || skillDraft.includes(skill.slug); const disabled = required || skillSnapshot?.mode === "unsupported"; const checkbox = (