bosun 0.41.8 → 0.41.10

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 (58) hide show
  1. package/.env.example +1 -1
  2. package/README.md +23 -1
  3. package/agent/agent-event-bus.mjs +31 -2
  4. package/agent/agent-pool.mjs +275 -40
  5. package/agent/agent-prompts.mjs +9 -1
  6. package/agent/agent-supervisor.mjs +22 -0
  7. package/agent/autofix.mjs +1 -1
  8. package/agent/primary-agent.mjs +115 -5
  9. package/cli.mjs +3 -2
  10. package/config/config.mjs +47 -33
  11. package/config/context-shredding-config.mjs +1 -1
  12. package/config/repo-root.mjs +41 -33
  13. package/desktop/main.mjs +350 -25
  14. package/desktop/preload.cjs +8 -0
  15. package/desktop/preload.mjs +19 -0
  16. package/entrypoint.mjs +332 -0
  17. package/git/sdk-conflict-resolver.mjs +1 -1
  18. package/infra/health-status.mjs +72 -0
  19. package/infra/library-manager.mjs +58 -1
  20. package/infra/maintenance.mjs +1 -2
  21. package/infra/monitor.mjs +26 -8
  22. package/infra/session-tracker.mjs +30 -3
  23. package/package.json +12 -4
  24. package/server/bosun-mcp-server.mjs +1004 -0
  25. package/server/setup-web-server.mjs +288 -259
  26. package/server/ui-server.mjs +1323 -26
  27. package/shell/claude-shell.mjs +14 -1
  28. package/shell/codex-config.mjs +1 -1
  29. package/shell/codex-model-profiles.mjs +170 -30
  30. package/shell/codex-shell.mjs +63 -18
  31. package/shell/opencode-providers.mjs +20 -8
  32. package/task/task-executor.mjs +28 -0
  33. package/task/task-store.mjs +13 -4
  34. package/telegram/telegram-sentinel.mjs +54 -3
  35. package/tools/list-todos.mjs +7 -1
  36. package/ui/app.js +3 -2
  37. package/ui/components/agent-selector.js +127 -0
  38. package/ui/components/session-list.js +15 -10
  39. package/ui/demo-defaults.js +334 -336
  40. package/ui/modules/router.js +2 -0
  41. package/ui/modules/state.js +13 -5
  42. package/ui/tabs/chat.js +3 -0
  43. package/ui/tabs/library.js +284 -52
  44. package/ui/tabs/tasks.js +5 -13
  45. package/ui/tabs/workflows.js +766 -3
  46. package/workflow/workflow-engine.mjs +246 -5
  47. package/workflow/workflow-nodes/definitions.mjs +37 -0
  48. package/workflow/workflow-nodes.mjs +1014 -184
  49. package/workflow/workflow-templates.mjs +0 -5
  50. package/workflow-templates/_helpers.mjs +253 -0
  51. package/workflow-templates/agents.mjs +199 -226
  52. package/workflow-templates/github.mjs +106 -16
  53. package/workflow-templates/sub-workflows.mjs +233 -0
  54. package/workflow-templates/task-execution.mjs +125 -471
  55. package/workflow-templates/task-lifecycle.mjs +11 -48
  56. package/workspace/command-diagnostics.mjs +460 -0
  57. package/workspace/context-cache.mjs +396 -28
  58. package/workspace/worktree-manager.mjs +1 -1
@@ -30,6 +30,8 @@ import {
30
30
  serializeGraphSnapshot,
31
31
  undoHistory,
32
32
  } from "./workflow-canvas-utils.mjs";
33
+ import { createSession } from "../components/session-list.js";
34
+ import { buildSessionApiPath, resolveSessionWorkspaceHint } from "../modules/session-api.js";
33
35
  import { Card, Badge, EmptyState } from "../components/shared.js";
34
36
  import {
35
37
  Typography, Box, Stack, Card as MuiCard, CardContent, Button, IconButton, Chip,
@@ -61,7 +63,7 @@ const selectedNodeId = signal(null);
61
63
  const selectedEdgeId = signal(null);
62
64
  const draggingNode = signal(null);
63
65
  const connectingFrom = signal(null);
64
- const viewMode = signal("list"); // "list" | "canvas" | "runs"
66
+ const viewMode = signal("list"); // "list" | "canvas" | "runs" | "code"
65
67
  const WORKFLOW_RUN_PAGE_SIZE = 50;
66
68
  const WORKFLOW_RUN_MAX_FETCH = 5000;
67
69
  const WORKFLOW_LIVE_POLL_MS = 3000;
@@ -176,6 +178,370 @@ export function openWorkflowRunsView(workflowId, runId = null) {
176
178
  }
177
179
  }
178
180
 
