@aion0/forge 0.10.31 → 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.31
1
+ # Forge v0.10.33
2
2
 
3
3
  Released: 2026-06-02
4
4
 
5
- ## Changes since v0.10.30
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): allow builtin tool names (no connector prefix) in start_watch
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.30...v0.10.31
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
@@ -163,50 +163,61 @@ const BUILTINS: Record<string, BuiltinHandler> = {
163
163
  }
164
164
 
165
165
  const pipeline = startPipeline(params.workflow, stringInput, { skills: skills.length ? skills : undefined });
166
- let line = `Pipeline started: ${pipeline.id} (workflow: ${params.workflow}, status: ${pipeline.status})`;
166
+ const fresh = getPipeline(pipeline.id) || pipeline;
167
+ const errors: string[] = [];
167
168
  if (pipeline.status === 'failed') {
168
- const fresh = getPipeline(pipeline.id) || pipeline;
169
- const errs: string[] = [];
170
169
  for (const [nid, n] of Object.entries(fresh.nodes || {})) {
171
- if ((n as any).error) errs.push(`${nid}: ${(n as any).error}`);
170
+ if ((n as any).error) errors.push(`${nid}: ${(n as any).error}`);
172
171
  }
173
- if (errs.length > 0) line += `\nFailure(s): ${errs.join(' | ').slice(0, 500)}`;
174
- } else if (pipeline.status === 'done') {
175
- // For for_each workflows, a "done" with zero iterations is the silent
176
- // failure mode (empty source). Warn the LLM explicitly so it doesn't
177
- // claim success.
178
- const fresh = getPipeline(pipeline.id) || pipeline;
172
+ }
173
+ let warning: string | undefined;
174
+ if (pipeline.status === 'done') {
179
175
  const forEach = (fresh as any).forEach;
180
176
  if (forEach && typeof forEach === 'object') {
181
177
  const iters = Array.isArray(forEach.iterations) ? forEach.iterations.length : 0;
182
178
  const total = typeof forEach.total === 'number' ? forEach.total : iters;
183
179
  if (total === 0) {
184
- line += '\n⚠ Pipeline finished with 0 iterations — likely empty source list. This is NOT a success; the work the user asked for did NOT happen. Re-check input fields (especially the one feeding for_each.source) and retry.';
180
+ warning = 'Pipeline finished with 0 iterations — likely empty source list. This is NOT a success; the work the user asked for did NOT happen. Re-check input fields (especially the one feeding for_each.source) and retry.';
185
181
  }
186
182
  }
187
- } else {
188
- line += '. Watch progress in the Pipelines view.';
189
183
  }
190
- return line;
184
+ return JSON.stringify({
185
+ ok: pipeline.status !== 'failed',
186
+ pipeline_id: pipeline.id,
187
+ workflow: params.workflow,
188
+ status: pipeline.status,
189
+ terminal: pipeline.status !== 'running',
190
+ ...(errors.length ? { errors: errors.join(' | ').slice(0, 500) } : {}),
191
+ ...(warning ? { warning } : {}),
192
+ hint: pipeline.status === 'running'
193
+ ? 'To wait for completion: start_watch poll="get_pipeline_status" poll_args={pipeline_id:"' + pipeline.id + '"} done_match={path:"status",equals:"done"} fail_path="status==failed". Do NOT poll get_pipeline_status in this conversation.'
194
+ : undefined,
195
+ });
191
196
  },
192
197
 
193
198
  // Query a pipeline run's status + per-node results by id (pairs with
194
- // trigger_pipeline's returned id). Mirrors the MCP get_pipeline_status tool.
199
+ // trigger_pipeline's returned id). Returns JSON so start_watch's
200
+ // done_match={path:"status",equals:"done"} can resolve — a string-shaped
201
+ // response leaves watches polling forever.
195
202
  get_pipeline_status: async (input) => {
196
203
  const params = (input as { pipeline_id?: string } | undefined) || {};
197
- if (!params.pipeline_id) return 'get_pipeline_status failed: pipeline_id is required (returned by trigger_pipeline).';
204
+ if (!params.pipeline_id) return JSON.stringify({ ok: false, error: 'pipeline_id is required (returned by trigger_pipeline)' });
198
205
  const { getPipeline } = await import('../pipeline');
199
206
  const pipeline = getPipeline(params.pipeline_id);
200
- if (!pipeline) return `Pipeline "${params.pipeline_id}" not found.`;
201
- const nodes = Object.entries(pipeline.nodes || {}).map(([id, n]) => {
202
- let line = ` ${id}: ${n.status}`;
203
- if (n.error) line += ` — ${n.error}`;
204
- for (const [k, v] of Object.entries(n.outputs || {})) {
205
- line += `\n ${k}: ${String(v).slice(0, 200)}`;
206
- }
207
- return line;
208
- }).join('\n');
209
- return `Pipeline ${pipeline.id} [${pipeline.status}] (${pipeline.workflowName})\n${nodes}`;
207
+ if (!pipeline) return JSON.stringify({ ok: false, error: `Pipeline "${params.pipeline_id}" not found` });
208
+ const nodes = Object.entries(pipeline.nodes || {}).map(([id, n]) => ({
209
+ id,
210
+ status: n.status,
211
+ ...(n.error ? { error: n.error } : {}),
212
+ outputs: Object.fromEntries(Object.entries(n.outputs || {}).map(([k, v]) => [k, String(v).slice(0, 500)])),
213
+ }));
214
+ return JSON.stringify({
215
+ id: pipeline.id,
216
+ status: pipeline.status,
217
+ terminal: pipeline.status !== 'running',
218
+ workflowName: pipeline.workflowName,
219
+ nodes,
220
+ });
210
221
  },
211
222
 
212
223
  // Surface Forge's local context (projects + agents + skills) so the chat
