@aion0/forge 0.10.32 → 0.10.33

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,11 +1,23 @@
1
- # Forge v0.10.32
1
+ # Forge v0.10.33
2
2
 
3
3
  Released: 2026-06-02
4
4
 
5
- ## Changes since v0.10.31
5
+ ## Changes since v0.10.32
6
+
7
+ ### Documentation
8
+ - docs: update help-docs for activity pill, marketplace, usage move, watch builtins
6
9
 
7
10
  ### Other
8
- - fix(watch): builtin pollers return JSON + add get_task_status
11
+ - refactor(marketplace): Pipelines first + default landing
12
+ - refactor(dashboard): move Activity pill next to Automation tab
13
+ - refactor(dashboard): promote Chat (web) + open standalone routes in new tab
14
+ - refactor(dashboard): promote Settings, add icons to user menu rows
15
+ - refactor(dashboard): move Usage into user menu next to Monitor/Login Status
16
+ - refactor(marketplace): split category dropdown by group
17
+ - perf(pipeline-view): invalidate cache after mutations
18
+ - fix(activity): view link uses forge:navigate event
19
+ - perf(pipeline-view): module-level SWR cache for meta + per-workflow runs
20
+ - feat(activity): top-right Activity pill — running pipelines + upcoming schedules
9
21
 
10
22
 
11
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.31...v0.10.32
23
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.32...v0.10.33
@@ -0,0 +1,135 @@
1
+ /**
2
+ * GET /api/activity/summary
3
+ *
4
+ * Small snapshot for the Activity pill / dashboard. Three sections:
5
+ * - running: pipelines currently in flight (status queued|running)
6
+ * - upcoming: enabled schedules sorted by next_run_at ASC
7
+ * - recent: last 8 finished pipelines (succeeded|failed|cancelled)
8
+ *
9
+ * Aim <5KB response, <30ms. No expensive joins, no per-pipeline detail.
10
+ * Front-end refetches every 5s when ActivityPanel is open.
11
+ */
12
+
13
+ import { NextResponse } from 'next/server';
14
+ import { listPipelinesSummary } from '@/lib/pipeline';
15
+ import { listSchedules } from '@/lib/schedules/store';
16
+
17
+ interface RunningRow {
18
+ id: string;
19
+ workflowName: string;
20
+ status: string;
21
+ currentNode: string | null;
22
+ progress: { done: number; total: number };
23
+ createdAt: string;
24
+ }
25
+
26
+ interface UpcomingRow {
27
+ id: string;
28
+ name: string;
29
+ body_kind: string;
30
+ body_ref: string;
31
+ next_run_at: string | null;
32
+ schedule_kind: string;
33
+ schedule_summary: string;
34
+ }
35
+
36
+ interface RecentRow {
37
+ id: string;
38
+ workflowName: string;
39
+ status: string;
40
+ completedAt: string | null;
41
+ durationMs: number | null;
42
+ }
43
+
44
+ interface Summary {
45
+ running: RunningRow[];
46
+ upcoming: UpcomingRow[];
47
+ recent: RecentRow[];
48
+ generated_at: string;
49
+ }
50
+
51
+ function scheduleSummary(s: {
52
+ schedule_kind: string;
53
+ schedule_interval_minutes: number;
54
+ schedule_at: string | null;
55
+ schedule_cron: string | null;
56
+ }): string {
57
+ switch (s.schedule_kind) {
58
+ case 'period':
59
+ return s.schedule_interval_minutes >= 60
60
+ ? `every ${Math.round(s.schedule_interval_minutes / 60)}h`
61
+ : `every ${s.schedule_interval_minutes}m`;
62
+ case 'cron':
63
+ return `cron: ${s.schedule_cron || '?'}`;
64
+ case 'once':
65
+ return s.schedule_at ? `at ${s.schedule_at}` : 'one-shot';
66
+ case 'manual':
67
+ return 'manual only';
68
+ default:
69
+ return s.schedule_kind;
70
+ }
71
+ }
72
+
73
+ export async function GET() {
74
+ // Single listPipelinesSummary call — uses the parse cache, sorted desc.
75
+ // 200 runs cap is plenty; we'll filter into running + recent and discard
76
+ // the rest.
77
+ const all = listPipelinesSummary({ limit: 200 });
78
+
79
+ const running: RunningRow[] = [];
80
+ const recent: RecentRow[] = [];
81
+ for (const p of all) {
82
+ const isActive = p.status === 'running';
83
+ if (isActive && running.length < 30) {
84
+ // Compute progress + current node from the in-memory nodes map.
85
+ let done = 0;
86
+ let current: string | null = null;
87
+ for (const name of p.nodeOrder) {
88
+ const st = p.nodes[name]?.status;
89
+ if (st === 'done' || st === 'skipped') done++;
90
+ else if (!current && st === 'running') current = name;
91
+ }
92
+ running.push({
93
+ id: p.id,
94
+ workflowName: p.workflowName,
95
+ status: p.status,
96
+ currentNode: current,
97
+ progress: { done, total: p.nodeOrder.length },
98
+ createdAt: p.createdAt,
99
+ });
100
+ } else if (!isActive && recent.length < 8) {
101
+ const startedMs = Date.parse(p.createdAt);
102
+ const endedMs = p.completedAt ? Date.parse(p.completedAt) : NaN;
103
+ recent.push({
104
+ id: p.id,
105
+ workflowName: p.workflowName,
106
+ status: p.status,
107
+ completedAt: p.completedAt || null,
108
+ durationMs: isFinite(startedMs) && isFinite(endedMs) ? endedMs - startedMs : null,
109
+ });
110
+ }
111
+ }
112
+
113
+ // Upcoming = enabled schedules sorted by next_run_at ASC, top 10.
114
+ const schedules = listSchedules()
115
+ .filter((s) => s.enabled && s.next_run_at)
116
+ .sort((a, b) => String(a.next_run_at).localeCompare(String(b.next_run_at)))
117
+ .slice(0, 10);
118
+ const upcoming: UpcomingRow[] = schedules.map((s) => ({
119
+ id: s.id,
120
+ name: s.name,
121
+ body_kind: s.body_kind,
122
+ body_ref: s.body_ref,
123
+ next_run_at: s.next_run_at,
124
+ schedule_kind: s.schedule_kind,
125
+ schedule_summary: scheduleSummary(s),
126
+ }));
127
+
128
+ const summary: Summary = {
129
+ running,
130
+ upcoming,
131
+ recent,
132
+ generated_at: new Date().toISOString(),
133
+ };
134
+ return NextResponse.json(summary);
135
+ }
@@ -0,0 +1,266 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef, useState } 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 UpcomingRow {
15
+ id: string;
16
+ name: string;
17
+ body_kind: string;
18
+ body_ref: string;
19
+ next_run_at: string | null;
20
+ schedule_kind: string;
21
+ schedule_summary: string;
22
+ }
23
+
24
+ interface RecentRow {
25
+ id: string;
26
+ workflowName: string;
27
+ status: string;
28
+ completedAt: string | null;
29
+ durationMs: number | null;
30
+ }
31
+
32
+ interface Summary {
33
+ running: RunningRow[];
34
+ upcoming: UpcomingRow[];
35
+ recent: RecentRow[];
36
+ generated_at: string;
37
+ }
38
+
39
+ const POLL_OPEN_MS = 5000; // active polling while open
40
+ const POLL_BADGE_MS = 30000; // background polling while closed (just for counts)
41
+
42
+ function timeAgo(iso: string | null): string {
43
+ if (!iso) return '';
44
+ const s = Math.round((Date.now() - Date.parse(iso)) / 1000);
45
+ if (s < 60) return `${s}s ago`;
46
+ if (s < 3600) return `${Math.round(s / 60)}m ago`;
47
+ if (s < 86400) return `${Math.round(s / 3600)}h ago`;
48
+ return `${Math.round(s / 86400)}d ago`;
49
+ }
50
+
51
+ function timeUntil(iso: string | null): string {
52
+ if (!iso) return '';
53
+ const s = Math.round((Date.parse(iso) - Date.now()) / 1000);
54
+ if (s < 0) return 'overdue';
55
+ if (s < 60) return `in ${s}s`;
56
+ if (s < 3600) return `in ${Math.round(s / 60)}m`;
57
+ if (s < 86400) return `in ${Math.round(s / 3600)}h`;
58
+ return `in ${Math.round(s / 86400)}d`;
59
+ }
60
+
61
+ function fmtDuration(ms: number | null): string {
62
+ if (ms == null) return '';
63
+ if (ms < 1000) return `${ms}ms`;
64
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
65
+ return `${Math.round(ms / 60_000)}m`;
66
+ }
67
+
68
+ function statusGlyph(s: string): string {
69
+ if (s === 'done') return '✓';
70
+ if (s === 'failed') return '✗';
71
+ if (s === 'cancelled') return '⊘';
72
+ if (s === 'running') return '▶';
73
+ return s;
74
+ }
75
+
76
+ function statusColor(s: string): string {
77
+ if (s === 'done') return 'text-green-500';
78
+ if (s === 'failed') return 'text-red-400';
79
+ if (s === 'cancelled') return 'text-gray-400';
80
+ if (s === 'running') return 'text-blue-400';
81
+ return 'text-[var(--text-secondary)]';
82
+ }
83
+
84
+ /**
85
+ * Activity pill — top-right glanceable status for running pipelines +
86
+ * upcoming schedules. Click to expand a 3-section dropdown. Polls every
87
+ * 5s while open, 30s in background for badge counts.
88
+ */
89
+ export default function ActivityPanel() {
90
+ const [summary, setSummary] = useState<Summary | null>(null);
91
+ const [open, setOpen] = useState(false);
92
+ const panelRef = useRef<HTMLDivElement | null>(null);
93
+
94
+ const refetch = useCallback(async () => {
95
+ try {
96
+ const r = await fetch('/api/activity/summary');
97
+ if (!r.ok) return;
98
+ const j: Summary = await r.json();
99
+ setSummary(j);
100
+ } catch { /* network blip — silent */ }
101
+ }, []);
102
+
103
+ // Initial fetch + adaptive polling cadence
104
+ useEffect(() => {
105
+ refetch();
106
+ const ms = open ? POLL_OPEN_MS : POLL_BADGE_MS;
107
+ const id = setInterval(refetch, ms);
108
+ return () => clearInterval(id);
109
+ }, [open, refetch]);
110
+
111
+ // Click-outside dismisses
112
+ useEffect(() => {
113
+ if (!open) return;
114
+ const onClick = (e: MouseEvent) => {
115
+ if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
116
+ setOpen(false);
117
+ }
118
+ };
119
+ window.addEventListener('mousedown', onClick);
120
+ return () => window.removeEventListener('mousedown', onClick);
121
+ }, [open]);
122
+
123
+ const runningCount = summary?.running.length ?? 0;
124
+ const upcomingCount = summary?.upcoming.length ?? 0;
125
+ const recentFailed = (summary?.recent ?? []).filter((r) => r.status === 'failed').length;
126
+
127
+ // Pill chips
128
+ const chips: string[] = [];
129
+ if (runningCount) chips.push(`▶${runningCount}`);
130
+ if (upcomingCount) chips.push(`⏰${upcomingCount}`);
131
+ if (!chips.length) chips.push('✓');
132
+
133
+ return (
134
+ <div className="relative" ref={panelRef}>
135
+ <button
136
+ onClick={() => setOpen((o) => !o)}
137
+ className={`text-[10px] px-2 py-0.5 rounded border border-[var(--border)] flex items-center gap-1.5
138
+ ${runningCount > 0 ? 'text-blue-400 border-blue-500/50' : 'text-[var(--text-secondary)]'}
139
+ hover:text-[var(--text-primary)]`}
140
+ title="Activity — running pipelines + upcoming schedules"
141
+ >
142
+ <span className="font-medium">{chips.join(' ')}</span>
143
+ {recentFailed > 0 && (
144
+ <span className="text-red-400 text-[9px]" title={`${recentFailed} recently failed`}>!{recentFailed}</span>
145
+ )}
146
+ </button>
147
+
148
+ {open && (
149
+ <div
150
+ className="absolute right-0 mt-1 w-[440px] max-h-[70vh] overflow-y-auto bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg shadow-xl z-50"
151
+ onClick={(e) => e.stopPropagation()}
152
+ >
153
+ {summary === null ? (
154
+ <div className="p-6 text-center text-xs text-[var(--text-secondary)]">Loading…</div>
155
+ ) : (
156
+ <div className="p-3 space-y-4">
157
+ {/* Running */}
158
+ <Section title="Running" count={runningCount}>
159
+ {runningCount === 0 ? (
160
+ <Empty>Nothing running.</Empty>
161
+ ) : (
162
+ summary.running.map((r) => (
163
+ <div key={r.id} className="flex items-start gap-2 text-xs py-1">
164
+ <span className={`${statusColor(r.status)} mt-0.5`}>{statusGlyph(r.status)}</span>
165
+ <div className="flex-1 min-w-0">
166
+ <div className="flex items-center gap-2">
167
+ <span className="text-[var(--text-primary)] font-medium truncate">{r.workflowName}</span>
168
+ <span className="text-[10px] text-[var(--text-secondary)]">
169
+ {r.progress.done}/{r.progress.total}
170
+ </span>
171
+ <span className="text-[10px] text-gray-500">{timeAgo(r.createdAt)}</span>
172
+ </div>
173
+ {r.currentNode && (
174
+ <div className="text-[10px] text-[var(--text-secondary)] mt-0.5">
175
+ current: {r.currentNode}
176
+ </div>
177
+ )}
178
+ </div>
179
+ <button
180
+ onClick={() => {
181
+ setOpen(false);
182
+ window.dispatchEvent(new CustomEvent('forge:navigate', { detail: { view: 'pipelines', pipelineId: r.id } }));
183
+ }}
184
+ className="text-[10px] px-1.5 py-0.5 rounded border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
185
+ >
186
+ view
187
+ </button>
188
+ </div>
189
+ ))
190
+ )}
191
+ </Section>
192
+
193
+ {/* Upcoming */}
194
+ <Section title="Next up" count={upcomingCount}>
195
+ {upcomingCount === 0 ? (
196
+ <Empty>No enabled schedules.</Empty>
197
+ ) : (
198
+ summary.upcoming.map((u) => (
199
+ <div key={u.id} className="flex items-start gap-2 text-xs py-1">
200
+ <span className="text-[var(--text-secondary)] mt-0.5">⏰</span>
201
+ <div className="flex-1 min-w-0">
202
+ <div className="flex items-center gap-2">
203
+ <span className="text-[var(--text-primary)] font-medium truncate">{u.name}</span>
204
+ <span className="text-[10px] text-blue-400">{timeUntil(u.next_run_at)}</span>
205
+ </div>
206
+ <div className="text-[10px] text-[var(--text-secondary)] mt-0.5 truncate">
207
+ {u.body_kind}: {u.body_ref} · {u.schedule_summary}
208
+ </div>
209
+ </div>
210
+ </div>
211
+ ))
212
+ )}
213
+ </Section>
214
+
215
+ {/* Recent */}
216
+ <Section title="Recent" count={summary.recent.length}>
217
+ {summary.recent.length === 0 ? (
218
+ <Empty>No recent runs.</Empty>
219
+ ) : (
220
+ summary.recent.map((r) => (
221
+ <div key={r.id} className="flex items-start gap-2 text-xs py-1">
222
+ <span className={`${statusColor(r.status)} mt-0.5`}>{statusGlyph(r.status)}</span>
223
+ <div className="flex-1 min-w-0">
224
+ <div className="flex items-center gap-2">
225
+ <span className="text-[var(--text-primary)] truncate">{r.workflowName}</span>
226
+ <span className="text-[10px] text-[var(--text-secondary)]">
227
+ {fmtDuration(r.durationMs)} · {timeAgo(r.completedAt)}
228
+ </span>
229
+ </div>
230
+ </div>
231
+ <button
232
+ onClick={() => {
233
+ setOpen(false);
234
+ window.dispatchEvent(new CustomEvent('forge:navigate', { detail: { view: 'pipelines', pipelineId: r.id } }));
235
+ }}
236
+ className="text-[10px] px-1.5 py-0.5 rounded border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
237
+ >
238
+ view
239
+ </button>
240
+ </div>
241
+ ))
242
+ )}
243
+ </Section>
244
+ </div>
245
+ )}
246
+ </div>
247
+ )}
248
+ </div>
249
+ );
250
+ }
251
+
252
+ function Section({ title, count, children }: { title: string; count: number; children: React.ReactNode }) {
253
+ return (
254
+ <div>
255
+ <h3 className="text-[10px] font-semibold text-[var(--text-secondary)] uppercase mb-1 flex items-center gap-2">
256
+ {title}
257
+ <span className="text-gray-500 font-normal">({count})</span>
258
+ </h3>
259
+ <div className="space-y-0.5">{children}</div>
260
+ </div>
261
+ );
262
+ }
263
+
264
+ function Empty({ children }: { children: React.ReactNode }) {
265
+ return <div className="text-[10px] text-gray-500 italic py-0.5">{children}</div>;
266
+ }
@@ -25,6 +25,7 @@ const NewTaskModal = lazy(() => import('./NewTaskModal'));
25
25
  const SettingsModal = lazy(() => import('./SettingsModal'));