181
+ const WORKFLOW_COPILOT_MAX_CHARS = 5000;
182
+
183
+ function formatWorkflowCopilotBlock(value, maxChars = WORKFLOW_COPILOT_MAX_CHARS) {
184
+ try {
185
+ const json = JSON.stringify(value, null, 2);
186
+ if (json.length <= maxChars) return json;
187
+ const omitted = json.length - maxChars;
188
+ return `${json.slice(0, maxChars)}\n\n[truncated ${omitted} chars]`;
189
+ } catch {
190
+ const text = String(value ?? "");
191
+ if (text.length <= maxChars) return text;
192
+ const omitted = text.length - maxChars;
193
+ return `${text.slice(0, maxChars)}\n\n[truncated ${omitted} chars]`;
194
+ }
195
+ }
196
+
197
+ function summarizeWorkflowNodes(workflow, limit = 20) {
198
+ const nodes = Array.isArray(workflow?.nodes) ? workflow.nodes : [];
199
+ if (!nodes.length) return "No nodes defined.";
200
+ const lines = nodes.slice(0, limit).map((node, index) => {
201
+ const nodeId = String(node?.id || `node-${index + 1}`).trim();
202
+ const nodeType = String(node?.type || "unknown").trim();
203
+ const nodeName =
204
+ String(node?.name || node?.label || node?.title || "").trim() || null;
205
+ const configKeys = Object.keys(node?.config || {}).slice(0, 6);
206
+ const configSummary = configKeys.length ? ` config keys: ${configKeys.join(", ")}` : "";
207
+ return `${index + 1}. ${nodeId} [${nodeType}]${nodeName ? ` - ${nodeName}` : ""}${configSummary}`;
208
+ });
209
+ if (nodes.length > limit) {
210
+ lines.push(`... ${nodes.length - limit} more node(s) omitted`);
211
+ }
212
+ return lines.join("\n");
213
+ }
214
+
215
+ function summarizeWorkflowEdges(workflow, limit = 24) {
216
+ const edges = Array.isArray(workflow?.edges) ? workflow.edges : [];
217
+ if (!edges.length) return "No edges defined.";
218
+ const lines = edges.slice(0, limit).map((edge, index) => {
219
+ const from = String(edge?.source || edge?.from || "?").trim() || "?";
220
+ const to = String(edge?.target || edge?.to || "?").trim() || "?";
221
+ const fromPort = String(edge?.sourcePort || edge?.fromPort || "").trim();
222
+ const toPort = String(edge?.targetPort || edge?.toPort || "").trim();
223
+ const portSummary = fromPort || toPort ? ` (${fromPort || "default"} -> ${toPort || "default"})` : "";
224
+ return `${index + 1}. ${from} -> ${to}${portSummary}`;
225
+ });
226
+ if (edges.length > limit) {
227
+ lines.push(`... ${edges.length - limit} more edge(s) omitted`);
228
+ }
229
+ return lines.join("\n");
230
+ }
231
+
232
+ function summarizeWorkflowNodeLinks(workflow, nodeId, direction = "incoming", limit = 10) {
233
+ const safeNodeId = String(nodeId || "").trim();
234
+ if (!safeNodeId) return direction === "outgoing" ? "No downstream edges." : "No upstream edges.";
235
+ const edges = Array.isArray(workflow?.edges) ? workflow.edges : [];
236
+ const relevant = edges.filter((edge) => {
237
+ const sourceId = String(edge?.source || edge?.from || "").trim();
238
+ const targetId = String(edge?.target || edge?.to || "").trim();
239
+ return direction === "outgoing" ? sourceId === safeNodeId : targetId === safeNodeId;
240
+ });
241
+ if (!relevant.length) return direction === "outgoing" ? "No downstream edges." : "No upstream edges.";
242
+ const lines = relevant.slice(0, limit).map((edge, index) => {
243
+ const sourceId = String(edge?.source || edge?.from || "?").trim() || "?";
244
+ const targetId = String(edge?.target || edge?.to || "?").trim() || "?";
245
+ const sourcePort = String(edge?.sourcePort || edge?.fromPort || "").trim() || "default";
246
+ const targetPort = String(edge?.targetPort || edge?.toPort || "").trim() || "default";
247
+ return `${index + 1}. ${sourceId}:${sourcePort} -> ${targetId}:${targetPort}`;
248
+ });
249
+ if (relevant.length > limit) {
250
+ lines.push(`... ${relevant.length - limit} more ${direction} edge(s) omitted`);
251
+ }
252
+ return lines.join("\n");
253
+ }
254
+
255
+ function summarizeRunNodeStatuses(run, limit = 25) {
256
+ const nodeStatuses = buildNodeStatusesFromRunDetail(run);
257
+ const entries = Object.entries(nodeStatuses || {});
258
+ if (!entries.length) return "No node status data recorded.";
259
+ const sorted = entries.sort((a, b) => {
260
+ const rankDiff = getNodeStatusRank(a[1]) - getNodeStatusRank(b[1]);
261
+ if (rankDiff !== 0) return rankDiff;
262
+ return String(a[0]).localeCompare(String(b[0]));
263
+ });
264
+ const lines = sorted.slice(0, limit).map(([nodeId, status], index) => (
265
+ `${index + 1}. ${nodeId}: ${status || "unknown"}`
266
+ ));
267
+ if (sorted.length > limit) {
268
+ lines.push(`... ${sorted.length - limit} more node status entries omitted`);
269
+ }
270
+ return lines.join("\n");
271
+ }
272
+
273
+ function summarizeRunNodeOutputs(run, limit = 12) {
274
+ const outputs = run?.detail?.nodeOutputs && typeof run.detail.nodeOutputs === "object"
275
+ ? run.detail.nodeOutputs
276
+ : {};
277
+ const entries = Object.entries(outputs);
278
+ if (!entries.length) return "No node outputs recorded.";
279
+ const lines = entries.slice(0, limit).map(([nodeId, output], index) => {
280
+ const summary = String(output?.summary || "").trim();
281
+ const narrative = String(output?.narrative || "").trim();
282
+ if (summary || narrative) {
283
+ const parts = [summary, narrative].filter(Boolean);
284
+ return `${index + 1}. ${nodeId}: ${parts.join(" | ")}`;
285
+ }
286
+ return `${index + 1}. ${nodeId}: ${formatWorkflowCopilotBlock(output, 500)}`;
287
+ });
288
+ if (entries.length > limit) {
289
+ lines.push(`... ${entries.length - limit} more node output entries omitted`);
290
+ }
291
+ return lines.join("\n");
292
+ }
293
+
294
+ function buildWorkflowExplainPrompt(workflow) {
295
+ const workflowId = String(workflow?.id || "").trim() || "(unknown)";
296
+ const workflowName = String(workflow?.name || workflowId).trim() || workflowId;
297
+ const description = String(workflow?.description || "").trim() || "None provided.";
298
+ const variables = workflow?.variables && typeof workflow.variables === "object"
299
+ ? workflow.variables
300
+ : {};
301
+ return [
302
+ "You are helping inside Bosun with a workflow authoring review.",
303
+ "Explain this workflow in plain English, identify the riskiest nodes or missing guardrails, and suggest the smallest high-leverage improvements.",
304
+ "",
305
+ "Return:",
306
+ "1. A concise summary of what the workflow is trying to do",
307
+ "2. The critical nodes or transitions that matter most",
308
+ "3. Failure risks, ambiguity, or missing validation/retry/observability",
309
+ "4. Concrete next edits Bosun should make",
310
+ "",
311
+ "Workflow Context",
312
+ `- Name: ${workflowName}`,
313
+ `- ID: ${workflowId}`,
314
+ `- Enabled: ${workflow?.enabled === false ? "no" : "yes"}`,
315
+ `- Core workflow: ${workflow?.core === true ? "yes" : "no"}`,
316
+ `- Description: ${description}`,
317
+ `- Node count: ${Array.isArray(workflow?.nodes) ? workflow.nodes.length : 0}`,
318
+ `- Edge count: ${Array.isArray(workflow?.edges) ? workflow.edges.length : 0}`,
319
+ "",
320
+ "Variables",
321
+ formatWorkflowCopilotBlock(variables, 2500),
322
+ "",
323
+ "Node Summary",
324
+ summarizeWorkflowNodes(workflow),
325
+ "",
326
+ "Edge Summary",
327
+ summarizeWorkflowEdges(workflow),
328
+ "",
329
+ "Raw Workflow Snapshot",
330
+ formatWorkflowCopilotBlock({
331
+ id: workflow?.id,
332
+ name: workflow?.name,
333
+ description: workflow?.description,
334
+ enabled: workflow?.enabled,
335
+ core: workflow?.core,
336
+ metadata: workflow?.metadata || {},
337
+ }, 2500),
338
+ ].join("\n");
339
+ }
340
+
341
+ function buildWorkflowNodePrompt(workflow, node, nodeTypeMap = new Map()) {
342
+ if (!node) return "";
343
+ const workflowId = String(workflow?.id || "").trim() || "(unknown)";
344
+ const workflowName = String(workflow?.name || workflowId).trim() || workflowId;
345
+ const nodeType = String(node?.type || "unknown").trim() || "unknown";
346
+ const typeInfo = nodeTypeMap.get(nodeType) || null;
347
+ const schemaKeys = Object.keys(typeInfo?.schema?.properties || {});
348
+ return [
349
+ "You are helping inside Bosun with workflow node authoring.",
350
+ "Explain what this node does, how it interacts with adjacent nodes, what is risky or underspecified, and which exact config edits Bosun should make next.",
351
+ "",
352
+ "Return:",
353
+ "1. Node purpose",
354
+ "2. Upstream/downstream interaction notes",
355
+ "3. Risks, missing validation, or bad defaults",
356
+ "4. Concrete config or graph edits",
357
+ "",
358
+ "Workflow Context",
359
+ `- Workflow: ${workflowName}`,
360
+ `- Workflow ID: ${workflowId}`,
361
+ `- Node count: ${Array.isArray(workflow?.nodes) ? workflow.nodes.length : 0}`,
362
+ `- Edge count: ${Array.isArray(workflow?.edges) ? workflow.edges.length : 0}`,
363
+ "",
364
+ "Node Context",
365
+ `- Node ID: ${String(node?.id || "").trim() || "(unknown)"}`,
366
+ `- Label: ${String(node?.label || node?.name || "").trim() || "(none)"}`,
367
+ `- Type: ${nodeType}`,
368
+ `- Category: ${nodeType.split(".")[0] || "unknown"}`,
369
+ `- Description: ${String(typeInfo?.description || "").trim() || "None provided."}`,
370
+ `- Schema keys: ${schemaKeys.length ? schemaKeys.join(", ") : "None"}`,
371
+ "",
372
+ "Upstream Edges",
373
+ summarizeWorkflowNodeLinks(workflow, node?.id, "incoming"),
374
+ "",
375
+ "Downstream Edges",
376
+ summarizeWorkflowNodeLinks(workflow, node?.id, "outgoing"),
377
+ "",
378
+ "Node Config",
379
+ formatWorkflowCopilotBlock(node?.config || {}, 3500),
380
+ "",
381
+ "Raw Node Snapshot",
382
+ formatWorkflowCopilotBlock(node, 3500),
383
+ ].join("\n");
384
+ }
385
+
386
+ function buildRunCopilotPrompt(run, intent = "ask") {
387
+ const workflowName = String(run?.workflowName || getWorkflowNameById(run?.workflowId) || run?.workflowId || "Unknown Workflow").trim();
388
+ const status = String(run?.status || "unknown").trim() || "unknown";
389
+ const errors = Array.isArray(run?.detail?.errors) ? run.detail.errors : [];
390
+ const logs = Array.isArray(run?.detail?.logs) ? run.detail.logs : [];
391
+ const failed = intent === "fix" || status === "failed";
392
+ const request = failed
393
+ ? "Analyze why this workflow run failed. Identify the root cause, name the most likely failing node or nodes, propose the smallest concrete fix, and say whether Bosun should retry from failed state or rerun from the beginning."
394
+ : "Explain what happened in this workflow run, call out unusual or risky behavior, and suggest the next debugging or hardening steps.";
395
+ return [
396
+ "You are helping inside Bosun with workflow run analysis.",
397
+ request,
398
+ "",
399
+ "Return:",
400
+ "1. Short diagnosis",
401
+ "2. Evidence from the run",
402
+ failed ? "3. Concrete fix plan" : "3. Recommended next steps",
403
+ failed ? "4. Retry advice: retry from failed, rerun from start, or do not retry yet" : "4. Risks or follow-up checks",
404
+ "",
405
+ "Run Context",
406
+ `- Workflow: ${workflowName}`,
407
+ `- Workflow ID: ${String(run?.workflowId || "").trim() || "(unknown)"}`,
408
+ `- Run ID: ${String(run?.runId || "").trim() || "(unknown)"}`,
409
+ `- Status: ${status}`,
410
+ `- Started: ${formatDate(run?.startedAt)}`,
411
+ `- Finished: ${run?.endedAt ? formatDate(run.endedAt) : "Running"}`,
412
+ `- Duration: ${formatDuration(run?.duration)}`,
413
+ `- Active nodes: ${Number(run?.activeNodeCount || 0)}`,
414
+ `- Error count: ${Number(run?.errorCount || errors.length)}`,
415
+ `- Log count: ${Number(run?.logCount || logs.length)}`,
416
+ "",
417
+ "Node Statuses",
418
+ summarizeRunNodeStatuses(run),
419
+ "",
420
+ "Node Output Summaries",
421
+ summarizeRunNodeOutputs(run),
422
+ "",
423
+ "Errors",
424
+ formatWorkflowCopilotBlock(errors.slice(0, 8), 3500),
425
+ "",
426
+ "Recent Logs",
427
+ formatWorkflowCopilotBlock(logs.slice(-40), 4000),
428
+ ].join("\n");
429
+ }
430
+
431
+ function buildRunNodeCopilotPrompt(run, nodeId, opts = {}) {
432
+ const safeNodeId = String(nodeId || "").trim();
433
+ if (!safeNodeId) return "";
434
+ const workflow = opts?.workflow || null;
435
+ const node = Array.isArray(workflow?.nodes)
436
+ ? workflow.nodes.find((entry) => String(entry?.id || "").trim() === safeNodeId) || null
437
+ : null;
438
+ const nodeStatuses = buildNodeStatusesFromRunDetail(run);
439
+ const nodeOutputs = run?.detail?.nodeOutputs && typeof run.detail.nodeOutputs === "object"
440
+ ? run.detail.nodeOutputs
441
+ : {};
442
+ const errors = Array.isArray(run?.detail?.errors) ? run.detail.errors : [];
443
+ const relatedErrors = errors.filter((entry) => formatWorkflowCopilotBlock(entry, 500).includes(safeNodeId));
444
+ const failed = String(opts?.intent || "").trim().toLowerCase() === "fix"
445
+ || String(nodeStatuses[safeNodeId] || "").trim().toLowerCase() === "failed";
446
+ return [
447
+ "You are helping inside Bosun with workflow run node analysis.",
448
+ failed
449
+ ? "Diagnose why this node failed or behaved incorrectly, identify the root cause, and propose the smallest concrete fix Bosun should make."
450
+ : "Explain what happened in this node during the run, what inputs or outputs matter, and what Bosun should inspect next.",
451
+ "",
452
+ "Return:",
453
+ "1. Short diagnosis",
454
+ "2. Evidence from this node",
455
+ failed ? "3. Concrete fix plan" : "3. Recommended next checks",
456
+ failed ? "4. Retry advice for this node or run" : "4. Risks or follow-up notes",
457
+ "",
458
+ "Run Context",
459
+ `- Workflow ID: ${String(run?.workflowId || workflow?.id || "").trim() || "(unknown)"}`,
460
+ `- Run ID: ${String(run?.runId || "").trim() || "(unknown)"}`,
461
+ `- Run status: ${String(run?.status || "unknown").trim() || "unknown"}`,
462
+ `- Started: ${formatDate(run?.startedAt)}`,
463
+ `- Finished: ${run?.endedAt ? formatDate(run.endedAt) : "Running"}`,
464
+ "",
465
+ "Node Context",
466
+ `- Node ID: ${safeNodeId}`,
467
+ `- Node label: ${String(node?.label || node?.name || "").trim() || "(none)"}`,
468
+ `- Node type: ${String(node?.type || "").trim() || "(unknown)"}`,
469
+ `- Node status: ${String(nodeStatuses[safeNodeId] || "unknown").trim() || "unknown"}`,
470
+ "",
471
+ "Node Config",
472
+ formatWorkflowCopilotBlock(node?.config || {}, 2500),
473
+ "",
474
+ "Node Output",
475
+ formatWorkflowCopilotBlock(nodeOutputs[safeNodeId] ?? null, 3500),
476
+ "",
477
+ "Node Errors",
478
+ formatWorkflowCopilotBlock(relatedErrors.length ? relatedErrors : errors.slice(0, 8), 3000),
479
+ ].join("\n");
480
+ }
481
+
482
+ async function fetchWorkflowCopilotPrompt(endpoint, fetchOptions = {}) {
483
+ const safeEndpoint = String(endpoint || "").trim();
484
+ if (!safeEndpoint) return null;
485
+ try {
486
+ const data = await apiFetch(safeEndpoint, fetchOptions);
487
+ const prompt = String(data?.prompt || "").trim();
488
+ return prompt || null;
489
+ } catch {
490
+ return null;
491
+ }
492
+ }
493
+
494
+ async function startWorkflowCopilotSession({
495
+ endpoint = "",
496
+ fetchOptions = {},
497
+ fallbackPrompt = "",
498
+ title = "",
499
+ successToast = "",
500
+ } = {}) {
501
+ const prompt = await fetchWorkflowCopilotPrompt(endpoint, fetchOptions) || String(fallbackPrompt || "").trim();
502
+ return openWorkflowCopilotChat(prompt, { title, successToast });
503
+ }
504
+
505
+ async function openWorkflowCopilotChat(prompt, opts = {}) {
506
+ const content = String(prompt || "").trim();
507
+ if (!content) {
508
+ showToast("Workflow copilot prompt was empty", "error");
509
+ return null;
510
+ }
511
+ const successToast = String(opts?.successToast || "Opened workflow copilot chat").trim();
512
+ try {
513
+ const created = await createSession({
514
+ type: "primary",
515
+ reuseFresh: false,
516
+ ...(opts?.title ? { title: String(opts.title) } : {}),
517
+ });
518
+ const session = created?.session || null;
519
+ const sessionId = String(session?.id || "").trim();
520
+ if (!sessionId) throw new Error("Session creation failed");
521
+ const messagePath = buildSessionApiPath(sessionId, "message", {
522
+ workspace: resolveSessionWorkspaceHint(session, "active"),
523
+ });
524
+ if (!messagePath) throw new Error("Session path unavailable");
525
+ await apiFetch(messagePath, {
526
+ method: "POST",
527
+ body: JSON.stringify({ content }),
528
+ });
529
+ const navigated = navigateTo("chat", {
530
+ params: { sessionId },
531
+ forceRefresh: true,
532
+ });
533
+ if (!navigated) {
534
+ showToast("Workflow copilot session started. Open Chat after resolving unsaved changes.", "info");
535
+ return sessionId;
536
+ }
537
+ showToast(successToast, "success");
538
+ return sessionId;
539
+ } catch (err) {
540
+ showToast(`Failed to start workflow copilot: ${err.message || "Unknown error"}`, "error");
541
+ return null;
542
+ }
543
+ }
544
+
179
545
  /* ═══════════════════════════════════════════════════════════════
180
546
  * API Helpers
181
547
  * ═══════════════════════════════════════════════════════════════ */
