@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,303 @@
1
+ import { useMemo, useState } from 'react';
2
+ import { ChevronRightIcon, TerminalIcon, ZapIcon } from 'lucide-react';
3
+ import { cn } from '@/lib/utils';
4
+ import type { AgentEvent } from '../store/agentClient';
5
+ import { renderAgentMarkdown } from './agentMarkdown';
6
+
7
+ /** A command's status → a small glyph colour. */
8
+ const cmdDot: Record<string, string> = {
9
+ in_progress: 'bg-highlight animate-pulse',
10
+ completed: 'bg-success/70',
11
+ failed: 'bg-destructive',
12
+ };
13
+
14
+ /** The `emberflow` CLI operation commands the agent uses (run/inspect/delete). */
15
+ const EMBERFLOW_OPS = [
16
+ 'run', 'get-workflow', 'list-workflows', 'list-nodes', 'node-schema', 'list-environments',
17
+ 'create', 'delete', 'rename', 'validate', 'publish', 'save',
18
+ ];
19
+
20
+ /** If a shell command invokes the emberflow CLI bin with an operation command,
21
+ * pull out the operation + its target id so it can render as a distinct step. */
22
+ function parseEmberflowOp(command?: string): { op: string; target?: string } | null {
23
+ if (!command || !/emberflow\.mjs\b/.test(command)) return null;
24
+ const after = command.split(/emberflow\.mjs\b/)[1] ?? '';
25
+ const toks = after.trim().split(/\s+/).filter(Boolean);
26
+ const op = toks.find((t) => EMBERFLOW_OPS.includes(t));
27
+ if (!op) return null;
28
+ const target = toks.slice(toks.indexOf(op) + 1).find((t) => !t.startsWith('-'));
29
+ return { op, target };
30
+ }
31
+
32
+ /**
33
+ * A run of consecutive shell commands, collapsed to a single quiet line
34
+ * ("Ran N commands") that expands to the individual commands + their output.
35
+ * A failed command in the group tints the header destructive but stays
36
+ * COLLAPSED — the failure signal is visible, the raw output dump is a click
37
+ * away. This is the big de-noiser: a single edit can run 30 inspection
38
+ * commands — nobody wants 30 rows, they want the reasoning with the
39
+ * mechanics tucked underneath.
40
+ */
41
+ function CommandGroup({ commands }: { commands: AgentEvent[] }) {
42
+ const anyFailed = commands.some((c) => c.commandStatus === 'failed');
43
+ const [open, setOpen] = useState(false);
44
+ const running = commands.some((c) => c.commandStatus === 'in_progress');
45
+ const label = commands.length === 1 ? '1 command' : `${commands.length} commands`;
46
+ return (
47
+ <div className="my-0.5">
48
+ <button
49
+ type="button"
50
+ onClick={() => setOpen((o) => !o)}
51
+ className="group flex w-full items-center gap-1.5 rounded px-1 py-0.5 text-left transition-colors hover:bg-secondary/50"
52
+ >
53
+ <ChevronRightIcon
54
+ className={cn('size-3 shrink-0 text-muted-foreground/50 transition-transform', open && 'rotate-90')}
55
+ />
56
+ <span
57
+ className={cn(
58
+ 'size-1.5 shrink-0 rounded-full',
59
+ running ? cmdDot.in_progress : anyFailed ? cmdDot.failed : cmdDot.completed,
60
+ )}
61
+ />
62
+ <span className={cn('text-[11px]', anyFailed ? 'text-destructive/90' : 'text-muted-foreground/70')}>
63
+ {running ? 'Running' : 'Ran'} {label}
64
+ </span>
65
+ </button>
66
+ {open && (
67
+ <div className="ml-[18px] mt-0.5 space-y-0.5 border-l border-border/50 pl-2">
68
+ {commands.map((c, k) => (
69
+ <div key={k}>
70
+ <div className="flex items-center gap-1.5">
71
+ <span className={cn('size-1 shrink-0 rounded-full', cmdDot[c.commandStatus ?? 'in_progress'])} />
72
+ <span className="min-w-0 truncate font-mono text-[10.5px] text-muted-foreground/80">{c.command}</span>
73
+ {c.commandStatus === 'failed' && c.exitCode != null && (
74
+ <span className="ml-auto shrink-0 font-mono text-[9px] text-destructive">exit {c.exitCode}</span>
75
+ )}
76
+ </div>
77
+ {(c.commandStatus === 'failed' || open) && c.output && c.output.trim() && (
78
+ <pre className="mt-0.5 ml-3 max-h-32 overflow-auto rounded border border-border/50 bg-background/40 p-1.5 font-mono text-[10px] leading-relaxed whitespace-pre-wrap text-muted-foreground/70">
79
+ {c.output}
80
+ </pre>
81
+ )}
82
+ </div>
83
+ ))}
84
+ </div>
85
+ )}
86
+ </div>
87
+ );
88
+ }
89
+
90
+ /** An MCP tool call the agent made against the runner (run/list/delete/etc.) —
91
+ * visually distinct from a shell command (MCP-blue, a "MCP" chip), expandable
92
+ * to its result. This is the operational channel: files for edits, MCP for
93
+ * actions like running the operation or deleting it. */
94
+ function McpCall({ e }: { e: AgentEvent }) {
95
+ const [open, setOpen] = useState(false);
96
+ const status = e.mcpStatus ?? 'completed';
97
+ const failed = status === 'failed';
98
+ const running = status === 'in_progress';
99
+ return (
100
+ <div className="my-0.5">
101
+ <button
102
+ type="button"
103
+ onClick={() => setOpen((o) => !o)}
104
+ className="group flex w-full items-center gap-1.5 rounded px-1 py-0.5 text-left transition-colors hover:bg-secondary/50"
105
+ >
106
+ <ChevronRightIcon
107
+ className={cn('size-3 shrink-0 text-muted-foreground/50 transition-transform', open && 'rotate-90')}
108
+ />
109
+ <span className={cn('size-1.5 shrink-0 rounded-full', running ? 'animate-pulse bg-highlight' : failed ? 'bg-destructive' : 'bg-[#8fb8d8]')} />
110
+ <ZapIcon className="size-3 shrink-0 text-[#8fb8d8]" />
111
+ <span className="shrink-0 rounded border border-[#8fb8d8]/40 px-1 font-mono text-[8.5px] uppercase tracking-wide text-[#8fb8d8]">
112
+ MCP
113
+ </span>
114
+ <span className={cn('min-w-0 truncate font-mono text-[11px]', failed ? 'text-destructive/90' : 'text-foreground/85')}>
115
+ {e.mcpServer}·{e.mcpTool}
116
+ </span>
117
+ <span className="ml-auto shrink-0 text-[9.5px] text-muted-foreground/60">{running ? '…' : status}</span>
118
+ </button>
119
+ {open && e.output && e.output.trim() && (
120
+ <pre className="ml-[18px] mt-0.5 max-h-40 overflow-auto rounded border border-border/50 bg-background/40 p-1.5 font-mono text-[10px] leading-relaxed whitespace-pre-wrap text-muted-foreground/75">
121
+ {e.output}
122
+ </pre>
123
+ )}
124
+ </div>
125
+ );
126
+ }
127
+
128
+ /** An `emberflow` CLI operation the agent ran (run/inspect/delete against the
129
+ * live runner) — surfaced as a distinct operation step, expandable to its
130
+ * output. This is the agent's operational channel: files for edits, CLI ops
131
+ * for running/inspecting/removing. */
132
+ function OpCall({ op, target, e }: { op: string; target?: string; e: AgentEvent }) {
133
+ const [open, setOpen] = useState(false);
134
+ const status = e.commandStatus ?? 'completed';
135
+ const failed = status === 'failed' || (e.exitCode != null && e.exitCode !== 0);
136
+ const running = status === 'in_progress';
137
+ return (
138
+ <div className="my-0.5">
139
+ <button
140
+ type="button"
141
+ onClick={() => setOpen((o) => !o)}
142
+ className="group flex w-full items-center gap-1.5 rounded px-1 py-0.5 text-left transition-colors hover:bg-secondary/50"
143
+ >
144
+ <ChevronRightIcon className={cn('size-3 shrink-0 text-muted-foreground/50 transition-transform', open && 'rotate-90')} />
145
+ <span className={cn('size-1.5 shrink-0 rounded-full', running ? 'animate-pulse bg-highlight' : failed ? 'bg-destructive' : 'bg-[#8fb8d8]')} />
146
+ <ZapIcon className="size-3 shrink-0 text-[#8fb8d8]" />
147
+ <span className="shrink-0 rounded border border-[#8fb8d8]/40 px-1 font-mono text-[8.5px] uppercase tracking-wide text-[#8fb8d8]">
148
+ OP
149
+ </span>
150
+ <span className={cn('min-w-0 truncate font-mono text-[11px]', failed ? 'text-destructive/90' : 'text-foreground/85')}>
151
+ {op}{target ? ` · ${target}` : ''}
152
+ </span>
153
+ <span className="ml-auto shrink-0 text-[9.5px] text-muted-foreground/60">{running ? '…' : failed ? 'failed' : 'ok'}</span>
154
+ </button>
155
+ {open && e.output && e.output.trim() && (
156
+ <pre className="ml-[18px] mt-0.5 max-h-40 overflow-auto rounded border border-border/50 bg-background/40 p-1.5 font-mono text-[10px] leading-relaxed whitespace-pre-wrap text-muted-foreground/75">
157
+ {e.output}
158
+ </pre>
159
+ )}
160
+ </div>
161
+ );
162
+ }
163
+
164
+ type Block =
165
+ | { kind: 'message' | 'error' | 'approval' | 'mcp'; e: AgentEvent }
166
+ | { kind: 'op'; e: AgentEvent; op: string; target?: string }
167
+ | { kind: 'commands'; commands: AgentEvent[] };
168
+
169
+ /**
170
+ * Fold a raw agent event stream into display blocks. Reasoning prose and error
171
+ * lines stand alone; consecutive shell commands collapse into one CommandGroup;
172
+ * emberflow CLI ops + MCP calls render as their own steps; `started`/`done` are
173
+ * structural and `⚠` diagnostics are folded into a noise count. Exported so both
174
+ * the console and the component test can assert the partitioning directly.
175
+ */
176
+ export function partitionAgentEvents(events: AgentEvent[]): { blocks: Block[]; noiseCount: number } {
177
+ const blocks: Block[] = [];
178
+ let noiseCount = 0;
179
+ let group: AgentEvent[] | null = null;
180
+ const flush = (): void => {
181
+ if (group && group.length) blocks.push({ kind: 'commands', commands: group });
182
+ group = null;
183
+ };
184
+ for (const e of events) {
185
+ // An `emberflow` CLI operation (run/inspect/delete) — surface it as a
186
+ // distinct "operation" step, not a generic shell command.
187
+ const opInfo = e.type === 'command' ? parseEmberflowOp(e.command) : null;
188
+ if (e.type === 'command' && opInfo) {
189
+ flush();
190
+ const last = blocks[blocks.length - 1];
191
+ if (last && last.kind === 'op' && last.e.command === e.command && last.e.commandStatus === 'in_progress') {
192
+ last.e = e;
193
+ } else {
194
+ blocks.push({ kind: 'op', e, op: opInfo.op, target: opInfo.target });
195
+ }
196
+ continue;
197
+ }
198
+ if (e.type === 'command') {
199
+ group ??= [];
200
+ // Each command streams as a start (in_progress) then a complete
201
+ // (completed/failed) event with the same text — collapse the pair into
202
+ // one entry carrying the final status + output, so counts are accurate.
203
+ const last = group[group.length - 1];
204
+ if (last && last.command === e.command && last.commandStatus === 'in_progress') {
205
+ group[group.length - 1] = e;
206
+ } else {
207
+ group.push(e);
208
+ }
209
+ continue;
210
+ }
211
+ flush();
212
+ if (e.type === 'mcp') {
213
+ // MCP calls stream in_progress → completed for the same tool; collapse
214
+ // the pair so the completed status/result replaces the pending one.
215
+ const last = blocks[blocks.length - 1];
216
+ if (last && last.kind === 'mcp' && last.e.mcpTool === e.mcpTool && last.e.mcpStatus === 'in_progress') {
217
+ last.e = e;
218
+ } else {
219
+ blocks.push({ kind: 'mcp', e });
220
+ }
221
+ continue;
222
+ }
223
+ if (e.type === 'started' || e.type === 'done') continue;
224
+ if (e.type === 'message' && e.text?.startsWith('⚠')) {
225
+ noiseCount += 1;
226
+ continue;
227
+ }
228
+ if (e.type === 'message') blocks.push({ kind: 'message', e });
229
+ else if (e.type === 'error') blocks.push({ kind: 'error', e });
230
+ else if (e.type === 'approval-request') blocks.push({ kind: 'approval', e });
231
+ }
232
+ flush();
233
+ return { blocks, noiseCount };
234
+ }
235
+
236
+ /**
237
+ * Presentational render of a coding-agent event stream: the agent's reasoning
238
+ * as comfortable markdown prose, shell commands collapsed to quiet expandable
239
+ * one-liners, emberflow CLI ops + MCP calls as their own steps, error/approval
240
+ * rows, and background `⚠` diagnostics tucked into a count. Pure over its props
241
+ * — the AgentConsole (docked panel) and the WelcomeDialog (guided setup) both
242
+ * embed it. `running` drives the "Thinking…"/"Working…" live indicators.
243
+ */
244
+ export function AgentStream({ events, running }: { events: AgentEvent[]; running: boolean }) {
245
+ // Partition the raw stream into what's worth showing vs. noise. The agent's
246
+ // ⚠ diagnostics (an MCP server failing to auth, a hook firing) are real but
247
+ // not what the user is watching for — collapse them to a count.
248
+ const { blocks, noiseCount } = useMemo(() => partitionAgentEvents(events), [events]);
249
+
250
+ return (
251
+ <>
252
+ {blocks.length === 0 && running && (
253
+ <div className="flex items-center gap-2 text-[12px] text-muted-foreground/70">
254
+ <span className="size-1.5 animate-pulse rounded-full bg-highlight" />
255
+ Thinking…
256
+ </div>
257
+ )}
258
+ {blocks.map((b, i) => {
259
+ if (b.kind === 'commands') return <CommandGroup key={i} commands={b.commands} />;
260
+ if (b.kind === 'op') return <OpCall key={i} op={b.op} target={b.target} e={b.e} />;
261
+ if (b.kind === 'mcp') return <McpCall key={i} e={b.e} />;
262
+ if (b.kind === 'message') {
263
+ // The reasoning: the star of the panel — comfortable, readable prose.
264
+ // Render the markdown subset (bold/code/lists/headings). The renderer
265
+ // owns block structure, so whitespace-pre-wrap stays OFF here.
266
+ return (
267
+ <div key={i} className="space-y-1.5">
268
+ {renderAgentMarkdown(b.e.text ?? '')}
269
+ </div>
270
+ );
271
+ }
272
+ if (b.kind === 'error') {
273
+ return (
274
+ <p key={i} className="text-[12px] leading-relaxed text-destructive">
275
+ {b.e.text ?? 'The agent run failed.'}
276
+ </p>
277
+ );
278
+ }
279
+ return (
280
+ <p key={i} className="text-[12px] text-yellow-500">
281
+ Approval requested{b.e.text ? `: ${b.e.text}` : ''}
282
+ </p>
283
+ );
284
+ })}
285
+
286
+ {/* Live working indicator once there's content but the run's still going. */}
287
+ {running && blocks.length > 0 && (
288
+ <div className="flex items-center gap-2 pt-1 text-[11px] text-muted-foreground/60">
289
+ <span className="size-1.5 animate-pulse rounded-full bg-highlight" />
290
+ Working…
291
+ </div>
292
+ )}
293
+
294
+ {/* Background diagnostics, tucked away — acknowledged, not shouting. */}
295
+ {noiseCount > 0 && (
296
+ <div className="flex items-center gap-1.5 pt-1 text-[10.5px] text-muted-foreground/45">
297
+ <TerminalIcon className="size-3" />
298
+ {noiseCount} background {noiseCount === 1 ? 'diagnostic' : 'diagnostics'} hidden
299
+ </div>
300
+ )}
301
+ </>
302
+ );
303
+ }
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, useState } from 'react';
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { FolderIcon, SparklesIcon } from 'lucide-react';
3
3
  import { Button } from '@/components/ui/button';