26
26
  const MonitorPanel = lazy(() => import('./MonitorPanel'));
27
27
  const LoginStatusPanel = lazy(() => import('./LoginStatusPanel'));
28
+ const ActivityPanel = lazy(() => import('./ActivityPanel'));
28
29
  const WorkspaceView = lazy(() => import('./WorkspaceView'));
29
30
  // WorkspaceTree moved into ProjectDetail — no longer needed at Dashboard level
30
31
 
@@ -398,6 +399,11 @@ export default function Dashboard({ user }: { user: any }) {
398
399
  >
399
400
  Automation
400
401
  </button>
402
+ {/* Activity sub-pill — sits next to Automation since its content
403
+ (running pipelines + upcoming schedules + recent runs) is the
404
+ live read-side of Automation. Click anywhere → dropdown with
405
+ 3 sections + a "view" jump to the run. */}
406
+ <Suspense fallback={null}><ActivityPanel /></Suspense>
401
407
  <span className="w-[2px] h-4 bg-[var(--text-secondary)]/30 mx-1.5" />
402
408
  {/* Marketplace */}
403
409
  <button
@@ -465,14 +471,6 @@ export default function Dashboard({ user }: { user: any }) {
465
471
  </>
466
472
  )}
467
473
  </div>
468
- <button
469
- onClick={() => setViewMode('usage')}
470
- className={`text-[10px] px-2 py-0.5 border rounded transition-colors ${
471
- viewMode === 'usage'
472
- ? 'border-[var(--accent)] text-[var(--accent)]'
473
- : 'border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)]'
474
- }`}
475
- >Usage</button>
476
474
  <Suspense fallback={null}><TunnelToggle /></Suspense>
