@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
@@ -1,8 +1,13 @@
1
1
  import { useEffect, useState } from 'react';
2
- import { ListChecksIcon, Loader2Icon, ShieldIcon } from 'lucide-react';
2
+ import { CheckIcon, ChevronUpIcon, DownloadIcon, ListChecksIcon, Loader2Icon, ServerIcon, ShieldIcon } from 'lucide-react';
3
3
  import { cn } from '@/lib/utils';
4
+ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
4
5
  import { useBuilderStore } from '../store/builderStore';
5
6
  import { setupProgress } from '../store/setupClient';
7
+ import { fetchInfrastructure, type InfrastructureResponse } from '../store/infraClient';
8
+ import { fetchUpdateStatus, postUpdate, type UpdateStatus } from '../store/updateClient';
9
+ import { InfrastructureDialog } from './InfrastructureDialog';
10
+ import { CopyCommand } from './WelcomeDialog';
6
11
  import type { EnvironmentSummary } from '../store/serverRunner';
7
12
  import type { WorkflowRun } from '../engine';
8
13
 
@@ -115,6 +120,100 @@ function EnvAuthBadge({ env }: { env: EnvironmentSummary }) {
115
120
  );
116
121
  }
117
122
 
123
+ /**
124
+ * Package update chip: rendered only when the runner reports a newer published
125
+ * version. Highlight/ember tint (not warn) — an update is good news, not a
126
+ * problem. The popover walks the whole one-click flow: install → restart
127
+ * command on success, manual npm command on failure. Props-driven (the bar
128
+ * owns the fetch) so the component test can render it directly.
129
+ */
130
+ export function UpdateChip({ status }: { status: UpdateStatus | null }) {
131
+ const [open, setOpen] = useState(false);
132
+ const [phase, setPhase] = useState<'idle' | 'installing' | 'done' | 'error'>('idle');
133
+ const [error, setError] = useState<string | null>(null);
134
+ if (!status?.updateAvailable || !status.latest) return null;
135
+
136
+ const onInstall = async () => {
137
+ setPhase('installing');
138
+ setError(null);
139
+ const result = await postUpdate();
140
+ if (result.ok) {
141
+ setPhase('done');
142
+ } else {
143
+ setError(result.error ?? 'Install failed');
144
+ setPhase('error');
145
+ }
146
+ };
147
+
148
+ return (
149
+ <>
150
+ <Divider />
151
+ <Popover open={open} onOpenChange={setOpen}>
152
+ <PopoverTrigger asChild>
153
+ <button
154
+ type="button"
155
+ title={`Update available — ${status.current} → ${status.latest}`}
156
+ className={cn(segment, interactive, 'bg-highlight/15 font-medium text-highlight')}
157
+ >
158
+ <DownloadIcon className="size-3 shrink-0" />
159
+ update
160
+ </button>
161
+ </PopoverTrigger>
162
+ <PopoverContent side="top" align="start" className="w-72 p-3">
163
+ <div className="flex items-center gap-2">
164
+ <span className="size-1.5 shrink-0 rounded-full bg-highlight" />
165
+ <span className="text-[12px] font-medium">
166
+ Update available — {status.current} → {status.latest}
167
+ </span>
168
+ </div>
169
+ {phase === 'done' ? (
170
+ <>
171
+ <p className="mt-1.5 text-[11.5px] leading-snug text-muted-foreground">
172
+ Installed — restart the runner to finish:
173
+ </p>
174
+ <CopyCommand command="npx emberflow dev" />
175
+ </>
176
+ ) : (
177
+ <>
178
+ <p className="mt-1.5 text-[11.5px] leading-snug text-muted-foreground">
179
+ New features and fixes for the studio and runner.
180
+ </p>
181
+ {phase === 'error' && error && (
182
+ <>
183
+ <p className="mt-1.5 truncate text-[11.5px] leading-snug text-destructive" title={error}>
184
+ {error}
185
+ </p>
186
+ <p className="mt-1.5 text-[11.5px] leading-snug text-muted-foreground">
187
+ Install manually instead:
188
+ </p>
189
+ <CopyCommand command="npm install @xdelivered/emberflow@latest" />
190
+ </>
191
+ )}
192
+ <button
193
+ type="button"
194
+ onClick={() => void onInstall()}
195
+ disabled={phase === 'installing'}
196
+ className="mt-2.5 flex items-center gap-1.5 rounded-md bg-highlight px-2.5 py-1 text-[11.5px] font-medium text-highlight-foreground transition-colors hover:bg-highlight/90 disabled:cursor-wait disabled:opacity-60"
197
+ >
198
+ {phase === 'installing' ? (
199
+ <>
200
+ <Loader2Icon className="size-3 shrink-0 animate-spin" />
201
+ Installing…
202
+ </>
203
+ ) : phase === 'error' ? (
204
+ 'Retry install'
205
+ ) : (
206
+ 'Install update'
207
+ )}
208
+ </button>
209
+ </>
210
+ )}
211
+ </PopoverContent>
212
+ </Popover>
213
+ </>
214
+ );
215
+ }
216
+
118
217
  /**
119
218
  * Bottom status bar: a calm, always-present readout of where a run points and
120
219
  * what's happening. Left = environment / runner / workspace source (interactive
@@ -136,6 +235,42 @@ export function StatusBar() {
136
235
  const run = useBuilderStore((s) => s.run);
137
236
  const setupStatus = useBuilderStore((s) => s.setupStatus);
138
237
  const setWelcomeOpen = useBuilderStore((s) => s.setWelcomeOpen);
238
+ const agentRun = useBuilderStore((s) => s.agentRun);
239
+
240
+ // Infrastructure chip: fetch on mount and whenever an agent run finishes (a
241
+ // completed scout writes the manifest) — mirroring InfraTab's refetch. Shown
242
+ // only once scouted; the welcome checklist owns the not-scouted path.
243
+ const [infraData, setInfraData] = useState<InfrastructureResponse | null>(null);
244
+ const [infraOpen, setInfraOpen] = useState(false);
245
+ const [registerOpen, setRegisterOpen] = useState(false);
246
+ const [mockOpen, setMockOpen] = useState(false);
247
+ const agentRunStatus = agentRun?.status;
248
+ useEffect(() => {
249
+ if (agentRunStatus === 'running') return;
250
+ let cancelled = false;
251
+ void fetchInfrastructure().then((d) => {
252
+ if (!cancelled) setInfraData(d);
253
+ });
254
+ return () => {
255
+ cancelled = true;
256
+ };
257
+ }, [agentRunStatus]);
258
+ // Scouted (present:true) → show the chip, even for a greenfield 0-item
259
+ // manifest; only the not-scouted path is hidden (owned by the checklist).
260
+ const infraCount = infraData && infraData.present ? infraData.manifest.items.length : null;
261
+
262
+ // Update notifier: one check per studio load — the runner caches the
263
+ // registry answer for an hour anyway, so polling would add nothing.
264
+ const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null);
265
+ useEffect(() => {
266
+ let cancelled = false;
267
+ void fetchUpdateStatus().then((s) => {
268
+ if (!cancelled) setUpdateStatus(s);
269
+ });
270
+ return () => {
271
+ cancelled = true;
272
+ };
273
+ }, []);
139
274
 
140
275
  // Setup entry point: a quiet progress chip while onboarding is unfinished.
141
276
  // Complete setups drop the chip — the checklist auto-open and this chip are
@@ -145,7 +280,7 @@ export function StatusBar() {
145
280
 
146
281
  const current = environments.find((e) => e.name === selectedEnvironment);
147
282
  const protectedEnv = current?.protected ?? false;
148
- const envLabel = selectedEnvironment || 'browser';
283
+ const envLabel = selectedEnvironment || (runnerOnline === false ? 'offline' : 'default');
149
284
 
150
285
  const onEnvClick = () => {
151
286
  if (!safeMode) {
@@ -196,13 +331,44 @@ export function StatusBar() {
196
331
  {runnerMock && (
197
332
  <>
198
333
  <Divider />
199
- <span
200
- className={cn(segment, 'bg-warn/15 font-medium uppercase tracking-wide text-warn')}
201
- title="The studio is in Mock mode mounted endpoints answer with example responses from scenarios; nothing executes, no auth. Pick an environment in the toolbar dropdown to go live."
202
- >
203
- <span className="size-1.5 shrink-0 rounded-full bg-warn" />
204
- mock
205
- </span>
334
+ {/* Discrete explainer: mock is the mode every new project starts in,
335
+ so the chip opens a small card saying what it means and how to
336
+ leave ita tooltip alone was too easy to never discover. */}
337
+ <Popover open={mockOpen} onOpenChange={setMockOpen}>
338
+ <PopoverTrigger asChild>
339
+ <button
340
+ type="button"
341
+ className={cn(segment, interactive, 'bg-warn/15 font-medium uppercase tracking-wide text-warn')}
342
+ >
343
+ <span className="size-1.5 shrink-0 rounded-full bg-warn" />
344
+ mock
345
+ </button>
346
+ </PopoverTrigger>
347
+ <PopoverContent side="top" align="start" className="w-72 p-3">
348
+ <div className="flex items-center gap-2">
349
+ <span className="size-1.5 shrink-0 rounded-full bg-warn" />
350
+ <span className="text-[12px] font-medium">Mock mode</span>
351
+ </div>
352
+ <p className="mt-1.5 text-[11.5px] leading-snug text-muted-foreground">
353
+ Runs answer with the example responses from your scenarios. Nothing real executes —
354
+ no databases, no external calls, no auth.
355
+ </p>
356
+ <p className="mt-1.5 text-[11.5px] leading-snug text-muted-foreground">
357
+ That makes it safe to run anything while you build. When you're ready, point runs at
358
+ a real environment.
359
+ </p>
360
+ <button
361
+ type="button"
362
+ onClick={() => {
363
+ setMockOpen(false);
364
+ onEnvClick();
365
+ }}
366
+ className="mt-2.5 text-[11.5px] font-medium text-highlight underline-offset-2 hover:underline"
367
+ >
368
+ Go live — pick an environment
369
+ </button>
370
+ </PopoverContent>
371
+ </Popover>
206
372
  </>