4
4
  import { Input } from '@/components/ui/input';
@@ -6,8 +6,12 @@ import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from '@
6
6
  import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
7
7
  import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
8
8
  import { useBuilderStore } from '../store/builderStore';
9
+ import type { CreateModalState } from '../store/builderStore';
10
+ import { buildApiTree, flattenLocations } from '../store/apiTree';
9
11
  import { cn } from '@/lib/utils';
10
12
 
13
+ export type { CreateModalState };
14
+
11
15
  /** lowercase, spaces -> '-', strip anything not URL-safe (matches builderStore.slug). */
12
16
  function slug(name: string): string {
13
17
  return name.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
@@ -39,11 +43,6 @@ function deriveOperation(goal: string, location: string): { name: string; method
39
43
  return { name, method, httpPath: `/${location}/${opSlug}` };
40
44
  }
41
45
 
42
- /** Configures the two entry points that share this modal. */
43
- export type CreateModalState =
44
- | { mode: 'api' }
45
- | { mode: 'operation'; location?: string }; // location preset scopes it to an API (from the + hover)
46
-
47
46
  const OPERATION_EXAMPLES = [
48
47
  'List overdue invoices for a customer',
49
48
  'Charge a saved card and return the receipt',
@@ -55,6 +54,39 @@ const API_EXAMPLES = [
55
54
  'Start a background export job',
56
55
  ];
57
56
 
57
+ /**
58
+ * Store-connected host for the ONE create modal — mounted once in App so any
59
+ * surface (the Sidebar's New API / + buttons, the canvas empty state) opens it
60
+ * via `setCreateModal`, sidebar open or not. Locations for the operation-mode
61
+ * picker are derived from the live workflow list.
62
+ */
63
+ export function CreateModalHost() {
64
+ const workflows = useBuilderStore((s) => s.workflows);
65
+ const state = useBuilderStore((s) => s.createModal);
66
+ const setCreateModal = useBuilderStore((s) => s.setCreateModal);
67
+ const locations = useMemo(
68
+ () =>
69
+ flattenLocations(
70
+ buildApiTree(
71
+ workflows.map((w) => ({
72
+ id: w.id,
73
+ name: w.name,
74
+ path: w.path ?? `default/${w.id}`,
75
+ http: w.http,
76
+ })),
77
+ ),
78
+ ),
79
+ [workflows],
80
+ );
81
+ return (
82
+ <CreateModal
83
+ state={state}
84
+ onOpenChange={(open) => !open && setCreateModal(null)}
85
+ locations={locations}
86
+ />
87
+ );
88
+ }
89
+
58
90
  /**
59
91
  * The agentic create surface — one centered modal for both "New API" (name +
60
92
  * its first operation) and "New operation" (scoped to an API, or with a
@@ -82,18 +114,20 @@ export function CreateModal({
82
114
  const open = state !== null;
83
115
  const mode = state?.mode ?? 'operation';
84
116
  const presetLocation = state?.mode === 'operation' ? state.location : undefined;
117
+ const initialGoal = state?.mode === 'api' ? state.initialGoal : undefined;
85
118
  const examples = mode === 'api' ? API_EXAMPLES : OPERATION_EXAMPLES;
86
119
 
87
- // Reset fields each time the modal opens; seed the location from the preset.
120
+ // Reset fields each time the modal opens; seed the location + goal from the
121
+ // preset (the goal arrives pre-filled from onboarding's first-build answer).
88
122
  useEffect(() => {
89
123
  if (!open) return;
90
124
  setApiName('');
91
- setGoal('');
125
+ setGoal(initialGoal ?? '');
92
126
  setLocation(presetLocation ?? '');
93
127
  setLocationPickerOpen(false);
94
128
  const t = setTimeout(() => goalRef.current?.focus(), 60);
95
129
  return () => clearTimeout(t);
96
- }, [open, presetLocation]);
130
+ }, [open, presetLocation, initialGoal]);
97
131
 
98
132
  const apiSlug = slug(apiName);
99
133
  const targetLocation = mode === 'api' ? apiSlug : location.trim() || 'default';
@@ -349,7 +349,7 @@ export function InfraTab() {
349
349
  return (
350
350
  <InfraPanel
351
351
  data={data}
352
- onScout={beginInfrastructureScout}
352
+ onScout={() => beginInfrastructureScout()}
353
353
  scouting={agentRunStatus === 'running'}
354
354
  canScout={hasAgent}
355
355
  canScoutReason={NO_AGENT_REASON}
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { renderToStaticMarkup } from 'react-dom/server';
3
+ import { EmptyState } from './EmptyState';
4
+ import type { SetupStatus } from '../store/setupClient';
5
+
6
+ /** A project that finished onboarding but still has only the hello example. */
7
+ const ONLY_HELLO: SetupStatus = {
8
+ agents: [{ kind: 'claude', version: '2.1.0' }],
9
+ git: { repo: true },
10
+ environments: { configured: true, count: 2, protectedCount: 1, anyAuthConfigured: true },
11
+ skills: { claude: true, codex: false },
12
+ language: 'typescript',
13
+ ops: { count: 1, onlyHello: true },
14
+ servingMode: 'mock',
15
+ infrastructure: { present: true, itemCount: 2 },
16
+ };
17
+
18
+ const html = (status: SetupStatus | null, dismissed = false): string =>
19
+ renderToStaticMarkup(
20
+ <EmptyState status={status} dismissed={dismissed} onCreate={() => {}} onExplore={() => {}} />,
21
+ );
22
+
23
+ describe('EmptyState', () => {
24
+ it('renders when the project has only the hello example and is not dismissed', () => {
25
+ const out = html(ONLY_HELLO);
26
+ expect(out).toContain('Build your first API');
27
+ expect(out).toContain('Describe what you want and the agent builds it');
28
+ expect(out).toContain('Create your first API');
29
+ expect(out).toContain('Explore the hello example');
30
+ });
31
+
32
+ it('hidden once dismissed', () => {
33
+ expect(html(ONLY_HELLO, true)).toBe('');
34
+ });
35
+
36
+ it('hidden once the project has more than the hello op (condition stops matching)', () => {
37
+ expect(html({ ...ONLY_HELLO, ops: { count: 3, onlyHello: false } })).toBe('');
38
+ });
39
+
40
+ it('hidden with no setup status yet', () => {
41
+ expect(html(null)).toBe('');
42
+ });
43
+
44
+ it('the template button is disabled and marked coming soon', () => {
45
+ const out = html(ONLY_HELLO);
46
+ expect(out).toMatch(/disabled=""[^>]*title="Coming soon"|title="Coming soon"[^>]*disabled=""/);
47
+ expect(out).toContain('Start from a template');
48
+ });
49
+ });
@@ -0,0 +1,61 @@
1
+ import { SparklesIcon } from 'lucide-react';
2
+ import { Button } from '@/components/ui/button';
3
+ import type { SetupStatus } from '../store/setupClient';
4
+
5
+ /** Set once the user takes the "explore the example" path — the empty state
6
+ * never returns. Building a second op dismisses it implicitly (onlyHello
7
+ * flips false), so no key write is needed on the create path. */
8
+ export const EMPTY_STATE_DISMISSED_KEY = 'emberflow.emptyState.dismissed';
9
+
10
+ /**
11
+ * Post-onboarding empty state: a fresh project with only the hello example
12
+ * shouldn't greet the user with someone else's op. Rendered centered in the
13
+ * canvas INSTEAD of the runbook (see CenterView) while the project has only
14
+ * the hello example and the user hasn't dismissed it. Prop-driven — the
15
+ * component test drives it across states without a live store; CenterView
16
+ * owns the store wiring and the welcome-dialog gate.
17
+ */
18
+ export function EmptyState({
19
+ status,
20
+ dismissed,
21
+ onCreate,
22
+ onExplore,
23
+ }: {
24
+ status: SetupStatus | null;
25
+ dismissed: boolean;
26
+ /** Opens the same New API modal as the sidebar's button (store's createModal). */
27
+ onCreate: () => void;
28
+ /** Records the dismissal and opens the hello example. */
29
+ onExplore: () => void;
30
+ }) {
31
+ if (!status?.ops.onlyHello || dismissed) return null;
32
+ return (
33
+ <div className="flex h-full min-h-0 items-center justify-center p-8">
34
+ <div className="flex max-w-md flex-col items-center gap-3 text-center">
35
+ <span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-highlight/15 text-highlight">
36
+ <SparklesIcon className="size-4" />
37
+ </span>
38
+ <h2 className="text-[15px] font-semibold tracking-tight">Build your first API</h2>
39
+ <p className="text-[13px] leading-relaxed text-muted-foreground">
40
+ Describe what you want and the agent builds it — or explore the example first.
41
+ </p>
42
+ <div className="mt-1 flex items-center gap-2">
43
+ <Button size="sm" onClick={onCreate}>
44
+ <SparklesIcon className="size-3.5" />
45
+ Create your first API
46
+ </Button>
47
+ <Button size="sm" variant="outline" disabled title="Coming soon">
48
+ Start from a template
49
+ </Button>
50
+ </div>
51
+ <button
52
+ type="button"
53
+ onClick={onExplore}
54
+ className="mt-1 text-[11px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
55
+ >
56
+ Explore the hello example
57
+ </button>
58
+ </div>
59
+ </div>
60
+ );
61
+ }
@@ -542,7 +542,10 @@ export function EnvironmentDialog({
542
542
  <DialogTitle className="flex items-center gap-2">
543
543
  {env.name}
544
544
  {env.protected && (
545
- <span className="flex items-center gap-1 text-[10.5px] font-normal uppercase tracking-wide text-destructive-foreground/80">
545
+ <span
546
+ title="Protected — runs ask for confirmation before touching this environment."
547
+ className="flex items-center gap-1 text-[10.5px] font-normal uppercase tracking-wide text-destructive-foreground/80"
548
+ >
546
549
  <ShieldIcon className="size-3" /> protected
547
550
  </span>
548
551
  )}
@@ -46,7 +46,9 @@ export function EnvironmentPicker() {
46
46
  const offline = environments.length === 0;
47
47
  // The unmissable state: writes will execute against a protected environment.
48
48
  const live = protectedEnv && !safeMode;
49
- const label = selected || 'browser';
49
+ // Runs are always server-side; with the runner down there is no environment
50
+ // to point at, so the picker says so plainly rather than naming a fallback.
51
+ const label = runnerOnline === false ? 'Runner offline' : selected || 'default';
50
52
 
51
53
  const toggleSafe = () => {
52
54
  if (safeMode) {
@@ -174,7 +176,7 @@ export function EnvironmentPicker() {
174
176
  ) : (
175
177
  <div className="px-2.5 py-3 text-[12px] text-muted-foreground">
176
178
  {runnerOnline === false
177
- ? 'Runner offline — runs execute in the browser as environment "browser".'
179
+ ? 'Runner offline — start it with `npx emberflow dev` to run and pick environments.'
178
180
  : 'Loading environments…'}
179
181
  </div>
180
182
  )
@@ -199,7 +201,10 @@ export function EnvironmentPicker() {
199
201
  {env.protected && <span className="size-1.5 rounded-full bg-destructive" />}
200
202
  <span className="text-[12.5px] font-medium">{env.name}</span>
201
203
  {env.protected && (
202
- <span className="ml-auto text-[10.5px] uppercase tracking-wide text-destructive-foreground/80">
204
+ <span
205
+ title="Protected — runs ask for confirmation before touching this environment."
206
+ className="ml-auto text-[10.5px] uppercase tracking-wide text-destructive-foreground/80"
207
+ >
203
208
  protected
204
209
  </span>
205
210
  )}
@@ -98,7 +98,10 @@ export function EnvironmentsDialog({
98
98
  </span>
99
99
  )}
100
100
  {env.protected && (
101
- <span className="text-[10px] uppercase tracking-wide text-destructive-foreground/80">
101
+ <span
102
+ title="Protected — runs ask for confirmation before touching this environment."
103
+ className="text-[10px] uppercase tracking-wide text-destructive-foreground/80"
104
+ >
102
105
  protected
103
106
  </span>
104
107
  )}
@@ -26,6 +26,25 @@ const KIND_COLOR: Record<InfrastructureKind, string> = {
26
26
  other: 'text-muted-foreground',
27
27
  };
28
28
 
29
+ /**
30
+ * Plain-words gloss per kind, ≤1 line each. Surfaced (muted) under the card when
31
+ * `explainKinds` is set — the modal wants to teach what each kind MEANS to the
32
+ * agent; the Dock tab (dense, familiar) leaves it off. One source of truth so
33
+ * both renders share the same vocabulary.
34
+ */
35
+ export const KIND_EXPLANATION: Record<InfrastructureKind, string> = {
36
+ database: 'A data store the project reads and writes — operations can query it through registered nodes.',
37
+ 'http-api': 'An external service reached over HTTP — operations call it with the named secret.',
38
+ queue: 'A job queue the project enqueues into or consumes.',
39
+ cache: 'A fast key/value store for transient or hot data.',
40
+ email: 'A transactional email provider.',
41
+ llm: 'A language-model API the project calls.',
42
+ auth: 'An identity/auth provider or token flow.',
43
+ framework: "The app's own serving framework — context, not a callable dependency.",
44
+ storage: 'Object or file storage the project reads and writes.',
45
+ other: "Infrastructure that doesn't fit the other kinds.",
46
+ };
47
+
29
48
  function KindChip({ kind }: { kind: InfrastructureKind }) {
30
49
  return (
31
50
  <Badge variant="mono" className="uppercase tracking-wider">
@@ -35,7 +54,7 @@ function KindChip({ kind }: { kind: InfrastructureKind }) {
35
54
  );
36
55
  }
37
56
 
38
- function InfraCard({ item }: { item: InfrastructureItem }) {
57
+ function InfraCard({ item, explainKinds = false }: { item: InfrastructureItem; explainKinds?: boolean }) {
39
58
  return (
40
59
  <div className="flex flex-col gap-2 rounded-md border border-border/70 bg-card px-3 py-2.5">
41
60
  <div className="flex items-center justify-between gap-2">
@@ -43,6 +62,12 @@ function InfraCard({ item }: { item: InfrastructureItem }) {
43
62
  <KindChip kind={item.kind} />
44
63
  </div>
45
64
 
65
+ {explainKinds && (
66
+ <p className="text-[10.5px] leading-snug text-muted-foreground/80">
67
+ {KIND_EXPLANATION[item.kind] ?? KIND_EXPLANATION.other}
68
+ </p>
69
+ )}
70
+
46
71
  {item.evidence.length > 0 && (
47
72
  <ul className="flex flex-col gap-0.5">
48
73
  {item.evidence.map((e, i) => (
@@ -118,12 +143,15 @@ export function InfraPanel({
118
143
  scouting = false,
119
144
  canScout = true,
120
145
  canScoutReason,
146
+ explainKinds = false,
121
147
  }: {
122
148
  data: InfrastructureResponse | null;
123
149
  onScout?: () => void;
124
150
  scouting?: boolean;
125
151
  canScout?: boolean;
126
152
  canScoutReason?: string;
153
+ /** Render the plain-words per-kind gloss under each card (used by the modal). */
154
+ explainKinds?: boolean;
127
155
  }) {
128
156
  if (!data || data.present === false) {
129
157
  return (
@@ -163,7 +191,7 @@ export function InfraPanel({
163
191
  )}
164
192
  <div className="grid grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-2.5">
165
193
  {manifest.items.map((item) => (
166
- <InfraCard key={item.id} item={item} />
194
+ <InfraCard key={item.id} item={item} explainKinds={explainKinds} />
167
195
  ))}
168
196
  </div>
169
197
  </div>