@aion0/forge 0.10.84 → 0.10.85

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.
@@ -27,6 +27,7 @@ const SettingsModal = lazy(() => import('./SettingsModal'));
27
27
  const MonitorPanel = lazy(() => import('./MonitorPanel'));
28
28
  const LoginStatusPanel = lazy(() => import('./LoginStatusPanel'));
29
29
  const ActivityPanel = lazy(() => import('./ActivityPanel'));
30
+ const HomeView = lazy(() => import('./HomeView'));
30
31
  const EnterpriseBadge = lazy(() => import('./EnterpriseBadge'));
31
32
  const WorkspaceView = lazy(() => import('./WorkspaceView'));
32
33
  // WorkspaceTree moved into ProjectDetail — no longer needed at Dashboard level
@@ -104,7 +105,7 @@ function FloatingBrowser({ onClose }: { onClose: () => void }) {
104
105
  }
105
106
 
106
107
  export default function Dashboard({ user }: { user: any }) {
107
- const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'pipelines' | 'history' | 'jobs' | 'schedules' | 'workspace' | 'skills' | 'logs' | 'usage'>('history');
108
+ const [viewMode, setViewMode] = useState<'home' | 'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'pipelines' | 'history' | 'jobs' | 'schedules' | 'workspace' | 'skills' | 'logs' | 'usage'>('home');
108
109
 
109
110
  // Honour `?view=<mode>` from the URL so external links (eg the VSCode
110
111
  // extension) can deep-link straight into a section. Only views that have a
@@ -117,7 +118,7 @@ export default function Dashboard({ user }: { user: any }) {
117
118
  if (raw) {
118
119
  const aliases: Record<string, string> = { workspace: 'projects', sessions: 'projects' };
119
120
  const v = aliases[raw] || raw;
120
- const valid = ['tasks', 'terminal', 'docs', 'projects', 'pipelines', 'history', 'jobs', 'schedules', 'skills', 'logs', 'usage'];
121
+ const valid = ['home', 'tasks', 'terminal', 'docs', 'projects', 'pipelines', 'history', 'jobs', 'schedules', 'skills', 'logs', 'usage'];
121
122
  if (valid.includes(v)) setViewMode(v as any);
122
123
  }
123
124
  // Optional deep-link to a specific pipeline run — used by the extension
@@ -507,26 +508,17 @@ export default function Dashboard({ user }: { user: any }) {
507
508
 
508
509
  {/* View mode toggle */}
509
510
  <div className="flex items-center bg-[var(--bg-tertiary)] rounded p-0.5">
510
- {/* Automationfirst. 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). */}
511
+ {/* Homelanding view: web chat + activity panel side-by-side. */}
513
512
  <button
514
- onClick={() => {
515
- if (!['tasks', 'pipelines', 'schedules', 'history'].includes(viewMode)) setViewMode('history');
516
- }}
513
+ onClick={() => setViewMode('home')}
517
514
  className={`text-[12px] px-2.5 py-0.5 rounded transition-colors ${
518
- ['tasks', 'pipelines', 'schedules', 'history'].includes(viewMode)
515
+ viewMode === 'home'
519
516
  ? 'bg-[var(--accent)]/15 text-[var(--accent)] shadow-sm'
520
517
  : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
521
518
  }`}
522
519
  >
523
- Automation
520
+ Home
524
521
  </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
522
  <span className="w-[2px] h-4 bg-[var(--text-secondary)]/30 mx-1.5" />
531
523
  {/* Workspace */}
532
524
  {(['terminal', 'projects'] as const).map(mode => (
@@ -543,6 +535,27 @@ export default function Dashboard({ user }: { user: any }) {
543
535
  </button>
544
536
  ))}
545
537
  <span className="w-[2px] h-4 bg-[var(--text-secondary)]/30 mx-1.5" />
538
+ {/* Automation — moved AFTER Projects, deemphasized now that Home
539
+ surfaces pipelines / tasks inline via the right rail. Sub-tabs:
540
+ pipelines / tasks / schedules. Jobs is deprecated (backend kept
541
+ for reversion, but no UI entry point). */}
542
+ <button
543
+ onClick={() => {
544
+ if (!['tasks', 'pipelines', 'schedules', 'history'].includes(viewMode)) setViewMode('history');
545
+ }}
546
+ className={`text-[11px] px-2 py-0.5 rounded transition-colors opacity-80 hover:opacity-100 ${
547
+ ['tasks', 'pipelines', 'schedules', 'history'].includes(viewMode)
548
+ ? 'bg-[var(--accent)]/15 text-[var(--accent)] shadow-sm opacity-100'
549
+ : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
550
+ }`}
551
+ >
552
+ Automation
553
+ </button>
554
+ {/* Activity sub-pill — sits next to Automation since its content
555
+ (running pipelines + upcoming schedules + recent runs) is the
556
+ live read-side of Automation. */}
557
+ <Suspense fallback={null}><ActivityPanel /></Suspense>
558
+ <span className="w-[2px] h-4 bg-[var(--text-secondary)]/30 mx-1.5" />
546
559
  {/* Docs */}
547
560
  <button
548
561
  onClick={() => setViewMode('docs')}
@@ -1073,6 +1086,15 @@ export default function Dashboard({ user }: { user: any }) {
1073
1086
  </Suspense>
1074
1087
  </div>
1075
1088
 
1089
+ {/* Home — web chat + activity rail */}
1090
+ {viewMode === 'home' && (
1091
+ <div className="flex-1 flex min-h-0 min-w-0">
1092
+ <Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
1093
+ <HomeView />
1094
+ </Suspense>
1095
+ </div>
1096
+ )}
1097
+
1076
1098
  {/* Pipelines */}
1077
1099
  {viewMode === 'pipelines' && (
1078
1100
  <Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
@@ -0,0 +1,142 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react';
4
+
5
+ const WebChatPanel = lazy(() => import('./WebChatPanel'));
6
+ const PipelineActivityPanel = lazy(() => import('./PipelineActivityPanel'));
7
+
8
+ // Home view: 3 columns with draggable splitters.
9
+ // col 1 — Session list (inside WebChatPanel; width controlled via prop)
10
+ // col 2 — Main chat (WebChatPanel's main area, fluid)
11
+ // col 3 — Activity (PipelineActivityPanel; collapsible to vertical strip)
12
+ const LEFT_KEY = 'forge.home.leftWidth';
13
+ const RIGHT_KEY = 'forge.home.rightWidth';
14
+ const RIGHT_COLLAPSED_KEY = 'forge.home.rightCollapsed';
15
+
16
+ const DEFAULT_LEFT = 208; // session list
17
+ const DEFAULT_RIGHT = 288; // activity panel
18
+ const MIN_LEFT = 140;
19
+ const MAX_LEFT = 380;
20
+ const MIN_RIGHT = 200;
21
+ const MAX_RIGHT = 500;
22
+
23
+ function readNumber(key: string, fallback: number): number {
24
+ if (typeof window === 'undefined') return fallback;
25
+ const v = parseInt(localStorage.getItem(key) || '', 10);
26
+ return Number.isFinite(v) ? v : fallback;
27
+ }
28
+
29
+ export default function HomeView() {
30
+ const [leftWidth, setLeftWidth] = useState<number>(() => readNumber(LEFT_KEY, DEFAULT_LEFT));
31
+ const [rightWidth, setRightWidth] = useState<number>(() => readNumber(RIGHT_KEY, DEFAULT_RIGHT));
32
+ const [rightCollapsed, setRightCollapsed] = useState<boolean>(() => {
33
+ if (typeof window === 'undefined') return false;
34
+ return localStorage.getItem(RIGHT_COLLAPSED_KEY) === '1';
35
+ });
36
+
37
+ // Persist on change (debounced via effect)
38
+ useEffect(() => { try { localStorage.setItem(LEFT_KEY, String(leftWidth)); } catch {} }, [leftWidth]);
39
+ useEffect(() => { try { localStorage.setItem(RIGHT_KEY, String(rightWidth)); } catch {} }, [rightWidth]);
40
+
41
+ const toggleRight = () => {
42
+ setRightCollapsed(v => {
43
+ const next = !v;
44
+ try { localStorage.setItem(RIGHT_COLLAPSED_KEY, next ? '1' : '0'); } catch {}
45
+ return next;
46
+ });
47
+ };
48
+
49
+ // ─── Drag handlers ────────────────────────────────────
50
+ // Each splitter installs window-level mousemove/mouseup on drag-start;
51
+ // these auto-clean themselves up on release.
52
+ const dragRef = useRef<{ which: 'left' | 'right'; startX: number; startW: number } | null>(null);
53
+
54
+ const startDrag = useCallback((which: 'left' | 'right') => (e: React.MouseEvent) => {
55
+ e.preventDefault();
56
+ dragRef.current = {
57
+ which,
58
+ startX: e.clientX,
59
+ startW: which === 'left' ? leftWidth : rightWidth,
60
+ };
61
+ document.body.style.cursor = 'col-resize';
62
+ document.body.style.userSelect = 'none';
63
+
64
+ const onMove = (mv: MouseEvent) => {
65
+ const ctx = dragRef.current;
66
+ if (!ctx) return;
67
+ if (ctx.which === 'left') {
68
+ const next = Math.max(MIN_LEFT, Math.min(MAX_LEFT, ctx.startW + (mv.clientX - ctx.startX)));
69
+ setLeftWidth(next);
70
+ } else {
71
+ const next = Math.max(MIN_RIGHT, Math.min(MAX_RIGHT, ctx.startW - (mv.clientX - ctx.startX)));
72
+ setRightWidth(next);
73
+ }
74
+ };
75
+ const onUp = () => {
76
+ dragRef.current = null;
77
+ document.body.style.cursor = '';
78
+ document.body.style.userSelect = '';
79
+ window.removeEventListener('mousemove', onMove);
80
+ window.removeEventListener('mouseup', onUp);
81
+ };
82
+ window.addEventListener('mousemove', onMove);
83
+ window.addEventListener('mouseup', onUp);
84
+ }, [leftWidth, rightWidth]);
85
+
86
+ return (
87
+ <div className="flex-1 flex min-h-0 min-w-0">
88
+ {/* Chat (session list + main chat — WebChatPanel handles both internally).
89
+ The session-list splitter is rendered INSIDE WebChatPanel, between its
90
+ aside and main, so it sits at the actual sidebar boundary. */}
91
+ <Suspense fallback={<div className="flex-1 p-4 text-xs text-[var(--text-secondary)]">Loading chat…</div>}>
92
+ <WebChatPanel sidebarWidth={leftWidth} onSidebarResizeStart={startDrag('left')} />
93
+ </Suspense>
94
+
95
+ {/* Right rail: collapsible activity panel */}
96
+ {rightCollapsed ? (
97
+ <button
98
+ onClick={toggleRight}
99
+ title="Show pipeline panel"
100
+ className="w-7 shrink-0 flex flex-col items-center justify-start pt-3 gap-2 hover:bg-[var(--bg-tertiary)] transition-colors group border-l border-[var(--border)]"
101
+ >
102
+ <span className="text-[var(--text-secondary)] group-hover:text-[var(--text-primary)]">‹</span>
103
+ <span
104
+ className="text-[10px] tracking-widest text-[var(--text-secondary)] group-hover:text-[var(--text-primary)]"
105
+ style={{ writingMode: 'vertical-rl' }}
106
+ >
107
+ PIPELINES
108
+ </span>
109
+ </button>
110
+ ) : (
111
+ <>
112
+ {/* Splitter between chat and activity */}
113
+ <div
114
+ onMouseDown={startDrag('right')}
115
+ className="w-1 shrink-0 cursor-col-resize bg-transparent hover:bg-[var(--accent)]/30 active:bg-[var(--accent)]/50 transition-colors border-l border-[var(--border)]"
116
+ title="Drag to resize activity panel"
117
+ />
118
+ <div
119
+ className="shrink-0 flex flex-col bg-[var(--bg-secondary)]"
120
+ style={{ width: rightWidth }}
121
+ >
122
+ <div className="flex items-center justify-between px-3 py-2 border-b border-[var(--border)] shrink-0">
123
+ <span className="text-[11px] font-medium text-[var(--text-primary)]">Activity</span>
124
+ <button
125
+ onClick={toggleRight}
126
+ title="Collapse"
127
+ className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] text-sm px-1"
128
+ >
129
+
130
+ </button>
131
+ </div>
132
+ <div className="flex-1 overflow-hidden">
133
+ <Suspense fallback={<div className="p-3 text-[10px] text-[var(--text-secondary)]">Loading…</div>}>
134
+ <PipelineActivityPanel />
135
+ </Suspense>
136
+ </div>
137
+ </div>
138
+ </>
139
+ )}
140
+ </div>
141
+ );
142
+ }
@@ -0,0 +1,327 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState, useCallback } from 'react';
4
+
5
+ interface RunningRow {
6
+ id: string;
7
+ workflowName: string;
8
+ status: string;
9
+ currentNode: string | null;
10
+ progress: { done: number; total: number };
11
+ createdAt: string;
12
+ }
13
+
14
+ interface RecentRow {
15
+ id: string;
16
+ workflowName: string;
17
+ status: string;
18
+ completedAt: string | null;
19
+ durationMs: number | null;
20
+ }
21
+
22
+ interface RunningTaskRow {
23
+ id: string;
24
+ project: string;
25
+ prompt_preview: string;
26
+ status: string;
27
+ startedAt: string | null;
28
+ createdAt: string;
29
+ agent: string | null;
30
+ }
31
+
32
+ interface Summary {
33
+ running: RunningRow[];
34
+ running_tasks: RunningTaskRow[];
35
+ recent: RecentRow[];
36
+ }
37
+
38
+ function statusColor(s: string): string {
39
+ if (s === 'running' || s === 'queued') return 'text-blue-400';
40
+ if (s === 'succeeded' || s === 'done') return 'text-green-400';
41
+ if (s === 'failed') return 'text-red-400';
42
+ if (s === 'cancelled') return 'text-gray-500';
43
+ return 'text-[var(--text-secondary)]';
44
+ }
45
+
46
+ function fmtDuration(ms: number | null): string {
47
+ if (!ms) return '';
48
+ if (ms < 1000) return `${ms}ms`;
49
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
50
+ return `${Math.round(ms / 60000)}m`;
51
+ }
52
+
53
+ function fmtRelative(ts: string | null): string {
54
+ if (!ts) return '';
55
+ const diff = Date.now() - new Date(ts).getTime();
56
+ if (diff < 60_000) return `${Math.floor(diff / 1000)}s ago`;
57
+ if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago`;
58
+ if (diff < 86400_000) return `${Math.floor(diff / 3600_000)}h ago`;
59
+ return `${Math.floor(diff / 86400_000)}d ago`;
60
+ }
61
+
62
+ // ─── Inline log ─────────────────────────────────────────
63
+
64
+ interface InlineLogState {
65
+ kind: 'task' | 'pipeline';
66
+ id: string;
67
+ loading: boolean;
68
+ lines: string[];
69
+ error?: string;
70
+ }
71
+
72
+ function trimEntry(s: string, max = 120): string {
73
+ const flat = (s || '').replace(/\s+/g, ' ').trim();
74
+ return flat.length > max ? flat.slice(0, max) + '…' : flat;
75
+ }
76
+
77
+ async function fetchTaskLog(taskId: string): Promise<string[]> {
78
+ const res = await fetch(`/api/tasks/${taskId}/log?limit=50`);
79
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
80
+ const data = await res.json() as { entries: Array<{ type?: string; subtype?: string; tool?: string; content?: string }> };
81
+ return (data.entries || []).map(e => {
82
+ const prefix = e.subtype === 'tool_use' ? `⚙ ${e.tool || 'tool'}: ` : e.type === 'result' ? '✓ ' : e.subtype === 'error' ? '✗ ' : '';
83
+ return prefix + trimEntry(e.content || '');
84
+ });
85
+ }
86
+
87
+ async function fetchPipelineLog(pipelineId: string): Promise<string[]> {
88
+ const res = await fetch(`/api/pipelines/${pipelineId}`);
89
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
90
+ // Pipeline shape: nodes is Record<string, PipelineNodeState>, nodeOrder gives display order.
91
+ const data = await res.json() as {
92
+ nodes?: Record<string, { status: string; startedAt?: string; completedAt?: string; error?: string; outputs?: Record<string, string>; attempts?: number }>;
93
+ nodeOrder?: string[];
94
+ error?: string;
95
+ };
96
+ const order = Array.isArray(data.nodeOrder) ? data.nodeOrder : Object.keys(data.nodes || {});
97
+ const lines: string[] = [];
98
+ if (data.error) lines.push(`✗ pipeline error: ${trimEntry(data.error, 100)}`);
99
+ for (const id of order) {
100
+ const n = data.nodes?.[id];
101
+ if (!n) continue;
102
+ const tag = n.status === 'running' ? '▶'
103
+ : n.status === 'done' || n.status === 'succeeded' ? '✓'
104
+ : n.status === 'failed' ? '✗'
105
+ : n.status === 'cancelled' ? '⊘'
106
+ : '·';
107
+ const attempts = n.attempts && n.attempts > 1 ? ` (try ${n.attempts})` : '';
108
+ const err = n.error ? ` — ${trimEntry(n.error, 80)}` : '';
109
+ lines.push(`${tag} ${id} [${n.status}]${attempts}${err}`);
110
+ }
111
+ return lines;
112
+ }
113
+
114
+ function InlineLog({ state, onClose }: { state: InlineLogState; onClose: () => void }) {
115
+ return (
116
+ <div className="mt-1 mb-1 p-2 bg-black/20 border border-[var(--border)] rounded text-[9.5px] font-mono text-[var(--text-secondary)] max-h-[120px] overflow-y-auto leading-tight">
117
+ <div className="flex items-center justify-between mb-1 text-[9px] uppercase tracking-wider text-[var(--text-secondary)]/70">
118
+ <span>{state.kind} log</span>
119
+ <button onClick={(e) => { e.stopPropagation(); onClose(); }} className="hover:text-[var(--text-primary)]">×</button>
120
+ </div>
121
+ {state.loading && <div className="italic">loading…</div>}
122
+ {state.error && <div className="text-red-400">err: {state.error}</div>}
123
+ {!state.loading && !state.error && state.lines.length === 0 && <div className="italic">no entries</div>}
124
+ {state.lines.slice(-50).map((line, i) => (
125
+ <div key={i} className="truncate" title={line}>{line}</div>
126
+ ))}
127
+ </div>
128
+ );
129
+ }
130
+
131
+ // Small inline "open full view" button — shown on row hover, click navigates
132
+ // without triggering the row's expand toggle.
133
+ function OpenButton({ onOpen }: { onOpen: () => void }) {
134
+ return (
135
+ <button
136
+ onClick={(e) => { e.stopPropagation(); onOpen(); }}
137
+ className="opacity-0 group-hover:opacity-100 text-[var(--text-secondary)] hover:text-[var(--accent)] text-[10px] leading-none px-1 transition-opacity"
138
+ title="Open in full view"
139
+ >
140
+
141
+ </button>
142
+ );
143
+ }
144
+
145
+ // ─── Main panel ─────────────────────────────────────────
146
+
147
+ export default function PipelineActivityPanel() {
148
+ const [data, setData] = useState<Summary>({ running: [], running_tasks: [], recent: [] });
149
+ const [expanded, setExpanded] = useState<InlineLogState | null>(null);
150
+
151
+ useEffect(() => {
152
+ let alive = true;
153
+ const fetchSummary = async () => {
154
+ try {
155
+ const res = await fetch('/api/activity/summary');
156
+ if (!res.ok || !alive) return;
157
+ const json = await res.json();
158
+ setData({
159
+ running: json.running || [],
160
+ running_tasks: json.running_tasks || [],
161
+ recent: json.recent || [],
162
+ });
163
+ } catch {}
164
+ };
165
+ fetchSummary();
166
+ const iv = setInterval(fetchSummary, 5000);
167
+ return () => { alive = false; clearInterval(iv); };
168
+ }, []);
169
+
170
+ const toggleExpand = useCallback(async (kind: 'task' | 'pipeline', id: string) => {
171
+ if (expanded && expanded.id === id && expanded.kind === kind) {
172
+ setExpanded(null);
173
+ return;
174
+ }
175
+ setExpanded({ kind, id, loading: true, lines: [] });
176
+ try {
177
+ const lines = kind === 'task' ? await fetchTaskLog(id) : await fetchPipelineLog(id);
178
+ setExpanded(cur => (cur && cur.id === id && cur.kind === kind) ? { ...cur, loading: false, lines } : cur);
179
+ } catch (e: any) {
180
+ setExpanded(cur => (cur && cur.id === id && cur.kind === kind) ? { ...cur, loading: false, error: e?.message || 'fetch failed' } : cur);
181
+ }
182
+ }, [expanded]);
183
+
184
+ // Open in a NEW tab so the current Home view (chat + activity rail) stays put.
185
+ // Dashboard deep-links accept ?view + ?pipelineId / ?taskId.
186
+ const openPipelineFull = (id: string) => {
187
+ window.open(`/?view=pipelines&pipelineId=${encodeURIComponent(id)}`, '_blank', 'noopener');
188
+ };
189
+ const openTaskFull = (id: string) => {
190
+ window.open(`/?view=tasks&taskId=${encodeURIComponent(id)}`, '_blank', 'noopener');
191
+ };
192
+
193
+ const isExpanded = (kind: 'task' | 'pipeline', id: string) =>
194
+ expanded?.id === id && expanded?.kind === kind;
195
+
196
+ return (
197
+ <div className="h-full flex flex-col text-xs overflow-y-auto">
198
+ {/* Running pipelines */}
199
+ <div className="px-3 py-2 border-b border-[var(--border)]">
200
+ <div className="text-[10px] uppercase tracking-wider text-[var(--text-secondary)] mb-1.5 flex items-center gap-1.5">
201
+ <span>Running Pipelines</span>
202
+ {data.running.length > 0 && (
203
+ <span className="text-[var(--accent)]">({data.running.length})</span>
204
+ )}
205
+ </div>
206
+ {data.running.length === 0 ? (
207
+ <div className="text-[10px] text-[var(--text-secondary)] italic">none</div>
208
+ ) : (
209
+ <div className="space-y-1">
210
+ {data.running.map(p => (
211
+ <div key={p.id}>
212
+ <div
213
+ role="button"
214
+ tabIndex={0}
215
+ onClick={() => toggleExpand('pipeline', p.id)}
216
+ className={`group w-full text-left p-1.5 rounded transition-colors cursor-pointer ${
217
+ isExpanded('pipeline', p.id) ? 'bg-[var(--bg-tertiary)]' : 'hover:bg-[var(--bg-tertiary)]'
218
+ }`}
219
+ title="Click to peek log"
220
+ >
221
+ <div className="flex items-center justify-between gap-1">
222
+ <span className="truncate font-medium">{p.workflowName}</span>
223
+ <div className="flex items-center gap-1 shrink-0">
224
+ <span className={`text-[9px] ${statusColor(p.status)}`}>{p.status}</span>
225
+ <OpenButton onOpen={() => openPipelineFull(p.id)} />
226
+ </div>
227
+ </div>
228
+ <div className="flex items-center justify-between text-[9px] text-[var(--text-secondary)] mt-0.5">
229
+ <span className="truncate">{p.currentNode || '—'}</span>
230
+ <span className="shrink-0">{p.progress.done}/{p.progress.total}</span>
231
+ </div>
232
+ </div>
233
+ {isExpanded('pipeline', p.id) && expanded && (
234
+ <InlineLog state={expanded} onClose={() => setExpanded(null)} />
235
+ )}
236
+ </div>
237
+ ))}
238
+ </div>
239
+ )}
240
+ </div>
241
+
242
+ {/* Running tasks */}
243
+ <div className="px-3 py-2 border-b border-[var(--border)]">
244
+ <div className="text-[10px] uppercase tracking-wider text-[var(--text-secondary)] mb-1.5 flex items-center gap-1.5">
245
+ <span>Running Tasks</span>
246
+ {data.running_tasks.length > 0 && (
247
+ <span className="text-[var(--accent)]">({data.running_tasks.length})</span>
248
+ )}
249
+ </div>
250
+ {data.running_tasks.length === 0 ? (
251
+ <div className="text-[10px] text-[var(--text-secondary)] italic">none</div>
252
+ ) : (
253
+ <div className="space-y-1">
254
+ {data.running_tasks.map(t => (
255
+ <div key={t.id}>
256
+ <div
257
+ role="button"
258
+ tabIndex={0}
259
+ onClick={() => toggleExpand('task', t.id)}
260
+ className={`group w-full text-left p-1.5 rounded transition-colors cursor-pointer ${
261
+ isExpanded('task', t.id) ? 'bg-[var(--bg-tertiary)]' : 'hover:bg-[var(--bg-tertiary)]'
262
+ }`}
263
+ title="Click to peek log"
264
+ >
265
+ <div className="flex items-center justify-between gap-1">
266
+ <span className="truncate font-medium">{t.project}</span>
267
+ <div className="flex items-center gap-1 shrink-0">
268
+ <span className={`text-[9px] ${statusColor(t.status)}`}>{t.status}</span>
269
+ <OpenButton onOpen={() => openTaskFull(t.id)} />
270
+ </div>
271
+ </div>
272
+ <div className="text-[9px] text-[var(--text-secondary)] mt-0.5 truncate">
273
+ {t.prompt_preview}
274
+ </div>
275
+ </div>
276
+ {isExpanded('task', t.id) && expanded && (
277
+ <InlineLog state={expanded} onClose={() => setExpanded(null)} />
278
+ )}
279
+ </div>
280
+ ))}
281
+ </div>
282
+ )}
283
+ </div>
284
+
285
+ {/* Recent pipelines */}
286
+ <div className="px-3 py-2 flex-1">
287
+ <div className="text-[10px] uppercase tracking-wider text-[var(--text-secondary)] mb-1.5">
288
+ Recent Runs
289
+ </div>
290
+ {data.recent.length === 0 ? (
291
+ <div className="text-[10px] text-[var(--text-secondary)] italic">none</div>
292
+ ) : (
293
+ <div className="space-y-1">
294
+ {data.recent.map(r => (
295
+ <div key={r.id}>
296
+ <div
297
+ role="button"
298
+ tabIndex={0}
299
+ onClick={() => toggleExpand('pipeline', r.id)}
300
+ className={`group w-full text-left p-1.5 rounded transition-colors cursor-pointer ${
301
+ isExpanded('pipeline', r.id) ? 'bg-[var(--bg-tertiary)]' : 'hover:bg-[var(--bg-tertiary)]'
302
+ }`}
303
+ title="Click to peek log"
304
+ >
305
+ <div className="flex items-center justify-between gap-1">
306
+ <span className="truncate font-medium">{r.workflowName}</span>
307
+ <div className="flex items-center gap-1 shrink-0">
308
+ <span className={`text-[9px] ${statusColor(r.status)}`}>{r.status}</span>
309
+ <OpenButton onOpen={() => openPipelineFull(r.id)} />
310
+ </div>
311
+ </div>
312
+ <div className="flex items-center justify-between text-[9px] text-[var(--text-secondary)] mt-0.5">
313
+ <span className="truncate">{fmtRelative(r.completedAt)}</span>
314
+ {r.durationMs != null && <span className="shrink-0">{fmtDuration(r.durationMs)}</span>}
315
+ </div>
316
+ </div>
317
+ {isExpanded('pipeline', r.id) && expanded && (
318
+ <InlineLog state={expanded} onClose={() => setExpanded(null)} />
319
+ )}
320
+ </div>
321
+ ))}
322
+ </div>
323
+ )}
324
+ </div>
325
+ </div>
326
+ );
327
+ }