@xdelivered/emberflow 0.2.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/commands.ts +7 -3
- package/bin/init.test.ts +94 -0
- package/bin/init.ts +108 -2
- package/dist/bin/commands.d.ts +1 -0
- package/dist/bin/commands.js +6 -3
- package/dist/bin/init.d.ts +4 -0
- package/dist/bin/init.js +96 -2
- package/dist/server/agents/codexParse.js +31 -0
- package/dist/server/agents/detect.d.ts +18 -8
- package/dist/server/agents/detect.js +78 -13
- package/dist/server/agents/modelRejectionHint.js +1 -1
- package/dist/server/agents/prompt.d.ts +15 -1
- package/dist/server/agents/prompt.js +59 -37
- package/dist/server/agents/runManager.d.ts +23 -2
- package/dist/server/agents/runManager.js +36 -8
- package/dist/server/client.d.ts +1 -0
- package/dist/server/client.js +7 -2
- package/dist/server/index.js +208 -16
- package/dist/server/nodesPayload.d.ts +14 -1
- package/dist/server/nodesPayload.js +15 -2
- package/dist/server/projectMode.js +5 -2
- package/dist/server/runRegistry.d.ts +52 -1
- package/dist/server/runRegistry.js +170 -1
- package/dist/server/sourceNav.d.ts +77 -0
- package/dist/server/sourceNav.js +503 -0
- package/dist/server/subflowRunner.d.ts +48 -1
- package/dist/server/subflowRunner.js +34 -7
- package/dist/server/updateCheck.d.ts +68 -0
- package/dist/server/updateCheck.js +142 -0
- package/dist/src/engine/executor.js +4 -3
- package/dist/src/engine/registry.d.ts +27 -2
- package/dist/src/engine/registry.js +84 -2
- package/dist/src/nodes/index.d.ts +8 -2
- package/dist/src/nodes/index.js +7 -3
- package/dist/src/nodes/login.d.ts +3 -1
- package/dist/src/nodes/login.js +2 -2
- package/package.json +28 -7
- package/server/agentRoute.test.ts +20 -0
- package/server/agents/codexParse.test.ts +37 -0
- package/server/agents/codexParse.ts +34 -0
- package/server/agents/detect.test.ts +26 -7
- package/server/agents/detect.ts +82 -14
- package/server/agents/modelRejectionHint.ts +2 -1
- package/server/agents/prompt.test.ts +128 -0
- package/server/agents/prompt.ts +102 -3
- package/server/agents/runManager.test.ts +9 -0
- package/server/agents/runManager.ts +37 -7
- package/server/client.ts +10 -4
- package/server/index.ts +213 -15
- package/server/nodeRun.test.ts +189 -0
- package/server/nodesPayload.test.ts +38 -0
- package/server/nodesPayload.ts +28 -3
- package/server/projectMode.ts +5 -2
- package/server/runRegistry.ts +200 -3
- package/server/setupStatus.test.ts +3 -0
- package/server/sourceNav.test.ts +382 -0
- package/server/sourceNav.ts +644 -0
- package/server/sourceNavRoute.test.ts +163 -0
- package/server/stepDrill.test.ts +380 -0
- package/server/subflowRunner.ts +92 -11
- package/server/updateCheck.test.ts +209 -0
- package/server/updateCheck.ts +175 -0
- package/server/workflowTestRoute.test.ts +1 -1
- package/src/App.tsx +82 -2
- package/src/components/AgentConsole.tsx +7 -280
- package/src/components/AgentStream.test.tsx +88 -0
- package/src/components/AgentStream.tsx +303 -0
- package/src/components/CreateModal.tsx +43 -9
- package/src/components/Dock.tsx +1 -1
- package/src/components/EmptyState.test.tsx +49 -0
- package/src/components/EmptyState.tsx +61 -0
- package/src/components/EnvironmentDialog.tsx +4 -1
- package/src/components/EnvironmentPicker.tsx +8 -3
- package/src/components/EnvironmentsDialog.tsx +4 -1
- package/src/components/InfraPanel.tsx +30 -2
- package/src/components/InfrastructureDialog.test.tsx +97 -0
- package/src/components/InfrastructureDialog.tsx +111 -0
- package/src/components/Inspector.tsx +9 -26
- package/src/components/RunbookView.tsx +70 -6
- package/src/components/SettingsDialog.tsx +4 -59
- package/src/components/Sidebar.tsx +6 -26
- package/src/components/SourceNavigator.test.tsx +226 -0
- package/src/components/SourceNavigator.tsx +520 -0
- package/src/components/StatusBar.tsx +227 -26
- package/src/components/Toolbar.tsx +28 -16
- package/src/components/UpdateChip.test.tsx +31 -0
- package/src/components/WelcomeDialog.test.tsx +384 -13
- package/src/components/WelcomeDialog.tsx +996 -177
- package/src/engine/executor.ts +4 -3
- package/src/engine/registry.sourceRef.test.ts +112 -0
- package/src/engine/registry.ts +103 -2
- package/src/lib/guidedQuestions.test.ts +224 -0
- package/src/lib/guidedQuestions.ts +181 -0
- package/src/lib/runbookModel.ts +3 -3
- package/src/lib/runbookProjection.test.ts +43 -0
- package/src/lib/runbookProjection.ts +26 -6
- package/src/nodes/index.ts +10 -3
- package/src/nodes/login.ts +5 -2
- package/src/store/agentClient.ts +27 -8
- package/src/store/apiTree.ts +12 -0
- package/src/store/builderStore.test.ts +441 -99
- package/src/store/builderStore.ts +385 -314
- package/src/store/nodeMeta.ts +10 -2
- package/src/store/serverRunner.ts +56 -5
- package/src/store/setupClient.ts +7 -2
- package/src/store/sourceNavClient.ts +48 -0
- package/src/store/updateClient.ts +50 -0
- package/studio-dist/assets/index-BbzppDpt.js +65 -0
- package/studio-dist/assets/index-CLf6blcA.css +1 -0
- package/studio-dist/index.html +2 -2
- package/templates/skills/emberflow-basics/SKILL.md +29 -1
- package/templates/skills/emberflow-model-process/SKILL.md +92 -9
- package/templates/skills/emberflow-new-workflow/SKILL.md +8 -1
- package/templates/skills/emberflow-review-workflow/SKILL.md +64 -1
- package/studio-dist/assets/index-DNJwW-hM.css +0 -1
- package/studio-dist/assets/index-O26dKRjW.js +0 -65
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
3
|
+
import { InfraPanel, KIND_EXPLANATION } from './InfraPanel';
|
|
4
|
+
import { formatScoutedAt } from './InfrastructureDialog';
|
|
5
|
+
import type { InfrastructureManifest } from '../store/infraClient';
|
|
6
|
+
|
|
7
|
+
/** The modal reuses InfraPanel with `explainKinds` — same manifest the Dock tab
|
|
8
|
+
* renders, plus the per-kind plain-words gloss. */
|
|
9
|
+
const MANIFEST: InfrastructureManifest = {
|
|
10
|
+
version: 1,
|
|
11
|
+
scannedAt: '2026-07-11T00:00:00Z',
|
|
12
|
+
greenfield: false,
|
|
13
|
+
summary: 'Express app with Postgres (Prisma) and Stripe.',
|
|
14
|
+
items: [
|
|
15
|
+
{
|
|
16
|
+
id: 'postgres-main',
|
|
17
|
+
kind: 'database',
|
|
18
|
+
name: 'Postgres (Prisma)',
|
|
19
|
+
evidence: [{ file: 'prisma/schema.prisma' }],
|
|
20
|
+
suggestedSecretRefs: ['DATABASE_URL'],
|
|
21
|
+
suggestedVars: [],
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: 'stripe',
|
|
25
|
+
kind: 'http-api',
|
|
26
|
+
name: 'Stripe',
|
|
27
|
+
evidence: [{ file: 'package.json' }],
|
|
28
|
+
suggestedSecretRefs: ['STRIPE_SECRET_KEY'],
|
|
29
|
+
suggestedVars: [],
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
describe('InfrastructureDialog', () => {
|
|
35
|
+
describe('manifest render with per-kind explanations', () => {
|
|
36
|
+
const out = renderToStaticMarkup(
|
|
37
|
+
<InfraPanel data={{ present: true, manifest: MANIFEST }} explainKinds />,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
it('renders the item cards', () => {
|
|
41
|
+
expect(out).toContain('Postgres (Prisma)');
|
|
42
|
+
expect(out).toContain('Stripe');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('renders the plain-words gloss for each present kind', () => {
|
|
46
|
+
expect(out).toContain(KIND_EXPLANATION.database);
|
|
47
|
+
expect(out).toContain(KIND_EXPLANATION['http-api']);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('does NOT render explanations when explainKinds is off (Dock tab)', () => {
|
|
51
|
+
const plain = renderToStaticMarkup(<InfraPanel data={{ present: true, manifest: MANIFEST }} />);
|
|
52
|
+
expect(plain).not.toContain(KIND_EXPLANATION.database);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('greenfield / empty state', () => {
|
|
57
|
+
it('renders the greenfield state from the shared panel', () => {
|
|
58
|
+
const out = renderToStaticMarkup(
|
|
59
|
+
<InfraPanel
|
|
60
|
+
data={{
|
|
61
|
+
present: true,
|
|
62
|
+
manifest: { version: 1, greenfield: true, summary: 'Clean slate — no infrastructure yet.', items: [] },
|
|
63
|
+
}}
|
|
64
|
+
explainKinds
|
|
65
|
+
/>,
|
|
66
|
+
);
|
|
67
|
+
expect(out).toContain('Greenfield project');
|
|
68
|
+
expect(out).toContain('Clean slate');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('formatScoutedAt', () => {
|
|
73
|
+
const base = new Date('2026-07-13T00:00:00Z').getTime();
|
|
74
|
+
|
|
75
|
+
it('returns null for an absent timestamp (tolerant)', () => {
|
|
76
|
+
expect(formatScoutedAt(undefined, base)).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('returns null for an unparseable timestamp', () => {
|
|
80
|
+
expect(formatScoutedAt('not-a-date', base)).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('formats a 2-day-old scan as "scouted 2 days ago"', () => {
|
|
84
|
+
expect(formatScoutedAt('2026-07-11T00:00:00Z', base)).toBe('scouted 2 days ago');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('formats a very recent scan as "scouted just now"', () => {
|
|
88
|
+
expect(formatScoutedAt('2026-07-12T23:59:30Z', base)).toBe('scouted just now');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('formats hours and minutes with correct pluralization', () => {
|
|
92
|
+
expect(formatScoutedAt('2026-07-12T23:00:00Z', base)).toBe('scouted 1 hour ago');
|
|
93
|
+
expect(formatScoutedAt('2026-07-12T22:00:00Z', base)).toBe('scouted 2 hours ago');
|
|
94
|
+
expect(formatScoutedAt('2026-07-12T23:58:00Z', base)).toBe('scouted 2 minutes ago');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { SparklesIcon } from 'lucide-react';
|
|
3
|
+
import { Button } from '@/components/ui/button';
|
|
4
|
+
import { Input } from '@/components/ui/input';
|
|
5
|
+
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog';
|
|
6
|
+
import { InfraPanel } from './InfraPanel';
|
|
7
|
+
import { useBuilderStore } from '../store/builderStore';
|
|
8
|
+
import type { InfrastructureResponse } from '../store/infraClient';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Relative "scouted N ago" for the manifest's scannedAt. Tolerant of an absent
|
|
12
|
+
* or unparseable timestamp (returns null → the freshness line is hidden).
|
|
13
|
+
*/
|
|
14
|
+
export function formatScoutedAt(scannedAt: string | undefined, now: number = Date.now()): string | null {
|
|
15
|
+
if (!scannedAt) return null;
|
|
16
|
+
const then = new Date(scannedAt).getTime();
|
|
17
|
+
if (Number.isNaN(then)) return null;
|
|
18
|
+
const secs = Math.max(0, Math.round((now - then) / 1000));
|
|
19
|
+
if (secs < 60) return 'scouted just now';
|
|
20
|
+
const mins = Math.round(secs / 60);
|
|
21
|
+
if (mins < 60) return `scouted ${mins} minute${mins === 1 ? '' : 's'} ago`;
|
|
22
|
+
const hours = Math.round(mins / 60);
|
|
23
|
+
if (hours < 24) return `scouted ${hours} hour${hours === 1 ? '' : 's'} ago`;
|
|
24
|
+
const days = Math.round(hours / 24);
|
|
25
|
+
return `scouted ${days} day${days === 1 ? '' : 's'} ago`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* "Project infrastructure" modal, opened from the StatusBar infra chip. Explains
|
|
30
|
+
* what the scout found and — crucially — WHY it matters: agents read this
|
|
31
|
+
* manifest before building operations and REUSE these systems (same secret
|
|
32
|
+
* names, same services) instead of inventing parallel config. Reuses the
|
|
33
|
+
* presentational InfraPanel for the item list (one source of truth) with the
|
|
34
|
+
* per-kind gloss enabled, and offers a free-text "Update with AI" amendment.
|
|
35
|
+
*/
|
|
36
|
+
export function InfrastructureDialog({
|
|
37
|
+
open,
|
|
38
|
+
onOpenChange,
|
|
39
|
+
data,
|
|
40
|
+
}: {
|
|
41
|
+
open: boolean;
|
|
42
|
+
onOpenChange: (open: boolean) => void;
|
|
43
|
+
data: InfrastructureResponse | null;
|
|
44
|
+
}) {
|
|
45
|
+
const beginInfrastructureScout = useBuilderStore((s) => s.beginInfrastructureScout);
|
|
46
|
+
const [instruction, setInstruction] = useState('');
|
|
47
|
+
|
|
48
|
+
const freshness =
|
|
49
|
+
data && data.present ? formatScoutedAt(data.manifest.scannedAt) : null;
|
|
50
|
+
|
|
51
|
+
const submitUpdate = () => {
|
|
52
|
+
const value = instruction.trim();
|
|
53
|
+
onOpenChange(false);
|
|
54
|
+
setInstruction('');
|
|
55
|
+
// Empty → full rescan; free text → a targeted amendment. Both dispatch the
|
|
56
|
+
// scout intent, which opens the agent panel so the run is visible.
|
|
57
|
+
beginInfrastructureScout(value.length > 0 ? value : undefined);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
62
|
+
<DialogContent className="max-w-2xl">
|
|
63
|
+
<DialogTitle>Project infrastructure</DialogTitle>
|
|
64
|
+
<DialogDescription>
|
|
65
|
+
Discovered by the scout from this project's code. Agents read this before building
|
|
66
|
+
operations and{' '}
|
|
67
|
+
<span className="font-medium text-foreground/90">
|
|
68
|
+
reuse these systems — same secret names, same services — instead of inventing parallel
|
|
69
|
+
config.
|
|
70
|
+
</span>
|
|
71
|
+
</DialogDescription>
|
|
72
|
+
|
|
73
|
+
{freshness && (
|
|
74
|
+
<p className="text-[11px] text-muted-foreground/70">{freshness}</p>
|
|
75
|
+
)}
|
|
76
|
+
|
|
77
|
+
<div className="max-h-[55vh] overflow-y-auto pr-0.5">
|
|
78
|
+
<InfraPanel data={data} explainKinds />
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div className="flex flex-col gap-1.5 border-t border-border/60 pt-3">
|
|
82
|
+
<label htmlFor="infra-update" className="text-[11px] font-medium text-foreground/80">
|
|
83
|
+
Update with AI
|
|
84
|
+
</label>
|
|
85
|
+
<div className="flex items-center gap-2">
|
|
86
|
+
<Input
|
|
87
|
+
id="infra-update"
|
|
88
|
+
value={instruction}
|
|
89
|
+
onChange={(e) => setInstruction(e.target.value)}
|
|
90
|
+
onKeyDown={(e) => {
|
|
91
|
+
if (e.key === 'Enter') {
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
submitUpdate();
|
|
94
|
+
}
|
|
95
|
+
}}
|
|
96
|
+
placeholder="e.g. add our Redis cache, or remove the legacy Stripe entry…"
|
|
97
|
+
className="h-8 text-[12px]"
|
|
98
|
+
/>
|
|
99
|
+
<Button size="sm" onClick={submitUpdate}>
|
|
100
|
+
<SparklesIcon className="size-3.5" />
|
|
101
|
+
Update
|
|
102
|
+
</Button>
|
|
103
|
+
</div>
|
|
104
|
+
<p className="text-[10.5px] text-muted-foreground/70">
|
|
105
|
+
Leave empty to re-scan the whole project.
|
|
106
|
+
</p>
|
|
107
|
+
</div>
|
|
108
|
+
</DialogContent>
|
|
109
|
+
</Dialog>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -4,8 +4,7 @@ import { BranchRulesEditor } from './BranchRulesEditor';
|
|
|
4
4
|
import { ExecutionPager } from './ExecutionPager';
|
|
5
5
|
import { NodeRunModal } from './NodeRunModal';
|
|
6
6
|
import { Json } from './Json';
|
|
7
|
-
import {
|
|
8
|
-
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
|
7
|
+
import { NodeCodeView } from './SourceNavigator';
|
|
9
8
|
import { Badge } from '@/components/ui/badge';
|
|
10
9
|
import { Button } from '@/components/ui/button';
|
|
11
10
|
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog';
|
|
@@ -781,6 +780,8 @@ export function Inspector() {
|
|
|
781
780
|
const outputValue = state?.output as Record<string, unknown> | undefined;
|
|
782
781
|
const executions = state?.executions;
|
|
783
782
|
const source = registry.has(node.type) ? (registry.getSource(node.type) ?? '') : '';
|
|
783
|
+
const sourceRef = registry.has(node.type) ? registry.getSourceRef(node.type) : undefined;
|
|
784
|
+
const builtinNode = registry.has(node.type) ? registry.isBuiltin(node.type) : false;
|
|
784
785
|
|
|
785
786
|
const codeDialog = (
|
|
786
787
|
<Dialog open={codeOpen} onOpenChange={setCodeOpen}>
|
|
@@ -790,30 +791,12 @@ export function Inspector() {
|
|
|
790
791
|
<Badge variant="mono">{node.type}</Badge>
|
|
791
792
|
</DialogTitle>
|
|
792
793
|
<DialogDescription>{definition?.description ?? 'Node implementation'}</DialogDescription>
|
|
793
|
-
<
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
margin: 0,
|
|
800
|
-
background: 'transparent',
|
|
801
|
-
fontSize: '12.5px',
|
|
802
|
-
lineHeight: 1.6,
|
|
803
|
-
padding: '12px 2px',
|
|
804
|
-
}}
|
|
805
|
-
codeTagProps={{ style: { background: 'transparent', textShadow: 'none' } }}
|
|
806
|
-
lineNumberStyle={{
|
|
807
|
-
color: 'var(--muted-foreground)',
|
|
808
|
-
opacity: 0.35,
|
|
809
|
-
background: 'transparent',
|
|
810
|
-
minWidth: '2.75em',
|
|
811
|
-
paddingRight: '1em',
|
|
812
|
-
}}
|
|
813
|
-
>
|
|
814
|
-
{source}
|
|
815
|
-
</SyntaxHighlighter>
|
|
816
|
-
</div>
|
|
794
|
+
<NodeCodeView
|
|
795
|
+
nodeType={node.type}
|
|
796
|
+
source={source}
|
|
797
|
+
sourceRef={sourceRef}
|
|
798
|
+
builtin={builtinNode}
|
|
799
|
+
/>
|
|
817
800
|
</DialogContent>
|
|
818
801
|
</Dialog>
|
|
819
802
|
);
|
|
@@ -833,21 +833,51 @@ function RunbookItems({ items, ctx }: { items: RunbookItem[]; ctx: RunbookCtx })
|
|
|
833
833
|
);
|
|
834
834
|
}
|
|
835
835
|
|
|
836
|
+
/** One clickable level of the step-drill breadcrumb (`Parent › Child`). */
|
|
837
|
+
interface DrillCrumb {
|
|
838
|
+
name: string;
|
|
839
|
+
current: boolean;
|
|
840
|
+
onClick: () => void;
|
|
841
|
+
}
|
|
842
|
+
|
|
836
843
|
function RunbookHeader({
|
|
837
844
|
name,
|
|
838
845
|
environment,
|
|
839
846
|
subtitle,
|
|
840
847
|
expandAll,
|
|
841
848
|
onToggleExpandAll,
|
|
849
|
+
crumbs,
|
|
842
850
|
}: {
|
|
843
851
|
name: string;
|
|
844
852
|
environment?: string;
|
|
845
853
|
subtitle: string;
|
|
846
854
|
expandAll: boolean;
|
|
847
855
|
onToggleExpandAll: () => void;
|
|
856
|
+
/** Step-drill trail, root first — rendered above the title while a stepped
|
|
857
|
+
* run is inside a subflow. Ancestor clicks are a view-only peek. */
|
|
858
|
+
crumbs?: DrillCrumb[];
|
|
848
859
|
}) {
|
|
849
860
|
return (
|
|
850
861
|
<div className="mb-6">
|
|
862
|
+
{crumbs && crumbs.length > 1 && (
|
|
863
|
+
<div className="mb-1.5 flex flex-wrap items-center gap-1 text-[11px] text-muted-foreground">
|
|
864
|
+
{crumbs.map((crumb, i) => (
|
|
865
|
+
<span key={i} className="flex items-center gap-1">
|
|
866
|
+
{i > 0 && <span className="text-muted-foreground/50">›</span>}
|
|
867
|
+
<button
|
|
868
|
+
type="button"
|
|
869
|
+
onClick={crumb.onClick}
|
|
870
|
+
className={cn(
|
|
871
|
+
'cursor-pointer transition-colors hover:text-foreground',
|
|
872
|
+
crumb.current && 'font-medium text-foreground',
|
|
873
|
+
)}
|
|
874
|
+
>
|
|
875
|
+
{crumb.name}
|
|
876
|
+
</button>
|
|
877
|
+
</span>
|
|
878
|
+
))}
|
|
879
|
+
</div>
|
|
880
|
+
)}
|
|
851
881
|
<h1 className="text-[22px] font-bold tracking-tight">{name}</h1>
|
|
852
882
|
<div className="mt-1.5 flex items-center gap-2 text-[12px] text-muted-foreground">
|
|
853
883
|
{environment && (
|
|
@@ -944,19 +974,36 @@ function BuildingHolding({ route }: { route?: string }) {
|
|
|
944
974
|
}
|
|
945
975
|
|
|
946
976
|
export function RunbookView({ register = 'simple' }: { register?: 'simple' | 'technical' } = {}) {
|
|
947
|
-
const
|
|
977
|
+
const liveFlow = useBuilderStore((s) => s.flow);
|
|
948
978
|
const registry = useBuilderStore((s) => s.registry);
|
|
949
979
|
const buildingOperationId = useBuilderStore((s) => s.buildingOperationId);
|
|
950
|
-
const
|
|
980
|
+
const liveRun = useBuilderStore((s) => s.run);
|
|
951
981
|
const logs = useBuilderStore((s) => s.logs);
|
|
952
982
|
const runHistory = useBuilderStore((s) => s.runHistory);
|
|
953
|
-
const
|
|
983
|
+
const storeSelectedNodeId = useBuilderStore((s) => s.selectedNodeId);
|
|
954
984
|
const selectNode = useBuilderStore((s) => s.selectNode);
|
|
985
|
+
const stepDrill = useBuilderStore((s) => s.stepDrill);
|
|
986
|
+
const drillPeek = useBuilderStore((s) => s.drillPeek);
|
|
987
|
+
const peekDrill = useBuilderStore((s) => s.peekDrill);
|
|
988
|
+
|
|
989
|
+
// While a stepped run is drilled into a subflow, an ancestor crumb click is
|
|
990
|
+
// a view-only PEEK: render that stashed level's flow/run without popping the
|
|
991
|
+
// drill stack or touching the server. The store's flow/run stay the deepest
|
|
992
|
+
// (live) level — child SSE states keep landing there during the peek.
|
|
993
|
+
const peeking = drillPeek !== null && drillPeek < stepDrill.length;
|
|
994
|
+
const flow = peeking ? stepDrill[drillPeek].savedFlow : liveFlow;
|
|
995
|
+
const run = peeking ? stepDrill[drillPeek].savedRun : liveRun;
|
|
996
|
+
// Peeked levels are read-only: selection stays with the live level.
|
|
997
|
+
const selectedNodeId = peeking ? null : storeSelectedNodeId;
|
|
955
998
|
|
|
956
999
|
const doc = useMemo(() => buildRunbook(flow, registry), [flow, registry]);
|
|
1000
|
+
// A drilled-in CHILD view (the live deepest level, or a peeked mid-level —
|
|
1001
|
+
// peek index 0 is the root) matches log lines by caller-prefixed suffix
|
|
1002
|
+
// too, since child log nodeIds arrive as `${callerNodeId}/${childNodeId}`.
|
|
1003
|
+
const drilled = peeking ? drillPeek! > 0 : stepDrill.length > 0;
|
|
957
1004
|
const projection = useMemo(
|
|
958
|
-
() => projectRunbook(doc, run, logs, runHistory, flow.id),
|
|
959
|
-
[doc, run, logs, runHistory],
|
|
1005
|
+
() => projectRunbook(doc, run, logs, runHistory, flow.id, drilled),
|
|
1006
|
+
[doc, run, logs, runHistory, flow.id, drilled],
|
|
960
1007
|
);
|
|
961
1008
|
|
|
962
1009
|
// Paced reveal: the document fills at a readable rhythm even when the engine
|
|
@@ -1051,7 +1098,8 @@ export function RunbookView({ register = 'simple' }: { register?: 'simple' | 'te
|
|
|
1051
1098
|
run,
|
|
1052
1099
|
resetKey: `${flow.id}:${run?.id ?? 'none'}`,
|
|
1053
1100
|
selectedNodeId,
|
|
1054
|
-
|
|
1101
|
+
// A peeked level is read-only — row clicks must not move the live selection.
|
|
1102
|
+
selectNode: peeking ? () => {} : selectNode,
|
|
1055
1103
|
registerRow,
|
|
1056
1104
|
register,
|
|
1057
1105
|
displayStatus,
|
|
@@ -1061,6 +1109,21 @@ export function RunbookView({ register = 'simple' }: { register?: 'simple' | 'te
|
|
|
1061
1109
|
const subtitle = flow.folder ?? `${flow.nodes.length} step${flow.nodes.length === 1 ? '' : 's'}`;
|
|
1062
1110
|
const building = buildingOperationId === flow.id;
|
|
1063
1111
|
|
|
1112
|
+
// Step-drill breadcrumb: ancestors from the stashed levels (root first),
|
|
1113
|
+
// then the deepest (live) flow. Ancestor clicks peek; the last crumb
|
|
1114
|
+
// returns to the live level.
|
|
1115
|
+
const crumbs: DrillCrumb[] | undefined =
|
|
1116
|
+
stepDrill.length > 0
|
|
1117
|
+
? [
|
|
1118
|
+
...stepDrill.map((d, i) => ({
|
|
1119
|
+
name: d.savedFlow.name,
|
|
1120
|
+
current: peeking && drillPeek === i,
|
|
1121
|
+
onClick: () => peekDrill(i),
|
|
1122
|
+
})),
|
|
1123
|
+
{ name: liveFlow.name, current: !peeking, onClick: () => peekDrill(null) },
|
|
1124
|
+
]
|
|
1125
|
+
: undefined;
|
|
1126
|
+
|
|
1064
1127
|
return (
|
|
1065
1128
|
<div ref={scrollRef} className="h-full min-h-0 overflow-y-auto bg-background">
|
|
1066
1129
|
<div className="mx-auto max-w-[800px] px-4 pt-[26px] pb-[140px]">
|
|
@@ -1070,6 +1133,7 @@ export function RunbookView({ register = 'simple' }: { register?: 'simple' | 'te
|
|
|
1070
1133
|
subtitle={subtitle}
|
|
1071
1134
|
expandAll={expandAll}
|
|
1072
1135
|
onToggleExpandAll={() => setExpandAll((v) => !v)}
|
|
1136
|
+
crumbs={crumbs}
|
|
1073
1137
|
/>
|
|
1074
1138
|
{building ? (
|
|
1075
1139
|
<BuildingHolding route={flow.http ? `${flow.http.method} ${flow.http.path}` : undefined} />
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
|
-
import { ArrowLeftIcon,
|
|
2
|
+
import { ArrowLeftIcon, RotateCcwIcon, SettingsIcon } from 'lucide-react';
|
|
3
3
|
import { cn } from '@/lib/utils';
|
|
4
4
|
import { Button } from '@/components/ui/button';
|
|
5
5
|
import {
|
|
@@ -17,16 +17,6 @@ const AGENT_LABELS: Record<AgentKind, string> = {
|
|
|
17
17
|
claude: 'Claude',
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
-
const ENGINE_OPTIONS: Array<{
|
|
21
|
-
value: 'auto' | 'server' | 'browser';
|
|
22
|
-
label: string;
|
|
23
|
-
hint: string;
|
|
24
|
-
}> = [
|
|
25
|
-
{ value: 'auto', label: 'Auto', hint: 'runner when available (recommended)' },
|
|
26
|
-
{ value: 'server', label: 'Server', hint: 'always the local runner' },
|
|
27
|
-
{ value: 'browser', label: 'Browser', hint: 'in-tab engine, no secrets/CORS-free APIs' },
|
|
28
|
-
];
|
|
29
|
-
|
|
30
20
|
function SectionTitle({ children }: { children: React.ReactNode }) {
|
|
31
21
|
return (
|
|
32
22
|
<div className="text-[10px] font-medium uppercase tracking-widest text-muted-foreground">
|
|
@@ -36,14 +26,11 @@ function SectionTitle({ children }: { children: React.ReactNode }) {
|
|
|
36
26
|
}
|
|
37
27
|
|
|
38
28
|
/**
|
|
39
|
-
* Settings dialog behind the toolbar gear.
|
|
40
|
-
*
|
|
41
|
-
*
|
|
29
|
+
* Settings dialog behind the toolbar gear. Holds the coding-agent picker and
|
|
30
|
+
* the Reset button; runner status lives in the StatusBar. Execution is always
|
|
31
|
+
* server-side, so there is no engine choice here.
|
|
42
32
|
*/
|
|
43
33
|
export function SettingsDialog() {
|
|
44
|
-
const mode = useBuilderStore((s) => s.executionMode);
|
|
45
|
-
const setMode = useBuilderStore((s) => s.setExecutionMode);
|
|
46
|
-
const runnerOnline = useBuilderStore((s) => s.runnerOnline);
|
|
47
34
|
const resetRun = useBuilderStore((s) => s.resetRun);
|
|
48
35
|
const agentChoice = useBuilderStore((s) => s.agentChoice);
|
|
49
36
|
const setAgentChoice = useBuilderStore((s) => s.setAgentChoice);
|
|
@@ -65,9 +52,6 @@ export function SettingsDialog() {
|
|
|
65
52
|
};
|
|
66
53
|
}, [open]);
|
|
67
54
|
|
|
68
|
-
const effective = mode === 'auto' ? (runnerOnline ? 'server' : 'browser') : mode;
|
|
69
|
-
const offline = effective === 'server' && runnerOnline === false;
|
|
70
|
-
|
|
71
55
|
return (
|
|
72
56
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
73
57
|
<DialogTrigger asChild>
|
|
@@ -99,45 +83,6 @@ export function SettingsDialog() {
|
|
|
99
83
|
<DialogTitle>Settings</DialogTitle>
|
|
100
84
|
</div>
|
|
101
85
|
|
|
102
|
-
<div className="space-y-2">
|
|
103
|
-
<div className="flex items-center gap-2">
|
|
104
|
-
<SectionTitle>Execution engine</SectionTitle>
|
|
105
|
-
<span className="ml-auto flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
|
106
|
-
{effective === 'server' ? (
|
|
107
|
-
<ServerIcon className="size-3" />
|
|
108
|
-
) : (
|
|
109
|
-
<GlobeIcon className="size-3" />
|
|
110
|
-
)}
|
|
111
|
-
<span
|
|
112
|
-
className={cn(
|
|
113
|
-
'size-1.5 rounded-full',
|
|
114
|
-
offline
|
|
115
|
-
? 'bg-destructive'
|
|
116
|
-
: effective === 'server'
|
|
117
|
-
? 'bg-success'
|
|
118
|
-
: 'bg-muted-foreground',
|
|
119
|
-
)}
|
|
120
|
-
/>
|
|
121
|
-
{effective === 'server' ? 'runner online' : offline ? 'runner offline' : 'browser'}
|
|
122
|
-
</span>
|
|
123
|
-
</div>
|
|
124
|
-
<div className="flex flex-col gap-1">
|
|
125
|
-
{ENGINE_OPTIONS.map((option) => (
|
|
126
|
-
<button
|
|
127
|
-
key={option.value}
|
|
128
|
-
onClick={() => setMode(option.value)}
|
|
129
|
-
className={cn(
|
|
130
|
-
'flex cursor-pointer flex-col items-start rounded-md border px-2.5 py-1.5 text-left transition-colors hover:bg-accent',
|
|
131
|
-
mode === option.value ? 'border-ring bg-secondary/60' : 'border-border',
|
|
132
|
-
)}
|
|
133
|
-
>
|
|
134
|
-
<span className="text-[12.5px] font-medium">{option.label}</span>
|
|
135
|
-
<span className="text-[11px] text-muted-foreground">{option.hint}</span>
|
|
136
|
-
</button>
|
|
137
|
-
))}
|
|
138
|
-
</div>
|
|
139
|
-
</div>
|
|
140
|
-
|
|
141
86
|
<div className="space-y-2">
|
|
142
87
|
<SectionTitle>Coding agent</SectionTitle>
|
|
143
88
|
{availableAgents.length === 0 ? (
|
|
@@ -15,8 +15,6 @@ import { Button } from '@/components/ui/button';
|
|
|
15
15
|
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
|
16
16
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
17
17
|
import { Badge } from '@/components/ui/badge';
|
|
18
|
-
import { CreateModal } from './CreateModal';
|
|
19
|
-
import type { CreateModalState } from './CreateModal';
|
|
20
18
|
import { useBuilderStore } from '../store/builderStore';
|
|
21
19
|
import { buildApiTree } from '../store/apiTree';
|
|
22
20
|
import type { ApiTreeNode, OpItem } from '../store/apiTree';
|
|
@@ -233,18 +231,6 @@ function ApiSettingsButton({ node }: { node: ApiTreeNode }) {
|
|
|
233
231
|
);
|
|
234
232
|
}
|
|
235
233
|
|
|
236
|
-
/** Flatten an API tree into every API/folder path (e.g. 'default', 'billing', 'billing/charges'). */
|
|
237
|
-
function flattenLocations(tree: ApiTreeNode[]): string[] {
|
|
238
|
-
const paths: string[] = [];
|
|
239
|
-
const walk = (node: ApiTreeNode, prefix: string) => {
|
|
240
|
-
const path = prefix ? `${prefix}/${node.name}` : node.name;
|
|
241
|
-
paths.push(path);
|
|
242
|
-
for (const folder of node.folders) walk(folder, path);
|
|
243
|
-
};
|
|
244
|
-
for (const api of tree) walk(api, '');
|
|
245
|
-
return paths;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
234
|
function TreeNode({
|
|
249
235
|
node,
|
|
250
236
|
keyPath,
|
|
@@ -345,8 +331,10 @@ function TreeNode({
|
|
|
345
331
|
export function Sidebar() {
|
|
346
332
|
const workflows = useBuilderStore((s) => s.workflows);
|
|
347
333
|
const toggleSidebar = useBuilderStore((s) => s.toggleSidebar);
|
|
334
|
+
// The create modal itself is hosted once in App (CreateModalHost) — the
|
|
335
|
+
// sidebar only asks the store to open it, so it works with the sidebar closed.
|
|
336
|
+
const setCreateModal = useBuilderStore((s) => s.setCreateModal);
|
|
348
337
|
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
|
349
|
-
const [createState, setCreateState] = useState<CreateModalState | null>(null);
|
|
350
338
|
|
|
351
339
|
const tree = useMemo(
|
|
352
340
|
() =>
|
|
@@ -361,8 +349,6 @@ export function Sidebar() {
|
|
|
361
349
|
[workflows],
|
|
362
350
|
);
|
|
363
351
|
|
|
364
|
-
const locations = useMemo(() => flattenLocations(tree), [tree]);
|
|
365
|
-
|
|
366
352
|
const toggle = (key: string) => {
|
|
367
353
|
setCollapsed((prev) => {
|
|
368
354
|
const next = new Set(prev);
|
|
@@ -384,7 +370,7 @@ export function Sidebar() {
|
|
|
384
370
|
size="xs"
|
|
385
371
|
aria-label="New operation"
|
|
386
372
|
title="New operation"
|
|
387
|
-
onClick={() =>
|
|
373
|
+
onClick={() => setCreateModal({ mode: 'operation' })}
|
|
388
374
|
className="text-muted-foreground hover:text-foreground"
|
|
389
375
|
>
|
|
390
376
|
<PlusIcon />
|
|
@@ -411,7 +397,7 @@ export function Sidebar() {
|
|
|
411
397
|
isApi
|
|
412
398
|
collapsed={collapsed}
|
|
413
399
|
toggle={toggle}
|
|
414
|
-
onAddOperation={(location) =>
|
|
400
|
+
onAddOperation={(location) => setCreateModal({ mode: 'operation', location })}
|
|
415
401
|
/>
|
|
416
402
|
))}
|
|
417
403
|
</nav>
|
|
@@ -420,17 +406,11 @@ export function Sidebar() {
|
|
|
420
406
|
variant="secondary"
|
|
421
407
|
size="sm"
|
|
422
408
|
className="w-full"
|
|
423
|
-
onClick={() =>
|
|
409
|
+
onClick={() => setCreateModal({ mode: 'api' })}
|
|
424
410
|
>
|
|
425
411
|
<PlusIcon /> New API
|
|
426
412
|
</Button>
|
|
427
413
|
</div>
|
|
428
|
-
|
|
429
|
-
<CreateModal
|
|
430
|
-
state={createState}
|
|
431
|
-
onOpenChange={(open) => !open && setCreateState(null)}
|
|
432
|
-
locations={locations}
|
|
433
|
-
/>
|
|
434
414
|
</aside>
|
|
435
415
|
);
|
|
436
416
|
}
|