feat(profiles): add typed live step config profiles

Profiles store script + default config per owner. Workflow steps reference
them with profile:, overlay keys win, and the UI marks overrides. Profile
name is immutable after create so YAML refs stay stable.

Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
This commit is contained in:
Cursor Agent
2026-08-21 06:12:49 +00:00
co-authored by nsrb
parent 210fdb2244
commit e84432a492
25 changed files with 1512 additions and 123 deletions
@@ -1,13 +1,18 @@
import { useMemo, useState } from "react";
import { useScripts } from "../../api/hooks.js";
import { useProfiles, useScripts } from "../../api/hooks.js";
import { ScriptIcon } from "../ScriptIcon.jsx";
import { scriptTags } from "../../lib/script.js";
export function AddScriptDialog({ open, onClose, onPick }) {
export function AddScriptDialog({ open, onClose, onPick, owner }) {
const { data: scripts = [], isLoading } = useScripts();
const { data: profiles = [], isLoading: profilesLoading } = useProfiles(
owner || undefined,
{ enabled: open && Boolean(owner) },
);
const [q, setQ] = useState("");
const [tab, setTab] = useState("scripts");
const filtered = useMemo(() => {
const filteredScripts = useMemo(() => {
const term = q.trim().toLowerCase();
const list = scripts.map((s) => ({
name: typeof s === "string" ? s : s.name,
@@ -26,60 +31,128 @@ export function AddScriptDialog({ open, onClose, onPick }) {
);
}, [scripts, q]);
const filteredProfiles = useMemo(() => {
const term = q.trim().toLowerCase();
if (!term) return profiles;
return profiles.filter(
(p) =>
p.name.toLowerCase().includes(term) ||
String(p.script ?? "").toLowerCase().includes(term) ||
String(p.description ?? "").toLowerCase().includes(term),
);
}, [profiles, q]);
if (!open) return null;
return (
<dialog className="modal modal-open">
<div className="modal-box max-w-lg">
<h3 className="font-bold text-lg">Add script</h3>
<h3 className="font-bold text-lg">Add step</h3>
<div role="tablist" className="tabs tabs-box mt-3 w-full">
<button
type="button"
role="tab"
className={`tab ${tab === "scripts" ? "tab-active" : ""}`}
onClick={() => setTab("scripts")}
>
Scripts
</button>
<button
type="button"
role="tab"
className={`tab ${tab === "profiles" ? "tab-active" : ""}`}
onClick={() => setTab("profiles")}
>
Profiles
</button>
</div>
<input
className="input input-sm w-full mt-3"
placeholder="Search scripts"
placeholder={tab === "profiles" ? "Search profiles" : "Search scripts"}
value={q}
onChange={(e) => setQ(e.target.value)}
autoFocus
/>
<ul className="menu mt-3 max-h-80 overflow-auto rounded-box border border-base-300 p-0">
<li>
<button
type="button"
onClick={() => {
onPick({ kind: "set" });
setQ("");
}}
>
<span className="font-semibold">Set (JSONata)</span>
<span className="text-xs opacity-70">
JSONata over ctx; result becomes the next step’s data
</span>
</button>
</li>
{isLoading ? (
<li className="p-4">
<span className="loading loading-spinner loading-sm" />
{tab === "scripts" ? (
<ul className="menu mt-3 max-h-80 overflow-auto rounded-box border border-base-300 p-0">
<li>
<button
type="button"
onClick={() => {
onPick({ kind: "set" });
setQ("");
}}
>
<span className="font-semibold">Set (JSONata)</span>
<span className="text-xs opacity-70">
JSONata over ctx; result becomes the next step’s data
</span>
</button>
</li>
) : (
filtered.map((s) => (
<li key={s.name}>
<button
type="button"
onClick={() => {
onPick({ kind: "script", name: s.name, meta: s.meta });
setQ("");
}}
>
<ScriptIcon name={s.name} hasIcon={s.hasIcon} className="size-8 shrink-0" />
<span className="min-w-0">
<span className="font-mono">{s.name}</span>
<span className="text-xs opacity-70 line-clamp-2 block">
{s.metaError ? s.metaError : s.description || "No description"}
</span>
</span>
</button>
{isLoading ? (
<li className="p-4">
<span className="loading loading-spinner loading-sm" />
</li>
))
)}
</ul>
) : (
filteredScripts.map((s) => (
<li key={s.name}>
<button
type="button"
onClick={() => {
onPick({ kind: "script", name: s.name, meta: s.meta });
setQ("");
}}
>
<ScriptIcon name={s.name} hasIcon={s.hasIcon} className="size-8 shrink-0" />
<span className="min-w-0">
<span className="font-mono">{s.name}</span>
<span className="text-xs opacity-70 line-clamp-2 block">
{s.metaError ? s.metaError : s.description || "No description"}
</span>
</span>
</button>
</li>
))
)}
</ul>
) : (
<ul className="menu mt-3 max-h-80 overflow-auto rounded-box border border-accent/40 p-0">
{!owner ? (
<li className="p-4 text-sm opacity-60">Save the workflow with an owner first.</li>
) : profilesLoading ? (
<li className="p-4">
<span className="loading loading-spinner loading-sm" />
</li>
) : filteredProfiles.length === 0 ? (
<li className="p-4 text-sm opacity-60">No profiles for this owner.</li>
) : (
filteredProfiles.map((p) => (
<li key={p.id}>
<button
type="button"
onClick={() => {
onPick({
kind: "profile",
name: p.name,
script: p.script,
});
setQ("");
}}
>
<ScriptIcon name={p.script} className="size-8 shrink-0" />
<span className="min-w-0">
<span className="font-mono">{p.name}</span>
<span className="text-xs opacity-70 line-clamp-2 block">
{p.script}
{p.description ? ` · ${p.description}` : ""}
</span>
</span>
</button>
</li>
))
)}
</ul>
)}
<div className="modal-action">
<button
type="button"