207
373
  )}
208
374
  <Divider />
@@ -246,28 +412,62 @@ export function StatusBar() {
246
412
  </button>
247
413
  </>
248
414
  )}
415
+ <UpdateChip status={updateStatus} />
416
+ {infraCount !== null && (
417
+ <>
418
+ <Divider />
419
+ <button
420
+ type="button"
421
+ onClick={() => setInfraOpen(true)}
422
+ title="Project infrastructure — what the scout found that agents reuse"
423
+ className={cn(segment, interactive)}
424
+ >
425
+ <ServerIcon className="size-3 shrink-0" />
426
+ <span className="truncate text-foreground/80">infra</span>
427
+ <span className="shrink-0 font-mono tabular-nums text-muted-foreground/70">{infraCount}</span>
428
+ </button>
429
+ </>
430
+ )}
249
431
  <Divider />
250
- <div
251
- role="group"
252
- aria-label="Runbook register"
253
- title="Runbook register — Simple reads outcomes; Technical adds type names + trace badges"
254
- className="flex h-full items-center gap-0.5 px-1"
255
- >
256
- {(['simple', 'technical'] as const).map((v) => (
432
+ <Popover open={registerOpen} onOpenChange={setRegisterOpen}>
433
+ <PopoverTrigger asChild>
257
434
  <button
258
- key={v}
259
435
  type="button"
260
- onClick={() => setRegister(v)}
261
- aria-pressed={register === v}
262
- className={cn(
263
- 'rounded-[4px] px-1.5 py-0.5 text-[11px] capitalize transition-colors',
264
- register === v ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:text-foreground',
265
- )}
436
+ title="Detail level — Simple shows outcomes; Technical adds types and traces"
437
+ className={cn(segment, interactive)}
266
438
  >
267
- {v}
439
+ <span className="truncate capitalize text-foreground/80">{register}</span>
440
+ <ChevronUpIcon className="size-3 shrink-0 text-muted-foreground/70" />
268
441
  </button>
269
- ))}
270
- </div>
442
+ </PopoverTrigger>
443
+ <PopoverContent
444
+ side="top"
445
+ align="start"
446
+ className="w-40 p-1"
447
+ role="radiogroup"
448
+ aria-label="Runbook register"
449
+ >
450
+ {(['simple', 'technical'] as const).map((v) => (
451
+ <button
452
+ key={v}
453
+ type="button"
454
+ role="radio"
455
+ aria-checked={register === v}
456
+ onClick={() => {
457
+ setRegister(v);
458
+ setRegisterOpen(false);
459
+ }}
460
+ className={cn(
461
+ 'flex w-full items-center justify-between gap-2 rounded-[4px] px-2 py-1 text-left text-[11px] capitalize transition-colors',
462
+ register === v ? 'text-foreground' : 'text-muted-foreground hover:bg-secondary/50 hover:text-foreground',
463
+ )}
464
+ >
465
+ <span>{v}</span>
466
+ {register === v && <CheckIcon className="size-3 shrink-0 text-highlight" />}
467
+ </button>
468
+ ))}
469
+ </PopoverContent>
470
+ </Popover>
271
471
 
