@aion0/forge 0.2.4 → 0.2.6

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.
@@ -174,10 +174,12 @@ export async function GET(req: Request) {
174
174
  }
175
175
  const gitRepos: GitRepo[] = [];
176
176
 
177
+ const gitOpts = { encoding: 'utf-8' as const, timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] as any };
178
+
177
179
  function scanGitStatus(dir: string, repoName: string, pathPrefix: string) {
178
180
  try {
179
- const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: dir, encoding: 'utf-8', timeout: 3000 }).trim();
180
- const statusOut = execSync('git status --porcelain -u', { cwd: dir, encoding: 'utf-8', timeout: 5000 });
181
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', { ...gitOpts, cwd: dir, timeout: 3000 }).toString().trim();
182
+ const statusOut = execSync('git status --porcelain -u', { ...gitOpts, cwd: dir }).toString();
181
183
  const changes = statusOut.replace(/\n$/, '').split('\n').filter(Boolean)
182
184
  .map(line => {
183
185
  if (line.length < 4) return null;
@@ -188,7 +190,7 @@ export async function GET(req: Request) {
188
190
  })
189
191
  .filter((g): g is { status: string; path: string } => g !== null && !!g.path && !g.path.includes(' -> '));
190
192
  let remote = '';
191
- try { remote = execSync('git remote get-url origin', { cwd: dir, encoding: 'utf-8', timeout: 2000 }).trim(); } catch {}
193
+ try { remote = execSync('git remote get-url origin', { ...gitOpts, cwd: dir, timeout: 2000 }).toString().trim(); } catch {}
192
194
  if (branch || changes.length > 0) {
193
195
  gitRepos.push({ name: repoName, branch, remote, changes });
194
196
  }
@@ -197,7 +199,7 @@ export async function GET(req: Request) {
197
199
 
198
200
  // Check if root is a git repo
199
201
  try {
200
- execSync('git rev-parse --git-dir', { cwd: resolvedDir, encoding: 'utf-8', timeout: 2000 });
202
+ execSync('git rev-parse --git-dir', { cwd: resolvedDir, encoding: 'utf-8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'] });
201
203
  scanGitStatus(resolvedDir, '.', '');
202
204
  } catch {
203
205
  // Root is not a git repo — scan subdirectories
@@ -206,7 +208,7 @@ export async function GET(req: Request) {
206
208
  if (!entry.isDirectory() || entry.name.startsWith('.') || IGNORE.has(entry.name)) continue;
207
209
  const subDir = join(resolvedDir, entry.name);
208
210
  try {
209
- execSync('git rev-parse --git-dir', { cwd: subDir, encoding: 'utf-8', timeout: 2000 });
211
+ execSync('git rev-parse --git-dir', { cwd: subDir, encoding: 'utf-8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'] });
210
212
  scanGitStatus(subDir, entry.name, entry.name);
211
213
  } catch {}
212
214
  }
@@ -12,7 +12,7 @@ function isUnderProjectRoot(dir: string): boolean {
12
12
  }
13
13
 
14
14
  function git(cmd: string, cwd: string): string {
15
- return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 15000 }).trim();
15
+ return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim();
16
16
  }
17
17
 
18
18
  // GET /api/git?dir=<path> — git status for a project