477
475
  {onlineCount.total > 0 && (
478
476
  <span className="text-[10px] text-[var(--text-secondary)] flex items-center gap-1" title={`${onlineCount.total} online${onlineCount.remote > 0 ? `, ${onlineCount.remote} remote` : ''}`}>
@@ -618,52 +616,73 @@ export default function Dashboard({ user }: { user: any }) {
618
616
  {showUserMenu && (
619
617
  <>
620
618
  <div className="fixed inset-0 z-40" onClick={() => setShowUserMenu(false)} />
621
- <div className="absolute right-0 top-8 w-[140px] bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg shadow-xl z-50 py-1">
619
+ <div className="absolute right-0 top-8 w-[170px] bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg shadow-xl z-50 py-1">
620
+ {/* Settings promoted to the top — most-reached item in this
621
+ menu. Each row leads with a glyph so it's scannable. */}
622
+ <button
623
+ onClick={() => { setShowSettings(true); setShowUserMenu(false); }}
624
+ className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] flex items-center gap-2"
625
+ >
626
+ <span className="w-3 text-center">⚙</span><span>Settings</span>
627
+ </button>
628
+ {/* Chat (web) is a separate route — open in a new tab so
629
+ the dashboard isn't replaced. Promoted up here so it's
630
+ one of the prominent items, not buried below Logs. */}
631
+ <a
632
+ href="/chat"
633
+ target="_blank"
634
+ rel="noopener noreferrer"
635
+ className="block w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] flex items-center gap-2"
636
+ >
637
+ <span className="w-3 text-center">💬</span>
638
+ <span className="flex-1">Chat (web)</span>
639
+ <span className="text-[9px] text-[var(--text-secondary)]">↗</span>
640
+ </a>
641
+ <div className="border-t border-[var(--border)] my-1" />
622
642
  <button
623
643
  onClick={() => { setShowMonitor(true); setShowUserMenu(false); }}
624
- className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
644
+ className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] flex items-center gap-2"
625
645
  >
626
- Monitor
646
+ <span className="w-3 text-center">📊</span><span>Monitor</span>
627
647
  </button>
628
648
  <button
629
649
  onClick={() => { setShowLoginStatus(true); setShowUserMenu(false); }}
630
- className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] flex items-center justify-between"
650
+ className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] flex items-center gap-2"
631
651
  >
632
- <span>Login Status</span>
652
+ <span className="w-3 text-center">🔐</span>
653
+ <span className="flex-1">Login Status</span>
633
654
  {loginBadge && loginBadge.broken > 0 && (
634
- <span className="text-[9px] text-red-400">🔴 {loginBadge.broken}/{loginBadge.total}</span>
655
+ <span className="text-[9px] text-red-400">{loginBadge.broken}/{loginBadge.total}</span>
635
656
  )}
636
657
  </button>
637
658
  <button
638
- onClick={() => { setShowSettings(true); setShowUserMenu(false); }}
639
- className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
659
+ onClick={() => { setViewMode('usage'); setShowUserMenu(false); }}
660
+ className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] flex items-center gap-2"
640
661
  >
