-
Notifications
You must be signed in to change notification settings - Fork 0
Add Initial State Presets with Seeding to visual canvas run drawer #22
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,6 @@ | ||
| $ vite --port "8080" | ||
| $ vite --host 0.0.0.0 --port 3000 | ||
|
|
||
| VITE v5.4.21 ready in 310 ms | ||
| VITE v5.4.21 ready in 312 ms | ||
|
|
||
| ➜ Local: http://localhost:8080/ | ||
| ➜ Network: http://192.168.0.2:8080/ | ||
| ➜ Local: http://localhost:3000/ | ||
| ➜ Network: http://192.168.0.2:3000/ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| export interface StatePreset { | ||
| id: string; | ||
| name: string; | ||
| stateStr: string; // The JSON string representing the state preset | ||
| createdAt: number; | ||
| } | ||
|
|
||
| const STATE_PRESETS_PREFIX = "agent_flow.state_presets.v1"; | ||
|
|
||
| /** | ||
| * Loads the list of initial state presets for a given workflow ID. | ||
| * If workflowId is null/empty, defaults to "default". | ||
| * | ||
| * @param workflowId The active workflow ID. | ||
| * @returns An array of StatePreset objects. | ||
| */ | ||
| export function loadPresets(workflowId: string | null): StatePreset[] { | ||
| const id = workflowId || "default"; | ||
| try { | ||
| const raw = localStorage.getItem(`${STATE_PRESETS_PREFIX}.${id}`); | ||
| if (raw) { | ||
| const parsed = JSON.parse(raw); | ||
| if (Array.isArray(parsed)) { | ||
| return parsed; | ||
| } | ||
| } | ||
| } catch (e) { | ||
| console.error("Failed to load state presets:", e); | ||
| } | ||
| return []; | ||
| } | ||
|
Comment on lines
+17
to
+31
|
||
|
|
||
| /** | ||
| * Saves the list of initial state presets for a given workflow ID to localStorage. | ||
| * If workflowId is null/empty, defaults to "default". | ||
| * | ||
| * @param workflowId The active workflow ID. | ||
| * @param presets The full array of StatePreset objects to persist. | ||
| */ | ||
| export function savePresets(workflowId: string | null, presets: StatePreset[]): void { | ||
| const id = workflowId || "default"; | ||
| try { | ||
| localStorage.setItem(`${STATE_PRESETS_PREFIX}.${id}`, JSON.stringify(presets)); | ||
| } catch (e) { | ||
| console.error("Failed to save state presets:", e); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Generates a unique, descriptive ID for a preset. | ||
| */ | ||
| export function cryptoId(): string { | ||
| if (typeof crypto !== "undefined" && "randomUUID" in crypto) { | ||
| return crypto.randomUUID(); | ||
| } | ||
| return "preset_" + Math.random().toString(36).slice(2, 10); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -57,6 +57,12 @@ import { | |
| TEMPLATES, | ||
| } from "@/flow/workflows"; | ||
| import { WorkflowsManager } from "@/flow/WorkflowsManager"; | ||
| import { | ||
| loadPresets, | ||
| savePresets, | ||
| StatePreset, | ||
| cryptoId as presetCryptoId, | ||
| } from "@/flow/statePresets"; | ||
|
|
||
| const nodeTypes = { agent: AgentNode }; | ||
|
|
||
|
|
@@ -210,6 +216,60 @@ function Canvas() { | |
| return JSON.stringify({ query: "hello world" }, null, 2); | ||
| }); | ||
| const [initialStateError, setInitialStateError] = useState<string | null>(null); | ||
|
|
||
| // ---- State Presets ---- | ||
| const [presets, setPresets] = useState<StatePreset[]>(() => loadPresets(loadActiveWorkflowId())); | ||
| const [selectedPresetId, setSelectedPresetId] = useState<string>(""); | ||
| const [newPresetName, setNewPresetName] = useState<string>(""); | ||
|
|
||
| useEffect(() => { | ||
| let loaded = loadPresets(activeWorkflowId); | ||
| if (loaded.length === 0) { | ||
| let defaultPreset: StatePreset | null = null; | ||
| if (activeWorkflowId === "template-react") { | ||
| defaultPreset = { | ||
| id: "seed-react", | ||
| name: "OpenAI Search Query", | ||
| stateStr: JSON.stringify({ query: "Should we search the web for OpenAI?" }, null, 2), | ||
| createdAt: Date.now(), | ||
| }; | ||
| } else if (activeWorkflowId === "template-http-router") { | ||
| defaultPreset = { | ||
| id: "seed-http", | ||
| name: "GitHub Octocat User", | ||
| stateStr: JSON.stringify({ username: "octocat" }, null, 2), | ||
| createdAt: Date.now(), | ||
| }; | ||
| } else if (activeWorkflowId === "template-translation-hitl") { | ||
| defaultPreset = { | ||
| id: "seed-translation", | ||
| name: "Lennon Quote Translation", | ||
| stateStr: JSON.stringify({ query: "Life is what happens when you're busy making other plans." }, null, 2), | ||
| createdAt: Date.now(), | ||
| }; | ||
| } else if (!activeWorkflowId) { | ||
| defaultPreset = { | ||
| id: "seed-default", | ||
| name: "Default Query", | ||
| stateStr: JSON.stringify({ query: "hello world" }, null, 2), | ||
| createdAt: Date.now(), | ||
| }; | ||
| } | ||
|
|
||
| if (defaultPreset) { | ||
| loaded = [defaultPreset]; | ||
| savePresets(activeWorkflowId, loaded); | ||
| } | ||
| } | ||
|
Comment on lines
+225
to
+263
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Do not seed when the user saved an empty preset list. Deleting the final preset saves Seed only when the workflow storage key is absent. Keep an existing empty array unchanged. Add a regression test that deletes the final preset, reloads the workflow, and expects no presets. 🤖 Prompt for AI Agents |
||
| setPresets(loaded); | ||
| setSelectedPresetId(""); | ||
| setNewPresetName(""); | ||
|
|
||
| if (loaded.length > 0) { | ||
| setSelectedPresetId(loaded[0].id); | ||
| setInitialStateStr(loaded[0].stateStr); | ||
| } | ||
| }, [activeWorkflowId]); | ||
| const [visualSpeed, setVisualSpeed] = useState<"fast" | "visualized">("visualized"); | ||
| const [pendingApproval, setPendingApproval] = useState<{ | ||
| nodeId: string; | ||
|
|
@@ -1793,9 +1853,120 @@ function Canvas() { | |
| </span> | ||
| )} | ||
| </div> | ||
|
|
||
| {/* State Presets Toolbar */} | ||
| <div className="flex flex-col gap-1.5 pt-1 pb-1"> | ||
| <div className="flex items-center gap-1.5 flex-wrap"> | ||
| <span className="font-mono text-[9px] text-[hsl(var(--ink-faint))] uppercase tracking-wider"> | ||
| Preset: | ||
| </span> | ||
| <select | ||
| value={selectedPresetId} | ||
|
Comment on lines
+1860
to
+1864
|
||
| onChange={(e) => { | ||
| const pid = e.target.value; | ||
| setSelectedPresetId(pid); | ||
| if (pid) { | ||
| const found = presets.find((p) => p.id === pid); | ||
| if (found) { | ||
| setInitialStateStr(found.stateStr); | ||
| toast.success(`Applied preset "${found.name}"`); | ||
| } | ||
| } | ||
| }} | ||
| disabled={running} | ||
| className="flex-1 bg-[hsl(var(--paper))] border border-dashed border-[hsl(var(--ink-faint))] focus:border-[hsl(var(--ink))] outline-none py-0.5 px-1.5 font-mono text-[10px] text-[hsl(var(--ink))]" | ||
| > | ||
| <option value="">-- select preset --</option> | ||
| {presets.map((p) => ( | ||
| <option key={p.id} value={p.id}> | ||
| {p.name} | ||
| </option> | ||
| ))} | ||
| </select> | ||
| {selectedPresetId && ( | ||
| <button | ||
| type="button" | ||
| disabled={running} | ||
| onClick={() => { | ||
| const target = presets.find((p) => p.id === selectedPresetId); | ||
| if (target) { | ||
| if (confirm(`Are you sure you want to delete preset "${target.name}"?`)) { | ||
| const next = presets.filter((p) => p.id !== selectedPresetId); | ||
| setPresets(next); | ||
| savePresets(activeWorkflowId, next); | ||
| setSelectedPresetId(""); | ||
| toast.success(`Deleted preset "${target.name}"`); | ||
| } | ||
| } | ||
| }} | ||
| className="font-mono text-[9px] uppercase tracking-wider px-1.5 py-0.5 border border-dashed text-[hsl(var(--issue))] border-[hsl(var(--issue))] hover:bg-[hsl(var(--issue))] hover:text-[hsl(var(--paper))] transition-all" | ||
| > | ||
| Delete | ||
| </button> | ||
| )} | ||
| </div> | ||
|
|
||
| {/* Save Current as Preset Form */} | ||
| <div className="flex items-center gap-1.5 pt-1 border-t border-dotted border-[hsl(var(--grid-line))]"> | ||
| <input | ||
| type="text" | ||
| value={newPresetName} | ||
| disabled={running} | ||
| onChange={(e) => setNewPresetName(e.target.value)} | ||
| placeholder="New preset name..." | ||
| className="flex-1 bg-transparent border-b border-dashed border-[hsl(var(--ink-faint))] focus:border-[hsl(var(--ink))] outline-none py-0.5 px-1 font-mono text-[10px] text-[hsl(var(--ink))]" | ||
| /> | ||
| <button | ||
| type="button" | ||
| disabled={running || !newPresetName.trim() || !!initialStateError} | ||
| onClick={() => { | ||
| const name = newPresetName.trim(); | ||
| if (!name) return; | ||
|
|
||
| const existing = presets.find((p) => p.name.toLowerCase() === name.toLowerCase()); | ||
| let nextPresets = [...presets]; | ||
|
|
||
| if (existing) { | ||
| if (!confirm(`A preset named "${existing.name}" already exists. Overwrite it?`)) { | ||
| return; | ||
| } | ||
| nextPresets = presets.map((p) => | ||
| p.id === existing.id | ||
| ? { ...p, stateStr: initialStateStr, createdAt: Date.now() } | ||
| : p | ||
| ); | ||
| setSelectedPresetId(existing.id); | ||
| toast.success(`Overwrote preset "${existing.name}"`); | ||
| } else { | ||
| const newId = presetCryptoId(); | ||
| const newPreset: StatePreset = { | ||
| id: newId, | ||
| name, | ||
| stateStr: initialStateStr, | ||
| createdAt: Date.now(), | ||
| }; | ||
| nextPresets.push(newPreset); | ||
| setSelectedPresetId(newId); | ||
| toast.success(`Created preset "${name}"`); | ||
| } | ||
|
|
||
| setPresets(nextPresets); | ||
| savePresets(activeWorkflowId, nextPresets); | ||
| setNewPresetName(""); | ||
| }} | ||
| className="font-mono text-[9px] uppercase tracking-wider px-2 py-0.5 border border-dashed border-[hsl(var(--ink))] hover:bg-[hsl(var(--ink))] hover:text-[hsl(var(--paper))] disabled:opacity-40 disabled:cursor-not-allowed transition-all" | ||
| > | ||
| Save Preset | ||
| </button> | ||
| </div> | ||
| </div> | ||
|
|
||
| <textarea | ||
| value={initialStateStr} | ||
| onChange={(e) => setInitialStateStr(e.target.value)} | ||
| onChange={(e) => { | ||
| setInitialStateStr(e.target.value); | ||
| setSelectedPresetId(""); | ||
| }} | ||
| disabled={running} | ||
| rows={4} | ||
| className={`w-full font-mono text-[10px] p-2 bg-transparent border border-dashed outline-none resize-y ${ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| import { describe, it, expect, vi, beforeEach } from "vitest"; | ||
| import { loadPresets, savePresets, cryptoId, StatePreset } from "../flow/statePresets"; | ||
|
|
||
| describe("State Presets Library", () => { | ||
| beforeEach(() => { | ||
| localStorage.clear(); | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| it("should return an empty array if no presets are saved for a workflow", () => { | ||
| const presets = loadPresets("test-workflow-id"); | ||
| expect(presets).toEqual([]); | ||
| }); | ||
|
|
||
| it("should correctly save and load presets for a workflow ID", () => { | ||
| const mockPresets: StatePreset[] = [ | ||
| { | ||
| id: "preset-1", | ||
| name: "Test Preset 1", | ||
| stateStr: '{"query": "hello 1"}', | ||
| createdAt: 123456789, | ||
| }, | ||
| { | ||
| id: "preset-2", | ||
| name: "Test Preset 2", | ||
| stateStr: '{"query": "hello 2"}', | ||
| createdAt: 123456790, | ||
| }, | ||
| ]; | ||
|
|
||
| savePresets("wf-abc", mockPresets); | ||
| const loaded = loadPresets("wf-abc"); | ||
|
|
||
| expect(loaded).toHaveLength(2); | ||
| expect(loaded[0]).toEqual(mockPresets[0]); | ||
| expect(loaded[1]).toEqual(mockPresets[1]); | ||
| }); | ||
|
|
||
| it("should fallback to 'default' when workflow ID is null", () => { | ||
| const mockPresets: StatePreset[] = [ | ||
| { | ||
| id: "preset-def", | ||
| name: "Default Preset", | ||
| stateStr: '{"query": "default query"}', | ||
| createdAt: 123456789, | ||
| }, | ||
| ]; | ||
|
|
||
| savePresets(null, mockPresets); | ||
|
|
||
| // Verify it saved to localStorage under default | ||
| const stored = localStorage.getItem("agent_flow.state_presets.v1.default"); | ||
| expect(stored).not.toBeNull(); | ||
| expect(JSON.parse(stored!)).toHaveLength(1); | ||
|
|
||
| // Verify loadPresets(null) loads it | ||
| const loaded = loadPresets(null); | ||
| expect(loaded).toHaveLength(1); | ||
| expect(loaded[0].name).toBe("Default Preset"); | ||
| }); | ||
|
|
||
| it("should generate a random id when cryptoId is called", () => { | ||
| const id1 = cryptoId(); | ||
| const id2 = cryptoId(); | ||
| expect(id1).not.toBe(id2); | ||
| expect(id1.length).toBeGreaterThan(3); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Validate each persisted preset record.
Line 24 returns untyped localStorage values as
StatePreset[]. A value such as[null]causesIndex.tsxto accessp.idduring render and throws. Filter invalid records before returning them.Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents