@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 +16 -4
- package/app/api/activity/summary/route.ts +135 -0
- package/components/ActivityPanel.tsx +266 -0
- package/components/Dashboard.tsx +48 -29
- package/components/PipelineView.tsx +74 -3
- package/components/SkillsPanel.tsx +60 -25
- package/lib/help-docs/00-overview.md +14 -0
- package/lib/help-docs/06-skills.md +11 -1
- package/lib/help-docs/12-usage.md +1 -1
- package/lib/help-docs/24-watch.md +36 -11
- package/package.json +1 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.33
|
|
2
2
|
|
|
3
3
|
Released: 2026-06-02
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
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
|
-
-
|
|
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.
|
|
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
|
+
}
|
package/components/Dashboard.tsx
CHANGED
|
@@ -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-[
|
|
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
|
|
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
|
|
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"
|
|
655
|
+
<span className="text-[9px] text-red-400">{loginBadge.broken}/{loginBadge.total}</span>
|
|
635
656
|
)}
|
|
636
657
|
</button>
|
|
637
658
|
<button
|
|
638
|
-
onClick={() => {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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'>('
|
|
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
|
-
{/*
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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 **
|
|
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
|
-
|
|
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",
|
|
57
|
+
poll: "jenkins.get_build",
|
|
55
58
|
poll_args: { job_path: "job/foo", build_number: 18 },
|
|
56
|
-
done_match: { path: "result", equals: "SUCCESS" },
|
|
59
|
+
done_match: { path: "result", equals: "SUCCESS" },
|
|
57
60
|
interval_sec: 60, timeout_sec: 1800,
|
|
58
|
-
message: "Build 18 finished: {poll.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
|
|
63
|
-
completion message arrives in chat; the user can cancel it from the
|
|
64
|
-
list. Pick `done_match`/`done_path` from a field you saw in the
|
|
65
|
-
tool's output (you usually called it once already). Queue/startup
|
|
66
|
-
(e.g. a build number 404 while still queued) are tolerated for a
|
|
67
|
-
Guards (max polls, timeout, lifetime, active cap) keep it from
|
|
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