641
- Settings
662
+ <span className="w-3 text-center">💰</span><span>Usage</span>
642
663
  </button>
643
664
  <button
644
665
  onClick={() => { setViewMode('logs'); setShowUserMenu(false); }}
645
- className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
666
+ className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] flex items-center gap-2"
646
667
  >
647
- Logs
668
+ <span className="w-3 text-center">📜</span><span>Logs</span>
648
669
  </button>
649
- <a
650
- href="/chat"
651
- className="block w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
652
- >
653
- Chat (web)
654
- </a>
655
670
  <a
656
671
  href="/mobile"
657
- className="block w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
672
+ target="_blank"
673
+ rel="noopener noreferrer"
674
+ className="block w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] flex items-center gap-2"
658
675
  >
659
- Mobile View
676
+ <span className="w-3 text-center">📱</span>
677
+ <span className="flex-1">Mobile View</span>
678
+ <span className="text-[9px] text-[var(--text-secondary)]">↗</span>
660
679
  </a>
661
680
  <div className="border-t border-[var(--border)] my-1" />
662
681
  <button
663
682
  onClick={() => signOut({ callbackUrl: '/login' })}
664
- className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--red)] hover:bg-[var(--bg-tertiary)]"
683
+ className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--red)] hover:bg-[var(--bg-tertiary)] flex items-center gap-2"
665
684
  >
666
- Logout
685
+ <span className="w-3 text-center">⏻</span><span>Logout</span>
667
686
  </button>
668
687
  </div>
669
688
  </>
@@ -7,6 +7,42 @@ import type { TaskLogEntry } from '@/src/types';
7
7
  const PipelineEditor = lazy(() => import('./PipelineEditor'));
8
8
  const ConversationEditor = lazy(() => import('./ConversationEditor'));
9
9
 
10
+ // ─── Module-level SWR cache ──────────────────────────────
11
+ // Survives tab switches (but not page refresh). On mount we hydrate
12
+ // state from cache instantly if present (<30s old) and revalidate in
13
+ // the background. Eliminates the "blank then slow load" feeling when
14
+ // users switch away and come back to this tab. Cache is per-component-
15
+ // module — entries are flushed when the page reloads.
16
+ const _metaCache: { ts: number; workflows: any[]; projects: any[]; agents: any[] } | null = null as any;
17
+ const _runsCache = new Map<string, { ts: number; runs: any[] }>();
18
+ const META_TTL_MS = 30_000;
19
+ const RUNS_TTL_MS = 15_000;
20
+ let _metaCacheRef: typeof _metaCache = _metaCache;
21
+
22
+ function readMetaCache() {
23
+ if (_metaCacheRef && Date.now() - _metaCacheRef.ts < META_TTL_MS) return _metaCacheRef;
24
+ return null;
25
+ }
26
+ function writeMetaCache(workflows: any[], projects: any[], agents: any[]) {
27
+ _metaCacheRef = { ts: Date.now(), workflows, projects, agents };
28
+ }
29
+ function readRunsCache(workflow: string) {
30
+ const c = _runsCache.get(workflow);
31
+ if (c && Date.now() - c.ts < RUNS_TTL_MS) return c;
32
+ return null;
33
+ }
34
+ function writeRunsCache(workflow: string, runs: any[]) {
35
+ _runsCache.set(workflow, { ts: Date.now(), runs });
36
+ }
37
+ // Invalidate after user mutations (delete/import/reinstall) so the next
38
+ // fetchMeta/fetchWorkflowRuns doesn't hydrate stale UI from cache while
39
+ // the live refetch is in flight.
40
+ function clearMetaCache() { _metaCacheRef = null; }
41
+ function clearRunsCache(workflow?: string) {
42
+ if (workflow) _runsCache.delete(workflow);
43
+ else _runsCache.clear();
44
+ }
45
+
10
46
  // ─── Live Task Log Hook ──────────────────────────────────
11
47
  // Subscribes to SSE stream for a running task, returns live log entries