@@ -235,6 +601,32 @@ async function saveWorkflow(def) {
235
601
  }
236
602
  }
237
603
 
604
+ async function exportWorkflow(workflow) {
605
+ if (!workflow?.id) return;
606
+ try {
607
+ const bundle = await apiFetch(`/api/workflows/${encodeURIComponent(workflow.id)}/export`);
608
+ if (!bundle?.files) throw new Error("No export data received");
609
+
610
+ const content = JSON.stringify({
611
+ _bosunExport: true,
612
+ projectName: bundle.projectName,
613
+ metadata: bundle.metadata,
614
+ files: bundle.files,
615
+ }, null, 2);
616
+
617
+ const blob = new Blob([content], { type: "application/json" });
618
+ const url = URL.createObjectURL(blob);
619
+ const a = document.createElement("a");
620
+ a.href = url;
621
+ a.download = `${bundle.projectName || "workflow"}-export.json`;
622
+ a.click();
623
+ URL.revokeObjectURL(url);
624
+ showToast("Workflow exported successfully", "success");
625
+ } catch (err) {
626
+ showToast("Export failed: " + (err.message || err), "error");
627
+ }
628
+ }
629
+
238
630
  async function deleteWorkflow(id) {
239
631
  try {
240
632
  await apiFetch(`/api/workflows/${id}`, { method: "DELETE" });
@@ -1564,6 +1956,44 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
1564
1956
  const normalizeNodesForCanvas = useCallback((nodeList = []) => (
1565
1957
  (Array.isArray(nodeList) ? nodeList : []).map((node) => ensureNodePortMetadata(node))
1566
1958
  ), [ensureNodePortMetadata]);
1959
+ const createWorkflowSnapshotForCopilot = useCallback(() => ({
1960
+ ...(workflow || {}),
1961
+ nodes: normalizeNodesForCanvas(nodesRef.current || []),
1962
+ edges: Array.isArray(edgesRef.current) ? [...edgesRef.current] : [],
1963
+ }), [normalizeNodesForCanvas, workflow]);
1964
+ const openWorkflowCopilotFromCanvas = useCallback(async ({
1965
+ intent = "explain",
1966
+ nodeId = "",
1967
+ title = "",
1968
+ successToast = "",
1969
+ } = {}) => {
1970
+ const snapshot = createWorkflowSnapshotForCopilot();
1971
+ const safeWorkflowId = String(snapshot?.id || workflow?.id || "").trim();
1972
+ const safeNodeId = String(nodeId || "").trim();
1973
+ const fallbackNode = safeNodeId
1974
+ ? (snapshot.nodes || []).find((entry) => String(entry?.id || "").trim() === safeNodeId) || null
1975
+ : null;
1976
+ const fallbackPrompt = safeNodeId
1977
+ ? buildWorkflowNodePrompt(snapshot, fallbackNode, nodeTypeMap)
1978
+ : buildWorkflowExplainPrompt(snapshot);
1979
+ if (!safeWorkflowId) {
1980
+ return openWorkflowCopilotChat(fallbackPrompt, { title, successToast });
1981
+ }
1982
+ return startWorkflowCopilotSession({
1983
+ endpoint: `/api/workflows/${encodeURIComponent(safeWorkflowId)}/copilot-context`,
1984
+ fetchOptions: {
1985
+ method: "POST",
1986
+ body: JSON.stringify({
1987
+ intent,
1988
+ ...(safeNodeId ? { nodeId: safeNodeId } : {}),
1989
+ workflow: snapshot,
1990
+ }),
1991
+ },
1992
+ fallbackPrompt,
1993
+ title,
1994
+ successToast,
1995
+ });
1996
+ }, [createWorkflowSnapshotForCopilot, nodeTypeMap, workflow]);
1567
1997
  useEffect(() => { selectedNodeIdsRef.current = selectedNodeIds; }, [selectedNodeIds]);
1568
1998
  useEffect(() => {
1569
1999
  nodesRef.current = nodes;
@@ -2634,6 +3064,18 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
2634
3064
  <${Button} variant="text" size="small" onClick=${returnToWorkflowList}>
2635
3065
  ← Back to Workflows
2636
3066
  <//>
3067
+ <${Button}
3068
+ variant="outlined"
3069
+ size="small"
3070
+ onClick=${() => openWorkflowCopilotFromCanvas({
3071
+ intent: "explain",
3072
+ title: `Explain workflow ${workflow?.name || workflow?.id || ""}`.trim(),
3073
+ successToast: "Opened workflow explanation chat",
3074
+ })}
3075
+ >
3076
+ <span class="btn-icon">${resolveIcon("bot")}</span>
3077
+ Explain With Bosun
3078
+ <//>
2637
3079
  <${Button} variant="contained" size="small" onClick=${() => openNodePalette()} sx=${{ display: 'flex', alignItems: 'center', gap: '6px' }}>
2638
3080
  <span style="font-size: 18px;">+</span> Add Node /
2639
3081
  <//>
@@ -2665,6 +3107,14 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
2665
3107
  <span class="btn-icon">${resolveIcon("chart")}</span>
2666
3108
  Runs
2667
3109
  <//>
3110
+ <${Button} variant="outlined" size="small" onClick=${() => { viewMode.value = "code"; }}>
3111
+ <span class="btn-icon">${resolveIcon("settings")}</span>
3112
+ Code
3113
+ <//>
3114
+ <${Button} variant="outlined" size="small" onClick=${() => exportWorkflow(workflow)}>
3115
+ <span class="btn-icon">${resolveIcon("save")}</span>
3116
+ Export
3117
+ <//>
2668
3118
  ${workflow?.metadata?.installedFrom && html`<${Button}
2669
3119
  variant="outlined"
2670
3120
  size="small"
@@ -3165,6 +3615,21 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
3165
3615
  <!-- Context Menu -->
3166
3616
  ${contextMenu && html`
3167
3617
  <div class="wf-context-menu" style="position: fixed; left: ${contextMenu.x}px; top: ${contextMenu.y}px; z-index: 50;">
3618
+ <${MenuItem}
3619
+ onClick=${() => {
3620
+ const node = nodes.find((entry) => entry.id === contextMenu.nodeId) || null;
3621
+ setContextMenu(null);
3622
+ openWorkflowCopilotFromCanvas({
3623
+ intent: "node",
3624
+ nodeId: contextMenu.nodeId,
3625
+ title: `Ask Bosun about node ${node?.label || contextMenu.nodeId}`.trim(),
3626
+ successToast: "Opened node copilot chat",
3627
+ });
3628
+ }}
3629
+ >
3630
+ <span class="btn-icon">${resolveIcon("bot")}</span>
3631
+ Ask Bosun About Node
3632
+ <//>
3168
3633
  <${MenuItem} onClick=${() => { setEditingNode(contextMenu.nodeId); setContextMenu(null); }}>
3169
3634
  <span class="btn-icon">${resolveIcon("settings")}</span>
3170
3635
  Edit Config
@@ -3193,6 +3658,12 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
3193
3658
  inlineFieldKeys=${inlineDescriptors.map((field) => field.key)}
3194
3659
  onUpdate=${(config) => updateNodeConfig(editingNode, config)}
3195
3660
  onUpdateLabel=${(label) => updateNodeLabel(editingNode, label)}
3661
+ onAskBosun=${() => openWorkflowCopilotFromCanvas({
3662
+ intent: "node",
3663
+ nodeId: editingNode,
3664
+ title: `Ask Bosun about node ${editingNodeDef?.label || editingNode}`.trim(),
3665
+ successToast: "Opened node copilot chat",
3666
+ })}
3196
3667
  onClose=${() => setEditingNode(null)}
3197
3668
  onDelete=${() => deleteNode(editingNode)}
3198
3669
  />
@@ -3604,7 +4075,7 @@ function WorkflowAgentLibraryPicker({ config, onUpdate }) {
3604
4075
  * Node Config Editor (right side panel)
3605
4076
  * ═══════════════════════════════════════════════════════════════ */
3606
4077
 
3607
- function NodeConfigEditor({ node, nodeTypes: types, inlineFieldKeys = [], onUpdate, onUpdateLabel, onClose, onDelete }) {
4078
+ function NodeConfigEditor({ node, nodeTypes: types, inlineFieldKeys = [], onUpdate, onUpdateLabel, onAskBosun, onClose, onDelete }) {
3608
4079
  if (!node) return null;
3609
4080
 
3610
4081
  const meta = getNodeMeta(node.type);
@@ -3656,6 +4127,17 @@ function NodeConfigEditor({ node, nodeTypes: types, inlineFieldKeys = [], onUpda
3656
4127
  ${typeInfo.description}
3657
4128
  </div>
3658
4129
  `}
4130
+ ${typeof onAskBosun === "function" && html`
4131
+ <${Button}
4132
+ onClick=${onAskBosun}
4133
+ variant="outlined"
4134
+ size="small"
4135
+ sx=${{ width: "100%", marginBottom: "12px", textTransform: "none" }}
4136
+ >
4137
+ <span class="btn-icon">${resolveIcon("bot")}</span>
4138
+ Ask Bosun About This Node
4139
+ <//>
4140
+ `}
3659
4141
 
3660
4142
  <!-- ═══ Smart Presets: action.run_command ═══ -->
3661
4143
  ${node.type === "action.run_command" && html`
@@ -4356,6 +4838,17 @@ function WorkflowListView() {
4356
4838
  >
4357
4839
  <span class="icon-inline">${resolveIcon("play")}</span>
4358
4840
  <//>
4841
+ <${Button}
4842
+ variant="text"
4843
+ size="small"
4844
+ sx=${{ fontSize: '11px', textTransform: 'none' }}
4845
+ onClick=${(e) => {
4846
+ e.stopPropagation();
4847
+ exportWorkflow(wf);
4848
+ }}
4849
+ >
4850
+ <span class="icon-inline">${resolveIcon("save")}</span>
4851
+ <//>
4359
4852
  ${!isCore && html`<${Button} variant="text" size="small" sx=${{ fontSize: '11px', color: '#ef4444', textTransform: 'none' }} onClick=${(e) => { e.stopPropagation(); if (confirm("Delete " + wf.name + "?")) deleteWorkflow(wf.id); }}>
4360
4853
  <span class="icon-inline">${resolveIcon("trash")}</span>
4361
4854
  <//>`}
@@ -4700,11 +5193,36 @@ function RunHistoryView() {
4700
5193
  });
4701
5194
  return true;
4702
5195
  }, [canLoadMoreRuns, loadingMoreRuns, runs.length, totalRuns]);
5196
+ const openRunCopilot = useCallback((run, intent = "ask") => {
5197
+ const safeRunId = String(run?.runId || "").trim();
5198
+ const safeIntent = String(intent || "ask").trim().toLowerCase();
5199
+ if (!safeRunId) return null;
5200
+ return startWorkflowCopilotSession({
5201
+ endpoint: `/api/workflows/runs/${encodeURIComponent(safeRunId)}/copilot-context?intent=${encodeURIComponent(safeIntent)}`,
5202
+ fallbackPrompt: buildRunCopilotPrompt(run, safeIntent),
5203
+ title: `${safeIntent === "fix" ? "Fix failed workflow run" : "Ask about workflow run"} ${safeRunId}`.trim(),
5204
+ successToast: safeIntent === "fix" ? "Opened failed-run fix chat" : "Opened workflow run analysis chat",
5205
+ });
5206
+ }, []);
5207
+ const openRunNodeCopilot = useCallback((run, nodeId, intent = "node", workflow = null) => {
5208
+ const safeRunId = String(run?.runId || "").trim();
5209
+ const safeNodeId = String(nodeId || "").trim();
5210
+ const safeIntent = String(intent || "node").trim().toLowerCase();
5211
+ if (!safeRunId || !safeNodeId) return null;
5212
+ return startWorkflowCopilotSession({
5213
+ endpoint: `/api/workflows/runs/${encodeURIComponent(safeRunId)}/copilot-context?intent=${encodeURIComponent(safeIntent)}&nodeId=${encodeURIComponent(safeNodeId)}`,
5214
+ fallbackPrompt: buildRunNodeCopilotPrompt(run, safeNodeId, { intent: safeIntent, workflow }),
5215
+ title: `${safeIntent === "fix" ? "Fix node" : "Ask Bosun about node"} ${safeNodeId}`.trim(),
5216
+ successToast: safeIntent === "fix" ? "Opened node fix chat" : "Opened node copilot chat",
5217
+ });
5218
+ }, []);
4703
5219
 
4704
5220
  if (selectedRun) {
4705
5221
  const statusStyles = getRunStatusBadgeStyles(selectedRun.status);
4706
5222
  const logs = Array.isArray(selectedRun?.detail?.logs) ? selectedRun.detail.logs : [];
4707
5223
  const errors = Array.isArray(selectedRun?.detail?.errors) ? selectedRun.detail.errors : [];
5224
+ const currentWorkflow =
5225
+ (workflows.value || []).find((workflow) => workflow?.id === selectedRun.workflowId) || null;
4708
5226
  const nodeStatuses = buildNodeStatusesFromRunDetail(selectedRun);
4709
5227
  const nodeOutputs = selectedRun?.detail?.nodeOutputs || {};
4710
5228
  const nodeIds = Object.keys(nodeStatuses).sort((a, b) => {
@@ -4731,6 +5249,63 @@ function RunHistoryView() {
4731
5249
  ${selectedRun.workflowId && html`<${Button} variant="text" size="small" onClick=${() => openWorkflowCanvas(selectedRun.workflowId)}>Open Workflow<//>`}
4732
5250
  <h2 style="margin: 0; font-size: 18px; font-weight: 700;">Run Details</h2>
4733
5251
  <${Button} variant="text" size="small" onClick=${() => loadRunDetail(selectedRun.runId)}>Refresh<//>
5252
+ <${Button}
5253
+ variant="outlined"
5254
+ size="small"
5255
+ onClick=${() => openRunCopilot(selectedRun, "ask")}
5256
+ >
5257
+ <span class="btn-icon">${resolveIcon("bot")}</span>
5258
+ Ask Bosun
5259
+ <//>
5260
+ ${selectedRun.status === "failed" && html`
5261
+ <${Button}
5262
+ variant="contained"
5263
+ size="small"
5264
+ color="error"
5265
+ onClick=${() => openRunCopilot(selectedRun, "fix")}
5266
+ >
5267
+ <span class="btn-icon">${resolveIcon("settings")}</span>
5268
+ Fix With Bosun
5269
+ <//>
5270
+ `}
5271
+ ${selectedRun.status === "failed" && html`
5272
+ <${Button}
5273
+ variant="contained"
5274
+ size="small"
5275
+ color="warning"
5276
+ onClick=${async () => {
5277
+ try {
5278
+ await apiFetch("/api/workflows/runs/" + encodeURIComponent(selectedRun.runId) + "/retry", { method: "POST" });
5279
+ showToast("Run retry initiated", "success");
5280
+ setTimeout(() => loadRunDetail(selectedRun.runId), 1000);
5281
+ } catch (err) {
5282
+ showToast("Retry failed: " + (err.message || err), "error");
5283
+ }
5284
+ }}
5285
+ >
5286
+ <span class="btn-icon">${resolveIcon("refresh")}</span>
5287
+ Retry Run
5288
+ <//>
5289
+ `}
5290
+ ${selectedRun.status === "running" && html`
5291
+ <${Button}
5292
+ variant="contained"
5293
+ size="small"
5294
+ color="error"
5295
+ onClick=${async () => {
5296
+ try {
5297
+ await apiFetch("/api/workflows/runs/" + encodeURIComponent(selectedRun.runId) + "/cancel", { method: "POST" });
5298
+ showToast("Run cancellation requested", "success");
5299
+ setTimeout(() => loadRunDetail(selectedRun.runId), 1000);
5300
+ } catch (err) {
5301
+ showToast("Cancel failed: " + (err.message || err), "error");
5302
+ }
5303
+ }}
5304
+ >
5305
+ <span class="btn-icon">${resolveIcon("close")}</span>
5306
+ Stop Run
5307
+ <//>
5308
+ `}
4734
5309
  </div>
4735
5310
 
4736
5311
  <div style="background: var(--color-bg-secondary, #1a1f2e); border-radius: 10px; border: 1px solid var(--color-border, #2a3040); padding: 14px; margin-bottom: 12px;">
@@ -4783,6 +5358,27 @@ function RunHistoryView() {
4783
5358
  ${nodeNarrative ? html`<div style="margin-top: ${nodeSummary ? "6px" : "0"};"><b>Narrative:</b> ${nodeNarrative}</div>` : ""}
4784
5359
  </div>
4785
5360
  `}
5361
+ <div style="display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px;">
5362
+ <${Button}
5363
+ variant="outlined"
5364
+ size="small"
5365
+ onClick=${() => openRunNodeCopilot(selectedRun, nodeId, "node", currentWorkflow)}
5366
+ >
5367
+ <span class="btn-icon">${resolveIcon("bot")}</span>
5368
+ Ask Bosun About This Node
5369
+ <//>
5370
+ ${String(nodeStatus || "").trim().toLowerCase() === "failed" && html`
5371
+ <${Button}
5372
+ variant="contained"
5373
+ size="small"
5374
+ color="error"
5375
+ onClick=${() => openRunNodeCopilot(selectedRun, nodeId, "fix", currentWorkflow)}
5376
+ >
5377
+ <span class="btn-icon">${resolveIcon("settings")}</span>
5378
+ Fix This Node
5379
+ <//>
5380
+ `}
5381
+ </div>
4786
5382
  <pre style="margin-top: 8px; white-space: pre-wrap; word-break: break-word; font-size: 11px; color: #c9d1d9; background: #111827; border-radius: 6px; padding: 8px;">${safePrettyJson(nodeOutput)}</pre>
4787
5383
  </details>
4788
5384
  `;
@@ -4977,6 +5573,171 @@ function RunHistoryView() {
4977
5573
  `;
4978
5574
  }
4979
5575
 
5576
+ /* ═══════════════════════════════════════════════════════════════
5577
+ * Code View — JSON Editor for Workflows
5578
+ * ═══════════════════════════════════════════════════════════════ */
5579
+
5580
+ function WorkflowCodeView({ workflow, onSave }) {
5581
+ const [code, setCode] = useState("");
5582
+ const [originalCode, setOriginalCode] = useState("");
5583
+ const [errors, setErrors] = useState([]);
5584
+ const [isDirty, setIsDirty] = useState(false);
5585
+ const [saving, setSaving] = useState(false);
5586
+ const textareaRef = useRef(null);
5587
+
5588
+ useEffect(() => {
5589
+ if (!workflow?.id) return;
5590
+ apiFetch(`/api/workflows/${encodeURIComponent(workflow.id)}/code`)
5591
+ .then(data => {
5592
+ if (data?.code) {
5593
+ setCode(data.code);
5594
+ setOriginalCode(data.code);
5595
+ setErrors([]);
5596
+ setIsDirty(false);
5597
+ }
5598
+ })
5599
+ .catch(() => showToast("Failed to load workflow code", "error"));
5600
+ }, [workflow?.id]);
5601
+
5602
+ const handleCodeChange = useCallback((e) => {
5603
+ const newCode = e.target.value;
5604
+ setCode(newCode);
5605
+ setIsDirty(newCode !== originalCode);
5606
+
5607
+ try {
5608
+ JSON.parse(newCode);
5609
+ setErrors([]);
5610
+ } catch (err) {
5611
+ const lineMatch = String(err.message).match(/position\s+(\d+)/i);
5612
+ let line;
5613
+ if (lineMatch) {
5614
+ const pos = parseInt(lineMatch[1], 10);
5615
+ line = newCode.slice(0, pos).split("\n").length;
5616
+ }
5617
+ setErrors([{ message: err.message, line }]);
5618
+ }
5619
+ }, [originalCode]);
5620
+
5621
+ const handleSave = useCallback(async () => {
5622
+ if (!workflow?.id || !isDirty) return;
5623
+ setSaving(true);
5624
+ try {
5625
+ const resp = await apiFetch(`/api/workflows/${encodeURIComponent(workflow.id)}/code`, {
5626
+ method: "PUT",
5627
+ body: JSON.stringify({ code }),
5628
+ });
5629
+ if (resp?.ok) {
5630
+ showToast("Workflow updated from code", "success");
5631
+ setOriginalCode(code);
5632
+ setIsDirty(false);
5633
+ if (resp.workflow && onSave) onSave(resp.workflow);
5634
+ } else if (resp?.errors) {
5635
+ setErrors(resp.errors.map(e => typeof e === "string" ? { message: e } : e));
5636
+ showToast("Validation errors — check below", "error");
5637
+ }
5638
+ } catch (err) {
5639
+ showToast("Failed to save: " + (err.message || err), "error");
5640
+ } finally {
5641
+ setSaving(false);
5642
+ }
5643
+ }, [workflow?.id, code, isDirty, onSave]);
5644
+
5645
+ const handleRevert = useCallback(() => {
5646
+ setCode(originalCode);
5647
+ setIsDirty(false);
5648
+ setErrors([]);
5649
+ }, [originalCode]);
5650
+
5651
+ const handleFormat = useCallback(() => {
5652
+ try {
5653
+ const parsed = JSON.parse(code);
5654
+ const formatted = JSON.stringify(parsed, null, 2);
5655
+ setCode(formatted);
5656
+ setIsDirty(formatted !== originalCode);
5657
+ setErrors([]);
5658
+ } catch {
5659
+ showToast("Cannot format — fix JSON syntax errors first", "warning");
5660
+ }
5661
+ }, [code, originalCode]);
5662
+
5663
+ const lineCount = code.split("\n").length;
5664
+
5665
+ return html`
5666
+ <div style="padding: 0 4px; display: flex; flex-direction: column; height: calc(100vh - 120px);">
5667
+ <!-- Toolbar -->
5668
+ <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap;">
5669
+ <${Button} variant="text" size="small" onClick=${() => { viewMode.value = "canvas"; }}>
5670
+ ← Back to Canvas
5671
+ <//>
5672
+ <h2 style="margin: 0; font-size: 18px; font-weight: 700; flex: 1;">
5673
+ Code View: ${workflow?.name || "Workflow"}
5674
+ </h2>
5675
+ <${Button} variant="outlined" size="small" onClick=${handleFormat} disabled=${saving}>
5676
+ Format JSON
5677
+ <//>
5678
+ <${Button} variant="outlined" size="small" onClick=${handleRevert} disabled=${!isDirty || saving}>
5679
+ Revert
5680
+ <//>
5681
+ <${Button} variant="contained" size="small" onClick=${handleSave} disabled=${!isDirty || errors.length > 0 || saving}>
5682
+ ${saving ? "Saving…" : "Save"}
5683
+ <//>
5684
+ ${isDirty && html`
5685
+ <${Chip} label="Unsaved changes" size="small" color="warning" variant="outlined" />
5686
+ `}
5687
+ </div>
5688
+
5689
+ <!-- Error bar -->
5690
+ ${errors.length > 0 && html`
5691
+ <${Alert} severity="error" style="margin-bottom: 8px; font-size: 12px;">
5692
+ ${errors.map((e, i) => html`
5693
+ <div key=${i}>${e.line ? `Line ${e.line}: ` : ""}${e.message}</div>
5694
+ `)}
5695
+ <//>
5696
+ `}
5697
+
5698
+ <!-- Editor area -->
5699
+ <div style="flex: 1; display: flex; border: 1px solid ${errors.length > 0 ? '#ef4444' : 'var(--color-border, #2a3040)'}; border-radius: 8px; overflow: hidden; background: #0d1117;">
5700
+ <!-- Line numbers -->
5701
+ <div style="padding: 12px 8px; background: #161b22; color: #484f58; font-family: 'Fira Code', 'Cascadia Code', monospace; font-size: 12px; line-height: 1.6; text-align: right; user-select: none; min-width: 48px; border-right: 1px solid #21262d;">
5702
+ ${Array.from({ length: lineCount }, (_, i) => html`<div key=${i}>${i + 1}</div>`)}
5703
+ </div>
5704
+
5705
+ <!-- Code textarea -->
5706
+ <textarea
5707
+ ref=${textareaRef}
5708
+ value=${code}
5709
+ onInput=${handleCodeChange}
5710
+ spellcheck=${false}
5711
+ style="flex: 1; padding: 12px; background: transparent; color: #e6edf3; font-family: 'Fira Code', 'Cascadia Code', monospace; font-size: 12px; line-height: 1.6; border: none; outline: none; resize: none; tab-size: 2; white-space: pre; overflow: auto;"
5712
+ onKeyDown=${(e) => {
5713
+ if (e.key === "Tab") {
5714
+ e.preventDefault();
5715
+ const ta = e.target;
5716
+ const start = ta.selectionStart;
5717
+ const end = ta.selectionEnd;
5718
+ const newVal = code.slice(0, start) + " " + code.slice(end);
5719
+ setCode(newVal);
5720
+ setIsDirty(newVal !== originalCode);
5721
+ requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = start + 2; });
5722
+ }
5723
+ if ((e.ctrlKey || e.metaKey) && e.key === "s") {
5724
+ e.preventDefault();
5725
+ handleSave();
5726
+ }
5727
+ }}
5728
+ />
5729
+ </div>
5730
+
5731
+ <!-- Status bar -->
5732
+ <div style="display: flex; align-items: center; gap: 12px; padding: 6px 4px; font-size: 11px; color: var(--color-text-secondary, #8b95a5);">
5733
+ <span>${lineCount} lines</span>
5734
+ <span>${code.length} characters</span>
5735
+ <span>JSON${errors.length === 0 ? " ✓" : " ✗"}</span>
5736
+ </div>
5737
+ </div>
5738
+ `;
5739
+ }
5740
+
4980
5741
  /* ═══════════════════════════════════════════════════════════════
4981
5742
  * Main Tab Export
4982
5743
  * ═══════════════════════════════════════════════════════════════ */
@@ -5246,7 +6007,9 @@ export function WorkflowsTab() {
5246
6007
  class="wf-theme"
5247
6008
  style="padding: 8px; --color-bg: var(--bg-card); --color-bg-secondary: var(--bg-secondary); --color-border: var(--border); --color-text: var(--text-primary); --color-text-secondary: var(--text-secondary);"
5248
6009
  >
5249
- ${mode === "canvas" && activeWorkflow.value
6010
+ ${mode === "code" && activeWorkflow.value
6011
+ ? html`<${WorkflowCodeView} workflow=${activeWorkflow.value} onSave=${(wf) => { activeWorkflow.value = wf; viewMode.value = "canvas"; }} />`
6012
+ : mode === "canvas" && activeWorkflow.value
5250
6013
  ? html`<${WorkflowCanvas} workflow=${activeWorkflow.value} nodeTypes=${nodeTypes.value} />`
5251
6014
  : mode === "runs"
5252
6015
  ? html`<${RunHistoryView} />`