@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.
- package/RELEASE_NOTES.md +5 -5
- package/app/chat/page.tsx +7 -1231
- package/components/Dashboard.tsx +37 -15
- package/components/HomeView.tsx +142 -0
- package/components/PipelineActivityPanel.tsx +327 -0
- package/components/WebChatPanel.tsx +1253 -0
- package/lib/chat/agent-loop.ts +10 -11
- package/package.json +1 -1
package/components/Dashboard.tsx
CHANGED
|
@@ -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'>('
|
|
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
|
-
{/*
|
|
511
|
-
Jobs is deprecated and hidden from the nav (backend still
|
|
512
|
-
present in case of reversion, but no UI entry point). */}
|
|
511
|
+
{/* Home — landing 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
|
-
|
|
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
|
-
|
|
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
|
+
}
|