@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.
- package/bin/commands.ts +7 -3
- package/bin/init.test.ts +94 -0
- package/bin/init.ts +108 -2
- package/dist/bin/commands.d.ts +1 -0
- package/dist/bin/commands.js +6 -3
- package/dist/bin/init.d.ts +4 -0
- package/dist/bin/init.js +96 -2
- package/dist/server/agents/codexParse.js +31 -0
- package/dist/server/agents/detect.d.ts +18 -8
- package/dist/server/agents/detect.js +78 -13
- package/dist/server/agents/modelRejectionHint.js +1 -1
- package/dist/server/agents/prompt.d.ts +15 -1
- package/dist/server/agents/prompt.js +59 -37
- package/dist/server/agents/runManager.d.ts +23 -2
- package/dist/server/agents/runManager.js +36 -8
- package/dist/server/client.d.ts +1 -0
- package/dist/server/client.js +7 -2
- package/dist/server/index.js +185 -14
- package/dist/server/runRegistry.d.ts +52 -1
- package/dist/server/runRegistry.js +170 -1
- package/dist/server/subflowRunner.d.ts +48 -1
- package/dist/server/subflowRunner.js +34 -7
- package/dist/server/updateCheck.d.ts +68 -0
- package/dist/server/updateCheck.js +142 -0
- package/dist/src/engine/executor.js +4 -3
- package/package.json +27 -6
- package/server/agentRoute.test.ts +20 -0
- package/server/agents/codexParse.test.ts +37 -0
- package/server/agents/codexParse.ts +34 -0
- package/server/agents/detect.test.ts +26 -7
- package/server/agents/detect.ts +82 -14
- package/server/agents/modelRejectionHint.ts +2 -1
- package/server/agents/prompt.test.ts +128 -0
- package/server/agents/prompt.ts +102 -3
- package/server/agents/runManager.test.ts +9 -0
- package/server/agents/runManager.ts +37 -7
- package/server/client.ts +10 -4
- package/server/index.ts +189 -13
- package/server/nodeRun.test.ts +189 -0
- package/server/runRegistry.ts +200 -3
- package/server/setupStatus.test.ts +3 -0
- package/server/stepDrill.test.ts +380 -0
- package/server/subflowRunner.ts +92 -11
- package/server/updateCheck.test.ts +209 -0
- package/server/updateCheck.ts +175 -0
- package/server/workflowTestRoute.test.ts +1 -1
- package/src/App.tsx +82 -2
- package/src/components/AgentConsole.tsx +7 -280
- package/src/components/AgentStream.test.tsx +88 -0
- package/src/components/AgentStream.tsx +303 -0
- package/src/components/CreateModal.tsx +43 -9
- package/src/components/Dock.tsx +1 -1
- package/src/components/EmptyState.test.tsx +49 -0
- package/src/components/EmptyState.tsx +61 -0
- package/src/components/EnvironmentDialog.tsx +4 -1
- package/src/components/EnvironmentPicker.tsx +8 -3
- package/src/components/EnvironmentsDialog.tsx +4 -1
- package/src/components/InfraPanel.tsx +30 -2
- package/src/components/InfrastructureDialog.test.tsx +97 -0
- package/src/components/InfrastructureDialog.tsx +111 -0
- package/src/components/RunbookView.tsx +70 -6
- package/src/components/SettingsDialog.tsx +4 -59
- package/src/components/Sidebar.tsx +6 -26
- package/src/components/StatusBar.tsx +227 -26
- package/src/components/Toolbar.tsx +28 -16
- package/src/components/UpdateChip.test.tsx +31 -0
- package/src/components/WelcomeDialog.test.tsx +384 -13
- package/src/components/WelcomeDialog.tsx +996 -177
- package/src/engine/executor.ts +4 -3
- package/src/lib/guidedQuestions.test.ts +224 -0
- package/src/lib/guidedQuestions.ts +181 -0
- package/src/lib/runbookModel.ts +3 -3
- package/src/lib/runbookProjection.test.ts +43 -0
- package/src/lib/runbookProjection.ts +26 -6
- package/src/store/agentClient.ts +27 -8
- package/src/store/apiTree.ts +12 -0
- package/src/store/builderStore.test.ts +441 -99
- package/src/store/builderStore.ts +383 -312
- package/src/store/serverRunner.ts +56 -5
- package/src/store/setupClient.ts +7 -2
- package/src/store/updateClient.ts +50 -0
- package/studio-dist/assets/index-CAIDjNhv.css +1 -0
- package/studio-dist/assets/index-CGwEx82J.js +65 -0
- package/studio-dist/index.html +2 -2
- package/templates/skills/emberflow-model-process/SKILL.md +82 -9
- package/templates/skills/emberflow-review-workflow/SKILL.md +37 -1
- package/studio-dist/assets/index-DNJwW-hM.css +0 -1
- 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
|
|
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';
|
package/src/components/Dock.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 —
|
|
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
|
|
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
|
|
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>
|