@@ -260,11 +271,11 @@ const BUILTINS: Record<string, BuiltinHandler> = {
260
271
  // caller can ask "what's the status of task <id>?" later — we don't block.
261
272
  dispatch_task: async (input) => {
262
273
  const params = (input as { project?: string; prompt?: string; agent?: string } | undefined) || {};
263
- if (!params.prompt) return 'dispatch_task failed: prompt is required';
274
+ if (!params.prompt) return JSON.stringify({ ok: false, error: 'prompt is required' });
264
275
  const { getProjectInfo, SCRATCH_PROJECT_NAME } = await import('../projects');
265
276
  const projectName = params.project?.trim() || SCRATCH_PROJECT_NAME;
266
277
  const project = getProjectInfo(projectName);
267
- if (!project) return `dispatch_task failed: project "${projectName}" not found`;
278
+ if (!project) return JSON.stringify({ ok: false, error: `project "${projectName}" not found` });
268
279
  const { createTask } = await import('../task-manager');
269
280
  const task = createTask({
270
281
  projectName: project.name,
@@ -273,7 +284,33 @@ const BUILTINS: Record<string, BuiltinHandler> = {
273
284
  conversationId: '',
274
285
  agent: params.agent || undefined,
275
286
  });
276
- return `Task dispatched: ${task.id} (project: ${project.name}, status: ${task.status}). Watch in the Tasks view.`;
287
+ return JSON.stringify({
288
+ ok: true,
289
+ task_id: task.id,
290
+ project: project.name,
291
+ status: task.status,
292
+ hint: 'To wait for completion: start_watch poll="get_task_status" poll_args={task_id:"' + task.id + '"} done_path="terminal". Do NOT poll get_task_status in this conversation.',
293
+ });
294
+ },
295
+
296
+ // Companion to dispatch_task — read a task's status + result. Returns JSON
297
+ // so start_watch can poll via done_path="terminal" or done_match
298
+ // {path:"status", equals:"done"}.
299
+ get_task_status: async (input) => {
300
+ const params = (input as { task_id?: string } | undefined) || {};
301
+ if (!params.task_id) return JSON.stringify({ ok: false, error: 'task_id is required (returned by dispatch_task)' });
302
+ const { getTask } = await import('../task-manager');
303
+ const task = getTask(params.task_id);
304
+ if (!task) return JSON.stringify({ ok: false, error: `Task "${params.task_id}" not found` });
305
+ return JSON.stringify({
306
+ id: task.id,
307
+ status: task.status,
308
+ terminal: task.status === 'done' || task.status === 'failed' || task.status === 'cancelled',
309
+ project: task.projectName,
310
+ ...(task.resultSummary ? { result_summary: String(task.resultSummary).slice(0, 1000) } : {}),
311
+ ...(task.error ? { error: String(task.error).slice(0, 500) } : {}),
312
+ ...(task.completedAt ? { completed_at: task.completedAt } : {}),
313
+ });
277
314
  },
278
315
 
279
316
  // List Forge's own help/documentation files so the chat agent can answer
@@ -315,7 +352,7 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
315
352
  },
316
353
  {
317
354
  name: 'trigger_pipeline',
318
- description: 'Trigger a Forge pipeline workflow (YAML under flows/). Two-step usage: (1) call with NO args first — returns every workflow + its input schema (which fields are required vs have defaults). (2) call again with workflow=<name> and input={...} passing ONLY required fields and any optional fields the user explicitly specified. NEVER pass invented placeholder values for optional fields with defaults — omit them and the default is used. If the pipeline fails immediately, the response includes the validation error so you can fix the inputs and retry.',
355
+ description: 'Trigger a Forge pipeline workflow (YAML under flows/). Two-step usage: (1) call with NO args first — returns every workflow + its input schema (which fields are required vs have defaults). (2) call again with workflow=<name> and input={...} passing ONLY required fields and any optional fields the user explicitly specified. NEVER pass invented placeholder values for optional fields with defaults — omit them and the default is used. On success returns JSON: {ok, pipeline_id, workflow, status, terminal, errors?, warning?, hint?}. If the run is still running, follow the hint call start_watch on get_pipeline_status and STOP polling in this conversation.',
319
356
  input_schema: {
320
357
  type: 'object',
321
358
  properties: {
@@ -337,7 +374,7 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
337
374
  },
338
375
  {
339
376
  name: 'get_pipeline_status',
340
- description: "Check a Forge pipeline run's live status + per-node results by id. Pass pipeline_id (returned by trigger_pipeline) to get the run's overall status + each node's status / error / outputs. Use whenever the user asks how a running or finished pipeline is doing.",
377
+ description: "Check a Forge pipeline run's live status + per-node results by id. Pass pipeline_id (returned by trigger_pipeline) to get a JSON object: {id, status: 'running'|'done'|'failed'|'cancelled', terminal: bool, workflowName, nodes: [{id, status, error?, outputs}]}. Use whenever the user asks how a running or finished pipeline is doing. For start_watch, pair this with done_match={path:\"status\",equals:\"done\"} (or done_path=\"terminal\" to fire on ANY terminal state) and fail_path=... per your needs.",
341
378
  input_schema: {
342
379
  type: 'object',
343
380
  properties: {
@@ -356,7 +393,7 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
356
393
  },
357
394
  {
358
395
  name: 'dispatch_task',
359
- description: 'Dispatch a one-shot background Claude task in a Forge project. Use for longer-running asks the user wants to fire-and-forget ("analyze X codebase and write findings to a file", "run the test suite and summarize failures"). Returns immediately with the task id; the task runs in the background and the user can check the Tasks view for output.',
396
+ description: 'Dispatch a one-shot background Claude task in a Forge project. Use for longer-running asks the user wants to fire-and-forget ("analyze X codebase and write findings to a file", "run the test suite and summarize failures"). Returns JSON: {ok, task_id, project, status, hint}. The task runs in the background; if the user wants to be notified on completion, follow the hint call start_watch on get_task_status and STOP polling in this conversation.',
360
397
  input_schema: {
361
398
  type: 'object',
362
399
  properties: {
@@ -376,6 +413,20 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
376
413
  required: ['prompt'],
377
414
  },
378
415
  },
416
+ {
417
+ name: 'get_task_status',
418
+ description: "Check a dispatched Forge task's status + result by id. Pass task_id (returned by dispatch_task). Returns JSON: {id, status: 'queued'|'running'|'done'|'failed'|'cancelled', terminal: bool, project, result_summary?, error?, completed_at?}. For start_watch, use done_path=\"terminal\" (fires on done/failed/cancelled) or done_match={path:\"status\",equals:\"done\"}.",
419
+ input_schema: {
420
+ type: 'object',
421
+ properties: {
422
+ task_id: {
423
+ type: 'string',
424
+ description: 'Task id (returned by dispatch_task).',
425
+ },
426
+ },
427
+ required: ['task_id'],
428
+ },
429
+ },
379
430
  {
380
431
  name: 'list_help_docs',
381
432
  description: "List Forge's own documentation files. Call this FIRST whenever the user asks how Forge itself works — its features, settings/config, setup, or troubleshooting (e.g. pipelines, schedules, connectors, telegram, tunnel, workspace/smiths, skills, crafts, usage/cost, agents/models). Returns doc filenames; then read the relevant one(s) with read_help_doc and answer from their content. No arguments.",
@@ -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
 
@@ -36,11 +36,13 @@ export function buildStartWatchTool(sessionId: string | null): StartWatchTool {
36
36
  name: 'start_watch',
37
37
  description:
38
38
  'Register a BACKGROUND WATCH that polls a tool until done, then posts the result back here — for long-running jobs you just kicked off (a Forge pipeline, a Jenkins build, a test run, a device upgrade). Use this INSTEAD of polling in conversation: call the trigger tool, then call start_watch and STOP — Forge polls in the background and a completion message arrives in this chat. ' +
39
- 'Pick `poll` = the read tool that reports status. Two forms accepted: (a) connector tool "<connector>.<tool>" e.g. "jenkins.get_build", "gitlab.get_pipeline"; (b) BARE builtin name e.g. "get_pipeline_status" (Forge pipelines — pair with poll_args={pipeline_id} and done_match={path:"status",equals:"done"}). Set `poll_args` to call it with (e.g. the build number you predicted via get_next_build_number). Give a done condition: `done_match` {path, equals} on the poll result (e.g. path "result" equals "SUCCESS"), or `done_path` (a result path that becomes truthy). You usually already saw the poll tool\'s output once, so you know the right field. Optional `fail_path` (truthy = failed). Tune `interval_sec`/`timeout_sec` to the job (pipeline ≈ 30s / 1800s, build ≈ 60s / 1800s).',
39
+ 'Pick `poll` = the read tool that reports status. Two forms accepted: (a) connector tool "<connector>.<tool>" e.g. "jenkins.get_build", "gitlab.get_pipeline"; (b) BARE builtin name "get_pipeline_status" (pair with poll_args={pipeline_id} and done_match={path:"status",equals:"done"}) or "get_task_status" (pair with poll_args={task_id} and done_path="terminal"). Set `poll_args` to call it with (e.g. the build number you predicted via get_next_build_number). ' +
40
+ 'Give a done condition: `done_match` {path, equals} on the poll result (e.g. path "result" equals "SUCCESS"), or `done_path` (a result path that becomes truthy). You usually already saw the poll tool\'s output once, so you know the right field. Optional `fail_path` (truthy = failed). ' +
41
+ 'If the poll tool returns NON-JSON text (e.g. some shell-protocol tools), the result is wrapped as {_raw: "...full output..."}, so use done_match={path:"_raw",contains:"DONE"} to grep markers in the output. Tune `interval_sec`/`timeout_sec` to the job (pipeline ≈ 30s / 1800s, build ≈ 60s / 1800s).',
40
42
  input_schema: {
41
43
  type: 'object',
42
44
  properties: {
43
- poll: { type: 'string', description: 'Tool to poll. Either "<connector>.<tool>" e.g. "jenkins.get_build", OR a bare builtin name e.g. "get_pipeline_status" (for Forge pipelines). Must be a read/status tool.' },
45
+ poll: { type: 'string', description: 'Tool to poll. Either "<connector>.<tool>" e.g. "jenkins.get_build", OR a bare builtin name "get_pipeline_status" (Forge pipelines) or "get_task_status" (dispatched tasks). Must be a read/status tool.' },
44
46
  poll_args: { type: 'object', description: 'Args to call the poll tool with each tick, e.g. {"job_path":"job/foo","build_number":18}. Concrete values, not templates.' },
45
47
  done_match: {
46
48
  type: 'object',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.31",
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": {