Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions dev_server.log
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/
57 changes: 57 additions & 0 deletions frontend/src/flow/statePresets.ts
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 +30

Copy link
Copy Markdown
Contributor

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] causes Index.tsx to access p.id during render and throws. Filter invalid records before returning them.

Proposed fix
+function isStatePreset(value: unknown): value is StatePreset {
+  if (typeof value !== "object" || value === null) return false;
+  const preset = value as Record<string, unknown>;
+  return (
+    typeof preset.id === "string" &&
+    typeof preset.name === "string" &&
+    typeof preset.stateStr === "string" &&
+    typeof preset.createdAt === "number" &&
+    Number.isFinite(preset.createdAt)
+  );
+}
+
 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;
+        return parsed.filter(isStatePreset);
       }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 [];
function isStatePreset(value: unknown): value is StatePreset {
if (typeof value !== "object" || value === null) return false;
const preset = value as Record<string, unknown>;
return (
typeof preset.id === "string" &&
typeof preset.name === "string" &&
typeof preset.stateStr === "string" &&
typeof preset.createdAt === "number" &&
Number.isFinite(preset.createdAt)
);
}
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.filter(isStatePreset);
}
}
} catch (e) {
console.error("Failed to load state presets:", e);
}
return [];
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/flow/statePresets.ts` around lines 17 - 30, Update loadPresets
to validate each parsed array element before returning it as StatePreset[].
Filter out null, non-object, and records missing the required StatePreset
fields, while preserving valid presets and returning an empty array when none
remain.

}
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);
}
173 changes: 172 additions & 1 deletion frontend/src/pages/Index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 [] at lines 1894-1896. On the next reload or workflow switch, loaded.length === 0 seeds the template preset again. The delete operation therefore does not persist.

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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/pages/Index.tsx` around lines 225 - 263, Update the preset
initialization in the useEffect around loadPresets so default presets are
created only when the workflow’s storage key is absent, not when an existing key
contains an empty array; preserve saved [] unchanged. Add a regression test
covering deletion of the final preset followed by workflow reload, asserting
that no presets are recreated.

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;
Expand Down Expand Up @@ -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 ${
Expand Down
68 changes: 68 additions & 0 deletions frontend/src/test/statePresets.test.ts
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);
});
});