@@ -24,7 +24,7 @@ export async function GET(req: NextRequest) {
24
24
 
25
25
  try {
26
26
  const branch = git('rev-parse --abbrev-ref HEAD', dir);
27
- const statusRaw = execSync('git status --porcelain -u', { cwd: dir, encoding: 'utf-8', timeout: 15000 });
27
+ const statusRaw = execSync('git status --porcelain -u', { cwd: dir, encoding: 'utf-8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'] }).toString();
28
28
  const changes = statusRaw.replace(/\n$/, '').split('\n').filter(Boolean).map(line => ({
29
29
  status: line.substring(0, 2).trim() || 'M',
30
30
  path: line.substring(3).replace(/\/$/, ''),
@@ -16,6 +16,22 @@ export async function GET(req: Request) {
16
16
  return NextResponse.json(listWorkflows());
17
17
  }
18
18
 
19
+ if (type === 'workflow-yaml') {
20
+ const name = searchParams.get('name');
21
+ if (!name) return NextResponse.json({ error: 'name required' }, { status: 400 });
22
+ try {
23
+ const { readFileSync, existsSync } = await import('node:fs');
24
+ const filePath = join(FLOWS_DIR, `${name}.yaml`);
25
+ const altPath = join(FLOWS_DIR, `${name}.yml`);
26
+ const path = existsSync(filePath) ? filePath : existsSync(altPath) ? altPath : null;
27
+ if (!path) return NextResponse.json({ error: 'Not found' }, { status: 404 });
28
+ const yaml = readFileSync(path, 'utf-8');
29
+ return NextResponse.json({ yaml });
30
+ } catch {
31
+ return NextResponse.json({ error: 'Failed to read' }, { status: 500 });
32
+ }
33
+ }
34
+
19
35
  return NextResponse.json(listPipelines().sort((a, b) => b.createdAt.localeCompare(a.createdAt)));
20
36
  }
21
37
 
@@ -4,30 +4,39 @@ import { join, dirname } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { spawn, execSync, type ChildProcess } from 'node:child_process';
6
6
 
7
- const CONFIG_FILE = join(homedir(), '.forge', 'preview.json');
7
+ const CONFIG_FILE = join(process.env.FORGE_DATA_DIR || join(homedir(), '.forge'), 'preview.json');
8
8
 
9
- // Persist tunnel state across hot-reloads
9
+ interface PreviewEntry {
10
+ port: number;
11
+ url: string | null;
12
+ status: string;
13
+ label?: string;
14
+ }
15
+
16
+ // Persist state across hot-reloads
10
17
  const stateKey = Symbol.for('mw-preview-state');
11
18
  const g = globalThis as any;
12
- if (!g[stateKey]) g[stateKey] = { process: null, port: 0, url: null, status: 'stopped' };
13
- const state: { process: ChildProcess | null; port: number; url: string | null; status: string } = g[stateKey];
19
+ if (!g[stateKey]) g[stateKey] = { entries: new Map<number, { process: ChildProcess | null; url: string | null; status: string; label: string }>() };
20
+ const state: { entries: Map<number, { process: ChildProcess | null; url: string | null; status: string; label: string }> } = g[stateKey];
14
21
 
15
- function getConfig(): { port: number } {
22
+ function getConfig(): PreviewEntry[] {
16
23
  try {
17
- return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
24
+ const data = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
25
+ return Array.isArray(data) ? data : data.port ? [data] : [];
18
26
  } catch {
19
- return { port: 0 };
27
+ return [];
20
28
  }
21
29
  }
22
30
 
23
- function saveConfig(config: { port: number }) {
31
+ function saveConfig(entries: PreviewEntry[]) {
24
32
  const dir = dirname(CONFIG_FILE);
25
33
  mkdirSync(dir, { recursive: true });
26
- writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
34
+ writeFileSync(CONFIG_FILE, JSON.stringify(entries, null, 2));
27
35
  }
28
36
 
29
37
  function getCloudflaredPath(): string | null {
30
- const binPath = join(homedir(), '.forge', 'bin', 'cloudflared');
38
+ const dataDir = process.env.FORGE_DATA_DIR || join(homedir(), '.forge');
39
+ const binPath = join(dataDir, 'bin', 'cloudflared');
31
40
  if (existsSync(binPath)) return binPath;
32
41
  try {
33
42
  return execSync('which cloudflared', { encoding: 'utf-8' }).trim();
@@ -36,100 +45,105 @@ function getCloudflaredPath(): string | null {
36
45
  }
37
46
  }
38
47
 
39
- // GET — get current preview status
48
+ // GET — list all previews
40
49
  export async function GET() {
41
- return NextResponse.json({
42
- port: state.port,
43
- url: state.url,
44
- status: state.status,
45
- });
50
+ const entries: PreviewEntry[] = [];
51
+ for (const [port, s] of state.entries) {
52
+ entries.push({ port, url: s.url, status: s.status, label: s.label });
53
+ }
54
+ return NextResponse.json(entries);
46
55
  }
47
56
 
48
- // POST — start/stop preview tunnel
57
+ // POST — start/stop/manage previews
49
58
  export async function POST(req: Request) {
50
- const { port, action } = await req.json();
59
+ const body = await req.json();
51
60
 
52
- if (action === 'stop' || port === 0) {
53
- if (state.process) {
54
- state.process.kill('SIGTERM');
55
- state.process = null;
61
+ // Stop a preview
62
+ if (body.action === 'stop' && body.port) {
63
+ const entry = state.entries.get(body.port);
64
+ if (entry?.process) {
65
+ entry.process.kill('SIGTERM');
56
66
  }
57
- state.port = 0;
58
- state.url = null;
59
- state.status = 'stopped';
60
- saveConfig({ port: 0 });
61
- return NextResponse.json({ port: 0, url: null, status: 'stopped' });
67
+ state.entries.delete(body.port);
68
+ syncConfig();
69
+ return NextResponse.json({ ok: true });
62
70
  }
63
71
 
64
- const p = parseInt(port) || 0;
65
- if (!p || p < 1 || p > 65535) {
66
- return NextResponse.json({ error: 'Invalid port' }, { status: 400 });
67
- }
72
+ // Start a new preview
73
+ if (body.action === 'start' && body.port) {
74
+ const port = parseInt(body.port);
75
+ if (!port || port < 1 || port > 65535) {
76
+ return NextResponse.json({ error: 'Invalid port' }, { status: 400 });
77
+ }
68
78
 
69
- // Kill existing tunnel if any
70
- if (state.process) {
71
- state.process.kill('SIGTERM');
72
- state.process = null;
73
- }
79
+ // Already running?
80
+ const existing = state.entries.get(port);
81
+ if (existing && existing.status === 'running') {
82
+ return NextResponse.json({ port, url: existing.url, status: 'running', label: existing.label });
83
+ }
74
84
 
75
- const binPath = getCloudflaredPath();
76
- if (!binPath) {
77
- return NextResponse.json({ error: 'cloudflared not installed. Start the main tunnel first to auto-download it.' }, { status: 500 });
78
- }
85
+ const binPath = getCloudflaredPath();
86
+ if (!binPath) {
87
+ return NextResponse.json({ error: 'cloudflared not installed. Start the main tunnel first.' }, { status: 500 });
88
+ }
79
89
 
80
- state.port = p;
81
- state.status = 'starting';
82
- state.url = null;
83
- saveConfig({ port: p });
90
+ const label = body.label || `localhost:${port}`;
91
+ state.entries.set(port, { process: null, url: null, status: 'starting', label });
92
+ syncConfig();
93
+
94
+ // Start tunnel
95
+ return new Promise<NextResponse>((resolve) => {
96
+ let resolved = false;
97
+ const child = spawn(binPath, ['tunnel', '--url', `http://localhost:${port}`], {
98
+ stdio: ['ignore', 'pipe', 'pipe'],
99
+ });
100
+
101
+ const entry = state.entries.get(port)!;
102
+ entry.process = child;
103
+
104
+ const handleOutput = (data: Buffer) => {
105
+ const urlMatch = data.toString().match(/(https:\/\/[a-z0-9-]+\.trycloudflare\.com)/);
106
+ if (urlMatch && !entry.url) {
107
+ entry.url = urlMatch[1];
108
+ entry.status = 'running';
109
+ syncConfig();
110
+ if (!resolved) {
111
+ resolved = true;
112
+ resolve(NextResponse.json({ port, url: entry.url, status: 'running', label }));
113
+ }
114
+ }
115
+ };
84
116
 
85
- // Start tunnel
86
- return new Promise<NextResponse>((resolve) => {
87
- let resolved = false;
117
+ child.stdout?.on('data', handleOutput);
118
+ child.stderr?.on('data', handleOutput);
88
119
 
89
- const child = spawn(binPath, ['tunnel', '--url', `http://localhost:${p}`], {
90
- stdio: ['ignore', 'pipe', 'pipe'],
91
- });
92
- state.process = child;
93
-
94
- const handleOutput = (data: Buffer) => {
95
- const text = data.toString();
96
- const urlMatch = text.match(/(https:\/\/[a-z0-9-]+\.trycloudflare\.com)/);
97
- if (urlMatch && !state.url) {
98
- state.url = urlMatch[1];
99
- state.status = 'running';
120
+ child.on('exit', () => {
121
+ entry.process = null;
122
+ entry.status = 'stopped';
123
+ entry.url = null;
124
+ syncConfig();
100
125
  if (!resolved) {
101
126
  resolved = true;
102
- resolve(NextResponse.json({ port: p, url: state.url, status: 'running' }));
127
+ resolve(NextResponse.json({ port, url: null, status: 'stopped', error: 'Tunnel exited' }));
103
128
  }
104
- }
105
- };
106
-
107
- child.stdout?.on('data', handleOutput);
108
- child.stderr?.on('data', handleOutput);
109
-
110
- child.on('exit', () => {
111
- state.process = null;
112
- state.status = 'stopped';
113
- state.url = null;
114
- if (!resolved) {
115
- resolved = true;
116
- resolve(NextResponse.json({ port: p, url: null, status: 'stopped', error: 'Tunnel exited' }));
117
- }
118
- });
129
+ });
119
130
 
120
- child.on('error', (err) => {
121
- state.status = 'error';
122
- if (!resolved) {
123
- resolved = true;
124
- resolve(NextResponse.json({ error: err.message }, { status: 500 }));
125
- }
131
+ setTimeout(() => {
132
+ if (!resolved) {
133
+ resolved = true;
134
+ resolve(NextResponse.json({ port, url: null, status: entry.status, error: 'Timeout' }));
135
+ }
136
+ }, 30000);
126
137
  });
138
+ }
139
+
140
+ return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
141
+ }
127
142
 
128
- setTimeout(() => {
129
- if (!resolved) {
130
- resolved = true;
131
- resolve(NextResponse.json({ port: p, url: null, status: state.status, error: 'Timeout waiting for tunnel URL' }));
132
- }
133
- }, 30000);
134
- });
143
+ function syncConfig() {
144
+ const entries: PreviewEntry[] = [];
145
+ for (const [port, s] of state.entries) {
146
+ entries.push({ port, url: s.url, status: s.status, label: s.label });
147
+ }
148
+ saveConfig(entries);
135
149
  }
@@ -0,0 +1,15 @@
1
+ 'use client';
2
+
3
+ export default function GlobalError({ error, reset }: { error: Error; reset: () => void }) {
4
+ return (
5
+ <html>
6
+ <body style={{ background: '#0a0a0a', color: '#e5e5e5', fontFamily: 'monospace', padding: '2rem' }}>
7
+ <h2>Something went wrong</h2>
8
+ <p style={{ color: '#999' }}>{error.message}</p>
9
+ <button onClick={reset} style={{ marginTop: '1rem', padding: '0.5rem 1rem', background: '#3b82f6', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
10
+ Try again
11
+ </button>
12
+ </body>
13
+ </html>
14
+ );
15
+ }
@@ -12,6 +12,7 @@
12
12
  * forge-server --port 4000 Custom web port (default: 3000)
13
13
  * forge-server --terminal-port 4001 Custom terminal port (default: 3001)
14
14
  * forge-server --dir ~/.forge-test Custom data directory (default: ~/.forge)
15
+ * forge-server --reset-terminal Kill terminal server before start (loses tmux sessions)
15
16
  *
16
17
  * Examples:
17
18
  * forge-server --background --port 4000 --terminal-port 4001 --dir ~/.forge-staging
@@ -35,11 +36,19 @@ function getArg(name) {
35
36
  return process.argv[idx + 1];
36
37
  }
37
38
 
39
+ // ── Version ──
40
+ if (process.argv.includes('--version') || process.argv.includes('-v')) {
41
+ const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
42
+ console.log(`@aion0/forge v${pkg.version}`);
43
+ process.exit(0);
44
+ }
45
+
38
46
  const isDev = process.argv.includes('--dev');
39
47
  const isBackground = process.argv.includes('--background');
40
48
  const isStop = process.argv.includes('--stop');
41
49
  const isRestart = process.argv.includes('--restart');
42
50
  const isRebuild = process.argv.includes('--rebuild');
51
+ const resetTerminal = process.argv.includes('--reset-terminal');
43
52
 
44
53
  const webPort = parseInt(getArg('--port')) || 3000;
45
54
  const terminalPort = parseInt(getArg('--terminal-port')) || 3001;
@@ -70,6 +79,20 @@ process.env.PORT = String(webPort);
70
79
  process.env.TERMINAL_PORT = String(terminalPort);
71
80
  process.env.FORGE_DATA_DIR = DATA_DIR;
72
81
 
82
+ // ── Reset terminal server (kill port + tmux sessions) ──
83
+ if (resetTerminal) {
84
+ console.log(`[forge] Resetting terminal server (port ${terminalPort})...`);
85
+ try {
86
+ const pids = execSync(`lsof -ti:${terminalPort}`, { encoding: 'utf-8' }).trim();
87
+ for (const pid of pids.split('\n').filter(Boolean)) {
88
+ try { execSync(`kill ${pid.trim()}`); } catch {}
89
+ }
90
+ console.log(`[forge] Killed terminal server on port ${terminalPort}`);
91
+ } catch {
92
+ console.log(`[forge] No process on port ${terminalPort}`);
93
+ }
94
+ }
95
+
73
96
  // ── Helper: stop running instance ──
74
97
  function stopServer() {
75
98
  try {
package/cli/mw.ts CHANGED
@@ -31,6 +31,19 @@ async function api(path: string, opts?: RequestInit) {
31
31
  }
32
32
 
33
33
  async function main() {
34
+ if (cmd === '--version' || cmd === '-v') {
35
+ const { readFileSync } = await import('node:fs');
36
+ const { join, dirname } = await import('node:path');
37
+ const { fileURLToPath } = await import('node:url');
38
+ try {
39
+ const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf-8'));
40
+ console.log(`@aion0/forge v${pkg.version}`);
41
+ } catch {
42
+ console.log('forge (version unknown)');
43
+ }
44
+ process.exit(0);
45
+ }
46
+
34
47
  switch (cmd) {
35
48
  case 'task':
36
49
  case 't': {
@@ -394,7 +394,7 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
394
394
  }, []);
395
395
 
396
396
  return (
397
- <div className="flex-1 flex flex-col min-h-0">
397
+ <div className="flex-1 flex flex-col min-h-0 min-w-0 overflow-hidden">
398
398
  {/* Task completion notification */}
399
399
  {taskNotification && (
400
400
  <div className="shrink-0 px-3 py-1.5 bg-green-900/30 border-b border-green-800/50 flex items-center gap-2 text-xs">
@@ -431,7 +431,7 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
431
431
  )}
432
432
 
433
433
  {/* File browser + code viewer — bottom */}
434
- {codeOpen && <div className="flex-1 flex min-h-0">
434
+ {codeOpen && <div className="flex-1 flex min-h-0 min-w-0 overflow-hidden">
435
435
  {/* Sidebar */}
436
436
  {sidebarOpen && (
437
437
  <aside className="w-56 border-r border-[var(--border)] flex flex-col shrink-0">
@@ -609,7 +609,7 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
609
609
  )}
610
610
 
611
611
  {/* Code viewer */}
612
- <main className="flex-1 flex flex-col min-w-0">
612
+ <main className="flex-1 flex flex-col min-w-0 overflow-hidden" style={{ width: 0 }}>
613
613
  <div className="px-3 py-1.5 border-b border-[var(--border)] shrink-0 flex items-center gap-2">
614
614
  <button
615
615
  onClick={() => setSidebarOpen(v => !v)}
@@ -672,7 +672,7 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
672
672
  </div>
673
673
  ) : viewMode === 'diff' && diffContent ? (
674
674
  <div className="flex-1 overflow-auto bg-[var(--bg-primary)]">
675
- <pre className="p-4 text-[12px] leading-[1.5] font-mono whitespace-pre" style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace', tabSize: 2 }}>
675
+ <pre className="p-4 text-[12px] leading-[1.5] font-mono whitespace-pre" style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace', tabSize: 2, overflow: 'auto', maxWidth: 0, minWidth: '100%' }}>
676
676
  {diffContent.split('\n').map((line, i) => {
677
677
  const color = line.startsWith('+') ? 'text-green-400 bg-green-900/20'
678
678
  : line.startsWith('-') ? 'text-red-400 bg-red-900/20'
@@ -689,11 +689,11 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
689
689
  </div>
690
690
  ) : selectedFile && content !== null ? (
691
691
  <div className="flex-1 overflow-auto bg-[var(--bg-primary)]">
692
- <pre className="p-4 text-[12px] leading-[1.5] font-mono text-[var(--text-primary)] whitespace-pre" style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace', tabSize: 2 }}>
692
+ <pre className="p-4 text-[12px] leading-[1.5] font-mono text-[var(--text-primary)] whitespace-pre" style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace', tabSize: 2, overflow: 'auto', maxWidth: 0, minWidth: '100%' }}>
693
693
  {content.split('\n').map((line, i) => (
694
694
  <div key={i} className="flex hover:bg-[var(--bg-tertiary)]/50">
695
695
  <span className="select-none text-[var(--text-secondary)]/40 text-right pr-4 w-10 shrink-0">{i + 1}</span>
696
- <span className="flex-1">{highlightLine(line, language)}</span>
696
+ <span className="whitespace-pre">{highlightLine(line, language)}</span>
697
697
  </div>
698
698
  ))}
699
699
  </pre>
@@ -324,7 +324,7 @@ export default function Dashboard({ user }: { user: any }) {
324
324
  {/* Pipelines */}
325
325
  {viewMode === 'pipelines' && (
326
326
  <Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
327
- <PipelineView />
327
+ <PipelineView onViewTask={(taskId) => { setViewMode('tasks'); setActiveTaskId(taskId); }} />
328
328
  </Suspense>
329
329
  )}
330
330
 
@@ -168,7 +168,7 @@ export default function PipelineEditor({ onSave, onClose, initialYaml }: {
168
168
  const [nodes, setNodes, onNodesChange] = useNodesState<Node<NodeData>>([]);
169
169
  const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
170
170
  const [editingNode, setEditingNode] = useState<{ id: string; project: string; prompt: string; outputs: { name: string; extract: string }[] } | null>(null);
171
- const [workflowName, setWorkflowName] = useState('my-workflow');
171
+ const [workflowName, setWorkflowName] = useState('');
172
172
  const [workflowDesc, setWorkflowDesc] = useState('');
173
173
  const [varsProject, setVarsProject] = useState('');
174
174
  const [projects, setProjects] = useState<{ name: string; root: string }[]>([]);
@@ -60,7 +60,7 @@ const STATUS_COLOR: Record<string, string> = {
60
60
  skipped: 'text-gray-500',
61
61
  };
62
62
 
63
- export default function PipelineView() {
63
+ export default function PipelineView({ onViewTask }: { onViewTask?: (taskId: string) => void }) {
64
64
  const [pipelines, setPipelines] = useState<Pipeline[]>([]);
65
65
  const [workflows, setWorkflows] = useState<Workflow[]>([]);
66
66
  const [selectedPipeline, setSelectedPipeline] = useState<Pipeline | null>(null);
@@ -69,6 +69,7 @@ export default function PipelineView() {
69
69
  const [inputValues, setInputValues] = useState<Record<string, string>>({});
70
70
  const [creating, setCreating] = useState(false);
71
71
  const [showEditor, setShowEditor] = useState(false);
72
+ const [editorYaml, setEditorYaml] = useState<string | undefined>(undefined);
72
73
 
73
74
  const fetchData = useCallback(async () => {
74
75
  const [pRes, wRes] = await Promise.all([
@@ -150,12 +151,25 @@ export default function PipelineView() {
150
151
  <aside className="w-72 border-r border-[var(--border)] flex flex-col shrink-0">
151
152
  <div className="px-3 py-2 border-b border-[var(--border)] flex items-center justify-between">
152
153
  <span className="text-[11px] font-semibold text-[var(--text-primary)]">Pipelines</span>
153
- <button
154
- onClick={() => setShowEditor(true)}
155
- className="text-[10px] px-2 py-0.5 rounded text-green-400 hover:bg-green-400/10"
154
+ <select
155
+ onChange={async (e) => {
156
+ const name = e.target.value;
157
+ if (!name) { setEditorYaml(undefined); setShowEditor(true); return; }
158
+ try {
159
+ const res = await fetch(`/api/pipelines?type=workflow-yaml&name=${encodeURIComponent(name)}`);
160
+ const data = await res.json();
161
+ setEditorYaml(data.yaml || undefined);
162
+ } catch { setEditorYaml(undefined); }
163
+ setShowEditor(true);
164
+ e.target.value = '';
165
+ }}
166
+ className="text-[10px] px-1 py-0.5 rounded text-green-400 bg-transparent hover:bg-green-400/10 cursor-pointer"
167
+ defaultValue=""
156
168
  >
157
- Editor
158
- </button>
169
+ <option value="">Editor ▾</option>
170
+ <option value="">+ New workflow</option>
171
+ {workflows.map(w => <option key={w.name} value={w.name}>{w.name}</option>)}
172
+ </select>
159
173
  <button
160
174
  onClick={() => setShowCreate(v => !v)}
161
175
  className={`text-[10px] px-2 py-0.5 rounded ${showCreate ? 'text-white bg-[var(--accent)]' : 'text-[var(--accent)] hover:bg-[var(--accent)]/10'}`}
@@ -322,7 +336,12 @@ export default function PipelineView() {
322
336
  <span className={STATUS_COLOR[node.status]}>{STATUS_ICON[node.status]}</span>
323
337
  <span className="text-xs font-semibold text-[var(--text-primary)]">{nodeId}</span>
324
338
  {node.taskId && (
325
- <span className="text-[9px] text-[var(--text-secondary)] font-mono">task:{node.taskId}</span>
339
+ <button
340
+ onClick={() => onViewTask?.(node.taskId!)}
341
+ className="text-[9px] text-[var(--accent)] font-mono hover:underline"
342
+ >
343
+ task:{node.taskId}
344
+ </button>
326
345
  )}
327
346
  {node.iterations > 1 && (
328
347
  <span className="text-[9px] text-yellow-400">iter {node.iterations}</span>
@@ -416,6 +435,7 @@ nodes:
416
435
  {showEditor && (
417
436
  <Suspense fallback={null}>
418
437
  <PipelineEditor
438
+ initialYaml={editorYaml}
419
439
  onSave={async (yaml) => {
420
440
  // Save YAML to ~/.forge/flows/
421
441
  await fetch('/api/pipelines', {