@aion0/forge 0.10.20 → 0.10.23
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 +22 -4
- package/app/api/connectors/route.ts +1 -1
- package/app/api/watches/[id]/route.ts +25 -0
- package/app/api/watches/route.ts +17 -0
- package/app/chat/page.tsx +66 -4
- package/components/Dashboard.tsx +21 -5
- package/components/MonitorPanel.tsx +88 -0
- package/components/WatchesPanel.tsx +97 -0
- package/docs/forge-long-task-watch-design.md +223 -0
- package/docs/tp-automation-api.md +617 -0
- package/lib/browser-bridge-standalone.ts +13 -4
- package/lib/chat/agent-loop.ts +34 -4
- package/lib/chat/bridge-client.ts +2 -2
- package/lib/chat/protocols/ssh.ts +206 -0
- package/lib/chat/tool-dispatcher.ts +60 -5
- package/lib/chat-standalone.ts +12 -0
- package/lib/connectors/types.ts +118 -2
- package/lib/help-docs/21-build-connector.md +42 -0
- package/lib/help-docs/24-watch.md +77 -0
- package/lib/help-docs/CLAUDE.md +2 -0
- package/lib/watch/register.ts +108 -0
- package/lib/watch/start-watch-tool.ts +116 -0
- package/lib/watch/template.ts +40 -0
- package/lib/watch/watch-runner.ts +158 -0
- package/lib/watch/watch-store.ts +218 -0
- package/package.json +1 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,8 +1,26 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.23
|
|
2
2
|
|
|
3
|
-
Released: 2026-
|
|
3
|
+
Released: 2026-06-01
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
5
|
+
## Changes since v0.10.22
|
|
6
6
|
|
|
7
|
+
### Features
|
|
8
|
+
- feat: long-task watch — lightweight async background tool-call primitive for chat
|
|
7
9
|
|
|
8
|
-
|
|
10
|
+
### Documentation
|
|
11
|
+
- docs: mark watch backend implemented on feat/watch
|
|
12
|
+
|
|
13
|
+
### Other
|
|
14
|
+
- fix(chat): cap tool_result at 16k chars — oversized result (e.g. full test tree) no longer blows the history budget → orphan strip → empty messages → provider 'messages must not be empty'; + clear-error guard if history is ever empty
|
|
15
|
+
- feat(dashboard): expandable Alerts/notifications — click a row to see full title+body (was truncated, no way to read in full); auto-marks read on expand
|
|
16
|
+
- docs(help): add 24-watch.md (background watches: async block + start_watch, where to see/cancel) + index/routing
|
|
17
|
+
- feat(watch): start_watch builtin — LLM-driven dynamic watch (poll/done/interval), coexists with declarative async blocks
|
|
18
|
+
- feat(chat): show per-message timestamp in /chat (time-of-day, or date+time if older)
|
|
19
|
+
- docs(watch): document terminal watch_status → immediate chip removal (prune is fallback)
|
|
20
|
+
- fix(watch): emit terminal watch_status on finish so the progress chip clears immediately (was lingering until 150s prune)
|
|
21
|
+
- feat(watch): Background Watches section in MonitorPanel (list + cancel/delete)
|
|
22
|
+
- fix(watch): pre-resolve {args.*}/{result.*}/{settings.*} in watch messages at register time
|
|
23
|
+
- feat(watch): /chat UI — ambient progress chip (watch_status) + Background watches panel (list/cancel/delete); help doc for async block
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.22...v0.10.23
|
|
@@ -65,7 +65,7 @@ function deriveModeLabel(entries: ConnectorEntry[]): 'server-side' | 'browser-si
|
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
if (ps.size === 0) return 'browser-side';
|
|
68
|
-
const allServer = [...ps].every((p) => p === 'http' || p === 'shell');
|
|
68
|
+
const allServer = [...ps].every((p) => p === 'http' || p === 'shell' || p === 'ssh');
|
|
69
69
|
const allBrowser = [...ps].every((p) => p === 'browser');
|
|
70
70
|
return allServer ? 'server-side' : allBrowser ? 'browser-side' : 'mixed';
|
|
71
71
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/watches/:id { action: "cancel" } — stop an active watch.
|
|
3
|
+
* DELETE /api/watches/:id — remove the row.
|
|
4
|
+
*
|
|
5
|
+
* Writes go to workflow.db; the runner (chat-standalone) reads state each
|
|
6
|
+
* tick, so a cancel here takes effect within one tick without IPC.
|
|
7
|
+
*/
|
|
8
|
+
import { NextResponse } from 'next/server';
|
|
9
|
+
import { cancelWatch, deleteWatch, getWatch } from '@/lib/watch/watch-store';
|
|
10
|
+
|
|
11
|
+
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
12
|
+
const { id } = await params;
|
|
13
|
+
const body = await req.json().catch(() => ({}));
|
|
14
|
+
const action = String(body?.action || 'cancel');
|
|
15
|
+
if (action !== 'cancel') return NextResponse.json({ error: `unknown action "${action}"` }, { status: 400 });
|
|
16
|
+
if (!getWatch(id)) return NextResponse.json({ error: 'watch not found' }, { status: 404 });
|
|
17
|
+
const ok = cancelWatch(id, Date.now());
|
|
18
|
+
return NextResponse.json({ ok, watch: getWatch(id) });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
22
|
+
const { id } = await params;
|
|
23
|
+
const ok = deleteWatch(id);
|
|
24
|
+
return NextResponse.json({ ok });
|
|
25
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/watches — list background long-task watches (active first,
|
|
3
|
+
* then recent terminal). Read-only management surface for the watch
|
|
4
|
+
* runner that lives in chat-standalone; both share workflow.db.
|
|
5
|
+
*/
|
|
6
|
+
import { NextResponse } from 'next/server';
|
|
7
|
+
import { listWatches } from '@/lib/watch/watch-store';
|
|
8
|
+
|
|
9
|
+
export async function GET(req: Request) {
|
|
10
|
+
const url = new URL(req.url);
|
|
11
|
+
const limit = Math.min(200, Math.max(1, Number(url.searchParams.get('limit')) || 50));
|
|
12
|
+
try {
|
|
13
|
+
return NextResponse.json({ watches: listWatches(limit) });
|
|
14
|
+
} catch (e) {
|
|
15
|
+
return NextResponse.json({ error: (e as Error).message }, { status: 500 });
|
|
16
|
+
}
|
|
17
|
+
}
|
package/app/chat/page.tsx
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
23
23
|
import MarkdownContent from '@/components/MarkdownContent';
|
|
24
|
+
import WatchesPanel from '@/components/WatchesPanel';
|
|
24
25
|
import type { ContentBlock, Message, Session } from '@/lib/chat/types';
|
|
25
26
|
|
|
26
27
|
const PROXY = '/api/chat-proxy';
|
|
@@ -46,6 +47,9 @@ export default function ChatPage() {
|
|
|
46
47
|
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
|
47
48
|
const [activeId, setActiveId] = useState<string>('');
|
|
48
49
|
const [messages, setMessages] = useState<Message[]>([]);
|
|
50
|
+
// Background-watch progress chips (watch_id → {text, ts}). Ambient,
|
|
51
|
+
// updated in place; pruned when stale. Not part of the message thread.
|
|
52
|
+
const [watchChips, setWatchChips] = useState<Record<string, { text: string; ts: number }>>({});
|
|
49
53
|
const [input, setInput] = useState('');
|
|
50
54
|
const [streaming, setStreaming] = useState(false);
|
|
51
55
|
const [partial, setPartial] = useState('');
|
|
@@ -129,6 +133,18 @@ export default function ChatPage() {
|
|
|
129
133
|
setPartial('');
|
|
130
134
|
loadMessages(activeId);
|
|
131
135
|
refreshSessions();
|
|
136
|
+
} else if (type === 'watch_status') {
|
|
137
|
+
// Ambient background-watch progress — a chip that updates in
|
|
138
|
+
// place, NOT a chat message (doesn't touch the thread / LLM).
|
|
139
|
+
// A terminal status (done/non-active) drops the chip at once.
|
|
140
|
+
const wid = String(data.watch_id || '');
|
|
141
|
+
if (wid) {
|
|
142
|
+
const terminal = !!data.done || (data.state && data.state !== 'active');
|
|
143
|
+
setWatchChips((c) => {
|
|
144
|
+
if (terminal) { const n = { ...c }; delete n[wid]; return n; }
|
|
145
|
+
return { ...c, [wid]: { text: String(data.text || ''), ts: Date.now() } };
|
|
146
|
+
});
|
|
147
|
+
}
|
|
132
148
|
} else if (type === 'error') {
|
|
133
149
|
setStreaming(false);
|
|
134
150
|
setError(String(data.error || 'unknown error'));
|
|
@@ -145,6 +161,22 @@ export default function ChatPage() {
|
|
|
145
161
|
if (el) el.scrollTop = el.scrollHeight;
|
|
146
162
|
}, [messages, partial]);
|
|
147
163
|
|
|
164
|
+
// ─── Prune stale watch chips (no update in >150s = done/gone) ──
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
const t = setInterval(() => {
|
|
167
|
+
setWatchChips((c) => {
|
|
168
|
+
const now = Date.now();
|
|
169
|
+
const next: typeof c = {};
|
|
170
|
+
let changed = false;
|
|
171
|
+
for (const [k, v] of Object.entries(c)) {
|
|
172
|
+
if (now - v.ts < 150_000) next[k] = v; else changed = true;
|
|
173
|
+
}
|
|
174
|
+
return changed ? next : c;
|
|
175
|
+
});
|
|
176
|
+
}, 30_000);
|
|
177
|
+
return () => clearInterval(t);
|
|
178
|
+
}, []);
|
|
179
|
+
|
|
148
180
|
// ─── Auto-resize composer ─────────────────────────────────
|
|
149
181
|
useEffect(() => {
|
|
150
182
|
const el = composerRef.current;
|
|
@@ -282,6 +314,8 @@ export default function ChatPage() {
|
|
|
282
314
|
})}
|
|
283
315
|
</div>
|
|
284
316
|
|
|
317
|
+
<WatchesPanel />
|
|
318
|
+
|
|
285
319
|
<div className="px-4 py-3 border-t border-[var(--border)] text-xs text-[var(--text-secondary)] space-y-1">
|
|
286
320
|
<button
|
|
287
321
|
type="button"
|
|
@@ -369,6 +403,23 @@ export default function ChatPage() {
|
|
|
369
403
|
</div>
|
|
370
404
|
</div>
|
|
371
405
|
|
|
406
|
+
{Object.keys(watchChips).length > 0 && (
|
|
407
|
+
<div className="px-6 pt-2">
|
|
408
|
+
<div className="max-w-3xl mx-auto flex flex-wrap gap-2">
|
|
409
|
+
{Object.entries(watchChips).map(([id, w]) => (
|
|
410
|
+
<span
|
|
411
|
+
key={id}
|
|
412
|
+
title={id}
|
|
413
|
+
className="inline-flex items-center gap-1.5 text-xs rounded-full border border-[var(--border)] bg-[var(--bg-secondary)] px-3 py-1 text-[var(--text-secondary)]"
|
|
414
|
+
>
|
|
415
|
+
<span className="inline-block h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" />
|
|
416
|
+
{w.text}
|
|
417
|
+
</span>
|
|
418
|
+
))}
|
|
419
|
+
</div>
|
|
420
|
+
</div>
|
|
421
|
+
)}
|
|
422
|
+
|
|
372
423
|
<form
|
|
373
424
|
className="border-t border-[var(--border)] px-6 py-4"
|
|
374
425
|
onSubmit={(e) => { e.preventDefault(); send(); }}
|
|
@@ -411,7 +462,17 @@ export default function ChatPage() {
|
|
|
411
462
|
|
|
412
463
|
// ─── Message renderers ────────────────────────────────────
|
|
413
464
|
|
|
414
|
-
function
|
|
465
|
+
function fmtTs(ts?: number): string {
|
|
466
|
+
if (!ts) return '';
|
|
467
|
+
const d = new Date(ts);
|
|
468
|
+
if (Number.isNaN(d.getTime())) return '';
|
|
469
|
+
const now = new Date();
|
|
470
|
+
const sameDay = d.toDateString() === now.toDateString();
|
|
471
|
+
const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
472
|
+
return sameDay ? time : `${d.toLocaleDateString([], { month: 'short', day: 'numeric' })} ${time}`;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function RoleBlock({ role, ts, children }: { role: 'user' | 'assistant'; ts?: number; children: React.ReactNode }) {
|
|
415
476
|
const isUser = role === 'user';
|
|
416
477
|
return (
|
|
417
478
|
<div className="flex gap-4">
|
|
@@ -426,8 +487,9 @@ function RoleBlock({ role, children }: { role: 'user' | 'assistant'; children: R
|
|
|
426
487
|
{isUser ? 'U' : 'AI'}
|
|
427
488
|
</div>
|
|
428
489
|
<div className="flex-1 min-w-0">
|
|
429
|
-
<div className="
|
|
430
|
-
{role}
|
|
490
|
+
<div className="flex items-baseline gap-2 mb-1">
|
|
491
|
+
<span className="text-[11px] uppercase tracking-wide text-[var(--text-secondary)]">{role}</span>
|
|
492
|
+
{ts ? <span className="text-[10px] text-[var(--text-secondary)] opacity-60">{fmtTs(ts)}</span> : null}
|
|
431
493
|
</div>
|
|
432
494
|
<div className="space-y-2">{children}</div>
|
|
433
495
|
</div>
|
|
@@ -437,7 +499,7 @@ function RoleBlock({ role, children }: { role: 'user' | 'assistant'; children: R
|
|
|
437
499
|
|
|
438
500
|
function MessageView({ m }: { m: Message }) {
|
|
439
501
|
return (
|
|
440
|
-
<RoleBlock role={m.role}>
|
|
502
|
+
<RoleBlock role={m.role} ts={m.ts}>
|
|
441
503
|
{m.blocks.map((b, i) => (
|
|
442
504
|
<BlockView key={i} b={b} />
|
|
443
505
|
))}
|
package/components/Dashboard.tsx
CHANGED
|
@@ -141,6 +141,7 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
141
141
|
const [notifications, setNotifications] = useState<any[]>([]);
|
|
142
142
|
const [unreadCount, setUnreadCount] = useState(0);
|
|
143
143
|
const [showNotifications, setShowNotifications] = useState(false);
|
|
144
|
+
const [expandedNotif, setExpandedNotif] = useState<string | null>(null);
|
|
144
145
|
const [showUserMenu, setShowUserMenu] = useState(false);
|
|
145
146
|
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
|
|
146
147
|
const [displayName, setDisplayName] = useState(user?.name || 'Forge');
|
|
@@ -511,10 +512,23 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
511
512
|
{notifications.length === 0 ? (
|
|
512
513
|
<div className="p-6 text-center text-xs text-[var(--text-secondary)]">No notifications</div>
|
|
513
514
|
) : (
|
|
514
|
-
notifications.map((n: any) =>
|
|
515
|
+
notifications.map((n: any) => {
|
|
516
|
+
const expanded = expandedNotif === n.id;
|
|
517
|
+
return (
|
|
515
518
|
<div
|
|
516
519
|
key={n.id}
|
|
517
|
-
|
|
520
|
+
onClick={async () => {
|
|
521
|
+
setExpandedNotif(expanded ? null : n.id);
|
|
522
|
+
if (!expanded && !n.read) {
|
|
523
|
+
await fetch('/api/notifications', {
|
|
524
|
+
method: 'POST',
|
|
525
|
+
headers: { 'Content-Type': 'application/json' },
|
|
526
|
+
body: JSON.stringify({ action: 'markRead', id: n.id }),
|
|
527
|
+
});
|
|
528
|
+
fetchNotifications();
|
|
529
|
+
}
|
|
530
|
+
}}
|
|
531
|
+
className={`group px-3 py-2 border-b border-[var(--border)]/50 hover:bg-[var(--bg-tertiary)] cursor-pointer ${!n.read ? 'bg-[var(--accent)]/5' : ''}`}
|
|
518
532
|
>
|
|
519
533
|
<div className="flex items-start gap-2">
|
|
520
534
|
<span className="text-[10px] mt-0.5 shrink-0">
|
|
@@ -522,16 +536,17 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
522
536
|
</span>
|
|
523
537
|
<div className="flex-1 min-w-0">
|
|
524
538
|
<div className="flex items-center gap-1">
|
|
525
|
-
<span className={`text-[11px] truncate ${!n.read ? 'font-semibold text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'}`}>
|
|
539
|
+
<span className={`text-[11px] ${expanded ? 'break-words whitespace-pre-wrap' : 'truncate'} ${!n.read ? 'font-semibold text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'}`}>
|
|
526
540
|
{n.title}
|
|
527
541
|
</span>
|
|
528
542
|
{!n.read && <span className="w-1.5 h-1.5 rounded-full bg-[var(--accent)] shrink-0" />}
|
|
529
543
|
</div>
|
|
530
544
|
{n.body && (
|
|
531
|
-
<p className=
|
|
545
|
+
<p className={`text-[9px] text-[var(--text-secondary)] mt-0.5 ${expanded ? 'break-words whitespace-pre-wrap' : 'truncate'}`}>{n.body}</p>
|
|
532
546
|
)}
|
|
533
547
|
<span className="text-[8px] text-[var(--text-secondary)]">
|
|
534
548
|
{new Date(n.createdAt).toLocaleString()}
|
|
549
|
+
<span className="ml-1 opacity-50">· {expanded ? 'click to collapse' : 'click for detail'}</span>
|
|
535
550
|
</span>
|
|
536
551
|
</div>
|
|
537
552
|
<div className="hidden group-hover:flex items-center gap-1 shrink-0">
|
|
@@ -568,7 +583,8 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
568
583
|
</div>
|
|
569
584
|
</div>
|
|
570
585
|
</div>
|
|
571
|
-
|
|
586
|
+
);
|
|
587
|
+
})
|
|
572
588
|
)}
|
|
573
589
|
</div>
|
|
574
590
|
</div>
|
|
@@ -157,6 +157,9 @@ export default function MonitorPanel({ onClose }: { onClose: () => void }) {
|
|
|
157
157
|
</div>
|
|
158
158
|
)}
|
|
159
159
|
</div>
|
|
160
|
+
|
|
161
|
+
{/* Background watches — long-task pollers (nac upgrade etc.) */}
|
|
162
|
+
<MonitorWatches />
|
|
160
163
|
</div>
|
|
161
164
|
) : (
|
|
162
165
|
<div className="p-8 text-center text-xs text-[var(--text-secondary)]">Loading...</div>
|
|
@@ -165,3 +168,88 @@ export default function MonitorPanel({ onClose }: { onClose: () => void }) {
|
|
|
165
168
|
</div>
|
|
166
169
|
);
|
|
167
170
|
}
|
|
171
|
+
|
|
172
|
+
// ─── Background watches ────────────────────────────────────
|
|
173
|
+
// Long-task pollers registered by `async` connector tools (e.g. nac
|
|
174
|
+
// upgrade → poll get_version until the build matches). Same data as the
|
|
175
|
+
// /chat panel; lives here so it's visible alongside the other running
|
|
176
|
+
// background resources. Reads /api/watches; cancel/delete per [id].
|
|
177
|
+
|
|
178
|
+
interface WatchRow {
|
|
179
|
+
id: string;
|
|
180
|
+
label: string;
|
|
181
|
+
state: 'active' | 'done' | 'failed' | 'timed_out' | 'cancelled' | 'errored';
|
|
182
|
+
polls: number;
|
|
183
|
+
max_polls: number;
|
|
184
|
+
last_text: string | null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const WATCH_STATE_COLOR: Record<string, string> = {
|
|
188
|
+
active: 'text-amber-400',
|
|
189
|
+
done: 'text-green-400',
|
|
190
|
+
failed: 'text-red-400',
|
|
191
|
+
timed_out: 'text-red-400',
|
|
192
|
+
errored: 'text-red-400',
|
|
193
|
+
cancelled: 'text-[var(--text-secondary)]',
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
function MonitorWatches() {
|
|
197
|
+
const [watches, setWatches] = useState<WatchRow[]>([]);
|
|
198
|
+
const load = useCallback(() => {
|
|
199
|
+
fetch('/api/watches?limit=30')
|
|
200
|
+
.then(r => r.json())
|
|
201
|
+
.then(j => setWatches(Array.isArray(j.watches) ? j.watches : []))
|
|
202
|
+
.catch(() => {});
|
|
203
|
+
}, []);
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
load();
|
|
206
|
+
const t = setInterval(load, 10_000);
|
|
207
|
+
return () => clearInterval(t);
|
|
208
|
+
}, [load]);
|
|
209
|
+
|
|
210
|
+
const act = async (id: string, method: 'POST' | 'DELETE') => {
|
|
211
|
+
try {
|
|
212
|
+
await fetch(`/api/watches/${id}`, {
|
|
213
|
+
method,
|
|
214
|
+
...(method === 'POST'
|
|
215
|
+
? { headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'cancel' }) }
|
|
216
|
+
: {}),
|
|
217
|
+
});
|
|
218
|
+
} catch { /* ignore */ }
|
|
219
|
+
load();
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const activeCount = watches.filter(w => w.state === 'active').length;
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<div>
|
|
226
|
+
<h3 className="text-[10px] font-semibold text-[var(--text-secondary)] uppercase mb-2">
|
|
227
|
+
Background Watches ({activeCount} active)
|
|
228
|
+
</h3>
|
|
229
|
+
{watches.length === 0 ? (
|
|
230
|
+
<div className="text-[10px] text-[var(--text-secondary)]">No watches</div>
|
|
231
|
+
) : (
|
|
232
|
+
<div className="space-y-1.5">
|
|
233
|
+
{watches.map(w => (
|
|
234
|
+
<div key={w.id} className="rounded border border-[var(--border)] px-2 py-1.5">
|
|
235
|
+
<div className="flex items-center gap-2 text-[11px]">
|
|
236
|
+
<span className={`text-[9px] uppercase tracking-wide ${WATCH_STATE_COLOR[w.state] || ''}`}>{w.state}</span>
|
|
237
|
+
<span className="font-mono text-[var(--text-primary)] truncate flex-1" title={w.label}>{w.label}</span>
|
|
238
|
+
<span className="text-[9px] text-[var(--text-secondary)]">{w.polls}/{w.max_polls}</span>
|
|
239
|
+
</div>
|
|
240
|
+
{w.last_text && (
|
|
241
|
+
<div className="text-[9px] text-[var(--text-secondary)] truncate mt-0.5" title={w.last_text}>{w.last_text}</div>
|
|
242
|
+
)}
|
|
243
|
+
<div className="flex gap-2 mt-1">
|
|
244
|
+
{w.state === 'active' && (
|
|
245
|
+
<button type="button" onClick={() => act(w.id, 'POST')} className="text-[9px] text-amber-400 hover:underline">cancel</button>
|
|
246
|
+
)}
|
|
247
|
+
<button type="button" onClick={() => act(w.id, 'DELETE')} className="text-[9px] text-[var(--text-secondary)] hover:underline">delete</button>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
))}
|
|
251
|
+
</div>
|
|
252
|
+
)}
|
|
253
|
+
</div>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Background-watch management — a compact list of long-task watches with
|
|
5
|
+
* cancel/delete. Reads GET /api/watches; cancel/delete via the [id] route.
|
|
6
|
+
* Lives in the /chat sidebar so users can see and rein in background
|
|
7
|
+
* pollers (avoid runaway / resource hogging). Pure backend otherwise —
|
|
8
|
+
* the watch runner lives in chat-standalone.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
12
|
+
|
|
13
|
+
interface Watch {
|
|
14
|
+
id: string;
|
|
15
|
+
label: string;
|
|
16
|
+
state: 'active' | 'done' | 'failed' | 'timed_out' | 'cancelled' | 'errored';
|
|
17
|
+
polls: number;
|
|
18
|
+
max_polls: number;
|
|
19
|
+
last_text: string | null;
|
|
20
|
+
updated_at: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const STATE_COLOR: Record<string, string> = {
|
|
24
|
+
active: 'text-amber-400',
|
|
25
|
+
done: 'text-green-400',
|
|
26
|
+
failed: 'text-red-400',
|
|
27
|
+
timed_out: 'text-red-400',
|
|
28
|
+
errored: 'text-red-400',
|
|
29
|
+
cancelled: 'text-[var(--text-secondary)]',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export default function WatchesPanel() {
|
|
33
|
+
const [watches, setWatches] = useState<Watch[]>([]);
|
|
34
|
+
const [open, setOpen] = useState(false);
|
|
35
|
+
|
|
36
|
+
const load = useCallback(() => {
|
|
37
|
+
fetch('/api/watches?limit=30')
|
|
38
|
+
.then((r) => r.json())
|
|
39
|
+
.then((j) => setWatches(Array.isArray(j.watches) ? j.watches : []))
|
|
40
|
+
.catch(() => {});
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
load();
|
|
45
|
+
const t = setInterval(load, 10_000);
|
|
46
|
+
return () => clearInterval(t);
|
|
47
|
+
}, [load]);
|
|
48
|
+
|
|
49
|
+
const act = async (id: string, method: 'POST' | 'DELETE') => {
|
|
50
|
+
try {
|
|
51
|
+
await fetch(`/api/watches/${id}`, {
|
|
52
|
+
method,
|
|
53
|
+
...(method === 'POST' ? { headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'cancel' }) } : {}),
|
|
54
|
+
});
|
|
55
|
+
} catch { /* ignore */ }
|
|
56
|
+
load();
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const activeCount = watches.filter((w) => w.state === 'active').length;
|
|
60
|
+
if (watches.length === 0) return null;
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div className="px-4 py-2 border-t border-[var(--border)] text-xs text-[var(--text-secondary)]">
|
|
64
|
+
<button
|
|
65
|
+
type="button"
|
|
66
|
+
onClick={() => setOpen((o) => !o)}
|
|
67
|
+
className="flex items-center gap-2 w-full text-left hover:text-[var(--text-primary)] transition-colors"
|
|
68
|
+
>
|
|
69
|
+
<span>Background watches</span>
|
|
70
|
+
{activeCount > 0 && (
|
|
71
|
+
<span className="inline-block h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" />
|
|
72
|
+
)}
|
|
73
|
+
<span className="ml-auto text-[10px] opacity-60">{activeCount} active · {open ? '▾' : '▸'}</span>
|
|
74
|
+
</button>
|
|
75
|
+
{open && (
|
|
76
|
+
<div className="mt-1.5 space-y-1.5 max-h-56 overflow-y-auto">
|
|
77
|
+
{watches.map((w) => (
|
|
78
|
+
<div key={w.id} className="rounded border border-[var(--border)] px-2 py-1.5">
|
|
79
|
+
<div className="flex items-center gap-2">
|
|
80
|
+
<span className={`text-[10px] uppercase tracking-wide ${STATE_COLOR[w.state] || ''}`}>{w.state}</span>
|
|
81
|
+
<span className="truncate font-medium text-[var(--text-primary)]" title={w.label}>{w.label}</span>
|
|
82
|
+
<span className="ml-auto text-[10px] opacity-60">{w.polls}/{w.max_polls}</span>
|
|
83
|
+
</div>
|
|
84
|
+
{w.last_text && <div className="text-[10px] opacity-70 truncate mt-0.5" title={w.last_text}>{w.last_text}</div>}
|
|
85
|
+
<div className="flex gap-2 mt-1">
|
|
86
|
+
{w.state === 'active' && (
|
|
87
|
+
<button type="button" onClick={() => act(w.id, 'POST')} className="text-[10px] text-amber-400 hover:underline">cancel</button>
|
|
88
|
+
)}
|
|
89
|
+
<button type="button" onClick={() => act(w.id, 'DELETE')} className="text-[10px] opacity-60 hover:opacity-100 hover:underline">delete</button>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
))}
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|