272
472
  <div className="ml-auto flex h-full min-w-0 items-center">
273
473
  {selectedNode && (
@@ -297,6 +497,7 @@ export function StatusBar() {
297
497
  </>
298
498
  )}
299
499
  </div>
500
+ <InfrastructureDialog open={infraOpen} onOpenChange={setInfraOpen} data={infraData} />
300
501
  </footer>
301
502
  );
302
503
  }
@@ -55,10 +55,12 @@ function infraNodesOf(
55
55
  }
56
56
 
57
57
  /** Derives diagnoseOperation's `mutationSourcesByNode` extra from the studio's
58
- * client-side node registry, mirroring infraNodesOf's per-node degradation:
59
- * a node whose type isn't registered, isn't a mutation, or whose
60
- * implementation isn't a function is simply absent from the map rather than
61
- * aborting the whole computation. */
58
+ * definitions-only node registry. The implementation source comes from
59
+ * `registry.getSource(type)` the runner-synced `source` string (see
60
+ * syncNodeMeta / nodesPayload), not a bundled implementation so the
61
+ * simulated-commit chip works even though the studio bundles no node code.
62
+ * Per-node degradation mirrors infraNodesOf: a node whose type isn't
63
+ * registered, isn't a mutation, or has no known source is simply absent. */
62
64
  function mutationSourcesOf(
63
65
  flow: { nodes: Array<{ id: string; type: string }> },
64
66
  registry: NodeRegistry,
@@ -66,10 +68,10 @@ function mutationSourcesOf(
66
68
  const mutationSourcesByNode: NonNullable<DiagnoseOperationExtras['mutationSourcesByNode']> = {};
67
69
  for (const node of flow.nodes) {
68
70
  if (!registry.has(node.type)) continue;
69
- const { definition, implementation } = registry.get(node.type);
70
- if (definition.effects === 'mutation' && typeof implementation === 'function') {
71
- mutationSourcesByNode[node.id] = String(implementation);
72
- }
71
+ const { definition } = registry.get(node.type);
72
+ if (definition.effects !== 'mutation') continue;
73
+ const source = registry.getSource(node.type);
74
+ if (source !== undefined) mutationSourcesByNode[node.id] = source;
73
75
  }
74
76
  return mutationSourcesByNode;
75
77
  }
@@ -90,11 +92,14 @@ const statusDot: Record<string, string> = {
90
92
  function RunSplitButton({
91
93
  onRun,
92
94
  runDisabled,
95
+ disabledReason,
93
96
  live,
94
97
  stepping,
95
98
  }: {
96
99
  onRun: () => void;
97
100
  runDisabled: boolean;
101
+ /** Shown as the button title when runDisabled (e.g. the runner is offline). */
102
+ disabledReason?: string;
98
103
  live: boolean;
99
104
  stepping: boolean;
100
105
  }) {
@@ -124,13 +129,15 @@ function RunSplitButton({
124
129
  const safeMode = useBuilderStore((s) => s.safeMode);
125
130
  // Blocked wins (it says why the button is disabled); otherwise plain-terms
126
131
  // explainer of what a real run means.
127
- const runTitle = paramsBlocked
132
+ const runTitle = runDisabled && disabledReason
133
+ ? disabledReason
134
+ : paramsBlocked
128
135
  ? paramBlockers.map((d) => d.message).join('\n')
129
136
  : stepping
130
137
  ? 'Runs the stepped run to the end.'
131
138
  : runnerMock
132
139
  ? 'Runs this operation against scenario mocks — real logic, canned infrastructure. Nothing real is touched.'
133
- : `Runs this operation for real against "${selectedEnvironment || 'browser'}" — every node executes, and requests hit real services. Safe mode ${safeMode ? 'is on: writes are skipped' : 'is off: writes happen'}.`;
140
+ : `Runs this operation for real against "${selectedEnvironment || 'the default environment'}" — every node executes, and requests hit real services. Safe mode ${safeMode ? 'is on: writes are skipped' : 'is off: writes happen'}.`;
134
141
 
135
142
  const lastRunFor = (sc: ScenarioDefinition) =>
136
143
  runHistory.find((r) => r.workflowId === flow.id && r.scenarioName === sc.name);
@@ -513,7 +520,6 @@ export function Toolbar() {
513
520
  const saveFlow = useBuilderStore((s) => s.saveFlow);
514
521
  const exportFlow = useBuilderStore((s) => s.exportFlow);
515
522
  const importFlow = useBuilderStore((s) => s.importFlow);
516
- const executionMode = useBuilderStore((s) => s.executionMode);
517
523
  const runnerOnline = useBuilderStore((s) => s.runnerOnline);
518
524
  const environments = useBuilderStore((s) => s.environments);
519
525
  const selectedEnvironment = useBuilderStore((s) => s.selectedEnvironment);
@@ -524,9 +530,12 @@ export function Toolbar() {
524
530
  const fileRef = useRef<HTMLInputElement>(null);
525
531
  const [moreOpen, setMoreOpen] = useState(false);
526
532
 
527
- // Only a manual server override with the runner down blocks running;
528
- // auto mode always has the browser fallback.
529
- const runDisabled = busy || (executionMode === 'server' && runnerOnline === false);
533
+ // Runs are always server-side: with the runner offline there is nowhere to
534
+ // execute, so Run/Step are disabled until it's back.
535
+ const runDisabled = busy || runnerOnline === false;
536
+ const runOfflineTitle = runnerOnline === false
537
+ ? 'Runner offline — start it with `npx emberflow dev` to run.'
538
+ : undefined;
530
539
  const currentEnv = environments.find((e) => e.name === selectedEnvironment);
531
540
  const live = (currentEnv?.protected ?? false) && !safeMode;
532
541
 
@@ -582,6 +591,7 @@ export function Toolbar() {
582
591
  <RunSplitButton
583
592
  onRun={guard(runToEnd)}
584
593
  runDisabled={runDisabled}
594
+ disabledReason={runOfflineTitle}
585
595
  live={live}
586
596
  stepping={stepping}
587
597
  />
@@ -591,11 +601,13 @@ export function Toolbar() {
591
601
  onClick={guard(stepRun)}
592
602
  disabled={runDisabled}
593
603
  title={
594
- stepping
604
+ runDisabled && runOfflineTitle
605
+ ? runOfflineTitle
606
+ : stepping
595
607
  ? 'Runs the next node, then pauses again.'
596
608
  : runnerMock
597
609
  ? 'Runs one node at a time against scenario mocks — real logic, canned infrastructure, paused between nodes.'
598
- : `Runs one node at a time against "${selectedEnvironment || 'browser'}" — same real execution, paused between nodes.`
610
+ : `Runs one node at a time against "${selectedEnvironment || 'the default environment'}" — same real execution, paused between nodes.`
599
611
  }
600
612
  >
601
613
  {stepping ? <PlayIcon /> : <StepForwardIcon />}
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { renderToStaticMarkup } from 'react-dom/server';
3
+ import { UpdateChip } from './StatusBar';
4
+
5
+ /** Props-driven chip (StatusBar owns the /update-status fetch), so the test
6
+ * renders it directly — same pattern as InfrastructureDialog.test.tsx. */
7
+ describe('UpdateChip', () => {
8
+ it('renders nothing while the status is unknown (fetch failed / pending)', () => {
9
+ expect(renderToStaticMarkup(<UpdateChip status={null} />)).toBe('');
10
+ });
11
+
12
+ it('renders nothing when the runner is current', () => {
13
+ const out = renderToStaticMarkup(
14
+ <UpdateChip status={{ current: '0.3.0', latest: '0.3.0', updateAvailable: false }} />,
15
+ );
16
+ expect(out).toBe('');
17
+ });
18
+
19
+ it('renders nothing when the check was unavailable (no latest)', () => {
20
+ const out = renderToStaticMarkup(<UpdateChip status={{ current: '0.3.0', updateAvailable: false }} />);
21
+ expect(out).toBe('');
22
+ });
23
+
24
+ it('shows the chip with both versions when an update is available', () => {
25
+ const out = renderToStaticMarkup(
26
+ <UpdateChip status={{ current: '0.3.0', latest: '0.4.0', updateAvailable: true }} />,
27
+ );
28
+ expect(out).toContain('update');
29
+ expect(out).toContain('Update available — 0.3.0 → 0.4.0');
30
+ });
31
+ });