@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.
Files changed (116) hide show
  1. package/bin/commands.ts +7 -3
  2. package/bin/init.test.ts +94 -0
  3. package/bin/init.ts +108 -2
  4. package/dist/bin/commands.d.ts +1 -0
  5. package/dist/bin/commands.js +6 -3
  6. package/dist/bin/init.d.ts +4 -0
  7. package/dist/bin/init.js +96 -2
  8. package/dist/server/agents/codexParse.js +31 -0
  9. package/dist/server/agents/detect.d.ts +18 -8
  10. package/dist/server/agents/detect.js +78 -13
  11. package/dist/server/agents/modelRejectionHint.js +1 -1
  12. package/dist/server/agents/prompt.d.ts +15 -1
  13. package/dist/server/agents/prompt.js +59 -37
  14. package/dist/server/agents/runManager.d.ts +23 -2
  15. package/dist/server/agents/runManager.js +36 -8
  16. package/dist/server/client.d.ts +1 -0
  17. package/dist/server/client.js +7 -2
  18. package/dist/server/index.js +208 -16
  19. package/dist/server/nodesPayload.d.ts +14 -1
  20. package/dist/server/nodesPayload.js +15 -2
  21. package/dist/server/projectMode.js +5 -2
  22. package/dist/server/runRegistry.d.ts +52 -1
  23. package/dist/server/runRegistry.js +170 -1
  24. package/dist/server/sourceNav.d.ts +77 -0
  25. package/dist/server/sourceNav.js +503 -0
  26. package/dist/server/subflowRunner.d.ts +48 -1
  27. package/dist/server/subflowRunner.js +34 -7
  28. package/dist/server/updateCheck.d.ts +68 -0
  29. package/dist/server/updateCheck.js +142 -0
  30. package/dist/src/engine/executor.js +4 -3
  31. package/dist/src/engine/registry.d.ts +27 -2
  32. package/dist/src/engine/registry.js +84 -2
  33. package/dist/src/nodes/index.d.ts +8 -2
  34. package/dist/src/nodes/index.js +7 -3
  35. package/dist/src/nodes/login.d.ts +3 -1
  36. package/dist/src/nodes/login.js +2 -2
  37. package/package.json +28 -7
  38. package/server/agentRoute.test.ts +20 -0
  39. package/server/agents/codexParse.test.ts +37 -0
  40. package/server/agents/codexParse.ts +34 -0
  41. package/server/agents/detect.test.ts +26 -7
  42. package/server/agents/detect.ts +82 -14
  43. package/server/agents/modelRejectionHint.ts +2 -1
  44. package/server/agents/prompt.test.ts +128 -0
  45. package/server/agents/prompt.ts +102 -3
  46. package/server/agents/runManager.test.ts +9 -0
  47. package/server/agents/runManager.ts +37 -7
  48. package/server/client.ts +10 -4
  49. package/server/index.ts +213 -15
  50. package/server/nodeRun.test.ts +189 -0
  51. package/server/nodesPayload.test.ts +38 -0
  52. package/server/nodesPayload.ts +28 -3
  53. package/server/projectMode.ts +5 -2
  54. package/server/runRegistry.ts +200 -3
  55. package/server/setupStatus.test.ts +3 -0
  56. package/server/sourceNav.test.ts +382 -0
  57. package/server/sourceNav.ts +644 -0
  58. package/server/sourceNavRoute.test.ts +163 -0
  59. package/server/stepDrill.test.ts +380 -0
  60. package/server/subflowRunner.ts +92 -11
  61. package/server/updateCheck.test.ts +209 -0
  62. package/server/updateCheck.ts +175 -0
  63. package/server/workflowTestRoute.test.ts +1 -1
  64. package/src/App.tsx +82 -2
  65. package/src/components/AgentConsole.tsx +7 -280
  66. package/src/components/AgentStream.test.tsx +88 -0
  67. package/src/components/AgentStream.tsx +303 -0
  68. package/src/components/CreateModal.tsx +43 -9
  69. package/src/components/Dock.tsx +1 -1
  70. package/src/components/EmptyState.test.tsx +49 -0
  71. package/src/components/EmptyState.tsx +61 -0
  72. package/src/components/EnvironmentDialog.tsx +4 -1
  73. package/src/components/EnvironmentPicker.tsx +8 -3
  74. package/src/components/EnvironmentsDialog.tsx +4 -1
  75. package/src/components/InfraPanel.tsx +30 -2
  76. package/src/components/InfrastructureDialog.test.tsx +97 -0
  77. package/src/components/InfrastructureDialog.tsx +111 -0
  78. package/src/components/Inspector.tsx +9 -26
  79. package/src/components/RunbookView.tsx +70 -6
  80. package/src/components/SettingsDialog.tsx +4 -59
  81. package/src/components/Sidebar.tsx +6 -26
  82. package/src/components/SourceNavigator.test.tsx +226 -0
  83. package/src/components/SourceNavigator.tsx +520 -0
  84. package/src/components/StatusBar.tsx +227 -26
  85. package/src/components/Toolbar.tsx +28 -16
  86. package/src/components/UpdateChip.test.tsx +31 -0
  87. package/src/components/WelcomeDialog.test.tsx +384 -13
  88. package/src/components/WelcomeDialog.tsx +996 -177
  89. package/src/engine/executor.ts +4 -3
  90. package/src/engine/registry.sourceRef.test.ts +112 -0
  91. package/src/engine/registry.ts +103 -2
  92. package/src/lib/guidedQuestions.test.ts +224 -0
  93. package/src/lib/guidedQuestions.ts +181 -0
  94. package/src/lib/runbookModel.ts +3 -3
  95. package/src/lib/runbookProjection.test.ts +43 -0
  96. package/src/lib/runbookProjection.ts +26 -6
  97. package/src/nodes/index.ts +10 -3
  98. package/src/nodes/login.ts +5 -2
  99. package/src/store/agentClient.ts +27 -8
  100. package/src/store/apiTree.ts +12 -0
  101. package/src/store/builderStore.test.ts +441 -99
  102. package/src/store/builderStore.ts +385 -314
  103. package/src/store/nodeMeta.ts +10 -2
  104. package/src/store/serverRunner.ts +56 -5
  105. package/src/store/setupClient.ts +7 -2
  106. package/src/store/sourceNavClient.ts +48 -0
  107. package/src/store/updateClient.ts +50 -0
  108. package/studio-dist/assets/index-BbzppDpt.js +65 -0
  109. package/studio-dist/assets/index-CLf6blcA.css +1 -0
  110. package/studio-dist/index.html +2 -2
  111. package/templates/skills/emberflow-basics/SKILL.md +29 -1
  112. package/templates/skills/emberflow-model-process/SKILL.md +92 -9
  113. package/templates/skills/emberflow-new-workflow/SKILL.md +8 -1
  114. package/templates/skills/emberflow-review-workflow/SKILL.md +64 -1
  115. package/studio-dist/assets/index-DNJwW-hM.css +0 -1
  116. 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 { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
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
- <div className="min-h-0 flex-1 overflow-auto rounded-md border border-border bg-background">
794
- <SyntaxHighlighter
795
- language="javascript"
796
- style={vscDarkPlus}
797
- showLineNumbers
798
- customStyle={{
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 flow = useBuilderStore((s) => s.flow);
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 run = useBuilderStore((s) => s.run);
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 selectedNodeId = useBuilderStore((s) => s.selectedNodeId);
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
- selectNode,
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, GlobeIcon, RotateCcwIcon, ServerIcon, SettingsIcon } from 'lucide-react';
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. Absorbs the old EngineChip (execution
40
- * engine + runner status) and the Reset button, keeping the toolbar itself to
41
- * authoring + running.
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={() => setCreateState({ mode: 'operation' })}
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) => setCreateState({ mode: 'operation', 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={() => setCreateState({ mode: 'api' })}
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
  }