@xdelivered/emberflow 0.2.0 → 0.4.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 (88) 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 +185 -14
  19. package/dist/server/runRegistry.d.ts +52 -1
  20. package/dist/server/runRegistry.js +170 -1
  21. package/dist/server/subflowRunner.d.ts +48 -1
  22. package/dist/server/subflowRunner.js +34 -7
  23. package/dist/server/updateCheck.d.ts +68 -0
  24. package/dist/server/updateCheck.js +142 -0
  25. package/dist/src/engine/executor.js +4 -3
  26. package/package.json +27 -6
  27. package/server/agentRoute.test.ts +20 -0
  28. package/server/agents/codexParse.test.ts +37 -0
  29. package/server/agents/codexParse.ts +34 -0
  30. package/server/agents/detect.test.ts +26 -7
  31. package/server/agents/detect.ts +82 -14
  32. package/server/agents/modelRejectionHint.ts +2 -1
  33. package/server/agents/prompt.test.ts +128 -0
  34. package/server/agents/prompt.ts +102 -3
  35. package/server/agents/runManager.test.ts +9 -0
  36. package/server/agents/runManager.ts +37 -7
  37. package/server/client.ts +10 -4
  38. package/server/index.ts +189 -13
  39. package/server/nodeRun.test.ts +189 -0
  40. package/server/runRegistry.ts +200 -3
  41. package/server/setupStatus.test.ts +3 -0
  42. package/server/stepDrill.test.ts +380 -0
  43. package/server/subflowRunner.ts +92 -11
  44. package/server/updateCheck.test.ts +209 -0
  45. package/server/updateCheck.ts +175 -0
  46. package/server/workflowTestRoute.test.ts +1 -1
  47. package/src/App.tsx +82 -2
  48. package/src/components/AgentConsole.tsx +7 -280
  49. package/src/components/AgentStream.test.tsx +88 -0
  50. package/src/components/AgentStream.tsx +303 -0
  51. package/src/components/CreateModal.tsx +43 -9
  52. package/src/components/Dock.tsx +1 -1
  53. package/src/components/EmptyState.test.tsx +49 -0
  54. package/src/components/EmptyState.tsx +61 -0
  55. package/src/components/EnvironmentDialog.tsx +4 -1
  56. package/src/components/EnvironmentPicker.tsx +8 -3
  57. package/src/components/EnvironmentsDialog.tsx +4 -1
  58. package/src/components/InfraPanel.tsx +30 -2
  59. package/src/components/InfrastructureDialog.test.tsx +97 -0
  60. package/src/components/InfrastructureDialog.tsx +111 -0
  61. package/src/components/RunbookView.tsx +70 -6
  62. package/src/components/SettingsDialog.tsx +4 -59
  63. package/src/components/Sidebar.tsx +6 -26
  64. package/src/components/StatusBar.tsx +227 -26
  65. package/src/components/Toolbar.tsx +28 -16
  66. package/src/components/UpdateChip.test.tsx +31 -0
  67. package/src/components/WelcomeDialog.test.tsx +384 -13
  68. package/src/components/WelcomeDialog.tsx +996 -177
  69. package/src/engine/executor.ts +4 -3
  70. package/src/lib/guidedQuestions.test.ts +224 -0
  71. package/src/lib/guidedQuestions.ts +181 -0
  72. package/src/lib/runbookModel.ts +3 -3
  73. package/src/lib/runbookProjection.test.ts +43 -0
  74. package/src/lib/runbookProjection.ts +26 -6
  75. package/src/store/agentClient.ts +27 -8
  76. package/src/store/apiTree.ts +12 -0
  77. package/src/store/builderStore.test.ts +441 -99
  78. package/src/store/builderStore.ts +383 -312
  79. package/src/store/serverRunner.ts +56 -5
  80. package/src/store/setupClient.ts +7 -2
  81. package/src/store/updateClient.ts +50 -0
  82. package/studio-dist/assets/index-CAIDjNhv.css +1 -0
  83. package/studio-dist/assets/index-CGwEx82J.js +65 -0
  84. package/studio-dist/index.html +2 -2
  85. package/templates/skills/emberflow-model-process/SKILL.md +82 -9
  86. package/templates/skills/emberflow-review-workflow/SKILL.md +37 -1
  87. package/studio-dist/assets/index-DNJwW-hM.css +0 -1
  88. package/studio-dist/assets/index-O26dKRjW.js +0 -65
@@ -1,9 +1,11 @@
1
- import { useEffect, useState } from 'react';
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
2
  import {
3
+ ArrowUpIcon,
3
4
  CheckCircle2Icon,
4
5
  CheckIcon,
5
6
  CircleIcon,
6
7
  CopyIcon,
8
+ LoaderCircleIcon,
7
9
  MinusCircleIcon,
8
10
  SparklesIcon,
9
11
  } from 'lucide-react';
@@ -11,24 +13,34 @@ import { cn } from '@/lib/utils';
11
13
  import { Button } from '@/components/ui/button';
12
14
  import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog';
13
15
  import { EnvironmentsDialog } from './EnvironmentsDialog';
16
+ import { AgentStream } from './AgentStream';
14
17
  import { useBuilderStore } from '../store/builderStore';
15
- import { setupProgress, type SetupStatus } from '../store/setupClient';
16
- import type { AgentKind } from '../store/agentClient';
18
+ import type { SetupStatus } from '../store/setupClient';
19
+ import type { AgentEvent, AgentKind } from '../store/agentClient';
20
+ import {
21
+ composeAnsweredSubset,
22
+ extractGuidedQuestions,
23
+ resolveGuidedAnswers,
24
+ type GuidedAnswers,
25
+ type GuidedQuestion,
26
+ } from '../lib/guidedQuestions';
27
+
28
+ const WELCOME_DISMISSED_KEY = 'emberflow.welcome.dismissed';
29
+ /** Re-run this exact command to (re)install the agent skills only. */
30
+ const SKILLS_INSTALL_COMMAND = 'npx emberflow init --local --no-launch';
31
+ /** Initialize a repo so agent changes can be snapshotted/reverted. */
32
+ const GIT_INIT_COMMAND = 'git init && git add -A && git commit -m "initial"';
17
33
 
18
34
  const AGENT_LABELS: Record<AgentKind, string> = {
19
35
  codex: 'Codex',
20
36
  claude: 'Claude',
21
37
  };
22
38
 
23
- const WELCOME_DISMISSED_KEY = 'emberflow.welcome.dismissed';
24
- /** Re-run this exact command to (re)install the agent skills only. */
25
- const SKILLS_INSTALL_COMMAND = 'npx emberflow init --local --no-launch';
26
-
27
39
  type RowStatus = 'complete' | 'incomplete' | 'unavailable';
28
40
 
