Skip to content

Commit c76cbc9

Browse files
authored
Merge pull request #25 from gramaziokohler/scope_editor
Scope editor
2 parents 8c6ea8c + 51c9482 commit c76cbc9

21 files changed

Lines changed: 1970 additions & 87 deletions

src/AuthorApp.tsx

Lines changed: 257 additions & 33 deletions
Large diffs are not rendered by default.

src/components/author/AuthorToolbar.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
CheckCircle2,
1010
Plus,
1111
Play,
12+
Group,
1213
} from 'lucide-react';
1314
import type { BlueprintMeta } from '../../types/blueprint-schema';
1415

@@ -30,6 +31,9 @@ interface AuthorToolbarProps {
3031
isSimulating?: boolean;
3132
onAddNode: () => void;
3233
isPlacing?: boolean;
34+
onGroupIntoScope: () => void;
35+
/** How many task nodes are selected — a scope needs at least two. */
36+
selectionCount?: number;
3337
errors: string[];
3438
}
3539

@@ -46,8 +50,11 @@ export function AuthorToolbar({
4650
isSimulating = false,
4751
onAddNode,
4852
isPlacing = false,
53+
onGroupIntoScope,
54+
selectionCount = 0,
4955
errors,
5056
}: AuthorToolbarProps) {
57+
const canGroup = selectionCount >= 2;
5158
const fileInputRef = useRef<HTMLInputElement>(null);
5259

5360
return (
@@ -129,6 +136,21 @@ export function AuthorToolbar({
129136
{isPlacing ? 'Placing… (Esc)' : 'Add Task'}
130137
</button>
131138

139+
{/* Group a selection into a scope */}
140+
<button
141+
className="toolbar-btn"
142+
onClick={onGroupIntoScope}
143+
disabled={!canGroup}
144+
title={
145+
canGroup
146+
? `Wrap the ${selectionCount} selected tasks in a scope (retry / while / skip)`
147+
: 'Select two or more connected tasks — Shift+drag, or Ctrl/⌘+click — to group them into a scope'
148+
}
149+
>
150+
<Group size={14} />
151+
{canGroup ? `Group ${selectionCount} into Scope` : 'Group into Scope'}
152+
</button>
153+
132154
<div className="toolbar-spacer" />
133155

134156
{/* Inline blueprint metadata */}

src/components/author/BlueprintCanvas.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,10 @@ import type {
2424
} from '@xyflow/react';
2525
import '@xyflow/react/dist/style.css';
2626
import { AuthorTaskNode } from './AuthorTaskNode';
27+
import { ScopeGroupNode } from '../graph/ScopeGroupNode';
2728
import { NODE_WIDTH } from '../../utils/flow-layout';
2829

29-
const nodeTypes = { authorTask: AuthorTaskNode };
30+
const nodeTypes = { authorTask: AuthorTaskNode, scopeGroup: ScopeGroupNode };
3031

3132
function DeletableEdge({
3233
id,
@@ -111,7 +112,8 @@ interface BlueprintCanvasProps {
111112
onNodesChange: (changes: NodeChange[]) => void;
112113
onEdgesChange: (changes: EdgeChange[]) => void;
113114
onSetEdges: (updater: (eds: Edge[]) => Edge[]) => void;
114-
onNodeSelect: (nodeId: string | null) => void;
115+
/** Every selected task node, so the toolbar can offer to group them into a scope. */
116+
onSelectionChange: (nodeIds: string[]) => void;
115117
isPlacing?: boolean;
116118
onPlaceNode?: (position: { x: number; y: number }) => void;
117119
onCancelPlace?: () => void;
@@ -123,7 +125,7 @@ export function BlueprintCanvas({
123125
onNodesChange,
124126
onEdgesChange,
125127
onSetEdges,
126-
onNodeSelect,
128+
onSelectionChange: onSelectionChanged,
127129
isPlacing = false,
128130
onPlaceNode,
129131
onCancelPlace,
@@ -158,9 +160,9 @@ export function BlueprintCanvas({
158160

159161
const onSelectionChange = useCallback(
160162
({ nodes: selectedNodes }: OnSelectionChangeParams) => {
161-
onNodeSelect(selectedNodes.length > 0 ? selectedNodes[0].id : null);
163+
onSelectionChanged(selectedNodes.map((n) => n.id));
162164
},
163-
[onNodeSelect],
165+
[onSelectionChanged],
164166
);
165167

166168
const handlePaneClick = useCallback(
@@ -205,6 +207,7 @@ export function BlueprintCanvas({
205207
<Controls />
206208
<MiniMap
207209
nodeColor={(n) => {
210+
if (n.type === 'scopeGroup') return 'transparent';
208211
const taskType = (n.data as any)?.taskType ?? '';
209212
if (taskType === 'system.start') return '#22c55e';
210213
if (taskType === 'system.end') return '#ef4444';
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
import { useRef } from 'react';
2+
import { X, Ungroup, RefreshCw, Repeat, SkipForward, LogIn, LogOut } from 'lucide-react';
3+
import type { ReactNode } from 'react';
4+
import type { ScopeStart } from '../../types/blueprint-schema';
5+
import type { EditorScope, ScopePolicyType } from '../../utils/blueprint-scopes';
6+
import { withPolicyType } from '../../utils/blueprint-scopes';
7+
8+
/**
9+
* Editor for one scope's looping policy.
10+
*
11+
* The three policies are mutually exclusive in the data model, so they are picked
12+
* from a segmented control rather than assembled field by field: choosing one
13+
* shows only the fields that policy actually reads, and no combination of inputs
14+
* can produce a scope_start carrying two policies at once.
15+
*/
16+
17+
const POLICIES: { type: ScopePolicyType; label: string; icon: ReactNode; blurb: string }[] = [
18+
{
19+
type: 'skip',
20+
label: 'Skip',
21+
icon: <SkipForward size={13} />,
22+
blurb: 'Runs once. The whole region is skipped when the entry task’s condition is false.',
23+
},
24+
{
25+
type: 'retry',
26+
label: 'Retry',
27+
icon: <RefreshCw size={13} />,
28+
blurb: 'Re-runs the region a fixed number of times after the first pass.',
29+
},
30+
{
31+
type: 'while',
32+
label: 'While',
33+
icon: <Repeat size={13} />,
34+
blurb: 'Re-runs the region for as long as a condition holds after each pass.',
35+
},
36+
];
37+
38+
interface ScopeEditPanelProps {
39+
scope: EditorScope;
40+
/** Condition on the entry task — what a skip-policy scope is gated on. */
41+
startCondition: string;
42+
onPolicyChange: (policy: ScopeStart) => void;
43+
onStartConditionChange: (condition: string) => void;
44+
onSelectTask: (taskId: string) => void;
45+
onUngroup: () => void;
46+
onClose: () => void;
47+
}
48+
49+
export function ScopeEditPanel({
50+
scope,
51+
startCondition,
52+
onPolicyChange,
53+
onStartConditionChange,
54+
onSelectTask,
55+
onUngroup,
56+
onClose,
57+
}: ScopeEditPanelProps) {
58+
const { policy, policyType } = scope;
59+
60+
// Only one policy may exist in the blueprint, so switching type drops the other.
61+
// Hold on to what was dropped for as long as this scope is open, so a misclick
62+
// on the picker does not discard a hand-typed condition for good. Remounted per
63+
// scope (see the `key` in AuthorApp), so it never leaks across scopes.
64+
const discarded = useRef<Pick<ScopeStart, 'retry_policy' | 'while_policy'>>({});
65+
66+
const switchPolicyType = (type: ScopePolicyType) => {
67+
if (policy.retry_policy) discarded.current.retry_policy = policy.retry_policy;
68+
if (policy.while_policy) discarded.current.while_policy = policy.while_policy;
69+
onPolicyChange(withPolicyType({ ...discarded.current, ...policy }, type));
70+
};
71+
72+
/** Drops keys the author cleared, so an unset field leaves no trace in the JSON. */
73+
const compact = <T extends object>(value: T): T =>
74+
Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as T;
75+
76+
const patchRetry = (patch: Partial<NonNullable<ScopeStart['retry_policy']>>) =>
77+
onPolicyChange({ ...policy, retry_policy: compact({ retries: 1, ...policy.retry_policy, ...patch }) });
78+
79+
const patchWhile = (patch: Partial<NonNullable<ScopeStart['while_policy']>>) =>
80+
onPolicyChange({ ...policy, while_policy: compact({ condition: '', ...policy.while_policy, ...patch }) });
81+
82+
const active = POLICIES.find((p) => p.type === policyType)!;
83+
84+
return (
85+
<div className="tep-root">
86+
<div className="tep-header">
87+
<span className="tep-header-title">Edit Scope: {scope.label}</span>
88+
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
89+
<button
90+
className="tep-close-btn"
91+
onClick={onUngroup}
92+
title="Ungroup — remove the scope, keep the tasks"
93+
>
94+
<Ungroup size={15} />
95+
</button>
96+
<button className="tep-close-btn" onClick={onClose} title="Close panel">
97+
<X size={15} />
98+
</button>
99+
</div>
100+
</div>
101+
102+
<div className="tep-body">
103+
{/* ---- Identity ---- */}
104+
<div className="tep-section">
105+
<div className="tep-section-title">Identity</div>
106+
<div className="tep-field">
107+
<label className="tep-label">Name</label>
108+
<input
109+
className="tep-input"
110+
placeholder={scope.startId}
111+
value={policy.name ?? ''}
112+
onChange={(e) => {
113+
const next = { ...policy };
114+
if (e.target.value) next.name = e.target.value;
115+
else delete next.name;
116+
onPolicyChange(next);
117+
}}
118+
/>
119+
<p className="tep-hint">Display only — the scope is identified by its entry task.</p>
120+
</div>
121+
</div>
122+
123+
{/* ---- Policy ---- */}
124+
<div className="tep-section">
125+
<div className="tep-section-title">Policy</div>
126+
127+
<div className="sep-policy-picker" role="radiogroup" aria-label="Scope policy">
128+
{POLICIES.map((option) => (
129+
<button
130+
key={option.type}
131+
role="radio"
132+
aria-checked={option.type === policyType}
133+
className={`sep-policy-option scope-${option.type}${option.type === policyType ? ' active' : ''}`}
134+
onClick={() => switchPolicyType(option.type)}
135+
title={option.blurb}
136+
>
137+
{option.icon}
138+
{option.label}
139+
</button>
140+
))}
141+
</div>
142+
<p className="tep-hint">{active.blurb}</p>
143+
144+
{policyType === 'skip' && (
145+
<div className="tep-field">
146+
<label className="tep-label">Entry condition</label>
147+
<input
148+
className="tep-input mono"
149+
placeholder="e.g. needs_review"
150+
value={startCondition}
151+
onChange={(e) => onStartConditionChange(e.target.value)}
152+
/>
153+
<p className="tep-hint">
154+
Evaluated on <code>{scope.startId}</code> before the scope runs. Leave empty to
155+
always run the region once.
156+
</p>
157+
</div>
158+
)}
159+
160+
{policyType === 'retry' && (
161+
<>
162+
<div className="tep-field">
163+
<label className="tep-label">Retries</label>
164+
<input
165+
className="tep-input mono"
166+
type="number"
167+
min={0}
168+
value={policy.retry_policy?.retries ?? 1}
169+
onChange={(e) => patchRetry({ retries: Math.max(0, Number(e.target.value) || 0) })}
170+
/>
171+
<p className="tep-hint">
172+
Extra passes after the first one — {(policy.retry_policy?.retries ?? 1) + 1} runs
173+
in total at most.
174+
</p>
175+
</div>
176+
<div className="tep-field">
177+
<label className="tep-label">Backoff between retries (ms)</label>
178+
<input
179+
className="tep-input mono"
180+
type="number"
181+
min={0}
182+
placeholder="none"
183+
value={policy.retry_policy?.backoff?.constant_ms ?? ''}
184+
onChange={(e) => {
185+
const ms = e.target.value === '' ? undefined : Math.max(0, Number(e.target.value) || 0);
186+
patchRetry({ backoff: ms === undefined ? undefined : { constant_ms: ms } });
187+
}}
188+
/>
189+
<p className="tep-hint">Experimental — recorded in the blueprint, not yet enforced.</p>
190+
</div>
191+
</>
192+
)}
193+
194+
{policyType === 'while' && (
195+
<>
196+
<div className="tep-field">
197+
<label className="tep-label">Condition</label>
198+
<input
199+
className="tep-input mono"
200+
placeholder="e.g. not converged"
201+
value={policy.while_policy?.condition ?? ''}
202+
onChange={(e) => patchWhile({ condition: e.target.value })}
203+
/>
204+
<p className="tep-hint">
205+
Evaluated after each pass against session data. It may only read names that some
206+
task in this blueprint declares as an output.
207+
</p>
208+
</div>
209+
<div className="tep-field">
210+
<label className="tep-label">Max iterations</label>
211+
<input
212+
className="tep-input mono"
213+
type="number"
214+
min={1}
215+
placeholder="unbounded"
216+
value={policy.while_policy?.max_iterations ?? ''}
217+
onChange={(e) => {
218+
const max = e.target.value === '' ? undefined : Math.max(1, Number(e.target.value) || 1);
219+
patchWhile({ max_iterations: max });
220+
}}
221+
/>
222+
<p className="tep-hint">
223+
Total passes including the first. Without a cap, a condition that never turns
224+
false loops forever.
225+
</p>
226+
</div>
227+
</>
228+
)}
229+
</div>
230+
231+
{/* ---- Members ---- */}
232+
<div className="tep-section">
233+
<div className="tep-section-title">Tasks in scope ({scope.taskIds.length})</div>
234+
<div className="sep-member-list">
235+
{scope.taskIds.map((taskId) => (
236+
<button
237+
key={taskId}
238+
className="sep-member"
239+
onClick={() => onSelectTask(taskId)}
240+
title="Select this task"
241+
>
242+
<span className="sep-member-id">{taskId}</span>
243+
{taskId === scope.startId && (
244+
<span className="sep-member-tag">
245+
<LogIn size={10} /> entry
246+
</span>
247+
)}
248+
{taskId === scope.endId && (
249+
<span className="sep-member-tag">
250+
<LogOut size={10} /> exit
251+
</span>
252+
)}
253+
</button>
254+
))}
255+
</div>
256+
<p className="tep-hint">
257+
Membership follows the graph: every task between the entry and exit tasks belongs to the
258+
scope. Rewire the tasks to change it.
259+
</p>
260+
</div>
261+
</div>
262+
</div>
263+
);
264+
}

0 commit comments

Comments
 (0)