@aion0/forge 0.6.1 → 0.8.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 (145) hide show
  1. package/.forge/mcp.json +8 -0
  2. package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/01-settings.md +5 -5
  3. package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/07-projects.md +1 -1
  4. package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/01-settings.md +5 -5
  5. package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/07-projects.md +1 -1
  6. package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/01-settings.md +5 -5
  7. package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/07-projects.md +1 -1
  8. package/.forge/worktrees/pipeline-316c6574/lib/help-docs/01-settings.md +5 -5
  9. package/.forge/worktrees/pipeline-316c6574/lib/help-docs/07-projects.md +1 -1
  10. package/.forge/worktrees/pipeline-44a94121/lib/help-docs/01-settings.md +5 -5
  11. package/.forge/worktrees/pipeline-44a94121/lib/help-docs/07-projects.md +1 -1
  12. package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/01-settings.md +5 -5
  13. package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/07-projects.md +1 -1
  14. package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/01-settings.md +5 -5
  15. package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/07-projects.md +1 -1
  16. package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/01-settings.md +5 -5
  17. package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/07-projects.md +1 -1
  18. package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/01-settings.md +5 -5
  19. package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/07-projects.md +1 -1
  20. package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/01-settings.md +5 -5
  21. package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/07-projects.md +1 -1
  22. package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/01-settings.md +5 -5
  23. package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/07-projects.md +1 -1
  24. package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/01-settings.md +5 -5
  25. package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/07-projects.md +1 -1
  26. package/CLAUDE.md +2 -2
  27. package/RELEASE_NOTES.md +101 -5
  28. package/app/api/auth/check/route.ts +18 -0
  29. package/app/api/browser-bridge/route.ts +70 -0
  30. package/app/api/chat/sessions/[id]/events/route.ts +17 -0
  31. package/app/api/chat/sessions/[id]/fork/route.ts +15 -0
  32. package/app/api/chat/sessions/[id]/messages/route.ts +21 -0
  33. package/app/api/chat/sessions/[id]/route.ts +23 -0
  34. package/app/api/chat/sessions/route.ts +12 -0
  35. package/app/api/chat/temper-ping/route.ts +18 -0
  36. package/app/api/chat-proxy/[...path]/route.ts +83 -0
  37. package/app/api/connector-tool/route.ts +38 -0
  38. package/app/api/connectors/[id]/settings/route.ts +112 -0
  39. package/app/api/connectors/route.ts +108 -0
  40. package/app/api/health/tools/route.ts +14 -0
  41. package/app/api/issue-scanner-gitlab/route.ts +95 -0
  42. package/app/api/jobs/[id]/reset_dedup/route.ts +15 -0
  43. package/app/api/jobs/[id]/route.ts +31 -0
  44. package/app/api/jobs/[id]/run/route.ts +44 -0
  45. package/app/api/jobs/[id]/runs/[runId]/route.ts +15 -0
  46. package/app/api/jobs/[id]/runs/route.ts +15 -0
  47. package/app/api/jobs/preview/route.ts +193 -0
  48. package/app/api/jobs/route.ts +36 -0
  49. package/app/api/notify/test/route.ts +39 -7
  50. package/app/api/pipelines/[id]/route.ts +10 -1
  51. package/app/api/pipelines/route.ts +16 -2
  52. package/app/api/plugins/route.ts +40 -8
  53. package/app/api/project-sessions/route.ts +50 -10
  54. package/app/api/settings/route.ts +13 -0
  55. package/app/chat/page.tsx +531 -0
  56. package/bin/forge-server.mjs +3 -1
  57. package/cli/chat.ts +283 -0
  58. package/cli/jobs.ts +176 -0
  59. package/cli/mw.ts +28 -1
  60. package/cli/worktree.ts +245 -0
  61. package/components/ConnectorsPanel.tsx +275 -0
  62. package/components/Dashboard.tsx +90 -37
  63. package/components/JobsView.tsx +361 -0
  64. package/components/LogViewer.tsx +12 -2
  65. package/components/PipelineView.tsx +275 -56
  66. package/components/PluginsPanel.tsx +3 -1
  67. package/components/SettingsModal.tsx +229 -40
  68. package/components/SkillsPanel.tsx +12 -4
  69. package/components/TerminalLauncher.tsx +3 -1
  70. package/components/WebTerminal.tsx +32 -9
  71. package/components/WorkspaceView.tsx +18 -10
  72. package/docs/Connector-DeclarativeExtract-Handoff.md +471 -0
  73. package/docs/Connector-DeclarativeExtract-Spec.md +364 -0
  74. package/docs/Implementation-Plan-Browser-Agent.md +487 -0
  75. package/docs/Jobs-Design.md +240 -0
  76. package/docs/LOCAL-DEPLOY.md +3 -3
  77. package/docs/RFC-Browser-Connectors.md +509 -0
  78. package/lib/agents/index.ts +44 -6
  79. package/lib/agents/types.ts +1 -1
  80. package/lib/browser-bridge-standalone.ts +317 -0
  81. package/lib/builtin-plugins/github-api.yaml +93 -0
  82. package/lib/builtin-plugins/gitlab.yaml +860 -0
  83. package/lib/builtin-plugins/mantis.probe.js +176 -0
  84. package/lib/builtin-plugins/mantis.yaml +964 -0
  85. package/lib/builtin-plugins/pmdb.yaml +178 -0
  86. package/lib/builtin-plugins/teams.yaml +913 -0
  87. package/lib/chat/__test__/smoke.ts +30 -0
  88. package/lib/chat/agent-loop.ts +523 -0
  89. package/lib/chat/bridge-client.ts +59 -0
  90. package/lib/chat/llm/anthropic.ts +99 -0
  91. package/lib/chat/llm/index.ts +20 -0
  92. package/lib/chat/llm/openai.ts +215 -0
  93. package/lib/chat/llm/types.ts +42 -0
  94. package/lib/chat/local-memory.ts +300 -0
  95. package/lib/chat/memory-store.ts +87 -0
  96. package/lib/chat/memory-tools.ts +157 -0
  97. package/lib/chat/protocols/http.ts +118 -0
  98. package/lib/chat/protocols/shell.ts +101 -0
  99. package/lib/chat/proxy.ts +51 -0
  100. package/lib/chat/session-store.ts +272 -0
  101. package/lib/chat/telegram-bridge.ts +276 -0
  102. package/lib/chat/temper.ts +281 -0
  103. package/lib/chat/tool-dispatcher.ts +190 -0
  104. package/lib/chat/types.ts +50 -0
  105. package/lib/chat-standalone.ts +286 -0
  106. package/lib/crypto.ts +1 -1
  107. package/lib/health.ts +131 -0
  108. package/lib/help-docs/00-overview.md +2 -1
  109. package/lib/help-docs/01-settings.md +46 -25
  110. package/lib/help-docs/07-projects.md +1 -1
  111. package/lib/help-docs/10-troubleshooting.md +10 -2
  112. package/lib/help-docs/16-gitlab-autofix.md +114 -0
  113. package/lib/help-docs/17-connectors.md +322 -0
  114. package/lib/help-docs/18-chrome-mcp.md +134 -0
  115. package/lib/help-docs/19-jobs.md +140 -0
  116. package/lib/help-docs/20-mantis-bug-fix.md +115 -0
  117. package/lib/help-docs/CLAUDE.md +10 -0
  118. package/lib/init.ts +137 -50
  119. package/lib/iso-time.ts +30 -0
  120. package/lib/issue-scanner-gitlab.ts +281 -0
  121. package/lib/jobs/dispatcher.ts +217 -0
  122. package/lib/jobs/scheduler.ts +334 -0
  123. package/lib/jobs/store.ts +319 -0
  124. package/lib/jobs/types.ts +117 -0
  125. package/lib/pipeline-scheduler.ts +1 -6
  126. package/lib/pipeline.ts +790 -10
  127. package/lib/plugins/registry.ts +133 -8
  128. package/lib/plugins/templates.ts +83 -0
  129. package/lib/plugins/types.ts +140 -1
  130. package/lib/session-watcher.ts +36 -10
  131. package/lib/settings.ts +65 -33
  132. package/lib/skills.ts +3 -1
  133. package/lib/task-manager.ts +50 -22
  134. package/lib/telegram-bot.ts +71 -0
  135. package/lib/terminal-standalone.ts +58 -36
  136. package/lib/workspace/orchestrator.ts +1 -0
  137. package/middleware.ts +10 -0
  138. package/package.json +3 -2
  139. package/scripts/bench/README.md +1 -1
  140. package/scripts/bench/tasks/01-text-utils/validator.sh +1 -1
  141. package/scripts/bench/tasks/02-pagination/setup.sh +1 -1
  142. package/scripts/bench/tasks/02-pagination/validator.sh +1 -1
  143. package/scripts/bench/tasks/03-bug-fix/setup.sh +1 -1
  144. package/scripts/bench/tasks/03-bug-fix/validator.sh +1 -1
  145. package/src/core/db/database.ts +21 -12
@@ -149,14 +149,16 @@ const STATUS_COLOR: Record<string, string> = {
149
149
 
150
150
  // ─── DAG Node Card with live logs ─────────────────────────
151
151
 
152
- function DagNodeCard({ nodeId, node, nodeDef, onViewTask }: {
152
+ function DagNodeCard({ nodeId, node, nodeDef, onViewTask, onRetry }: {
153
153
  nodeId: string;
154
154
  node: PipelineNodeState;
155
155
  nodeDef?: WorkflowNode;
156
156
  onViewTask?: (taskId: string) => void;
157
+ onRetry?: (nodeId: string) => void;
157
158
  }) {
158
159
  const isRunning = node.status === 'running';
159
160
  const { log } = useTaskStream(node.taskId, isRunning);
161
+ const [retryBusy, setRetryBusy] = useState(false);
160
162
 
161
163
  return (
162
164
  <div className={`border rounded-lg p-3 ${
@@ -177,6 +179,16 @@ function DagNodeCard({ nodeId, node, nodeDef, onViewTask }: {
177
179
  </button>
178
180
  )}
179
181
  {node.iterations > 1 && <span className="text-[9px] text-yellow-400">iter {node.iterations}</span>}
182
+ {node.status === 'failed' && onRetry && (
183
+ <button
184
+ onClick={async () => { setRetryBusy(true); try { await onRetry(nodeId); } finally { setRetryBusy(false); } }}
185
+ disabled={retryBusy}
186
+ className="text-[9px] px-1.5 py-0.5 rounded bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-300 disabled:opacity-50"
187
+ title="Reset this node + all downstream to pending and re-run. Useful when an upstream node like fix-code already produced a valid worktree commit and only the later step (push-and-mr / notify-teams) needs to be re-tried."
188
+ >
189
+ {retryBusy ? 'retrying…' : '↻ retry'}
190
+ </button>
191
+ )}
180
192
  <span className="text-[9px] text-[var(--text-secondary)] ml-auto">{node.status}</span>
181
193
  </div>
182
194
 
@@ -449,38 +461,117 @@ export default function PipelineView({ onViewTask, focusPipelineId, onFocusHandl
449
461
  const [importYaml, setImportYaml] = useState('');
450
462
  const [agents, setAgents] = useState<{ id: string; name: string; detected?: boolean }[]>([]);
451
463
 
452
- const fetchData = useCallback(async () => {
453
- const [pRes, wRes, projRes, agentRes] = await Promise.all([
454
- fetch('/api/pipelines'),
464
+ // "Load older runs" state, per-workflow. The initial /api/pipelines fetch
465
+ // returns the newest 100 across ALL workflows; if a particular workflow
466
+ // had older history beyond that window, the user can expand it and click
467
+ // "Load older runs". `expanded` = the user opted in to show all loaded
468
+ // runs (not just slice 20); `exhausted` = last load returned <50 → no more.
469
+ const [expandedHistory, setExpandedHistory] = useState<Set<string>>(new Set());
470
+ const [exhaustedHistory, setExhaustedHistory] = useState<Set<string>>(new Set());
471
+ const [loadingMoreFor, setLoadingMoreFor] = useState<string | null>(null);
472
+
473
+ async function loadOlderRuns(workflowName: string) {
474
+ const existing = pipelines.filter(p => p.workflowName === workflowName);
475
+ let oldest = existing[0]?.createdAt;
476
+ for (const p of existing) if (p.createdAt < oldest) oldest = p.createdAt;
477
+ if (!oldest) return;
478
+ setLoadingMoreFor(workflowName);
479
+ try {
480
+ const res = await fetch(`/api/pipelines?workflow=${encodeURIComponent(workflowName)}&before=${encodeURIComponent(oldest)}&limit=50`);
481
+ const more: Pipeline[] = await res.json();
482
+ if (Array.isArray(more) && more.length > 0) {
483
+ setPipelines(prev => {
484
+ const seen = new Set(prev.map(p => p.id));
485
+ return [...prev, ...more.filter(p => !seen.has(p.id))];
486
+ });
487
+ }
488
+ setExpandedHistory(prev => new Set(prev).add(workflowName));
489
+ if (!Array.isArray(more) || more.length < 50) {
490
+ setExhaustedHistory(prev => new Set(prev).add(workflowName));
491
+ }
492
+ } finally {
493
+ setLoadingMoreFor(null);
494
+ }
495
+ }
496
+
497
+ // Lightweight metadata only — no pipeline runs. Runs are lazy-loaded
498
+ // per-workflow on click. Reduces initial page to a single workflow
499
+ // table fetch + projects + agents, even with hundreds of runs on disk.
500
+ const fetchMeta = useCallback(async () => {
501
+ const [wRes, projRes, agentRes] = await Promise.all([
455
502
  fetch('/api/pipelines?type=workflows'),
456
503
  fetch('/api/projects'),
457
504
  fetch('/api/agents'),
458
505
  ]);
459
- const pData = await pRes.json();
460
- const wData = await wRes.json();
461
- const projData = await projRes.json();
462
- const agentData = await agentRes.json();
463
- if (Array.isArray(pData)) setPipelines(pData);
506
+ const [wData, projData, agentData] = await Promise.all([wRes.json(), projRes.json(), agentRes.json()]);
464
507
  if (Array.isArray(wData)) setWorkflows(wData);
465
508
  if (Array.isArray(projData)) setProjects(projData.map((p: any) => ({ name: p.name, path: p.path })));
466
509
  if (Array.isArray(agentData?.agents)) setAgents(agentData.agents);
467
510
  }, []);
468
511
 
512
+ // Fetch the run history for ONE workflow. Used on first expand + on
513
+ // the 5s polling tick (only for the currently active workflow).
514
+ const fetchWorkflowRuns = useCallback(async (workflowName: string, opts: { append?: boolean } = {}) => {
515
+ try {
516
+ const res = await fetch(`/api/pipelines?workflow=${encodeURIComponent(workflowName)}&limit=100`);
517
+ const data: Pipeline[] = await res.json();
518
+ if (!Array.isArray(data)) return;
519
+ setPipelines(prev => {
520
+ if (!opts.append) {
521
+ // Replace this workflow's runs (covers status changes during polling),
522
+ // keep other workflows' loaded runs untouched.
523
+ const others = prev.filter(p => p.workflowName !== workflowName);
524
+ return [...others, ...data];
525
+ }
526
+ const seen = new Set(prev.map(p => p.id));
527
+ return [...prev, ...data.filter(p => !seen.has(p.id))];
528
+ });
529
+ } catch { /* ignore — polling will retry */ }
530
+ }, []);
531
+
532
+ // fetchData kept for places that explicitly want "refresh everything"
533
+ // (e.g. after Cancel/Delete actions). Just wraps the two pieces.
534
+ const fetchData = useCallback(async () => {
535
+ await fetchMeta();
536
+ if (activeWorkflow) await fetchWorkflowRuns(activeWorkflow);
537
+ }, [fetchMeta, fetchWorkflowRuns, activeWorkflow]);
538
+
539
+ useEffect(() => { void fetchMeta(); }, [fetchMeta]);
540
+
541
+ // Polling — refresh ONLY the active workflow (5s while expanded).
542
+ // Stops polling entirely when nothing is expanded — no background
543
+ // network traffic just from having the page open.
469
544
  useEffect(() => {
470
- fetchData();
471
- const timer = setInterval(fetchData, 5000);
545
+ if (!activeWorkflow) return;
546
+ void fetchWorkflowRuns(activeWorkflow);
547
+ const timer = setInterval(() => { void fetchWorkflowRuns(activeWorkflow); }, 5000);
472
548
  return () => clearInterval(timer);
473
- }, [fetchData]);
549
+ }, [activeWorkflow, fetchWorkflowRuns]);
474
550
 
475
551
  // Focus on a specific pipeline (from external navigation)
476
552
  useEffect(() => {
477
- if (!focusPipelineId || pipelines.length === 0) return;
478
- const target = pipelines.find(p => p.id === focusPipelineId);
479
- if (target) {
480
- setSelectedPipeline(target);
481
- setShowEditor(false);
482
- onFocusHandled?.();
483
- }
553
+ if (!focusPipelineId) return;
554
+ // External navigation may target a pipeline whose workflow isn't
555
+ // expanded yet (and so its runs aren't in `pipelines`). Look it up
556
+ // directly via /api/pipelines/:id, set as selected, and also expand
557
+ // its workflow rail-side so the user can see context.
558
+ // Always fetch the FULL pipeline — the in-memory cache only holds
559
+ // summary projections (no node.outputs, no conversation.messages),
560
+ // and the detail panel needs the heavy fields.
561
+ let cancelled = false;
562
+ void (async () => {
563
+ try {
564
+ const res = await fetch(`/api/pipelines/${encodeURIComponent(focusPipelineId)}`);
565
+ if (!res.ok) return;
566
+ const data: Pipeline = await res.json();
567
+ if (cancelled || !data?.id) return;
568
+ setSelectedPipeline(data);
569
+ setShowEditor(false);
570
+ if (data.workflowName) setActiveWorkflow(data.workflowName);
571
+ onFocusHandled?.();
572
+ } catch { /* ignore */ }
573
+ })();
574
+ return () => { cancelled = true; };
484
575
  }, [focusPipelineId, pipelines, onFocusHandled]);
485
576
 
486
577
  // Refresh selected pipeline
@@ -527,6 +618,24 @@ export default function PipelineView({ onViewTask, focusPipelineId, onFocusHandl
527
618
  }
528
619
  };
529
620
 
621
+ const handleRetryNode = async (pipelineId: string, nodeId: string) => {
622
+ const res = await fetch(`/api/pipelines/${pipelineId}`, {
623
+ method: 'POST',
624
+ headers: { 'Content-Type': 'application/json' },
625
+ body: JSON.stringify({ action: 'retry-node', nodeId }),
626
+ });
627
+ if (!res.ok) {
628
+ const j = await res.json().catch(() => ({}));
629
+ alert(`Retry failed: ${j?.error || res.statusText}`);
630
+ return;
631
+ }
632
+ fetchData();
633
+ if (selectedPipeline?.id === pipelineId) {
634
+ const r = await fetch(`/api/pipelines/${pipelineId}`);
635
+ setSelectedPipeline(await r.json());
636
+ }
637
+ };
638
+
530
639
  const handleDelete = async (id: string) => {
531
640
  if (!confirm('Delete this pipeline?')) return;
532
641
  await fetch(`/api/pipelines/${id}`, {
@@ -566,7 +675,9 @@ initial_prompt: "{{input.task}}"
566
675
  const currentWorkflow = workflows.find(w => w.name === selectedWorkflow);
567
676
 
568
677
  return (
569
- <div className="flex-1 flex min-h-0">
678
+ <div className="flex-1 flex flex-col min-h-0">
679
+ <MissingToolsBanner />
680
+ <div className="flex-1 flex min-h-0">
570
681
  {/* Left — Workflow list */}
571
682
  <aside style={{ width: sidebarWidth }} className="flex flex-col shrink-0 overflow-hidden">
572
683
  <div className="px-3 py-2 border-b border-[var(--border)] flex items-center gap-1.5">
@@ -741,44 +852,85 @@ initial_prompt: "{{input.task}}"
741
852
  >{w.builtin ? 'View' : 'Edit'}</button>
742
853
  </div>
743
854
  {/* Execution history for this workflow */}
744
- {isActive && (
745
- <div className="bg-[var(--bg-tertiary)]/50">
746
- {runs.length === 0 ? (
747
- <div className="px-4 py-2 text-[9px] text-[var(--text-secondary)]">No runs yet</div>
748
- ) : (
749
- runs.sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 20).map(p => (
750
- <button
751
- key={p.id}
752
- onClick={() => setSelectedPipeline(p)}
753
- className={`w-full text-left px-4 py-1.5 border-b border-[var(--border)]/20 hover:bg-[var(--bg-tertiary)] ${
754
- selectedPipeline?.id === p.id ? 'bg-[var(--accent)]/5' : ''
755
- }`}
756
- >
757
- <div className="flex items-center gap-1.5">
758
- <span className={`text-[9px] ${STATUS_COLOR[p.status]}`}>●</span>
759
- <span className="text-[9px] text-[var(--text-secondary)] font-mono">{p.id.slice(0, 8)}</span>
760
- {p.type === 'conversation' ? (
761
- <span className="text-[7px] px-1 rounded bg-[var(--accent)]/15 text-[var(--accent)]">
762
- R{p.conversation?.currentRound || 0}/{p.conversation?.config.maxRounds || '?'}
763
- </span>
764
- ) : (
765
- <div className="flex gap-0.5 ml-1">
766
- {p.nodeOrder.map(nodeId => (
767
- <span key={nodeId} className={`text-[8px] ${STATUS_COLOR[p.nodes[nodeId]?.status || 'pending']}`}>
768
- {STATUS_ICON[p.nodes[nodeId]?.status || 'pending']}
855
+ {isActive && (() => {
856
+ const sorted = runs.slice().sort((a, b) => b.createdAt.localeCompare(a.createdAt));
857
+ // After clicking "Load older runs", show all loaded for this
858
+ // workflow. Otherwise slice to 20 to keep the rail compact.
859
+ const expanded = expandedHistory.has(w.name);
860
+ const visible = expanded ? sorted : sorted.slice(0, 20);
861
+ const showLoadMore = !exhaustedHistory.has(w.name) && (expanded || sorted.length >= 20);
862
+ return (
863
+ <div className="bg-[var(--bg-tertiary)]/50">
864
+ {sorted.length === 0 ? (
865
+ <div className="px-4 py-2 text-[9px] text-[var(--text-secondary)]">No runs yet</div>
866
+ ) : (
867
+ visible.map(p => (
868
+ <button
869
+ key={p.id}
870
+ onClick={async () => {
871
+ // The list payload is a LIGHT summary — no
872
+ // node.outputs, no conversation.messages.
873
+ // The detail panel walks those (e.g.
874
+ // Object.entries(p.nodes[id].outputs)) and
875
+ // crashes with "Cannot convert undefined to
876
+ // object" when handed a summary. Fetch the
877
+ // full pipeline before selecting.
878
+ try {
879
+ const res = await fetch(`/api/pipelines/${encodeURIComponent(p.id)}`);
880
+ if (res.ok) {
881
+ const full = await res.json();
882
+ setSelectedPipeline(full);
883
+ return;
884
+ }
885
+ } catch { /* fall back to summary */ }
886
+ setSelectedPipeline(p);
887
+ }}
888
+ className={`w-full text-left px-4 py-1.5 border-b border-[var(--border)]/20 hover:bg-[var(--bg-tertiary)] ${
889
+ selectedPipeline?.id === p.id ? 'bg-[var(--accent)]/5' : ''
890
+ }`}
891
+ >
892
+ <div className="flex items-center gap-1.5">
893
+ <span className={`text-[9px] ${STATUS_COLOR[p.status]}`}>●</span>
894
+ <span className="text-[9px] text-[var(--text-secondary)] font-mono">{p.id.slice(0, 8)}</span>
895
+ {p.type === 'conversation' ? (
896
+ <span className="text-[7px] px-1 rounded bg-[var(--accent)]/15 text-[var(--accent)]">
897
+ R{p.conversation?.currentRound || 0}/{p.conversation?.config.maxRounds || '?'}
769
898
  </span>
770
- ))}
899
+ ) : (
900
+ <div className="flex gap-0.5 ml-1">
901
+ {p.nodeOrder.map(nodeId => (
902
+ <span key={nodeId} className={`text-[8px] ${STATUS_COLOR[p.nodes[nodeId]?.status || 'pending']}`}>
903
+ {STATUS_ICON[p.nodes[nodeId]?.status || 'pending']}
904
+ </span>
905
+ ))}
906
+ </div>
907
+ )}
908
+ <span className="text-[8px] text-[var(--text-secondary)] ml-auto">
909
+ {new Date(p.createdAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
910
+ </span>
771
911
  </div>
772
- )}
773
- <span className="text-[8px] text-[var(--text-secondary)] ml-auto">
774
- {new Date(p.createdAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
775
- </span>
776
- </div>
912
+ </button>
913
+ ))
914
+ )}
915
+ {showLoadMore && (
916
+ <button
917
+ onClick={() => void loadOlderRuns(w.name)}
918
+ disabled={loadingMoreFor === w.name}
919
+ className="w-full text-center px-4 py-1.5 text-[9px] text-[var(--accent)] hover:bg-[var(--bg-tertiary)] disabled:opacity-50 border-b border-[var(--border)]/20"
920
+ >
921
+ {loadingMoreFor === w.name
922
+ ? 'Loading…'
923
+ : expanded
924
+ ? `↓ Load more (currently showing ${sorted.length})`
925
+ : `↓ Load older runs (more than 20)`}
777
926
  </button>
778
- ))
779
- )}
780
- </div>
781
- )}
927
+ )}
928
+ {exhaustedHistory.has(w.name) && expanded && (
929
+ <div className="px-4 py-1.5 text-[8px] text-center text-[var(--text-secondary)]">— end of history ({sorted.length} runs) —</div>
930
+ )}
931
+ </div>
932
+ );
933
+ })()}
782
934
  </div>
783
935
  );
784
936
  })}
@@ -892,7 +1044,13 @@ initial_prompt: "{{input.task}}"
892
1044
  <div className="w-px h-4 bg-[var(--border)]" />
893
1045
  </div>
894
1046
  )}
895
- <DagNodeCard nodeId={nodeId} node={node} nodeDef={nodeDef} onViewTask={onViewTask} />
1047
+ <DagNodeCard
1048
+ nodeId={nodeId}
1049
+ node={node}
1050
+ nodeDef={nodeDef}
1051
+ onViewTask={onViewTask}
1052
+ onRetry={(nid) => handleRetryNode(selectedPipeline.id, nid)}
1053
+ />
896
1054
  </div>
897
1055
  );
898
1056
  })}
@@ -1013,6 +1171,67 @@ initial_prompt: "{{input.task}}"
1013
1171
  </div>
1014
1172
  )}
1015
1173
  </main>
1174
+ </div>
1175
+ </div>
1176
+ );
1177
+ }
1178
+
1179
+ /**
1180
+ * Small banner shown atop PipelineView when an external CLI a built-in
1181
+ * workflow depends on (glab, gh, jq, git) isn't installed. Polls /api/health/tools
1182
+ * once on mount + lets the user expand each row for the copy-pasteable
1183
+ * install hint.
1184
+ */
1185
+ function MissingToolsBanner() {
1186
+ const [tools, setTools] = useState<Array<{ name: string; label: string; needed_for: string; installed: boolean; install_hint: string; version: string }>>([]);
1187
+ const [expanded, setExpanded] = useState<string>('');
1188
+ const [dismissed, setDismissed] = useState(false);
1189
+
1190
+ useEffect(() => {
1191
+ fetch('/api/health/tools')
1192
+ .then((r) => r.json())
1193
+ .then((j) => setTools(j.tools || []))
1194
+ .catch(() => {});
1195
+ }, []);
1196
+
1197
+ if (dismissed) return null;
1198
+ const missing = tools.filter((t) => !t.installed);
1199
+ if (missing.length === 0) return null;
1200
+
1201
+ return (
1202
+ <div className="border-b border-[var(--border)] bg-yellow-500/10 px-4 py-2">
1203
+ <div className="flex items-center gap-2">
1204
+ <span className="text-[11px] font-semibold text-yellow-500">⚠ Missing tools</span>
1205
+ <span className="text-[10.5px] text-[var(--text-secondary)]">
1206
+ Some built-in pipelines need these CLIs. Click a name for install instructions.
1207
+ </span>
1208
+ <span className="flex-1" />
1209
+ <button
1210
+ onClick={() => setDismissed(true)}
1211
+ className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
1212
+ title="Dismiss until next reload"
1213
+ >
1214
+ ×
1215
+ </button>
1216
+ </div>
1217
+ <div className="mt-1.5 flex flex-wrap gap-1.5">
1218
+ {missing.map((t) => (
1219
+ <div key={t.name} className="flex flex-col">
1220
+ <button
1221
+ onClick={() => setExpanded(expanded === t.name ? '' : t.name)}
1222
+ className="text-[10.5px] px-2 py-0.5 border border-yellow-500/40 text-yellow-500 rounded hover:bg-yellow-500/10"
1223
+ title={t.needed_for}
1224
+ >
1225
+ {t.name} {expanded === t.name ? '▾' : '▸'}
1226
+ </button>
1227
+ {expanded === t.name && (
1228
+ <pre className="text-[10px] font-mono whitespace-pre-wrap mt-1 p-2 bg-black/30 text-[var(--text-secondary)] border border-[var(--border)] rounded max-w-[520px]">
1229
+ {`# ${t.label} — needed for: ${t.needed_for}\n\n${t.install_hint}`}
1230
+ </pre>
1231
+ )}
1232
+ </div>
1233
+ ))}
1234
+ </div>
1016
1235
  </div>
1017
1236
  );
1018
1237
  }
@@ -29,6 +29,7 @@ interface PluginDetail {
29
29
  version: string;
30
30
  author?: string;
31
31
  description?: string;
32
+ category?: string;
32
33
  config: Record<string, { type: string; label?: string; description?: string; required?: boolean; default?: any; options?: string[] }>;
33
34
  params: Record<string, { type: string; label?: string; description?: string; required?: boolean; default?: any }>;
34
35
  actions: Record<string, { run: string; method?: string; url?: string; command?: string }>;
@@ -63,7 +64,8 @@ export default function PluginsPanel() {
63
64
  ]);
64
65
  const allData = await allRes.json();
65
66
  const instData = await instRes.json();
66
- setPlugins(allData.plugins || []);
67
+ // Connectors have their own panel — keep this one focused on pipeline plugins.
68
+ setPlugins((allData.plugins || []).filter((p: any) => p.category !== 'connector'));
67
69
  // Build instances list from installed plugins
68
70
  const inst: PluginInstance[] = (instData.plugins || []).map((p: any) => ({
69
71
  id: p.id,