29
- function StatusGlyph({ status }: { status: RowStatus }) {
41
+ function StatusGlyph({ status, emphasized }: { status: RowStatus; emphasized?: boolean }) {
30
42
  if (status === 'complete') {
31
- return <CheckCircle2Icon className="mt-0.5 size-4 shrink-0 text-success" aria-label="done" />;
43
+ return <CheckCircle2Icon className="mt-0.5 size-4 shrink-0 text-success/80" aria-label="done" />;
32
44
  }
33
45
  if (status === 'unavailable') {
34
46
  return (
@@ -40,36 +52,78 @@ function StatusGlyph({ status }: { status: RowStatus }) {
40
52
  }
41
53
  return (
42
54
  <CircleIcon
43
- className="mt-0.5 size-4 shrink-0 text-muted-foreground/50"
55
+ className={`mt-0.5 size-4 shrink-0 ${emphasized ? 'text-highlight' : 'text-muted-foreground/50'}`}
44
56
  aria-label="to do"
45
57
  />
46
58
  );
47
59
  }
48
60
 
49
- function SectionTitle({ children }: { children: React.ReactNode }) {
61
+ /** Header fraction + 2px progress track. Quiet: mono numbers, ember fill. */
62
+ function Progress({ done, total }: { done: number; total: number }) {
50
63
  return (
51
- <div className="text-[10px] font-medium uppercase tracking-widest text-muted-foreground">
52
- {children}
64
+ <div className="flex items-center gap-2.5" aria-label={`${done} of ${total} steps done`}>
65
+ <div className="h-0.5 flex-1 overflow-hidden rounded-full bg-secondary/60">
66
+ <div
67
+ className="h-full rounded-full bg-highlight transition-[width] duration-300 ease-out motion-reduce:transition-none"
68
+ style={{ width: `${total === 0 ? 0 : Math.round((done / total) * 100)}%` }}
69
+ />
70
+ </div>
71
+ <span className="shrink-0 font-mono text-[10.5px] tabular-nums text-muted-foreground">
72
+ {done}/{total}
73
+ </span>
53
74
  </div>
54
75
  );
55
76
  }
56
77
 
78
+ /**
79
+ * One checklist row. Completed rows compress to a single dim line (title +
80
+ * inline action link) so the eye lands on what's left; the NEXT incomplete row
81
+ * is emphasized (tinted, primary action). Everything stays reachable.
82
+ */
57
83
  function ChecklistRow({
58
84
  status,
85
+ emphasized = false,
59
86
  title,
60
87
  detail,
88
+ completedDetail,
89
+ completedExtra,
61
90
  action,
62
91
  children,
63
92
  }: {
64
93
  status: RowStatus;
94
+ emphasized?: boolean;
65
95
  title: string;
66
96
  detail: string;
97
+ completedDetail?: string;
98
+ /** Replaces `completedDetail` in the compressed done row — e.g. the agent
99
+ * picker, which stays interactive even once the row is complete. */
100
+ completedExtra?: React.ReactNode;
67
101
  action?: React.ReactNode;
68
102
  children?: React.ReactNode;
69
103
  }) {
104
+ if (status === 'complete') {
105
+ return (
106
+ <div className="flex items-center gap-2.5 px-2.5 py-1.5">
107
+ <StatusGlyph status={status} />
108
+ <span className="text-[12px] text-muted-foreground">{title}</span>
109
+ {completedExtra ?? (
110
+ completedDetail ? (
111
+ <span className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground/60">
112
+ {completedDetail}
113
+ </span>
114
+ ) : (
115
+ <span className="flex-1" />
116
+ )
117
+ )}
118
+ {action && <div className="shrink-0">{action}</div>}
119
+ </div>
120
+ );
121
+ }
70
122
  return (
71
- <div className="flex items-start gap-2.5 rounded-md border border-border/70 px-2.5 py-2">
72
- <StatusGlyph status={status} />
123
+ <div
124
+ className={`flex items-start gap-2.5 px-2.5 py-2.5 ${emphasized ? 'bg-highlight/[0.06]' : ''}`}
125
+ >
126
+ <StatusGlyph status={status} emphasized={emphasized} />
73
127
  <div className="flex min-w-0 flex-1 flex-col gap-1">
74
128
  <span className="text-[12.5px] font-medium leading-tight">{title}</span>
75
129
  <span className="text-[11px] leading-snug text-muted-foreground">{detail}</span>
@@ -80,7 +134,9 @@ function ChecklistRow({
80
134
  );
81
135
  }
82
136
 
83
- function CopyCommand({ command }: { command: string }) {
137
+ /** Click-to-copy command pill. Shared: checklist rows here, and the
138
+ * StatusBar update chip's restart/manual-install commands. */
139
+ export function CopyCommand({ command }: { command: string }) {
84
140
  const [copied, setCopied] = useState(false);
85
141
  return (
86
142
  <button
@@ -125,176 +181,820 @@ const NOOP_ACTIONS: WelcomeChecklistActions = {
125
181
  * from props — the component test drives it across fresh/configured/no-agent
126
182
  * states without a live runner.
127
183
  */
184
+ interface ChecklistItem {
185
+ key: string;
186
+ status: RowStatus;
187
+ title: string;
188
+ detail: string;
189
+ completedDetail?: string;
190
+ /** Label + handler for the row's action; the emphasized row's becomes primary. */
191
+ actionLabel?: string;
192
+ actionSparkle?: boolean;
193
+ actionDisabled?: boolean;
194
+ actionDisabledReason?: string;
195
+ onAction?: () => void;
196
+ extra?: 'skills-command' | 'manage-manually' | 'git-command';
197
+ }
198
+
199
+ /** Derive the checklist rows once — the dialog reuses this for its footer CTA. */
200
+ export function deriveChecklist(status: SetupStatus, actions: WelcomeChecklistActions): ChecklistItem[] {
201
+ const { agents, git, environments, skills, ops, infrastructure } = status;
202
+ const hasAgent = agents.length > 0;
203
+ const hasGit = git.repo;
204
+ const skillsInstalled = skills.claude || skills.codex;
205
+ const agentSummary = agents.map((a) => `${a.kind}${a.version ? ` ${a.version}` : ''}`).join(', ');
206
+
207
+ return [
208
+ {
209
+ key: 'git',
210
+ status: hasGit ? 'complete' : 'incomplete',
211
+ title: 'Git repository',
212
+ detail: 'Agent changes are snapshotted with git so you can review and revert them.',
213
+ completedDetail: 'Repository ready',
214
+ extra: hasGit ? undefined : 'git-command',
215
+ },
216
+ {
217
+ key: 'agent',
218
+ status: hasAgent ? 'complete' : 'incomplete',
219
+ title: 'Coding agent',
220
+ detail: 'None detected on PATH — install codex or claude to let the agent build for you.',
221
+ completedDetail: agentSummary,
222
+ actionLabel: 'Settings',
223
+ onAction: actions.onOpenSettings,
224
+ },
225
+ // Skills sit with git + agent: all three are satisfied by `emberflow init`,
226
+ // so a fresh install reads as a solid completed block at the top — the
227
+ // remaining rows below are the journey still ahead. (No dependency forces
228
+ // skills after environments; the old ordering just interleaved done/todo.)
229
+ {
230
+ key: 'skills',
231
+ status: skillsInstalled ? 'complete' : 'incomplete',
232
+ title: 'Agent skills',
233
+ detail:
234
+ 'Installed automatically by emberflow init — this project doesn’t have them yet. Add them (skills only, nothing else changes):',
235
+ completedDetail: 'Installed',
236
+ extra: skillsInstalled ? undefined : 'skills-command',
237
+ },
238
+ {
239
+ key: 'environments',
240
+ status: environments.configured ? 'complete' : 'incomplete',
241
+ title: 'Environments',
242
+ detail:
243
+ "You're in Mock — nothing real is touched. Point runs at real systems when you're ready.",
244
+ completedDetail:
245
+ `${environments.count} environment${environments.count === 1 ? '' : 's'}` +
246
+ (environments.protectedCount > 0 ? `, ${environments.protectedCount} protected` : ''),
247
+ actionLabel: environments.configured ? 'Manage' : 'Set up with AI',
248
+ actionSparkle: !environments.configured,
249
+ // The "Set up with AI" action drives the coding agent, which needs both
250
+ // an agent on PATH and a git repo to snapshot against — gate it the same
251
+ // way scout is gated. Git is the earlier prerequisite, so its reason
252
+ // wins when both are missing.
253
+ actionDisabled: !environments.configured && (!hasGit || !hasAgent),
254
+ actionDisabledReason: !hasGit ? 'Initialize git first' : 'Detect a coding agent first',
255
+ onAction: environments.configured ? actions.onOpenEnvironments : actions.onSetupEnvironments,
256
+ extra: environments.configured ? undefined : 'manage-manually',
257
+ },
258
+ // NOTE: secrets & auth deliberately have no checklist row — they only
259
+ // matter once an operation touches real infrastructure, and they live in
260
+ // the Manage Environment dialog when that day comes. Surfacing them at
261
+ // onboarding was premature noise.
262
+ {
263
+ key: 'infrastructure',
264
+ status: infrastructure.present ? 'complete' : 'incomplete',
265
+ title: 'Project scanned',
266
+ detail: 'The agent scans this project for databases, APIs and providers it already uses.',
267
+ completedDetail: `${infrastructure.itemCount ?? 0} item${infrastructure.itemCount === 1 ? '' : 's'} found`,
268
+ actionLabel: 'Scout',
269
+ actionSparkle: true,
270
+ // Scout drives the coding agent, which needs both an agent on PATH and a
271
+ // git repo to snapshot against. Git is the earlier prerequisite, so its
272
+ // reason wins when both are missing.
273
+ actionDisabled: !hasGit || !hasAgent,
274
+ actionDisabledReason: !hasGit ? 'Initialize git first' : 'Detect a coding agent first',
275
+ onAction: actions.onScoutInfrastructure,
276
+ },
277
+ {
278
+ key: 'first-op',
279
+ status: ops.count > 1 ? 'complete' : 'incomplete',
280
+ title: 'First operation',
281
+ detail: 'Open the hello example to see how an operation is built — then run it.',
282
+ completedDetail: `${ops.count} operations`,
283
+ actionLabel: 'Open hello op',
284
+ onAction: actions.onOpenHelloOp,
285
+ },
286
+ ];
287
+ }
288
+
289
+ /** First incomplete row = the step we pull the user toward. */
290
+ export const nextStepIndex = (items: ChecklistItem[]) =>
291
+ items.findIndex((i) => i.status === 'incomplete');
292
+
293
+ /** Inline codex/claude choice chips — shared by the checklist's agent row and
294
+ * the guided card (where they must stay reachable while the checklist is
295
+ * collapsed behind the manual disclosure). */
296
+ function AgentPickerChips({
297
+ agents,
298
+ activeAgent,
299
+ onChoose,
300
+ }: {
301
+ agents: SetupStatus['agents'];
302
+ activeAgent?: AgentKind;
303
+ onChoose: (kind: AgentKind) => void;
304
+ }) {
305
+ return (
306
+ <div className="flex min-w-0 flex-1 flex-wrap gap-1" role="group" aria-label="Choose coding agent">
307
+ {agents.map(({ kind, version }) => (
308
+ <button
309
+ key={kind}
310
+ type="button"
311
+ onClick={() => onChoose(kind)}
312
+ aria-pressed={activeAgent === kind}
313
+ className={cn(
314
+ 'cursor-pointer rounded-md border px-2 py-0.5 text-[11px] transition-colors',
315
+ activeAgent === kind
316
+ ? 'border-ring bg-secondary/60 font-medium text-foreground'
317
+ : 'border-border text-muted-foreground hover:text-foreground',
318
+ )}
319
+ >
320
+ {AGENT_LABELS[kind]}
321
+ {version ? <span className="ml-1 font-normal text-muted-foreground">{version}</span> : null}
322
+ </button>
323
+ ))}
324
+ </div>
325
+ );
326
+ }
327
+
128
328
  export function WelcomeChecklist({
129
329
  status,
130
330
  actions = NOOP_ACTIONS,
131
331
  chosenAgent,
132
332
  onChooseAgent,
333
+ showProgress = true,
133
334
  }: {
134
335
  status: SetupStatus;
135
336
  actions?: WelcomeChecklistActions;
136
337
  /** The user's preferred agent (from Settings); defaults to the first detected. */
137
338
  chosenAgent?: AgentKind;
138
339
  onChooseAgent?: (kind: AgentKind) => void;
340
+ /** The idle screen renders its own top-level progress bar — suppress this one there. */
341
+ showProgress?: boolean;
139
342
  }) {
140
- const { agents, environments, skills, ops, infrastructure } = status;
141
- const hasAgent = agents.length > 0;
142
- const skillsInstalled = skills.claude || skills.codex;
143
- // With more than one agent CLI on PATH the row becomes a choice, not a fact.
144
- const multipleAgents = agents.length > 1 && onChooseAgent !== undefined;
145
- const activeAgent = chosenAgent ?? agents[0]?.kind;
146
-
147
- const agentDetail = hasAgent
148
- ? multipleAgents
149
- ? 'Both detected pick which one builds for you (changeable in Settings).'
150
- : agents.map((a) => `${a.kind}${a.version ? ` ${a.version}` : ''}`).join(', ')
151
- : 'None detected on PATH — install codex or claude to let the agent build for you.';
152
-
153
- const envDetail = environments.configured
154
- ? `${environments.count} environment${environments.count === 1 ? '' : 's'}` +
155
- (environments.protectedCount > 0 ? `, ${environments.protectedCount} protected` : '')
156
- : "You're in Mock — nothing real is touched. Set up environments to point runs at real systems.";
157
-
158
- const secretsStatus: RowStatus = !environments.configured
159
- ? 'unavailable'
160
- : environments.anyAuthConfigured
161
- ? 'complete'
162
- : 'incomplete';
163
- const secretsDetail = !environments.configured
164
- ? 'Configure environments first — secrets and auth live per environment.'
165
- : environments.anyAuthConfigured
166
- ? 'Auth configured on at least one environment.'
167
- : 'Add the secrets and login your operations need.';
168
-
169
- const progress = setupProgress(status);
343
+ const items = deriveChecklist(status, actions);
344
+ const done = items.filter((i) => i.status === 'complete').length;
345
+ const nextIdx = nextStepIndex(items);
346
+
347
+ // With more than one agent CLI on PATH the agent row becomes a choice, not a
348
+ // fact: an inline picker replaces the compressed summary and stays live.
349
+ const multipleAgents = status.agents.length > 1 && onChooseAgent !== undefined;
350
+ const activeAgent = chosenAgent ?? status.agents[0]?.kind;
351
+ const agentPicker = multipleAgents ? (
352
+ <AgentPickerChips agents={status.agents} activeAgent={activeAgent} onChoose={onChooseAgent} />
353
+ ) : undefined;
170
354
 
171
355
  return (
172
- <div className="space-y-2.5">
173
- <div className="flex items-baseline justify-between">
174
- <SectionTitle>Setup checklist</SectionTitle>
175
- <span className="text-[10.5px] tabular-nums text-muted-foreground">
176
- {progress.done}/{progress.total} done
177
- </span>
178
- </div>
179
- <div className="flex flex-col gap-1.5">
180
- <ChecklistRow
181
- status={hasAgent ? 'complete' : 'incomplete'}
182
- title="Coding agent detected"
183
- detail={agentDetail}
184
- action={
185
- <Button variant="outline" size="sm" onClick={actions.onOpenSettings}>
186
- Settings
187
- </Button>
188
- }
189
- >
190
- {multipleAgents && (
191
- <div className="mt-0.5 flex gap-1 self-start" role="group" aria-label="Choose coding agent">
192
- {agents.map(({ kind, version }) => (
356
+ <div className="space-y-3">
357
+ {showProgress && <Progress done={done} total={items.length} />}
358
+ <div className="divide-y divide-border/50 rounded-md border border-border/70">
359
+ {items.map((item, i) => {
360
+ const emphasized = i === nextIdx;
361
+ return (
362
+ <ChecklistRow
363
+ key={item.key}
364
+ status={item.status}
365
+ emphasized={emphasized}
366
+ title={item.title}
367
+ detail={item.detail}
368
+ completedDetail={item.completedDetail}
369
+ completedExtra={item.key === 'agent' ? agentPicker : undefined}
370
+ action={
371
+ item.actionLabel && item.onAction ? (
372
+ item.status === 'complete' ? (
373
+ <button
374
+ type="button"
375
+ onClick={item.onAction}
376
+ className="text-[11px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
377
+ >
378
+ {item.actionLabel}
379
+ </button>
380
+ ) : (
381
+ <Button
382
+ variant={emphasized ? 'default' : 'outline'}
383
+ size="sm"
384
+ disabled={item.actionDisabled}
385
+ title={item.actionDisabled ? item.actionDisabledReason : undefined}
386
+ onClick={item.onAction}
387
+ >
388
+ {item.actionSparkle && <SparklesIcon className="size-3.5" />}
389
+ {item.actionLabel}
390
+ </Button>
391
+ )
392
+ ) : undefined
393
+ }
394
+ >
395
+ {item.status !== 'complete' && item.extra === 'git-command' && (
396
+ <CopyCommand command={GIT_INIT_COMMAND} />
397
+ )}
398
+ {item.status !== 'complete' && item.extra === 'skills-command' && (
399
+ <CopyCommand command={SKILLS_INSTALL_COMMAND} />
400
+ )}
401
+ {item.status !== 'complete' && item.extra === 'manage-manually' && (
193
402
  <button
194
- key={kind}
195
403
  type="button"
196
- onClick={() => onChooseAgent(kind)}
197
- aria-pressed={activeAgent === kind}
198
- className={cn(
199
- 'cursor-pointer rounded-md border px-2 py-1 text-[11.5px] transition-colors',
200
- activeAgent === kind
201
- ? 'border-ring bg-secondary/60 font-medium text-foreground'
202
- : 'border-border text-muted-foreground hover:text-foreground',
203
- )}
404
+ onClick={actions.onOpenEnvironments}
405
+ className="self-start text-[10.5px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
204
406
  >
205
- {AGENT_LABELS[kind]}
206
- {version ? <span className="ml-1 font-normal text-muted-foreground">{version}</span> : null}
407
+ Manage manually
207
408
  </button>
208
- ))}
209
- </div>
210
- )}
211
- </ChecklistRow>
212
-
213
- <ChecklistRow
214
- status={environments.configured ? 'complete' : 'incomplete'}
215
- title="Environments configured"
216
- detail={envDetail}
217
- action={
218
- <div className="flex flex-col items-end gap-1">
219
- <Button size="sm" onClick={actions.onSetupEnvironments}>
220
- <SparklesIcon className="size-3.5" />
221
- Set up with AI
222
- </Button>
223
- <button
224
- type="button"
225
- onClick={actions.onOpenEnvironments}
226
- className="text-[10.5px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
227
- >
228
- Manage manually
229
- </button>
230
- </div>
231
- }
232
- />
409
+ )}
410
+ </ChecklistRow>
411
+ );
412
+ })}
413
+ </div>
414
+ </div>
415
+ );
416
+ }
233
417
 
234
- <ChecklistRow
235
- status={secretsStatus}
236
- title="Secrets & auth"
237
- detail={secretsDetail}
238
- action={
239
- <Button
240
- variant="outline"
241
- size="sm"
242
- disabled={!environments.configured}
243
- onClick={actions.onOpenEnvironments}
244
- >
245
- Open
246
- </Button>
247
- }
248
- />
418
+ /**
419
+ * Footer: the primary CTA mirrors the NEXT incomplete step (same handler as the
420
+ * emphasized row), so the strongest affordance always advances setup. All
421
+ * steps done → pull toward the real aha: building the first API.
422
+ * "Don't show again" is the true dismissal (records the flag).
423
+ */
424
+ function WelcomeFooter({
425
+ status,
426
+ actions,
427
+ onDismiss,
428
+ manualOpen,
429
+ onToggleManual,
430
+ }: {
431
+ status: SetupStatus | null;
432
+ actions: WelcomeChecklistActions;
433
+ onDismiss: () => void;
434
+ manualOpen?: boolean;
435
+ onToggleManual?: () => void;
436
+ }) {
437
+ const setCreateModal = useBuilderStore((s) => s.setCreateModal);
438
+ const setWelcomeOpen = useBuilderStore((s) => s.setWelcomeOpen);
439
+ const items = status ? deriveChecklist(status, actions) : [];
440
+ const nextIdx = status ? nextStepIndex(items) : -1;
441
+ const next = nextIdx === -1 ? undefined : items[nextIdx];
249
442
 
250
- <ChecklistRow
251
- status={skillsInstalled ? 'complete' : 'incomplete'}
252
- title="Agent skills installed"
253
- detail={
254
- skillsInstalled
255
- ? 'emberflow-basics is installed for your agent.'
256
- : 'Installed automatically by emberflow init — this project doesn’t have them yet. Add them (skills only, nothing else changes):'
257
- }
443
+ return (
444
+ <div className="flex items-center justify-between">
445
+ <div className="flex items-center gap-4">
446
+ <button
447
+ type="button"
448
+ onClick={onDismiss}
449
+ className="text-[11px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
258
450
  >
259
- {!skillsInstalled && <CopyCommand command={SKILLS_INSTALL_COMMAND} />}
260
- </ChecklistRow>
261
-
262
- <ChecklistRow
263
- status={infrastructure.present ? 'complete' : 'incomplete'}
264
- title="Infrastructure scouted"
265
- detail={
266
- infrastructure.present
267
- ? `${infrastructure.itemCount ?? 0} item${infrastructure.itemCount === 1 ? '' : 's'} discovered.`
268
- : 'Have the agent scan this project for databases, APIs and providers it already uses.'
269
- }
270
- action={
271
- <Button
272
- variant="outline"
273
- size="sm"
274
- disabled={!hasAgent}
275
- title={hasAgent ? undefined : 'Detect a coding agent first'}
276
- onClick={actions.onScoutInfrastructure}
451
+ Don't show this again
452
+ </button>
453
+ {onToggleManual && (
454
+ <button
455
+ type="button"
456
+ onClick={onToggleManual}
457
+ aria-expanded={manualOpen}
458
+ className="text-[11px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
459
+ >
460
+ {manualOpen ? 'Hide manual steps' : 'Set up manually instead'}
461
+ </button>
462
+ )}
463
+ </div>
464
+ {/* The emphasized row owns the next-step primary; the footer only takes
465
+ over once everything is done — pulling toward the real aha. One door:
466
+ the create modal. */}
467
+ {status && !next && (
468
+ <Button
469
+ size="sm"
470
+ onClick={() => {
471
+ setWelcomeOpen(false);
472
+ setCreateModal({ mode: 'api' });
473
+ }}
474
+ >
475
+ <SparklesIcon className="size-3.5" />
476
+ You're set — build your first API
477
+ </Button>
478
+ )}
479
+ </div>
480
+ );
481
+ }
482
+
483
+ /**
484
+ * The Start-setup gate + summary, derived from the SAME checklist state the rows
485
+ * use. `remaining` names what the one guided run will work through (skipping
486
+ * anything already done); git + a coding agent are the two prerequisites the
487
+ * user must satisfy manually, so their absence disables Start with a reason.
488
+ */
489
+ function guidedStartState(status: SetupStatus): {
490
+ remaining: string[];
491
+ disabled: boolean;
492
+ disabledReason?: string;
493
+ } {
494
+ const hasAgent = status.agents.length > 0;
495
+ const hasGit = status.git.repo;
496
+ const skillsInstalled = status.skills.claude || status.skills.codex;
497
+
498
+ const remaining: string[] = [];
499
+ if (!status.infrastructure.present) remaining.push('Scan your code for existing infrastructure');
500
+ if (!status.environments.configured) remaining.push('Set up environments — it asks you a few questions');
501
+ if (!skillsInstalled) remaining.push('Install the agent skills');
502
+ remaining.push('Verify the skills and its own connection');
503
+
504
+ return {
505
+ remaining,
506
+ disabled: !hasGit || !hasAgent,
507
+ // Git is the earlier prerequisite, so its reason wins when both are missing
508
+ // (mirrors the scout/environments row gating).
509
+ disabledReason: !hasGit ? 'Initialize git first' : !hasAgent ? 'Detect a coding agent first' : undefined,
510
+ };
511
+ }
512
+
513
+ /**
514
+ * idle-phase primary block: "Let the agent set this up." A short summary of what
515
+ * the one run will do (from `guidedStartState`) plus the Start-setup CTA. Sits
516
+ * ABOVE the manual checklist — the guided path is primary, the rows stay as the
517
+ * manual fallback.
518
+ */
519
+ export function GuidedSetupIntro({
520
+ status,
521
+ chosenAgent,
522
+ onChooseAgent,
523
+ onStart,
524
+ }: {
525
+ status: SetupStatus;
526
+ chosenAgent?: AgentKind;
527
+ onChooseAgent?: (kind: AgentKind) => void;
528
+ onStart: () => void;
529
+ }) {
530
+ const { remaining, disabled, disabledReason } = guidedStartState(status);
531
+ const multipleAgents = status.agents.length > 1 && onChooseAgent !== undefined;
532
+ const activeAgent = chosenAgent ?? status.agents[0]?.kind;
533
+ return (
534
+ <div className="rounded-md border border-highlight/30 bg-highlight/[0.06] p-3.5">
535
+ <div className="flex items-start gap-2.5">
536
+ <span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full bg-highlight/15 text-highlight">
537
+ <SparklesIcon className="size-4" />
538
+ </span>
539
+ <div className="min-w-0 flex-1">
540
+ <div className="text-[13px] font-medium leading-tight">Let the agent set this up</div>
541
+ <p className="mt-1 text-[11.5px] leading-snug text-muted-foreground">
542
+ One run finishes setup — it reads what's already done and skips it.
543
+ </p>
544
+ {/* The steps the run will work through: the same rows that tick live
545
+ in the two-pane view once started — the list IS the plan. */}
546
+ <ul className="mt-2.5 space-y-1.5">
547
+ {remaining.map((step) => (
548
+ <li key={step} className="flex items-center gap-2 text-[11.5px] text-foreground/85">
549
+ <CircleIcon className="size-3.5 shrink-0 text-highlight/60" aria-hidden />
550
+ {step}
551
+ </li>
552
+ ))}
553
+ </ul>
554
+ </div>
555
+ </div>
556
+ <div className="mt-3.5 flex items-center justify-between gap-3">
557
+ {multipleAgents ? (
558
+ <div className="flex min-w-0 items-center gap-2">
559
+ <span className="shrink-0 text-[10.5px] text-muted-foreground">Runs with</span>
560
+ <AgentPickerChips agents={status.agents} activeAgent={activeAgent} onChoose={onChooseAgent} />
561
+ </div>
562
+ ) : (
563
+ <span />
564
+ )}
565
+ <Button
566
+ size="sm"
567
+ className="shrink-0"
568
+ disabled={disabled}
569
+ title={disabled ? disabledReason : undefined}
570
+ onClick={onStart}
571
+ >
572
+ <SparklesIcon className="size-3.5" />
573
+ Start setup
574
+ </Button>
575
+ </div>
576
+ {disabled && disabledReason && (
577
+ <p className="mt-1.5 text-right text-[10.5px] text-muted-foreground/70">{disabledReason}</p>
578
+ )}
579
+ </div>
580
+ );
581
+ }
582
+
583
+ /**
584
+ * Read-only checklist for the guided two-pane view: while the agent works
585
+ * through setup, the left pane is a quiet progress MAP — glyph + title +
586
+ * completed detail, one line per step. No buttons: the interactive actions
587
+ * (Set up with AI, Scout, …) are exactly what the agent is doing, and
588
+ * clicking them mid-run would just collide with the single-flight run.
589
+ */
590
+ export function GuidedChecklistMap({ status, running }: { status: SetupStatus; running?: boolean }) {
591
+ const items = deriveChecklist(status, NOOP_ACTIONS);
592
+ const done = items.filter((i) => i.status === 'complete').length;
593
+ const nextIdx = nextStepIndex(items);
594
+ return (
595
+ <div className="space-y-3">
596
+ <Progress done={done} total={items.length} />
597
+ <div className="space-y-0.5">
598
+ {items.map((item, i) => (
599
+ <div
600
+ key={item.key}
601
+ className={cn(
602
+ 'flex min-w-0 items-center gap-2.5 rounded-md px-2 py-1.5',
603
+ i === nextIdx && 'bg-highlight/[0.06]',
604
+ )}
605
+ >
606
+ {/* While the agent runs, the NEXT step is the one being worked on —
607
+ its glyph becomes a spinner so the left map shows live activity.
608
+ Reduced motion: the spin stops but the ember ring still marks it. */}
609
+ {running && i === nextIdx ? (
610
+ <LoaderCircleIcon
611
+ className="mt-0.5 size-4 shrink-0 animate-spin text-highlight motion-reduce:animate-none"
612
+ aria-label="in progress"
613
+ />
614
+ ) : (
615
+ <StatusGlyph status={item.status} emphasized={i === nextIdx} />
616
+ )}
617
+ <span
618
+ className={cn(
619
+ 'shrink-0 text-[12px]',
620
+ item.status === 'complete'
621
+ ? 'text-muted-foreground'
622
+ : i === nextIdx
623
+ ? 'font-medium text-foreground'
624
+ : 'text-muted-foreground',
625
+ )}
277
626
  >
278
- <SparklesIcon className="size-3.5" />
279
- Scout
280
- </Button>
281
- }
627
+ {item.title}
628
+ </span>
629
+ {item.status === 'complete' && item.completedDetail && (
630
+ <span className="min-w-0 truncate text-[11px] text-muted-foreground/60">
631
+ {item.completedDetail}
632
+ </span>
633
+ )}
634
+ </div>
635
+ ))}
636
+ </div>
637
+ </div>
638
+ );
639
+ }
640
+
641
+ /** One dim line acknowledging what's already in place — proof of progress
642
+ * without a row (and a button) per item. */
643
+ export function DoneSummary({ status }: { status: SetupStatus }) {
644
+ const items = deriveChecklist(status, NOOP_ACTIONS).filter((i) => i.status === 'complete');
645
+ if (items.length === 0) return null;
646
+ return (
647
+ <div className="flex items-center gap-2 px-0.5 text-[11px] text-muted-foreground">
648
+ <CheckCircle2Icon className="size-3.5 shrink-0 text-success/70" aria-label="done" />
649
+ <span className="min-w-0 truncate">
650
+ {items.map((i) => (i.key === 'agent' ? i.completedDetail || i.title : i.title)).join(' · ')}
651
+ </span>
652
+ </div>
653
+ );
654
+ }
655
+
656
+ /**
657
+ * A slim follow-up input pinned under the embedded stream: answers the agent's
658
+ * questions by sending another guided-setup run (same panel thread). Disabled
659
+ * while the run is working. `focusRef` lets the done-phase "Continue setup" CTA
660
+ * pull focus here.
661
+ */
662
+ function GuidedFollowUp({
663
+ running,
664
+ onSend,
665
+ focusRef,
666
+ }: {
667
+ running: boolean;
668
+ onSend: (text: string) => void;
669
+ focusRef?: React.RefObject<HTMLTextAreaElement | null>;
670
+ }) {
671
+ const [text, setText] = useState('');
672
+ const send = () => {
673
+ const t = text.trim();
674
+ if (!t || running) return;
675
+ onSend(t);
676
+ setText('');
677
+ };
678
+ return (
679
+ <div className="shrink-0 border-t border-border/70 p-2.5">
680
+ <div className="flex items-end gap-2">
681
+ <textarea
682
+ ref={focusRef}
683
+ value={text}
684
+ onChange={(e) => setText(e.target.value)}
685
+ onKeyDown={(e) => {
686
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
687
+ e.preventDefault();
688
+ send();
689
+ }
690
+ }}
691
+ disabled={running}
692
+ placeholder={running ? 'Agent is working…' : 'Answer the agent’s questions…'}
693
+ rows={2}
694
+ className="min-h-0 flex-1 resize-none rounded-md border border-input bg-input/30 px-2.5 py-1.5 text-[12px] leading-relaxed text-foreground outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-60"
282
695
  />
696
+ <Button
697
+ size="icon"
698
+ className="size-8 shrink-0"
699
+ onClick={send}
700
+ disabled={running || !text.trim()}
701
+ aria-label="Send to agent"
702
+ title="Send (⌘⏎)"
703
+ >
704
+ <ArrowUpIcon className="size-4" />
705
+ </Button>
706
+ </div>
707
+ </div>
708
+ );
709
+ }
283
710
 
284
- <ChecklistRow
285
- status={ops.count > 1 ? 'complete' : 'incomplete'}
286
- title="First operation"
287
- detail={
288
- ops.count > 1
289
- ? "You've built beyond the hello example."
290
- : 'Only the hello example so far open it to see how an operation is built.'
291
- }
292
- action={
293
- <Button variant="outline" size="sm" onClick={actions.onOpenHelloOp}>
294
- Open hello op
711
+ /**
712
+ * The agent's trailing `emberflow-questions` block rendered as a clickable
713
+ * form (replacing the follow-up textarea while the run waits on answers).
714
+ * Single-select pills per question — the same chip vocabulary as the agent
715
+ * picker — plus an optional "Other…" free-text field that deselects the pills.
716
+ * Submit composes plaintext answers for the continuation run; an option with
717
+ * action 'finish' ends onboarding instead (`onFinish`), sending nothing; a
718
+ * lone typed 'first-build' answer routes into the create flow (`onBuild`).
719
+ */
720
+ function GuidedQuestionForm({
721
+ questions,
722
+ onSubmit,
723
+ onFinish,
724
+ onBuild,
725
+ onTypeInstead,
726
+ }: {
727
+ questions: GuidedQuestion[];
728
+ onSubmit: (composed: string) => void;
729
+ onFinish: () => void;
730
+ onBuild: (text: string) => void;
731
+ onTypeInstead: () => void;
732
+ }) {
733
+ const [answers, setAnswers] = useState<GuidedAnswers>({});
734
+ // Wizard: ONE question at a time — picking an option answers it and
735
+ // advances; the last answer reveals Send. `cursor` never exceeds the last
736
+ // question; Back steps to any earlier answer without losing later ones.
737
+ const [cursor, setCursor] = useState(0);
738
+ const outcome = resolveGuidedAnswers(questions, answers);
739
+ const q = questions[Math.min(cursor, questions.length - 1)];
740
+ const answer = answers[q.id];
741
+ const last = cursor >= questions.length - 1;
742
+ const advance = () => setCursor((c) => Math.min(c + 1, questions.length - 1));
743
+ const submit = () => {
744
+ if (outcome.kind === 'finish') onFinish();
745
+ else if (outcome.kind === 'build') onBuild(outcome.text);
746
+ else if (outcome.kind === 'send') onSubmit(outcome.text);
747
+ };
748
+ return (
749
+ <div className="shrink-0 space-y-3 border-t border-border/70 p-3.5">
750
+ <div className="flex items-baseline justify-between gap-3">
751
+ <div className="text-[13.5px] font-medium leading-snug">{q.text}</div>
752
+ {questions.length > 1 && (
753
+ <span className="shrink-0 font-mono text-[10.5px] tabular-nums text-muted-foreground">
754
+ {cursor + 1}/{questions.length}
755
+ </span>
756
+ )}
757
+ </div>
758
+ {q.why && (
759
+ <p className="max-w-[65ch] text-[11.5px] leading-snug text-muted-foreground">{q.why}</p>
760
+ )}
761
+ <div className="flex flex-wrap items-center gap-1.5" role="group" aria-label={q.text}>
762
+ {q.options.map((opt) => {
763
+ const selected = answer?.option?.label === opt.label;
764
+ return (
765
+ <button
766
+ key={opt.label}
767
+ type="button"
768
+ aria-pressed={selected}
769
+ onClick={() => {
770
+ const next: GuidedAnswers = { ...answers, [q.id]: { option: opt } };
771
+ setAnswers(next);
772
+ // A 'submit' option sends IMMEDIATELY with whatever has been
773
+ // answered so far — a subset; unanswered questions are skipped.
774
+ if (opt.action === 'submit') onSubmit(composeAnsweredSubset(questions, next));
775
+ else if (!last) advance();
776
+ }}
777
+ className={cn(
778
+ 'cursor-pointer rounded-md border px-3 py-1.5 text-[12.5px] transition-colors',
779
+ selected
780
+ ? 'border-ring bg-secondary/60 font-medium text-foreground'
781
+ : 'border-border text-foreground/80 hover:border-ring/50 hover:text-foreground',
782
+ )}
783
+ >
784
+ {opt.label}
785
+ </button>
786
+ );
787
+ })}
788
+ </div>
789
+ {q.custom && (
790
+ <div className="flex items-center gap-2">
791
+ <input
792
+ type="text"
793
+ value={answer?.text ?? ''}
794
+ onChange={(e) =>
795
+ // Typing a custom answer deselects the pills for this question.
796
+ setAnswers((a) => ({ ...a, [q.id]: { text: e.target.value } }))
797
+ }
798
+ onKeyDown={(e) => {
799
+ if (e.key === 'Enter' && (answer?.text ?? '').trim()) {
800
+ e.preventDefault();
801
+ if (last) submit();
802
+ else advance();
803
+ }
804
+ }}
805
+ placeholder="Or type your own…"
806
+ aria-label={`${q.text} — other`}
807
+ className="min-w-0 flex-1 rounded-md border border-input bg-input/30 px-2.5 py-1.5 text-[12.5px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
808
+ />
809
+ {!last && (answer?.text ?? '').trim() !== '' && (
810
+ <Button size="sm" variant="outline" onClick={advance}>
811
+ Next
295
812
  </Button>
296
- }
297
- />
813
+ )}
814
+ </div>
815
+ )}
816
+ <div className="flex items-center justify-between gap-3">
817
+ <div className="flex items-center gap-3">
818
+ {cursor > 0 && (
819
+ <button
820
+ type="button"
821
+ onClick={() => setCursor((c) => Math.max(0, c - 1))}
822
+ className="text-[11px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
823
+ >
824
+ Back
825
+ </button>
826
+ )}
827
+ <button
828
+ type="button"
829
+ onClick={onTypeInstead}
830
+ className="text-[11px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
831
+ >
832
+ Type a reply instead
833
+ </button>
834
+ </div>
835
+ {(last || outcome.kind !== 'incomplete') && (
836
+ <Button size="sm" onClick={submit} disabled={outcome.kind === 'incomplete'}>
837
+ Send answers
838
+ </Button>
839
+ )}
840
+ </div>
841
+ </div>
842
+ );
843
+ }
844
+
845
+ /**
846
+ * running/done phase: two panes. LEFT the live checklist (refetched on run
847
+ * finish by the dialog), RIGHT the embedded agent stream + follow-up input.
848
+ * `done` swaps the footer: everything complete → "You're set", else "Continue
849
+ * setup" focuses the follow-up so the interview can continue.
850
+ */
851
+ export function GuidedSetupPanes({
852
+ status,
853
+ actions,
854
+ events,
855
+ running,
856
+ failed,
857
+ onFollowUp,
858
+ onFinishComplete,
859
+ onBuildFirst,
860
+ onContinue,
861
+ onRetry,
862
+ retryAgent,
863
+ onRetryWith,
864
+ notice,
865
+ followUpRef,
866
+ }: {
867
+ status: SetupStatus | null;
868
+ actions: WelcomeChecklistActions;
869
+ events: React.ComponentProps<typeof AgentStream>['events'];
870
+ running: boolean;
871
+ /** The guided run ended on an error event (agent crashed / model rejected). */
872
+ failed?: boolean;
873
+ onFollowUp: (text: string) => void;
874
+ onFinishComplete: () => void;
875
+ /** The lone 'first-build' question answered with a typed description —
876
+ * open the real build flow pre-filled instead of sending a continuation.
877
+ * Falls back to `onFollowUp` (send) when not provided. */
878
+ onBuildFirst?: (text: string) => void;
879
+ onContinue: () => void;
880
+ /** Re-kick the guided run after a failure. */
881
+ onRetry?: () => void;
882
+ /** When the failure was the BACKEND's fault (e.g. a stale CLI rejecting its
883
+ * model) and another agent is detected, retry switches to it — plain
884
+ * "Try again" would just replay the same broken backend. */
885
+ retryAgent?: AgentKind;
886
+ onRetryWith?: (kind: AgentKind) => void;
887
+ /** One-line explanation when the dialog auto-switched backend after a failure. */
888
+ notice?: string;
889
+ followUpRef: React.RefObject<HTMLTextAreaElement | null>;
890
+ }) {
891
+ const allDone = status ? nextStepIndex(deriveChecklist(status, actions)) === -1 : false;
892
+
893
+ // The agent's final message may end with an `emberflow-questions` block —
894
+ // pull it out here (pure derivation over the event stream) so the stream
895
+ // renders the prose WITHOUT the raw fenced JSON and the block becomes the
896
+ // clickable form below. Malformed blocks extract as null and pass through
897
+ // untouched.
898
+ const { displayEvents, questions } = useMemo((): {
899
+ displayEvents: AgentEvent[];
900
+ questions: GuidedQuestion[] | null;
901
+ } => {
902
+ const idx = events.findLastIndex((e) => e.type === 'message');
903
+ if (idx === -1) return { displayEvents: events, questions: null };
904
+ const { stripped, questions: extracted } = extractGuidedQuestions(events[idx].text ?? '');
905
+ if (!extracted) return { displayEvents: events, questions: null };
906
+ const copy = events.slice();
907
+ copy[idx] = { ...copy[idx], text: stripped };
908
+ return { displayEvents: copy, questions: extracted };
909
+ }, [events]);
910
+
911
+ // "Type a reply instead" swaps the form for the plain textarea; a fresh set
912
+ // of questions (next agent turn) resets back to the form.
913
+ const [typedReply, setTypedReply] = useState(false);
914
+ useEffect(() => setTypedReply(false), [questions]);
915
+
916
+ const showForm = !running && !failed && questions !== null && !typedReply;
917
+ const headerText = running
918
+ ? 'Setting things up…'
919
+ : failed
920
+ ? 'Setup hit a problem'
921
+ : allDone
922
+ ? 'Setup complete'
923
+ : 'Waiting on you';
924
+ return (
925
+ <div className="grid min-h-0 grid-cols-1 gap-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)]">
926
+ <div className="min-h-0 overflow-y-auto">
927
+ {status ? (
928
+ <GuidedChecklistMap status={status} running={running} />
929
+ ) : (
930
+ <div className="py-6 text-center text-[12px] text-muted-foreground">Checking your project…</div>
931
+ )}
932
+ </div>
933
+ <div className="flex min-h-0 flex-col overflow-hidden rounded-md border border-border/70 bg-card">
934
+ <div className="flex h-9 shrink-0 items-center gap-2 border-b border-border/70 px-3">
935
+ <span
936
+ className={cn(
937
+ 'size-2 rounded-full',
938
+ running ? 'animate-pulse bg-highlight' : failed ? 'bg-destructive' : 'bg-success',
939
+ )}
940
+ />
941
+ <span className="text-[11.5px] font-medium">{headerText}</span>
942
+ </div>
943
+ <div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto px-3 py-2.5 text-[12px]">
944
+ {notice && <div className="text-[11px] text-highlight/90">{notice}</div>}
945
+ <AgentStream events={displayEvents} running={running} />
946
+ </div>
947
+ {showForm && questions ? (
948
+ <GuidedQuestionForm
949
+ questions={questions}
950
+ onSubmit={onFollowUp}
951
+ onFinish={onFinishComplete}
952
+ onBuild={onBuildFirst ?? onFollowUp}
953
+ onTypeInstead={() => setTypedReply(true)}
954
+ />
955
+ ) : (
956
+ <>
957
+ <GuidedFollowUp running={running} onSend={onFollowUp} focusRef={followUpRef} />
958
+ {!running && !failed && questions !== null && typedReply && (
959
+ <div className="shrink-0 px-2.5 pb-2">
960
+ <button
961
+ type="button"
962
+ onClick={() => setTypedReply(false)}
963
+ className="text-[11px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
964
+ >
965
+ Answer with the options instead
966
+ </button>
967
+ </div>
968
+ )}
969
+ </>
970
+ )}
971
+ {/* When the question form is up, the "Continue setup" nudge is redundant
972
+ — the form IS the continuation — so the footer only renders for the
973
+ failed / all-done / plain-textarea states. */}
974
+ {!running && (failed || allDone || questions === null) && (
975
+ <div className="flex shrink-0 justify-end border-t border-border/70 px-2.5 py-2">
976
+ {failed ? (
977
+ retryAgent && onRetryWith ? (
978
+ <Button size="sm" onClick={() => onRetryWith(retryAgent)}>
979
+ Try again with {AGENT_LABELS[retryAgent]}
980
+ </Button>
981
+ ) : (
982
+ <Button size="sm" variant="outline" onClick={onRetry}>
983
+ Try again
984
+ </Button>
985
+ )
986
+ ) : allDone ? (
987
+ <Button size="sm" onClick={onFinishComplete}>
988
+ <SparklesIcon className="size-3.5" />
989
+ You're set — build your first API
990
+ </Button>
991
+ ) : (
992
+ <Button size="sm" variant="outline" onClick={onContinue}>
993
+ Continue setup
994
+ </Button>
995
+ )}
996
+ </div>
997
+ )}
298
998
  </div>
299
999
  </div>
300
1000
  );
@@ -304,7 +1004,13 @@ export function WelcomeChecklist({
304
1004
  * First-run Welcome/Setup dialog. Mounted once (in the Toolbar). Fetches
305
1005
  * `/setup-status` on mount to decide whether to auto-open on a fresh project,
306
1006
  * and again whenever it's opened so the rows reflect the latest state. Always
307
- * reachable from the Toolbar's Setup button (via the store's `welcomeOpen`).
1007
+ * reachable from the StatusBar's setup chip (via the store's `welcomeOpen`).
1008
+ *
1009
+ * Grows a `idle → running → done` guided phase machine on top of the checklist:
1010
+ * Start setup kicks ONE `guided-setup` agent run (owned here, not the right-hand
1011
+ * console), whose stream embeds in a two-pane layout. Closing the dialog mid-run
1012
+ * doesn't kill it — the run lives in the store's single agentRun slot, so
1013
+ * reopening re-attaches (phase derives from `agentRun.guided` + status).
308
1014
  */
309
1015
  export function WelcomeDialog() {
310
1016
  const welcomeOpen = useBuilderStore((s) => s.welcomeOpen);
@@ -317,7 +1023,62 @@ export function WelcomeDialog() {
317
1023
  const refreshSetupStatus = useBuilderStore((s) => s.refreshSetupStatus);
318
1024
  const agentChoice = useBuilderStore((s) => s.agentChoice);
319
1025
  const setAgentChoice = useBuilderStore((s) => s.setAgentChoice);
1026
+ const beginGuidedSetup = useBuilderStore((s) => s.beginGuidedSetup);
1027
+ const setCreateModal = useBuilderStore((s) => s.setCreateModal);
1028
+ const agentRun = useBuilderStore((s) => s.agentRun);
1029
+ const guidedTranscript = useBuilderStore((s) => s.guidedTranscript);
320
1030
  const [envDialogOpen, setEnvDialogOpen] = useState(false);
1031
+ const [manualOpen, setManualOpen] = useState(false);
1032
+ const followUpRef = useRef<HTMLTextAreaElement | null>(null);
1033
+
1034
+ // Guided phase: derived from the single agentRun slot. `guided` marks the run
1035
+ // this dialog owns, so closing + reopening re-attaches to the same stream.
1036
+ const guidedRun = agentRun?.guided ? agentRun : null;
1037
+ const phase: 'idle' | 'running' | 'done' = !guidedRun
1038
+ ? 'idle'
1039
+ : guidedRun.status === 'running'
1040
+ ? 'running'
1041
+ : 'done';
1042
+
1043
+ // A failed run whose terminal error blames the BACKEND (stale CLI rejected
1044
+ // its model, or the process died) offers a one-click switch to the other
1045
+ // detected agent — a plain retry would replay the same broken backend.
1046
+ const activeAgent = agentChoice.agent ?? status?.agents[0]?.kind;
1047
+ const lastError = guidedRun?.status === 'error'
1048
+ ? [...guidedRun.events].reverse().find((e) => e.type === 'error')?.text ?? ''
1049
+ : '';
1050
+ const backendFault = /CLI may be too old|exited with code/i.test(lastError);
1051
+ const retryAgent = backendFault
1052
+ ? status?.agents.find((a) => a.kind !== activeAgent)?.kind
1053
+ : undefined;
1054
+
1055
+ const guidedStatus = guidedRun?.status;
1056
+
1057
+ // Auto-failover, once: a backend-fault failure with another agent available
1058
+ // switches to it and re-kicks the run WITHOUT waiting for a click — the
1059
+ // default backend being broken (stale codex CLI) shouldn't strand setup on
1060
+ // an error pane. One-shot per dialog mount so two broken backends can't
1061
+ // ping-pong; the manual "Try again with X" stays as the fallback.
1062
+ const autoFailoverUsed = useRef(false);
1063
+ const [autoSwitchNotice, setAutoSwitchNotice] = useState<string | null>(null);
1064
+ useEffect(() => {
1065
+ if (guidedStatus !== 'error' || !retryAgent || autoFailoverUsed.current) return;
1066
+ autoFailoverUsed.current = true;
1067
+ const from = activeAgent ? AGENT_LABELS[activeAgent] : 'The agent';
1068
+ setAutoSwitchNotice(`${from} failed to start — switched to ${AGENT_LABELS[retryAgent]} and retried automatically.`);
1069
+ setAgentChoice({ ...agentChoice, agent: retryAgent });
1070
+ beginGuidedSetup();
1071
+ // agentChoice/activeAgent are read at fire time; keying on them would re-arm the effect.
1072
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1073
+ }, [guidedStatus, retryAgent]);
1074
+
1075
+ // Refetch the checklist when the guided run finishes (a completed run may have
1076
+ // scouted, installed skills, or written environments) — mirrors the
1077
+ // agentRunStatus subscription StatusBar/InfraTab use.
1078
+ useEffect(() => {
1079
+ if (!guidedStatus || guidedStatus === 'running') return;
1080
+ void refreshSetupStatus();
1081
+ }, [guidedStatus, refreshSetupStatus]);
321
1082
 
322
1083
  // Mount: fetch once and auto-open on a fresh, undismissed project.
323
1084
  useEffect(() => {
@@ -370,32 +1131,90 @@ export function WelcomeDialog() {
370
1131
  },
371
1132
  };
372
1133
 
1134
+ const twoPane = phase !== 'idle';
1135
+
373
1136
  return (
374
1137
  <>
375
1138
  <Dialog open={welcomeOpen} onOpenChange={(o) => (o ? setWelcomeOpen(true) : dismiss())}>
376
- <DialogContent className="max-w-lg">
1139
+ <DialogContent className={cn('flex max-h-[85vh] flex-col', twoPane ? 'max-w-3xl' : 'max-w-lg')}>
377
1140
  <DialogTitle>Welcome to Emberflow</DialogTitle>
378
1141
  <DialogDescription>
379
- A few steps to get this project ready to build and run. Everything here is optional —
380
- you can revisit it any time from the toolbar.
1142
+ {twoPane
1143
+ ? 'The agent is working through setup — answer its questions on the right; the checklist updates as it goes.'
1144
+ : 'Get this project ready to build and run — revisit any time from the setup chip in the status bar.'}
381
1145
  </DialogDescription>
382
- {status ? (
383
- <WelcomeChecklist
1146
+ {twoPane ? (
1147
+ <GuidedSetupPanes
384
1148
  status={status}
385
1149
  actions={actions}
386
- chosenAgent={agentChoice.agent}
387
- onChooseAgent={(kind) => setAgentChoice({ ...agentChoice, agent: kind })}
1150
+ events={[...guidedTranscript, ...(guidedRun?.events ?? [])]}
1151
+ running={phase === 'running'}
1152
+ failed={guidedRun?.status === 'error'}
1153
+ onRetry={() => beginGuidedSetup()}
1154
+ retryAgent={retryAgent}
1155
+ onRetryWith={(kind) => {
1156
+ setAgentChoice({ ...agentChoice, agent: kind });
1157
+ beginGuidedSetup();
1158
+ }}
1159
+ notice={autoSwitchNotice ?? undefined}
1160
+ onFollowUp={(text) => beginGuidedSetup(text)}
1161
+ onFinishComplete={() => {
1162
+ // One door: setup ends in the create modal, not the agent panel.
1163
+ setWelcomeOpen(false);
1164
+ setCreateModal({ mode: 'api' });
1165
+ }}
1166
+ onBuildFirst={(text) => {
1167
+ // A typed first-build answer IS the goal — open the real build
1168
+ // flow pre-filled with it instead of another interview turn.
1169
+ setWelcomeOpen(false);
1170
+ setCreateModal({ mode: 'api', initialGoal: text });
1171
+ }}
1172
+ onContinue={() => followUpRef.current?.focus()}
1173
+ followUpRef={followUpRef}
388
1174
  />
389
1175
  ) : (
390
- <div className="py-6 text-center text-[12px] text-muted-foreground">
391
- Checking your project…
392
- </div>
1176
+ <>
1177
+ {status ? (
1178
+ <div className="min-h-0 flex-1 space-y-3 overflow-y-auto">
1179
+ {/* One story: progress → the guided plan → what's done → a
1180
+ quiet manual fallback. The full checklist (with its many
1181
+ buttons) stays behind the disclosure so the idle screen
1182
+ has a single primary action. */}
1183
+ <Progress
1184
+ done={deriveChecklist(status, actions).filter((i) => i.status === 'complete').length}
1185
+ total={deriveChecklist(status, actions).length}
1186
+ />
1187
+ <GuidedSetupIntro
1188
+ status={status}
1189
+ chosenAgent={agentChoice.agent}
1190
+ onChooseAgent={(kind) => setAgentChoice({ ...agentChoice, agent: kind })}
1191
+ onStart={() => beginGuidedSetup()}
1192
+ />
1193
+ <DoneSummary status={status} />
1194
+ {manualOpen && (
1195
+ <WelcomeChecklist
1196
+ status={status}
1197
+ actions={actions}
1198
+ chosenAgent={agentChoice.agent}
1199
+ onChooseAgent={(kind) => setAgentChoice({ ...agentChoice, agent: kind })}
1200
+ showProgress={false}
1201
+ />
1202
+ )}
1203
+ </div>
1204
+ ) : (
1205
+ <div className="py-6 text-center text-[12px] text-muted-foreground">
1206
+ Checking your project…
1207
+ </div>
1208
+ )}
1209
+ <WelcomeFooter
1210
+ status={status}
1211
+ actions={actions}
1212
+ onDismiss={dismiss}
1213
+ manualOpen={manualOpen}
1214
+ onToggleManual={() => setManualOpen((o) => !o)}
1215
+ />
1216
+ </>
393
1217
  )}
394
- <div className="flex justify-end">
395
- <Button variant="ghost" size="sm" onClick={dismiss}>
396
- Dismiss
397
- </Button>
398
- </div>
399
1218
  </DialogContent>
400
1219
  </Dialog>
401
1220