12
48
  function useTaskStream(taskId: string | undefined, isRunning: boolean) {
@@ -698,6 +734,7 @@ export default function PipelineView({ onViewTask, focusPipelineId, onFocusHandl
698
734
  });
699
735
  const data = await res.json();
700
736
  if (!data.ok) { setMarketErr(data.error || 'install failed'); return; }
737
+ clearMetaCache();
701
738
  await Promise.all([fetchMarketplace(), fetchData()]);
702
739
  } catch (e) {
703
740
  setMarketErr(e instanceof Error ? e.message : String(e));
@@ -743,25 +780,52 @@ export default function PipelineView({ onViewTask, focusPipelineId, onFocusHandl
743
780
  // Lightweight metadata only — no pipeline runs. Runs are lazy-loaded
744
781
  // per-workflow on click. Reduces initial page to a single workflow
745
782
  // table fetch + projects + agents, even with hundreds of runs on disk.
783
+ //
784
+ // SWR pattern: if module-level cache is fresh, hydrate UI instantly
785
+ // and revalidate in background. First visit per page-load still pays
786
+ // the full fetch cost; tab switches feel instant.
746
787
  const fetchMeta = useCallback(async () => {
788
+ const cached = readMetaCache();
789
+ if (cached) {
790
+ setWorkflows(cached.workflows);
791
+ setProjects(cached.projects.map((p: any) => ({ name: p.name, path: p.path })));
792
+ setAgents(cached.agents);
793
+ // Fall through to background refetch — comment out to make TTL strict.
794
+ }
747
795
  const [wRes, projRes, agentRes] = await Promise.all([
748
796
  fetch('/api/pipelines?type=workflows'),
749
797
  fetch('/api/projects'),
750
798
  fetch('/api/agents'),
751
799
  ]);
752
800
  const [wData, projData, agentData] = await Promise.all([wRes.json(), projRes.json(), agentRes.json()]);
753
- if (Array.isArray(wData)) setWorkflows(wData);
754
- if (Array.isArray(projData)) setProjects(projData.map((p: any) => ({ name: p.name, path: p.path })));
755
- if (Array.isArray(agentData?.agents)) setAgents(agentData.agents);
801
+ const ws = Array.isArray(wData) ? wData : [];
802
+ const ps = Array.isArray(projData) ? projData : [];
803
+ const ags = Array.isArray(agentData?.agents) ? agentData.agents : [];
804
+ if (ws.length) setWorkflows(ws);
805
+ if (ps.length) setProjects(ps.map((p: any) => ({ name: p.name, path: p.path })));
806
+ if (ags.length) setAgents(ags);
807
+ writeMetaCache(ws, ps, ags);
756
808
  }, []);
757
809
 
758
810
  // Fetch the run history for ONE workflow. Used on first expand + on
759
811
  // the 5s polling tick (only for the currently active workflow).
760
812
  const fetchWorkflowRuns = useCallback(async (workflowName: string, opts: { append?: boolean } = {}) => {
813
+ // SWR for the first expand of a workflow tab (not for append=true
814
+ // "load more" calls, those want fresh server pagination).
815
+ if (!opts.append) {
816
+ const cached = readRunsCache(workflowName);
817
+ if (cached) {
818
+ setPipelines((prev) => {
819
+ const others = prev.filter((p) => p.workflowName !== workflowName);
820
+ return [...others, ...cached.runs];
821
+ });
822
+ }
823
+ }
761
824
  try {
762
825
  const res = await fetch(`/api/pipelines?workflow=${encodeURIComponent(workflowName)}&limit=100`);
763
826
  const data: Pipeline[] = await res.json();
764
827
  if (!Array.isArray(data)) return;
828
+ if (!opts.append) writeRunsCache(workflowName, data);
765
829
  setPipelines(prev => {
766
830
  if (!opts.append) {
767
831
  // Replace this workflow's runs (covers status changes during polling),
@@ -890,6 +954,8 @@ export default function PipelineView({ onViewTask, focusPipelineId, onFocusHandl
890
954
  body: JSON.stringify({ action: 'delete' }),
891
955
  });
892
956
  if (selectedPipeline?.id === id) setSelectedPipeline(null);
957
+ const wf = pipelines.find((p) => p.id === id)?.workflowName;
958
+ clearRunsCache(wf);
893
959
  fetchData();
894
960
  };
895
961
 
@@ -1015,6 +1081,8 @@ initial_prompt: "{{input.task}}"
1015
1081
  });
1016
1082
  const data = await res.json();
1017
1083
  if (!data.ok) { setMarketErr(data.error || 'reinstall failed'); return; }
1084
+ clearMetaCache();
1085
+ clearRunsCache(r.name);
1018
1086
  await Promise.all([fetchMarketplace(), fetchData()]);
1019
1087
  alert(`"${r.name}" reinstalled from registry (v${r.version}).`);
1020
1088
  } catch (e) {
@@ -1052,6 +1120,7 @@ initial_prompt: "{{input.task}}"
1052
1120
  });
1053
1121
  const data = await res.json();
1054
1122
  if (!data.ok) { setMarketErr(data.error || 'import failed'); return; }
1123
+ clearMetaCache();
1055
1124
  await Promise.all([fetchMarketplace(), fetchData()]);
1056
1125
  alert(`Imported as "${data.installed_as}". Open it from the Workflows list.`);
1057
1126
  } catch (e) {
@@ -1287,6 +1356,8 @@ initial_prompt: "{{input.task}}"
1287
1356
  const data = await res.json();
1288
1357
  if (!res.ok || data.error) { alert(`Delete failed: ${data.error || res.status}`); return; }
1289
1358
  if (activeWorkflow === w.name) setActiveWorkflow(null);
1359
+ clearMetaCache();
1360
+ clearRunsCache(w.name);
1290
1361
  fetchData();
1291
1362
  } catch { alert('Delete failed'); }
1292
1363
  }}
@@ -142,7 +142,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
142
142
  const [syncing, setSyncing] = useState(false);
143
143
  const [loading, setLoading] = useState(true);
144
144
  const [installTarget, setInstallTarget] = useState<{ skill: string; show: boolean }>({ skill: '', show: false });
145
- const [typeFilter, setTypeFilter] = useState<'all' | 'skill' | 'command' | 'local' | 'rules' | 'plugins' | 'connectors' | 'crafts' | 'recipes' | 'pipelines'>('all');
145
+ const [typeFilter, setTypeFilter] = useState<'all' | 'skill' | 'command' | 'local' | 'rules' | 'plugins' | 'connectors' | 'crafts' | 'recipes' | 'pipelines'>('pipelines');
146
146
  const [localItems, setLocalItems] = useState<{ name: string; type: string; scope: string; fileCount: number; projectPath?: string }[]>([]);
147
147
  // Rules (CLAUDE.md templates)
148
148
  const [rulesTemplates, setRulesTemplates] = useState<{ id: string; name: string; description: string; tags: string[]; builtin: boolean; isDefault: boolean; content: string }[]>([]);
@@ -414,30 +414,65 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
414
414
  <div className="flex items-center justify-between px-4 py-2 border-b border-[var(--border)] shrink-0">
415
415
  <div className="flex items-center gap-2">
416
416
  <span className="text-xs font-semibold text-[var(--text-primary)]">Marketplace</span>
417
- {/* Grouped category dropdown replaces the long inline tab bar.
418
- Native <optgroup> gives free keyboard nav + a coherent layout
419
- regardless of category count. */}
420
- <select
421
- value={typeFilter}
422
- onChange={(e) => setTypeFilter(e.target.value as typeof typeFilter)}
423
- className="text-[10px] px-2 py-1 rounded bg-[var(--bg-tertiary)] border border-[var(--border)] text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
424
- >
425
- <optgroup label="Catalog">
426
- <option value="all">All ({skills.length})</option>
427
- <option value="skill">Skills ({skillCount})</option>
428
- <option value="command">Commands ({commandCount})</option>
429
- <option value="local">Local ({localCount})</option>
430
- <option value="rules">Rules</option>
431
- </optgroup>
432
- <optgroup label="Extensions">
433
- <option value="plugins">Plugins</option>
434
- <option value="connectors">Connectors</option>
435
- <option value="crafts">Crafts</option>
436
- </optgroup>
437
- <optgroup label="Templates">
438
- <option value="pipelines">Pipelines</option>
439
- </optgroup>
440
- </select>
417
+ {/* Three group-scoped dropdowns instead of one big <select>.
418
+ Order is by usage frequency: Extensions (Connectors lives
419
+ here) Templates → Catalog. Each dropdown highlights when
420
+ its current value is active; the inactive ones show the
421
+ group label as a placeholder. Picking from any sets the
422
+ single global typeFilter. */}
423
+ {(() => {
424
+ const groups: Array<{ label: string; opts: Array<{ value: typeof typeFilter; label: string }> }> = [
425
+ { label: 'Templates', opts: [
426
+ { value: 'pipelines', label: 'Pipelines' },
427
+ ]},
428
+ { label: 'Extensions', opts: [
429
+ { value: 'connectors', label: 'Connectors' },
430
+ { value: 'plugins', label: 'Plugins' },
431
+ { value: 'crafts', label: 'Crafts' },
432
+ ]},
433
+ { label: 'Catalog', opts: [
434
+ { value: 'all', label: `All (${skills.length})` },
435
+ { value: 'skill', label: `Skills (${skillCount})` },
436
+ { value: 'command', label: `Commands (${commandCount})` },
437
+ { value: 'local', label: `Local (${localCount})` },
438
+ { value: 'rules', label: 'Rules' },
439
+ ]},
440
+ ];
441
+ return groups.map((g) => {
442
+ const isActive = g.opts.some((o) => o.value === typeFilter);
443
+ if (g.opts.length === 1) {
444
+ const only = g.opts[0];
445
+ return (
446
+ <button
447
+ key={g.label}
448
+ onClick={() => setTypeFilter(only.value)}
449
+ className={`text-[10px] px-2 py-1 rounded border ${
450
+ isActive
451
+ ? 'border-[var(--accent)] text-[var(--accent)] bg-[var(--accent)]/10'
452
+ : 'border-[var(--border)] text-[var(--text-primary)] bg-[var(--bg-tertiary)] hover:border-[var(--text-secondary)]'
453
+ }`}
454
+ >{only.label}</button>
455
+ );
456
+ }
457
+ return (
458
+ <select
459
+ key={g.label}
460
+ value={isActive ? (typeFilter as string) : ''}
461
+ onChange={(e) => { if (e.target.value) setTypeFilter(e.target.value as typeof typeFilter); }}
462
+ className={`text-[10px] px-2 py-1 rounded bg-[var(--bg-tertiary)] border focus:outline-none ${
463
+ isActive
464
+ ? 'border-[var(--accent)] text-[var(--accent)]'
465
+ : 'border-[var(--border)] text-[var(--text-primary)] focus:border-[var(--accent)]'
466
+ }`}
467
+ >
468
+ <option value="" disabled hidden>{g.label}</option>
469
+ {g.opts.map((o) => (
470
+ <option key={o.value} value={o.value as string}>{o.label}</option>
471
+ ))}
472
+ </select>
473
+ );
474
+ });
475
+ })()}
441
476
  </div>
442
477
  <span className="text-[8px] px-1.5 py-0.5 rounded bg-blue-500/15 text-blue-400">Claude Code</span>
443
478
  <input
@@ -57,3 +57,17 @@ forge server start --port 4000 # custom port
57
57
  forge server start --dir ~/.forge-test # custom data dir
58
58
  forge --reset-password # reset admin password
59
59
  ```
60
+
61
+ ## Dashboard top bar
62
+
63
+ The top toolbar is split between **at-a-glance signals** (left to right) and a **user menu** (right edge):
64
+
65
+ - **? Help** — opens the in-app Help AI.
66
+ - **Browser ▾** — open an embedded browser (float / right / left dock or external tab).
67
+ - **Tunnel** — start/stop the Cloudflare tunnel + online-count badge.
68
+ - **Alerts** — notifications (task done, pipeline failed, tunnel events).
69
+
70
+ Next to the **Automation** tab in the left-side nav sits a small **Activity** sub-pill — running pipelines + upcoming schedules + recent runs (`▶<running>` `⏰<upcoming>`). Click for a 3-section dropdown with a "view" link that jumps to the run. It lives there because its content is the live read-side of Automation.
71
+ - **User menu (▾)** — `⚙ Settings` + `💬 Chat (web) ↗` at the top (Chat opens in a new tab so the dashboard isn't replaced); then a divider, then the periodic-check screens `📊 Monitor` (background watches, processes, queues), `🔐 Login Status` (connector creds), `💰 Usage` (token/cost analytics), `📜 Logs`, `📱 Mobile View ↗`; then `⏻ Logout`.
72
+
73
+ Periodic-check screens (Monitor / Login Status / Usage) live inside the user menu so the top bar only shows things worth glancing at.
@@ -16,11 +16,21 @@ Both register as `/slash-command` in Claude Code.
16
16
 
17
17
  ## Install
18
18
 
19
- 1. Go to **Skills** tab in Forge
19
+ 1. Go to **Marketplace** tab in Forge
20
20
  2. Click **Sync** to fetch latest registry
21
21
  3. Click **Install** on any skill → choose Global or specific project
22
22
  4. Use in Claude Code with `/<skill-name>`
23
23
 
24
+ ## Navigating the Marketplace
25
+
26
+ The toolbar has three group-scoped controls, ordered by usage frequency. Opening the Marketplace tab lands on **Pipelines** by default — the highest-traffic category.
27
+
28
+ - **Pipelines** (button) — Templates synced from `forge-workflow`. Default landing view.
29
+ - **Extensions ▾** — Connectors / Plugins / Crafts
30
+ - **Catalog ▾** — All / Skills / Commands / Local / Rules
31
+
32
+ The control whose value is currently active highlights in the accent color; the other two show their group label as a placeholder. Click any option to switch — no need to traverse a single long dropdown.
33
+
24
34
  ## Update
25
35
 
26
36
  Skills with newer versions show a yellow "update" indicator. Click to update (checks for local modifications first).
@@ -4,7 +4,7 @@ Forge tracks Claude API token usage and estimated costs across all your projects
4
4
 
5
5
  ## Access
6
6
 
7
- Click **Usage** in the Dashboard top navigation.
7
+ Open the **user menu** (top-right `▾`) and click **Usage**. It sits next to Monitor and Login Status — the three periodic-check screens are grouped there so the top toolbar only carries at-a-glance signals.
8
8
 
9
9
  ## Data Source
10
10
 
@@ -47,25 +47,50 @@ Both use the same background machinery; you experience them identically.
47
47
  ## `start_watch` (for the assistant)
48
48
 
49
49
  When you've just started a long job and have a tool that reports its
50
- status, register a watch instead of polling in the conversation:
50
+ status, register a watch instead of polling in the conversation. Two
51
+ poll forms are accepted:
52
+
53
+ **1. Connector tool** — `"<connector>.<tool>"`, e.g. `jenkins.get_build`:
51
54
 
52
55
  ```
53
56
  start_watch({
54
- poll: "jenkins.get_build", // <connector>.<status tool>
57
+ poll: "jenkins.get_build",
55
58
  poll_args: { job_path: "job/foo", build_number: 18 },
56
- done_match: { path: "result", equals: "SUCCESS" }, // or done_path (truthy)
59
+ done_match: { path: "result", equals: "SUCCESS" },
57
60
  interval_sec: 60, timeout_sec: 1800,
58
- message: "Build 18 finished: {poll.result}" // {poll.<path>} = latest result
61
+ message: "Build 18 finished: {poll.result}"
62
+ })
63
+ ```
64
+
65
+ **2. Bare builtin name** — Forge's own status readers:
66
+
67
+ | Builtin | Pair with | done condition |
68
+ |---|---|---|
69
+ | `get_pipeline_status` | `trigger_pipeline` → pipeline_id | `done_match={path:"status",equals:"done"}` (or `done_path:"terminal"`) |
70
+ | `get_task_status` | `dispatch_task` → task_id | `done_path:"terminal"` |
71
+
72
+ ```
73
+ start_watch({
74
+ poll: "get_pipeline_status", // bare builtin, no prefix
75
+ poll_args: { pipeline_id: "a8e049a3" },
76
+ done_path: "terminal",
77
+ message: "Pipeline {poll.workflowName} finished: {poll.status}"
59
78
  })
60
79
  ```
61
80
 
62
- Then **stop** — do not keep calling get_build in the conversation. A
63
- completion message arrives in chat; the user can cancel it from the watch
64
- list. Pick `done_match`/`done_path` from a field you saw in the status
65
- tool's output (you usually called it once already). Queue/startup errors
66
- (e.g. a build number 404 while still queued) are tolerated for a while.
67
- Guards (max polls, timeout, lifetime, active cap) keep it from running
68
- away; worst case it times out and reports that.
81
+ Then **stop** — do not keep calling the status tool in the conversation.
82
+ A completion message arrives in chat; the user can cancel it from the
83
+ watch list. Pick `done_match`/`done_path` from a field you saw in the
84
+ status tool's output (you usually called it once already). Queue/startup
85
+ errors (e.g. a build number 404 while still queued) are tolerated for a
86
+ while. Guards (max polls, timeout, lifetime, active cap) keep it from
87
+ running away; worst case it times out and reports that.
88
+
89
+ ### `_raw` fallback for non-JSON tools
90
+
91
+ The poll result is parsed as JSON. If the tool returns plain text (some
92
+ shell-protocol tools), Forge wraps it as `{_raw: "...full output..."}`
93
+ so you can still grep — e.g. `done_match={path:"_raw",contains:"DONE"}`.
69
94
 
70
95
  ## Limits
71
96
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.32",
3
+ "version": "0.10.33",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {