@aion0/forge 0.10.79 → 0.10.81

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/RELEASE_NOTES.md CHANGED
@@ -1,12 +1,11 @@
1
- # Forge v0.10.79
1
+ # Forge v0.10.81
2
2
 
3
3
  Released: 2026-06-14
4
4
 
5
- ## Changes since v0.10.78
5
+ ## Changes since v0.10.80
6
6
 
7
7
  ### Other
8
- - feat(terminal): hide API key env vars from pane echo + container-aware claude auth
9
- - Move file-tree computation server-side (fix browser stack overflow on large repos) (#35)
8
+ - feat(ui): URL deep-linking + Automation/History default + Pipeline History page
10
9
 
11
10
 
12
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.78...v0.10.79
11
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.80...v0.10.81
@@ -0,0 +1,15 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { fireTmuxHook, completeStaleTmuxTask } from '@/lib/task-tmux-backend';
3
+
4
+ // Called by the Claude Code Stop hook when a tmux-backend task turn completes.
5
+ // The hook script (installed in ~/.claude/settings.json) reads task-context.json
6
+ // from the project dir and POSTs here. We resolve the awaited promise in executeTmuxTask.
7
+ // If no waiter exists (server restart mid-task), fall back to directly completing the task.
8
+ export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
9
+ const { id } = await params;
10
+ const handled = fireTmuxHook(id);
11
+ if (!handled) {
12
+ completeStaleTmuxTask(id);
13
+ }
14
+ return NextResponse.json({ ok: true });
15
+ }
@@ -14,7 +14,7 @@ export async function GET(req: Request) {
14
14
 
15
15
  // Create a new task
16
16
  export async function POST(req: Request) {
17
- const { projectName, prompt, priority, newSession, conversationId, scheduledAt, mode, watchConfig, agent } = await req.json();
17
+ const { projectName, prompt, priority, newSession, conversationId, scheduledAt, mode, watchConfig, agent, backend } = await req.json();
18
18
 
19
19
  if (!projectName || !prompt) {
20
20
  return NextResponse.json({ error: 'projectName and prompt are required' }, { status: 400 });
@@ -38,6 +38,7 @@ export async function POST(req: Request) {
38
38
  mode: mode || 'prompt',
39
39
  watchConfig: watchConfig || undefined,
40
40
  agent: agent || undefined,
41
+ backend: backend === 'tmux' ? 'tmux' : undefined,
41
42
  });
42
43
 
43
44
  return NextResponse.json(task);
package/cli/mw.mjs CHANGED
@@ -1352,7 +1352,7 @@ var init_clean = __esm({
1352
1352
 
1353
1353
  // cli/mw.ts
1354
1354
  var _cliPort = process.argv.find((a, i) => i > 0 && process.argv[i - 1] === "--port");
1355
- var BASE2 = process.env.MW_URL || `http://localhost:${_cliPort || "3000"}`;
1355
+ var BASE2 = process.env.MW_URL || `http://localhost:${_cliPort || "8403"}`;
1356
1356
  var [, , cmd, ...args] = process.argv;
1357
1357
  async function checkForUpdate() {
1358
1358
  try {
@@ -1463,19 +1463,21 @@ async function main() {
1463
1463
  case "task":
1464
1464
  case "t": {
1465
1465
  const newSession = args.includes("--new");
1466
- const filtered = args.filter((a) => a !== "--new");
1466
+ const useTmux = args.includes("--tmux");
1467
+ const filtered = args.filter((a) => a !== "--new" && a !== "--tmux");
1467
1468
  const project = filtered[0];
1468
1469
  const prompt = filtered.slice(1).join(" ");
1469
1470
  if (!project || !prompt) {
1470
- console.log("Usage: mw task <project> <prompt> [--new]");
1471
- console.log(" --new Start a fresh session (ignore previous context)");
1471
+ console.log("Usage: mw task <project> <prompt> [--new] [--tmux]");
1472
+ console.log(" --new Start a fresh session (ignore previous context)");
1473
+ console.log(" --tmux Run via tmux backend (interactive mode, subscription billing)");
1472
1474
  console.log('Example: mw task my-app "Fix the login bug"');
1473
1475
  process.exit(1);
1474
1476
  }
1475
1477
  const task = await api3("/api/tasks", {
1476
1478
  method: "POST",
1477
1479
  headers: { "Content-Type": "application/json" },
1478
- body: JSON.stringify({ projectName: project, prompt, newSession })
1480
+ body: JSON.stringify({ projectName: project, prompt, newSession, ...useTmux ? { backend: "tmux" } : {} })
1479
1481
  });
1480
1482
  const session = task.conversationId ? "(continuing session)" : "(new session)";
1481
1483
  console.log(`\u2713 Task ${task.id} created ${session}`);
package/cli/mw.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  const _cliPort = process.argv.find((a, i) => i > 0 && process.argv[i - 1] === '--port');
19
- const BASE = process.env.MW_URL || `http://localhost:${_cliPort || '3000'}`;
19
+ const BASE = process.env.MW_URL || `http://localhost:${_cliPort || '8403'}`;
20
20
 
21
21
  const [, , cmd, ...args] = process.argv;
22
22
 
@@ -149,21 +149,23 @@ async function main() {
149
149
 
150
150
  case 'task':
151
151
  case 't': {
152
- // Parse --new flag to force a fresh session
152
+ // Parse --new and --tmux flags
153
153
  const newSession = args.includes('--new');
154
- const filtered = args.filter(a => a !== '--new');
154
+ const useTmux = args.includes('--tmux');
155
+ const filtered = args.filter(a => a !== '--new' && a !== '--tmux');
155
156
  const project = filtered[0];
156
157
  const prompt = filtered.slice(1).join(' ');
157
158
  if (!project || !prompt) {
158
- console.log('Usage: mw task <project> <prompt> [--new]');
159
- console.log(' --new Start a fresh session (ignore previous context)');
159
+ console.log('Usage: mw task <project> <prompt> [--new] [--tmux]');
160
+ console.log(' --new Start a fresh session (ignore previous context)');
161
+ console.log(' --tmux Run via tmux backend (interactive mode, subscription billing)');
160
162
  console.log('Example: mw task my-app "Fix the login bug"');
161
163
  process.exit(1);
162
164
  }
163
165
  const task = await api('/api/tasks', {
164
166
  method: 'POST',
165
167
  headers: { 'Content-Type': 'application/json' },
166
- body: JSON.stringify({ projectName: project, prompt, newSession }),
168
+ body: JSON.stringify({ projectName: project, prompt, newSession, ...(useTmux ? { backend: 'tmux' } : {}) }),
167
169
  });
168
170
  const session = task.conversationId ? '(continuing session)' : '(new session)';
169
171
  console.log(`✓ Task ${task.id} created ${session}`);
@@ -14,6 +14,7 @@ const CodeViewer = lazy(() => import('./CodeViewer'));
14
14
  const ProjectManager = lazy(() => import('./ProjectManager'));
15
15
  const BrowserPanel = lazy(() => import('./BrowserPanel'));
16
16
  const PipelineView = lazy(() => import('./PipelineView'));
17
+ const PipelineHistory = lazy(() => import('./PipelineHistory'));
17
18
  const JobsView = lazy(() => import('./JobsView'));
18
19
  const SchedulesView = lazy(() => import('./SchedulesView'));
19
20
  const HelpDialog = lazy(() => import('./HelpDialog'));
@@ -103,7 +104,7 @@ function FloatingBrowser({ onClose }: { onClose: () => void }) {
103
104
  }
104
105
 
105
106
  export default function Dashboard({ user }: { user: any }) {
106
- const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'pipelines' | 'jobs' | 'schedules' | 'workspace' | 'skills' | 'logs' | 'usage'>('terminal');
107
+ const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'pipelines' | 'history' | 'jobs' | 'schedules' | 'workspace' | 'skills' | 'logs' | 'usage'>('history');
107
108
 
108
109
  // Honour `?view=<mode>` from the URL so external links (eg the VSCode
109
110
  // extension) can deep-link straight into a section. Only views that have a
@@ -116,7 +117,7 @@ export default function Dashboard({ user }: { user: any }) {
116
117
  if (raw) {
117
118
  const aliases: Record<string, string> = { workspace: 'projects', sessions: 'projects' };
118
119
  const v = aliases[raw] || raw;
119
- const valid = ['tasks', 'terminal', 'docs', 'projects', 'pipelines', 'jobs', 'schedules', 'skills', 'logs', 'usage'];
120
+ const valid = ['tasks', 'terminal', 'docs', 'projects', 'pipelines', 'history', 'jobs', 'schedules', 'skills', 'logs', 'usage'];
120
121
  if (valid.includes(v)) setViewMode(v as any);
121
122
  }
122
123
  // Optional deep-link to a specific pipeline run — used by the extension
@@ -137,6 +138,24 @@ export default function Dashboard({ user }: { user: any }) {
137
138
  const [browserDragging, setBrowserDragging] = useState(false);
138
139
  const [tasks, setTasks] = useState<Task[]>([]);
139
140
  const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
141
+
142
+ // Write the current section back into the URL so clicking a menu updates the
143
+ // address bar and links are shareable / bookmarkable. replaceState → the
144
+ // Dashboard never remounts (terminals stay alive) and history isn't spammed.
145
+ // Skip the very first run so the incoming deep-link (read on mount above) is
146
+ // not clobbered before it's consumed.
147
+ const didSyncUrl = useRef(false);
148
+ useEffect(() => {
149
+ if (!didSyncUrl.current) { didSyncUrl.current = true; return; }
150
+ const params = new URLSearchParams(window.location.search);
151
+ params.set('view', viewMode);
152
+ if (viewMode === 'tasks' && activeTaskId) params.set('taskId', activeTaskId);
153
+ else params.delete('taskId');
154
+ // pipeline deep-link id only makes sense inside the pipelines view.
155
+ if (viewMode !== 'pipelines') { params.delete('pipeline'); params.delete('pipelineId'); }
156
+ window.history.replaceState(null, '', `${window.location.pathname}?${params.toString()}`);
157
+ }, [viewMode, activeTaskId]);
158
+
140
159
  const [showNewTask, setShowNewTask] = useState(false);
141
160
  const [showSettings, setShowSettings] = useState(false);
142
161
  const [needsOnboarding, setNeedsOnboarding] = useState(false);
@@ -282,10 +301,14 @@ export default function Dashboard({ user }: { user: any }) {
282
301
  // Listen for open-terminal events from ProjectManager
283
302
  useEffect(() => {
284
303
  const handler = (e: Event) => {
285
- const { projectPath, projectName, agentId, resumeMode, sessionId, profileEnv } = (e as CustomEvent).detail;
304
+ const { projectPath, projectName, agentId, resumeMode, sessionId, profileEnv, tmuxSession, tmuxLabel } = (e as CustomEvent).detail;
286
305
  setViewMode('terminal');
287
306
  setTimeout(() => {
288
- terminalRef.current?.openProjectTerminal?.(projectPath, projectName, agentId, resumeMode, sessionId, profileEnv);
307
+ if (tmuxSession) {
308
+ terminalRef.current?.openExistingSession?.(tmuxSession, tmuxLabel || tmuxSession);
309
+ } else {
310
+ terminalRef.current?.openProjectTerminal?.(projectPath, projectName, agentId, resumeMode, sessionId, profileEnv);
311
+ }
289
312
  }, 300);
290
313
  };
291
314
  window.addEventListener('forge:open-terminal', handler);
@@ -484,6 +507,27 @@ export default function Dashboard({ user }: { user: any }) {
484
507
 
485
508
  {/* View mode toggle */}
486
509
  <div className="flex items-center bg-[var(--bg-tertiary)] rounded p-0.5">
510
+ {/* Automation — first. Sub-tabs: pipelines / tasks / schedules.
511
+ Jobs is deprecated and hidden from the nav (backend still
512
+ present in case of reversion, but no UI entry point). */}
513
+ <button
514
+ onClick={() => {
515
+ if (!['tasks', 'pipelines', 'schedules', 'history'].includes(viewMode)) setViewMode('history');
516
+ }}
517
+ className={`text-[12px] px-2.5 py-0.5 rounded transition-colors ${
518
+ ['tasks', 'pipelines', 'schedules', 'history'].includes(viewMode)
519
+ ? 'bg-[var(--accent)]/15 text-[var(--accent)] shadow-sm'
520
+ : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
521
+ }`}
522
+ >
523
+ Automation
524
+ </button>
525
+ {/* Activity sub-pill — sits next to Automation since its content
526
+ (running pipelines + upcoming schedules + recent runs) is the
527
+ live read-side of Automation. Click anywhere → dropdown with
528
+ 3 sections + a "view" jump to the run. */}
529
+ <Suspense fallback={null}><ActivityPanel /></Suspense>
530
+ <span className="w-[2px] h-4 bg-[var(--text-secondary)]/30 mx-1.5" />
487
531
  {/* Workspace */}
488
532
  {(['terminal', 'projects'] as const).map(mode => (
489
533
  <button
@@ -511,27 +555,6 @@ export default function Dashboard({ user }: { user: any }) {
511
555
  Docs
512
556
  </button>
513
557
  <span className="w-[2px] h-4 bg-[var(--text-secondary)]/30 mx-1.5" />
514
- {/* Automation — sub-tabs: tasks / pipelines / schedules.
515
- Jobs is deprecated and hidden from the nav (backend still
516
- present in case of reversion, but no UI entry point). */}
517
- <button
518
- onClick={() => {
519
- if (!['tasks', 'pipelines', 'schedules'].includes(viewMode)) setViewMode('schedules');
520
- }}
521
- className={`text-[12px] px-2.5 py-0.5 rounded transition-colors ${
522
- ['tasks', 'pipelines', 'schedules'].includes(viewMode)
523
- ? 'bg-[var(--accent)]/15 text-[var(--accent)] shadow-sm'
524
- : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
525
- }`}
526
- >
527
- Automation
528
- </button>
529
- {/* Activity sub-pill — sits next to Automation since its content
530
- (running pipelines + upcoming schedules + recent runs) is the
531
- live read-side of Automation. Click anywhere → dropdown with
532
- 3 sections + a "view" jump to the run. */}
533
- <Suspense fallback={null}><ActivityPanel /></Suspense>
534
- <span className="w-[2px] h-4 bg-[var(--text-secondary)]/30 mx-1.5" />
535
558
  {/* Marketplace */}
536
559
  <button
537
560
  onClick={() => setViewMode('skills')}
@@ -907,10 +930,10 @@ export default function Dashboard({ user }: { user: any }) {
907
930
 
908
931
  {/* Automation secondary toolbar — sub-tabs + context actions.
909
932
  Lives below the main header so it never squishes the top nav. */}
910
- {['tasks', 'pipelines', 'schedules'].includes(viewMode) && (
933
+ {['tasks', 'pipelines', 'schedules', 'history'].includes(viewMode) && (
911
934
  <div className="h-8 border-b border-[var(--border)] flex items-center justify-between px-4 shrink-0 bg-[var(--bg-tertiary)]/40">
912
935
  <div className="flex items-center gap-1">
913
- {(['schedules', 'pipelines', 'tasks'] as const).map((m) => (
936
+ {(['history', 'pipelines', 'tasks', 'schedules'] as const).map((m) => (
914
937
  <button
915
938
  key={m}
916
939
  onClick={() => setViewMode(m)}
@@ -920,7 +943,7 @@ export default function Dashboard({ user }: { user: any }) {
920
943
  : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
921
944
  }`}
922
945
  >
923
- {{ tasks: 'Tasks', pipelines: 'Pipelines', schedules: 'Schedules' }[m]}
946
+ {{ tasks: 'Tasks', pipelines: 'Pipelines', schedules: 'Schedules', history: 'History' }[m]}
924
947
  </button>
925
948
  ))}
926
949
  {viewMode === 'tasks' && (
@@ -1054,6 +1077,16 @@ export default function Dashboard({ user }: { user: any }) {
1054
1077
  </Suspense>
1055
1078
  )}
1056
1079
 
1080
+ {/* History — flat list of all pipeline runs, expandable */}
1081
+ {viewMode === 'history' && (
1082
+ <Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
1083
+ <PipelineHistory
1084
+ onViewTask={(taskId) => { setViewMode('tasks'); setActiveTaskId(taskId); }}
1085
+ onViewPipeline={(pipelineId) => { setPendingPipelineId(pipelineId); setViewMode('pipelines'); }}
1086
+ />
1087
+ </Suspense>
1088
+ )}
1089
+
1057
1090
  {/* Jobs — scheduled connector polls (Forge web read-mostly; create via extension) */}
1058
1091
  {viewMode === 'jobs' && (
1059
1092
  <Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
@@ -52,10 +52,12 @@ function InlineLiveLog({ log }: { log: TaskLogEntry[] }) {
52
52
 
53
53
  // ─── DAG node card ────────────────────────────────────────
54
54
 
55
- function InlineDagNode({ nodeId, node }: { nodeId: string; node: any }) {
55
+ function InlineDagNode({ nodeId, node, onViewTask }: { nodeId: string; node: any; onViewTask?: (taskId: string) => void }) {
56
56
  const isRunning = node.status === 'running';
57
57
  const log = useTaskStreamInline(node.taskId, isRunning);
58
58
  const statusIcon = node.status === 'done' ? '✅' : node.status === 'failed' ? '❌' : node.status === 'running' ? '🔄' : node.status === 'skipped' ? '⏭' : '⏳';
59
+ const outputs: Record<string, string> = node.outputs || {};
60
+ const outputKeys = Object.keys(outputs).filter(k => (outputs[k] ?? '').length > 0);
59
61
  return (
60
62
  <div className={`border rounded p-2 ${
61
63
  isRunning ? 'border-yellow-500/40 bg-yellow-500/5' :
@@ -66,18 +68,33 @@ function InlineDagNode({ nodeId, node }: { nodeId: string; node: any }) {
66
68
  <div className="flex items-center gap-1.5 text-[9px]">
67
69
  <span>{statusIcon}</span>
68
70
  <span className="font-semibold text-[var(--text-primary)]">{nodeId}</span>
69
- {node.taskId && <span className="text-[7px] text-[var(--accent)] font-mono">task:{node.taskId}</span>}
71
+ {node.taskId && (
72
+ onViewTask
73
+ ? <button onClick={() => onViewTask(node.taskId)} className="text-[7px] text-[var(--accent)] font-mono hover:underline">task:{node.taskId} ↗</button>
74
+ : <span className="text-[7px] text-[var(--accent)] font-mono">task:{node.taskId}</span>
75
+ )}
70
76
  <span className="text-[var(--text-secondary)] ml-auto">{node.status}</span>
71
77
  </div>
72
78
  {isRunning && <div className="mt-1.5"><InlineLiveLog log={log} /></div>}
73
- {node.error && <div className="text-[8px] text-red-400 mt-1">{node.error}</div>}
79
+ {node.error && <div className="text-[8px] text-red-400 mt-1 whitespace-pre-wrap break-words">{node.error}</div>}
80
+ {outputKeys.map((key) => {
81
+ const isDiff = key === 'diff' || /diff/i.test(key);
82
+ return (
83
+ <details key={key} className="mt-1 text-[8px]">
84
+ <summary className="cursor-pointer text-[var(--accent)]">
85
+ {isDiff ? 'diff' : key === 'result' || key === 'report' || key === 'summary' ? `result: ${key}` : `output: ${key}`} ({outputs[key].length} chars)
86
+ </summary>
87
+ <pre className="mt-1 max-h-[200px] overflow-auto bg-[var(--bg-primary)] rounded p-1.5 whitespace-pre-wrap break-words text-[var(--text-secondary)]">{outputs[key].slice(0, 8000)}{outputs[key].length > 8000 ? '\n…(truncated)' : ''}</pre>
88
+ </details>
89
+ );
90
+ })}
74
91
  </div>
75
92
  );
76
93
  }
77
94
 
78
95
  // ─── Main component ───────────────────────────────────────
79
96
 
80
- export default function InlinePipelineView({ pipeline, onRefresh }: { pipeline: any; onRefresh: () => void }) {
97
+ export default function InlinePipelineView({ pipeline, onRefresh, onViewTask }: { pipeline: any; onRefresh: () => void; onViewTask?: (taskId: string) => void }) {
81
98
  useEffect(() => {
82
99
  if (pipeline.status !== 'running') return;
83
100
  const timer = setInterval(onRefresh, 3000);
@@ -97,7 +114,7 @@ export default function InlinePipelineView({ pipeline, onRefresh }: { pipeline:
97
114
  ) : (
98
115
  <div className="px-3 py-2 space-y-1.5">
99
116
  {pipeline.nodeOrder.map((nodeId: string) => (
100
- <InlineDagNode key={nodeId} nodeId={nodeId} node={pipeline.nodes[nodeId]} />
117
+ <InlineDagNode key={nodeId} nodeId={nodeId} node={pipeline.nodes[nodeId]} onViewTask={onViewTask} />
101
118
  ))}
102
119
  </div>
103
120
  )}
@@ -0,0 +1,306 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react';
4
+
5
+ const InlinePipelineView = lazy(() => import('./InlinePipelineView'));
6
+
7
+ // Light summary shape returned by GET /api/pipelines (heavy node outputs stripped).
8
+ interface PipelineSummary {
9
+ id: string;
10
+ workflowName: string;
11
+ status: 'running' | 'done' | 'failed' | 'cancelled';
12
+ type?: string;
13
+ createdAt: string;
14
+ completedAt?: string;
15
+ nodeOrder: string[];
16
+ nodes: Record<string, { status: string; iterations?: number }>;
17
+ input?: Record<string, string>;
18
+ }
19
+
20
+ const STATUS_META: Record<string, { icon: string; cls: string }> = {
21
+ running: { icon: '🔄', cls: 'text-yellow-400' },
22
+ done: { icon: '✅', cls: 'text-green-400' },
23
+ failed: { icon: '❌', cls: 'text-red-400' },
24
+ cancelled: { icon: '⏹', cls: 'text-[var(--text-secondary)]' },
25
+ };
26
+
27
+ function relTime(iso?: string): string {
28
+ if (!iso) return '';
29
+ const t = new Date(iso).getTime();
30
+ if (Number.isNaN(t)) return '';
31
+ const s = Math.floor((Date.now() - t) / 1000);
32
+ if (s < 60) return `${s}s ago`;
33
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
34
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
35
+ return `${Math.floor(s / 86400)}d ago`;
36
+ }
37
+
38
+ /** A compact one-liner of the most useful input fields. */
39
+ function inputSummary(input?: Record<string, string>): string {
40
+ if (!input) return '';
41
+ const keys = ['bug_id', 'mr_url', 'mr_iid', 'project', 'issue_id', 'base_branch'];
42
+ const parts: string[] = [];
43
+ for (const k of keys) {
44
+ if (input[k]) parts.push(`${k}=${String(input[k]).slice(0, 40)}`);
45
+ }
46
+ return parts.join(' · ');
47
+ }
48
+
49
+ export default function PipelineHistory({ onViewTask, onViewPipeline }: {
50
+ onViewTask: (taskId: string) => void;
51
+ onViewPipeline: (pipelineId: string) => void;
52
+ }) {
53
+ const PAGE = 20;
54
+ const [list, setList] = useState<PipelineSummary[]>([]);
55
+ const [loading, setLoading] = useState(true);
56
+ const [hasMore, setHasMore] = useState(false);
57
+ const [loadingMore, setLoadingMore] = useState(false);
58
+ const [expandedId, setExpandedId] = useState<string | null>(null);
59
+ const [fullById, setFullById] = useState<Record<string, any>>({});
60
+ const [statusFilter, setStatusFilter] = useState<'all' | 'running' | 'done' | 'failed'>('all');
61
+ const [search, setSearch] = useState('');
62
+ const [showCleanup, setShowCleanup] = useState(false);
63
+ const expandedRef = useRef(expandedId);
64
+ expandedRef.current = expandedId;
65
+
66
+ // Merge a fetched page into the list by id (fresh copy wins), newest-first.
67
+ // Lets the 4s poll refresh running runs without dropping older loaded pages.
68
+ const mergeIn = useCallback((incoming: PipelineSummary[]) => {
69
+ setList(prev => {
70
+ const byId = new Map(prev.map(p => [p.id, p]));
71
+ for (const p of incoming) byId.set(p.id, p);
72
+ return [...byId.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
73
+ });
74
+ }, []);
75
+
76
+ // First page (newest 20). Also the 4s poll target — keeps running runs fresh.
77
+ const fetchFirst = useCallback(async () => {
78
+ try {
79
+ const res = await fetch(`/api/pipelines?limit=${PAGE}`);
80
+ if (res.ok) {
81
+ const page: PipelineSummary[] = await res.json();
82
+ mergeIn(page);
83
+ setHasMore(page.length === PAGE);
84
+ }
85
+ } catch { /* keep prior list */ }
86
+ finally { setLoading(false); }
87
+ }, [mergeIn]);
88
+
89
+ // Cursor pagination: fetch the next 20 older than the oldest loaded run.
90
+ const loadMore = useCallback(async () => {
91
+ setLoadingMore(true);
92
+ try {
93
+ const oldest = list.length ? list[list.length - 1].createdAt : undefined;
94
+ const res = await fetch(`/api/pipelines?limit=${PAGE}${oldest ? `&before=${encodeURIComponent(oldest)}` : ''}`);
95
+ if (res.ok) {
96
+ const page: PipelineSummary[] = await res.json();
97
+ mergeIn(page);
98
+ setHasMore(page.length === PAGE);
99
+ }
100
+ } catch { /* ignore */ }
101
+ finally { setLoadingMore(false); }
102
+ }, [list, mergeIn]);
103
+
104
+ const fetchFull = useCallback(async (id: string) => {
105
+ try {
106
+ const res = await fetch(`/api/pipelines/${id}`);
107
+ if (res.ok) {
108
+ const full = await res.json();
109
+ setFullById(prev => ({ ...prev, [id]: full }));
110
+ }
111
+ } catch { /* ignore */ }
112
+ }, []);
113
+
114
+ // Initial load + poll every 4s so running pipelines update live.
115
+ useEffect(() => {
116
+ fetchFirst();
117
+ const t = setInterval(() => {
118
+ fetchFirst();
119
+ const id = expandedRef.current;
120
+ if (id) fetchFull(id); // keep the open run fresh while it runs
121
+ }, 4000);
122
+ return () => clearInterval(t);
123
+ }, [fetchFirst, fetchFull]);
124
+
125
+ const toggle = (id: string) => {
126
+ if (expandedId === id) { setExpandedId(null); return; }
127
+ setExpandedId(id);
128
+ if (!fullById[id]) fetchFull(id);
129
+ };
130
+
131
+ const op = async (id: string, action: 'cancel' | 'delete') => {
132
+ if (action === 'delete' && !confirm('Delete this pipeline run? This cannot be undone.')) return;
133
+ try {
134
+ await fetch(`/api/pipelines/${id}`, {
135
+ method: 'POST',
136
+ headers: { 'Content-Type': 'application/json' },
137
+ body: JSON.stringify({ action }),
138
+ });
139
+ if (action === 'delete') { setExpandedId(null); setList(prev => prev.filter(p => p.id !== id)); }
140
+ fetchFirst();
141
+ if (action === 'cancel') fetchFull(id);
142
+ } catch { /* ignore */ }
143
+ };
144
+
145
+ // Unified cleanup — bulk-delete terminal (done/failed/cancelled) runs.
146
+ // Running/pending runs are always skipped server-side.
147
+ const cleanup = async (olderThanDays: number, label: string) => {
148
+ if (!confirm(`Delete ${label}? Running pipelines are kept. This cannot be undone.`)) return;
149
+ setShowCleanup(false);
150
+ try {
151
+ const res = await fetch('/api/pipelines/bulk-delete', {
152
+ method: 'POST',
153
+ headers: { 'Content-Type': 'application/json' },
154
+ body: JSON.stringify({ older_than_days: olderThanDays }),
155
+ });
156
+ const data = res.ok ? await res.json() : null;
157
+ // Many rows gone — rebuild the list from scratch.
158
+ setExpandedId(null);
159
+ setList([]);
160
+ setLoading(true);
161
+ await fetchFirst();
162
+ if (data) alert(`Cleaned up ${data.removed} pipeline run${data.removed === 1 ? '' : 's'}.`);
163
+ } catch { /* ignore */ }
164
+ };
165
+
166
+ const filtered = list.filter(p => {
167
+ if (statusFilter !== 'all' && p.status !== statusFilter) return false;
168
+ if (search) {
169
+ const hay = `${p.workflowName} ${inputSummary(p.input)} ${p.id}`.toLowerCase();
170
+ if (!hay.includes(search.toLowerCase())) return false;
171
+ }
172
+ return true;
173
+ });
174
+
175
+ const runningCount = list.filter(p => p.status === 'running').length;
176
+
177
+ return (
178
+ <div className="flex-1 min-h-0 flex flex-col">
179
+ {/* Filter bar */}
180
+ <div className="flex items-center gap-2 px-4 py-2 border-b border-[var(--border)] shrink-0">
181
+ <div className="flex items-center gap-1">
182
+ {(['all', 'running', 'done', 'failed'] as const).map(s => (
183
+ <button
184
+ key={s}
185
+ onClick={() => setStatusFilter(s)}
186
+ className={`text-[11px] px-2 py-0.5 rounded ${
187
+ statusFilter === s
188
+ ? 'bg-[var(--accent)]/15 text-[var(--accent)]'
189
+ : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
190
+ }`}
191
+ >
192
+ {s === 'all' ? 'All' : s[0].toUpperCase() + s.slice(1)}
193
+ {s === 'running' && runningCount > 0 ? ` (${runningCount})` : ''}
194
+ </button>
195
+ ))}
196
+ </div>
197
+ <input
198
+ value={search}
199
+ onChange={e => setSearch(e.target.value)}
200
+ placeholder="Filter by workflow / bug_id / id…"
201
+ className="text-[11px] px-2 py-0.5 rounded bg-[var(--bg-tertiary)] border border-[var(--border)] text-[var(--text-primary)] flex-1 max-w-[320px] outline-none focus:border-[var(--accent)]"
202
+ />
203
+ <span className="text-[10px] text-[var(--text-secondary)] ml-auto">{filtered.length} run{filtered.length === 1 ? '' : 's'}</span>
204
+ {/* Unified cleanup */}
205
+ <div className="relative">
206
+ <button
207
+ onClick={() => setShowCleanup(v => !v)}
208
+ className="text-[11px] px-2 py-0.5 rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-[var(--border)]"
209
+ title="Bulk-delete finished pipeline runs"
210
+ >
211
+ Cleanup ▾
212
+ </button>
213
+ {showCleanup && (
214
+ <>
215
+ <div className="fixed inset-0 z-10" onClick={() => setShowCleanup(false)} />
216
+ <div className="absolute right-0 mt-1 z-20 bg-[var(--bg-secondary)] border border-[var(--border)] rounded shadow-lg py-1 w-56 text-[11px]">
217
+ <div className="px-3 py-1 text-[9px] text-[var(--text-secondary)] uppercase tracking-wide">Delete finished runs</div>
218
+ <button onClick={() => cleanup(30, 'finished runs older than 30 days')} className="w-full text-left px-3 py-1.5 hover:bg-[var(--bg-tertiary)] text-[var(--text-primary)]">Older than 30 days</button>
219
+ <button onClick={() => cleanup(7, 'finished runs older than 7 days')} className="w-full text-left px-3 py-1.5 hover:bg-[var(--bg-tertiary)] text-[var(--text-primary)]">Older than 7 days</button>
220
+ <button onClick={() => cleanup(0, 'ALL finished runs')} className="w-full text-left px-3 py-1.5 hover:bg-[var(--bg-tertiary)] text-red-400">All finished (done/failed/cancelled)</button>
221
+ <div className="px-3 py-1 text-[9px] text-[var(--text-secondary)] border-t border-[var(--border)] mt-1">Running pipelines are always kept.</div>
222
+ </div>
223
+ </>
224
+ )}
225
+ </div>
226
+ <button onClick={fetchFirst} className="text-[11px] px-2 py-0.5 rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)]" title="Refresh">↻</button>
227
+ </div>
228
+
229
+ {/* List */}
230
+ <div className="flex-1 min-h-0 overflow-y-auto">
231
+ {loading && list.length === 0 ? (
232
+ <div className="flex items-center justify-center h-32 text-[var(--text-secondary)] text-[12px]">Loading…</div>
233
+ ) : filtered.length === 0 ? (
234
+ <div className="flex items-center justify-center h-32 text-[var(--text-secondary)] text-[12px]">No pipeline runs.</div>
235
+ ) : (
236
+ <div className="divide-y divide-[var(--border)]">
237
+ {filtered.map(p => {
238
+ const meta = STATUS_META[p.status] || STATUS_META.cancelled;
239
+ const total = p.nodeOrder.length;
240
+ const done = Object.values(p.nodes).filter(n => n.status === 'done').length;
241
+ const isOpen = expandedId === p.id;
242
+ const full = fullById[p.id];
243
+ return (
244
+ <div key={p.id}>
245
+ {/* Row header */}
246
+ <div
247
+ onClick={() => toggle(p.id)}
248
+ className="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-[var(--bg-tertiary)]/40"
249
+ >
250
+ <span className={`text-[11px] w-4 text-center transition-transform ${isOpen ? 'rotate-90' : ''}`}>▸</span>
251
+ <span className="text-[12px]">{meta.icon}</span>
252
+ <span className="text-[12px] font-medium text-[var(--text-primary)] truncate max-w-[220px]">{p.workflowName}</span>
253
+ <span className={`text-[10px] ${meta.cls}`}>{p.status}</span>
254
+ <span className="text-[10px] text-[var(--text-secondary)]">{done}/{total} steps</span>
255
+ {inputSummary(p.input) && (
256
+ <span className="text-[10px] text-[var(--text-secondary)] truncate max-w-[280px] font-mono">{inputSummary(p.input)}</span>
257
+ )}
258
+ <span className="text-[10px] text-[var(--text-secondary)] ml-auto whitespace-nowrap">{relTime(p.createdAt)}</span>
259
+ </div>
260
+
261
+ {/* Expanded detail */}
262
+ {isOpen && (
263
+ <div className="bg-[var(--bg-tertiary)]/30 border-t border-[var(--border)]">
264
+ {/* Action bar */}
265
+ <div className="flex items-center gap-2 px-4 py-1.5 text-[11px]">
266
+ <button
267
+ onClick={() => onViewPipeline(p.id)}
268
+ className="px-2 py-0.5 rounded bg-[var(--accent)]/15 text-[var(--accent)] hover:bg-[var(--accent)]/25"
269
+ >
270
+ Open in Pipelines ↗
271
+ </button>
272
+ {p.status === 'running' && (
273
+ <button onClick={() => op(p.id, 'cancel')} className="px-2 py-0.5 rounded text-yellow-400 hover:bg-yellow-400/10">Cancel</button>
274
+ )}
275
+ <button onClick={() => op(p.id, 'delete')} className="px-2 py-0.5 rounded text-red-400 hover:bg-red-400/10">Delete</button>
276
+ <span className="text-[10px] text-[var(--text-secondary)] ml-auto font-mono">id:{p.id}</span>
277
+ </div>
278
+ {full ? (
279
+ <Suspense fallback={<div className="px-4 py-2 text-[10px] text-[var(--text-secondary)]">Loading detail…</div>}>
280
+ <InlinePipelineView pipeline={full} onRefresh={() => fetchFull(p.id)} onViewTask={onViewTask} />
281
+ </Suspense>
282
+ ) : (
283
+ <div className="px-4 py-3 text-[10px] text-[var(--text-secondary)]">Loading detail…</div>
284
+ )}
285
+ </div>
286
+ )}
287
+ </div>
288
+ );
289
+ })}
290
+ </div>
291
+ )}
292
+ {hasMore && !(loading && list.length === 0) && (
293
+ <div className="flex justify-center py-3">
294
+ <button
295
+ onClick={loadMore}
296
+ disabled={loadingMore}
297
+ className="text-[11px] px-3 py-1 rounded border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
298
+ >
299
+ {loadingMore ? 'Loading…' : 'Load more'}
300
+ </button>
301
+ </div>
302
+ )}
303
+ </div>
304
+ </div>
305
+ );
306
+ }