@aion0/forge 0.10.89 → 0.11.0

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.
@@ -0,0 +1,354 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Home Kanban board — connector cards rendered from a prompt-driven WidgetSpec.
5
+ * Two render modes share one block renderer:
6
+ * - compact (Style A): fixed-size card — accent bar, stat tiles, stripe lists.
7
+ * - rich (Style B): floating modal — big tiles, callouts, progress, avatars.
8
+ * See obsidian forge/kanban-design-mockup.html for the locked design.
9
+ */
10
+
11
+ import { useCallback, useEffect, useRef, useState } from 'react';
12
+ import type { KanbanCard, WidgetSpec, WidgetBlock, WidgetColor } from '@/lib/kanban/types';
13
+
14
+ const CARD_W = 300;
15
+ const CARD_H = 208;
16
+
17
+ // Vivid accent palette (reads well on light + dark). Structural colors use CSS vars.
18
+ const HEX: Record<WidgetColor, string> = {
19
+ gray: '#8b97a6', red: '#f0626b', amber: '#f5b14c', yellow: '#eab308',
20
+ green: '#46c993', emerald: '#10b981', blue: '#5aa9ff', violet: '#a78bfa',
21
+ };
22
+ function hx(c?: WidgetColor): string { return (c && HEX[c]) || '#5aa9ff'; }
23
+ function tint(c?: WidgetColor, a = '22'): string { return `${hx(c)}${a}`; }
24
+
25
+ // ─── tiny markdown (escape → bold / code / links / bullets / headers) ──
26
+ function mdInline(s: string): string {
27
+ let t = s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
28
+ t = t.replace(/\[([^\]]+)\]\((https?:[^)]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer" style="color:var(--accent)">$1</a>');
29
+ t = t.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>');
30
+ t = t.replace(/`([^`]+)`/g, '<code style="background:var(--bg-tertiary);padding:1px 4px;border-radius:4px;font-size:.9em">$1</code>');
31
+ return t;
32
+ }
33
+ function Markdown({ text }: { text: string }) {
34
+ const lines = (text || '').split('\n');
35
+ return (
36
+ <div className="space-y-1">
37
+ {lines.map((ln, i) => {
38
+ const t = ln.trim();
39
+ if (!t) return null;
40
+ if (/^#{1,3}\s/.test(t)) return <div key={i} className="text-[11px] font-semibold tracking-wide uppercase text-[var(--text-secondary)] mt-1" dangerouslySetInnerHTML={{ __html: mdInline(t.replace(/^#{1,3}\s/, '')) }} />;
41
+ if (/^[-*]\s/.test(t)) return <div key={i} className="text-[12px] flex gap-1.5"><span className="text-[var(--text-secondary)]">•</span><span dangerouslySetInnerHTML={{ __html: mdInline(t.replace(/^[-*]\s/, '')) }} /></div>;
42
+ // A short, fully-bold line acts as a section header.
43
+ if (/^\*\*.+\*\*$/.test(t)) return <div key={i} className="text-[10.5px] font-semibold uppercase tracking-[0.08em] text-[var(--text-secondary)] mt-2 mb-0.5" dangerouslySetInnerHTML={{ __html: mdInline(t) }} />;
44
+ return <div key={i} className="text-[12px] leading-relaxed text-[var(--text-primary)]" dangerouslySetInnerHTML={{ __html: mdInline(t) }} />;
45
+ })}
46
+ </div>
47
+ );
48
+ }
49
+
50
+ // ─── block renderer (rich toggles tile/avatar sizes) ──────
51
+ function Block({ b, rich }: { b: WidgetBlock; rich?: boolean }) {
52
+ switch (b.type) {
53
+ case 'metric_row':
54
+ // Always one row: columns = item count (capped).
55
+ return (
56
+ <div className="grid gap-2" style={{ gridTemplateColumns: `repeat(${Math.min(b.items.length, rich ? 6 : 4)}, minmax(0,1fr))` }}>
57
+ {b.items.map((it, i) => (
58
+ <div key={i} className="rounded-lg border border-[var(--border)] bg-[var(--bg-tertiary)] text-center overflow-hidden"
59
+ style={{ padding: rich ? '8px 6px' : '3px 3px' }}>
60
+ <div className="font-bold leading-none" style={{ color: it.color ? hx(it.color) : 'var(--text-primary)', fontSize: rich ? 19 : 13 }}>{it.value}</div>
61
+ <div className="text-[var(--text-secondary)] uppercase tracking-wide truncate" style={{ fontSize: rich ? 9.5 : 7.5, marginTop: rich ? 4 : 1 }}>{it.label}</div>
62
+ </div>
63
+ ))}
64
+ </div>
65
+ );
66
+ case 'callout':
67
+ return (
68
+ <div className={`flex items-center gap-2 rounded-lg ${rich ? 'px-3 py-2.5 text-[12px]' : 'px-2.5 py-1.5 text-[11px]'}`}
69
+ style={{ background: tint(b.color, '1a'), border: `1px solid ${tint(b.color, '55')}`, color: 'var(--text-primary)' }}>
70
+ <span className="leading-none" style={{ fontSize: rich ? 16 : 12 }}>{b.icon || '⚠️'}</span>
71
+ <span className={rich ? '' : 'truncate'} dangerouslySetInnerHTML={{ __html: mdInline(b.text) }} />
72
+ </div>
73
+ );
74
+ case 'list':
75
+ return (
76
+ <div>
77
+ {b.items.map((it, i) => (
78
+ <div key={i} className={`flex gap-2.5 border-b border-[var(--border)]/40 last:border-0 ${rich ? 'py-1.5' : 'py-[3px]'}`}>
79
+ {rich && it.avatar
80
+ ? <span className="shrink-0 grid place-items-center rounded-full font-bold" style={{ width: 24, height: 24, fontSize: 9.5, background: hx(it.badgeColor), color: '#0e1116' }}>{it.avatar}</span>
81
+ : <span className="shrink-0 rounded-full mt-1" style={{ width: 3, alignSelf: 'stretch', background: hx(it.badgeColor) }} />}
82
+ <div className="min-w-0 flex-1">
83
+ <div className={`text-[12px] leading-snug text-[var(--text-primary)] ${rich ? '' : 'truncate'}`}>
84
+ {it.badge && <span className="inline-block font-bold align-middle mr-1.5 rounded-full" style={{ fontSize: 8.5, padding: '2px 6px', background: tint(it.badgeColor), color: hx(it.badgeColor) }}>{it.badge}</span>}
85
+ {it.href ? <a href={it.href} target="_blank" rel="noreferrer" className="hover:underline" style={{ color: 'var(--text-primary)' }}>{it.text}</a> : it.text}
86
+ </div>
87
+ {it.sub && <div className={`text-[10.5px] text-[var(--text-secondary)] mt-0.5 ${rich ? '' : 'truncate'}`}>{it.sub}</div>}
88
+ </div>
89
+ </div>
90
+ ))}
91
+ </div>
92
+ );
93
+ case 'progress': {
94
+ const pct = Math.max(0, Math.min(100, b.max ? (b.value / b.max) * 100 : 0));
95
+ const c = hx(b.color || 'green');
96
+ return (
97
+ <div>
98
+ <div className="flex justify-between text-[11px] text-[var(--text-secondary)] mb-1"><span>{b.label}</span><span>{b.value}/{b.max}</span></div>
99
+ <div className="rounded-full overflow-hidden bg-[var(--bg-tertiary)]" style={{ height: rich ? 8 : 6 }}>
100
+ <div className="h-full rounded-full" style={{ width: `${pct}%`, background: `linear-gradient(90deg, ${c}, ${hx('blue')})` }} />
101
+ </div>
102
+ </div>
103
+ );
104
+ }
105
+ case 'stat':
106
+ return (
107
+ <div>
108
+ <div className="font-bold" style={{ fontSize: rich ? 30 : 24, color: 'var(--text-primary)' }}>{b.value}</div>
109
+ <div className="text-[10.5px] text-[var(--text-secondary)]">{b.label}{b.delta && <span style={{ color: hx(b.deltaColor) }}> {b.delta}</span>}</div>
110
+ </div>
111
+ );
112
+ case 'table':
113
+ return (
114
+ <table className="w-full text-[10.5px]">
115
+ <thead><tr>{b.columns.map((c, i) => <th key={i} className="text-left font-medium text-[var(--text-secondary)] pb-1">{c}</th>)}</tr></thead>
116
+ <tbody>{b.rows.map((r, ri) => <tr key={ri} className="border-t border-[var(--border)]/40">{r.map((c, ci) => <td key={ci} className="pr-2 py-1 align-top">{c}</td>)}</tr>)}</tbody>
117
+ </table>
118
+ );
119
+ case 'badges':
120
+ return (
121
+ <div className="flex flex-wrap gap-1.5">
122
+ {b.items.map((it, i) => <span key={i} className="font-medium rounded-full" style={{ fontSize: 9.5, padding: '3px 8px', background: tint(it.color), color: hx(it.color) }}>{it.text}</span>)}
123
+ </div>
124
+ );
125
+ case 'text':
126
+ default:
127
+ return <Markdown text={(b as any).markdown ?? ''} />;
128
+ }
129
+ }
130
+
131
+ function WidgetView({ spec, rich }: { spec: WidgetSpec; rich?: boolean }) {
132
+ return (
133
+ <div className={rich ? 'space-y-3.5' : 'space-y-1.5'}>
134
+ {spec.blocks.map((b, i) => <Block key={i} b={b} rich={rich} />)}
135
+ {/* footer is low-value detail — only show it in the expanded modal. */}
136
+ {spec.footer && rich && <div className="text-[10px] text-[var(--text-secondary)] pt-1 border-t border-[var(--border)]/40">{spec.footer}</div>}
137
+ </div>
138
+ );
139
+ }
140
+
141
+ // ─── helpers ──────────────────────────────────────────────
142
+ function ago(iso?: string | null): string {
143
+ if (!iso) return 'never';
144
+ const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
145
+ if (s < 60) return `${s}s`; if (s < 3600) return `${Math.floor(s / 60)}m`;
146
+ if (s < 86400) return `${Math.floor(s / 3600)}h`; return `${Math.floor(s / 86400)}d`;
147
+ }
148
+ function statusDot(s: KanbanCard['status']): string {
149
+ return s === 'error' ? HEX.red : s === 'running' ? HEX.amber : s === 'ok' ? HEX.green : 'var(--text-secondary)';
150
+ }
151
+
152
+ // ─── compact card (Style A) ───────────────────────────────
153
+ function Card({ card, onOpen, onRefresh, onDelete }: {
154
+ card: KanbanCard; onOpen: () => void; onRefresh: () => void; onDelete: () => void;
155
+ }) {
156
+ const running = card.status === 'running';
157
+ const accent = card.lastResult?.accent;
158
+ const stop = (e: React.MouseEvent) => e.stopPropagation();
159
+ return (
160
+ <button onClick={onOpen}
161
+ className="shrink-0 flex flex-col rounded-xl border border-[var(--border)] bg-[var(--bg-secondary)] overflow-hidden text-left hover:border-[var(--accent)]/50 transition-colors shadow-sm"
162
+ style={{ width: CARD_W, height: CARD_H }} title="Click to expand">
163
+ <div className="h-[3px] w-full shrink-0" style={{ background: hx(accent) }} />
164
+ <div className="flex items-center gap-2 px-3 py-2 shrink-0">
165
+ <span className="grid place-items-center rounded-lg shrink-0" style={{ width: 24, height: 24, background: tint(accent, '28'), fontSize: 13 }}>{card.icon || '🔧'}</span>
166
+ <span className="text-[12.5px] font-semibold text-[var(--text-primary)] truncate flex-1">{card.title}</span>
167
+ <span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ background: statusDot(card.status) }} />
168
+ <span className="text-[9px] text-[var(--text-secondary)] shrink-0">{running ? '…' : ago(card.lastRunAt)}</span>
169
+ {card.connectorUrl && <a href={card.connectorUrl} target="_blank" rel="noreferrer" onClick={stop} title="Open connector" className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] text-[11px]">↗</a>}
170
+ <span onClick={(e) => { stop(e); onRefresh(); }} title="Refresh" className={`text-[var(--text-secondary)] hover:text-[var(--text-primary)] text-[11px] ${running ? 'opacity-40 pointer-events-none' : ''}`}>⟳</span>
171
+ <span onClick={(e) => { stop(e); onDelete(); }} title="Delete" className="text-[var(--text-secondary)] hover:text-red-500 text-[11px]">✕</span>
172
+ </div>
173
+ <div className="relative flex-1 overflow-hidden px-3 pb-2">
174
+ {card.lastResult ? <WidgetView spec={card.lastResult} />
175
+ : card.status === 'error' ? <div className="text-[10px] text-red-500 line-clamp-3">{card.lastError || 'failed'}</div>
176
+ : <div className="text-[10px] text-[var(--text-secondary)]">{running ? 'Running…' : 'No data yet — click ⟳.'}</div>}
177
+ <div className="pointer-events-none absolute bottom-0 inset-x-0 h-7 bg-gradient-to-t from-[var(--bg-secondary)] to-transparent" />
178
+ </div>
179
+ </button>
180
+ );
181
+ }
182
+
183
+ // ─── rich modal (Style B) ─────────────────────────────────
184
+ function CardModal({ card, onClose, onRefresh, onSaved }: {
185
+ card: KanbanCard; onClose: () => void; onRefresh: () => void; onSaved: () => void;
186
+ }) {
187
+ const [editing, setEditing] = useState(false);
188
+ const [prompt, setPrompt] = useState(card.prompt);
189
+ const [periodMin, setPeriodMin] = useState(Math.round(card.periodSec / 60));
190
+ const [execMode, setExecMode] = useState<'inline' | 'task'>(card.execMode);
191
+ const [saving, setSaving] = useState(false);
192
+ const panelRef = useRef<HTMLDivElement>(null);
193
+ const accent = card.lastResult?.accent;
194
+
195
+ const [size] = useState<{ w: number; h: number }>(() => {
196
+ if (typeof window === 'undefined') return { w: 760, h: 680 };
197
+ try { const s = JSON.parse(localStorage.getItem('forge.kanban.modalSize') || ''); if (s?.w && s?.h) return s; } catch {}
198
+ return { w: Math.min(760, window.innerWidth - 64), h: Math.min(720, Math.round(window.innerHeight * 0.84)) };
199
+ });
200
+
201
+ useEffect(() => {
202
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
203
+ window.addEventListener('keydown', onKey);
204
+ return () => window.removeEventListener('keydown', onKey);
205
+ }, [onClose]);
206
+ useEffect(() => {
207
+ const el = panelRef.current;
208
+ if (!el || typeof ResizeObserver === 'undefined') return;
209
+ const ro = new ResizeObserver(() => { try { localStorage.setItem('forge.kanban.modalSize', JSON.stringify({ w: el.offsetWidth, h: el.offsetHeight })); } catch {} });
210
+ ro.observe(el);
211
+ return () => ro.disconnect();
212
+ }, []);
213
+
214
+ const save = async (thenRun: boolean) => {
215
+ setSaving(true);
216
+ await fetch(`/api/kanban?id=${card.id}`, {
217
+ method: 'PATCH', headers: { 'content-type': 'application/json' },
218
+ body: JSON.stringify({ prompt, periodSec: Math.max(60, periodMin * 60), execMode }),
219
+ }).catch(() => {});
220
+ setSaving(false); setEditing(false); onSaved();
221
+ if (thenRun) onRefresh();
222
+ };
223
+
224
+ return (
225
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
226
+ <div ref={panelRef} className="flex flex-col rounded-2xl border border-[var(--border)] bg-[var(--bg-secondary)] shadow-2xl"
227
+ style={{ width: size.w, height: size.h, maxWidth: '95vw', maxHeight: '92vh', minWidth: 480, minHeight: 320, resize: 'both', overflow: 'hidden' }}
228
+ onClick={(e) => e.stopPropagation()}>
229
+ <div className="h-[3px] w-full shrink-0" style={{ background: hx(accent) }} />
230
+ <div className="flex items-center gap-2.5 px-4 py-3 border-b border-[var(--border)]">
231
+ <span className="grid place-items-center rounded-lg" style={{ width: 30, height: 30, background: tint(accent, '28'), fontSize: 16 }}>{card.icon || '🔧'}</span>
232
+ <span className="text-[15px] font-semibold text-[var(--text-primary)] flex-1 truncate">{card.title}</span>
233
+ <span className="text-[10px] text-[var(--text-secondary)]">{card.status} · {ago(card.lastRunAt)}</span>
234
+ {card.connectorUrl && <a href={card.connectorUrl} target="_blank" rel="noreferrer" className="text-[11px] text-[var(--accent)] hover:underline">open ↗</a>}
235
+ <button onClick={() => onRefresh()} disabled={card.status === 'running'} className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] text-sm disabled:opacity-40" title="Run now">⟳</button>
236
+ <button onClick={onClose} className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] text-sm" title="Close">✕</button>
237
+ </div>
238
+
239
+ <div className="flex-1 overflow-auto p-4">
240
+ {editing ? (
241
+ <div className="space-y-3">
242
+ <div>
243
+ <label className="text-[10px] uppercase tracking-wide text-[var(--text-secondary)]">Prompt</label>
244
+ <textarea value={prompt} onChange={(e) => setPrompt(e.target.value)} rows={11}
245
+ className="mt-1 w-full text-[12px] rounded-md border border-[var(--border)] bg-[var(--bg-primary)] p-2 font-mono text-[var(--text-primary)] resize-y" />
246
+ </div>
247
+ <div className="flex items-center gap-2">
248
+ <label className="text-[10px] uppercase tracking-wide text-[var(--text-secondary)]">Refresh every</label>
249
+ <input type="number" min={1} value={periodMin} onChange={(e) => setPeriodMin(Math.max(1, parseInt(e.target.value || '1', 10)))}
250
+ className="w-20 text-[12px] rounded-md border border-[var(--border)] bg-[var(--bg-primary)] px-2 py-1 text-[var(--text-primary)]" />
251
+ <span className="text-[11px] text-[var(--text-secondary)]">minutes</span>
252
+ </div>
253
+ <div className="flex items-center gap-2">
254
+ <label className="text-[10px] uppercase tracking-wide text-[var(--text-secondary)]">Run mode</label>
255
+ <select value={execMode} onChange={(e) => setExecMode(e.target.value as 'inline' | 'task')}
256
+ className="text-[12px] rounded-md border border-[var(--border)] bg-[var(--bg-primary)] px-2 py-1 text-[var(--text-primary)]">
257
+ <option value="inline">inline — fast (simple cards)</option>
258
+ <option value="task">task — heavier, multi-step</option>
259
+ </select>
260
+ </div>
261
+ <div className="flex gap-2 pt-1">
262
+ <button onClick={() => save(true)} disabled={saving} className="text-[11px] px-3 py-1 rounded-md bg-[var(--accent)] text-white disabled:opacity-50">{saving ? 'Saving…' : 'Save & run'}</button>
263
+ <button onClick={() => save(false)} disabled={saving} className="text-[11px] px-3 py-1 rounded-md border border-[var(--border)] text-[var(--text-primary)]">Save</button>
264
+ <button onClick={() => { setEditing(false); setPrompt(card.prompt); setPeriodMin(Math.round(card.periodSec / 60)); setExecMode(card.execMode); }} className="text-[11px] px-3 py-1 rounded-md text-[var(--text-secondary)]">Cancel</button>
265
+ </div>
266
+ </div>
267
+ ) : (
268
+ <div className="space-y-3">
269
+ {card.lastResult ? <WidgetView spec={card.lastResult} rich />
270
+ : <div className="text-[11px] text-[var(--text-secondary)]">{card.status === 'running' ? 'Running…' : 'No data yet — run it with ⟳.'}</div>}
271
+ {card.status === 'error' && <div className="text-[10px] text-red-500/80 border-t border-[var(--border)] pt-2">last run failed: {card.lastError}</div>}
272
+ </div>
273
+ )}
274
+ </div>
275
+
276
+ {!editing && (
277
+ <div className="px-4 py-2 border-t border-[var(--border)] flex items-center">
278
+ <button onClick={() => setEditing(true)} className="text-[11px] text-[var(--accent)] hover:underline">Edit prompt / period</button>
279
+ <span className="flex-1" />
280
+ <span className="text-[10px] text-[var(--text-secondary)]">{card.connectorId} · {card.execMode} · every {Math.round(card.periodSec / 60)}m</span>
281
+ </div>
282
+ )}
283
+ </div>
284
+ </div>
285
+ );
286
+ }
287
+
288
+ // ─── board ────────────────────────────────────────────────
289
+ export default function KanbanBoard() {
290
+ const [cards, setCards] = useState<KanbanCard[] | null>(null);
291
+ const [collapsed, setCollapsed] = useState(false);
292
+ const [openId, setOpenId] = useState<string | null>(null);
293
+ const scroller = useRef<HTMLDivElement>(null);
294
+
295
+ useEffect(() => { try { setCollapsed(localStorage.getItem('forge.kanban.collapsed') === '1'); } catch {} }, []);
296
+ const toggle = () => setCollapsed((c) => { const n = !c; try { localStorage.setItem('forge.kanban.collapsed', n ? '1' : '0'); } catch {} return n; });
297
+
298
+ const load = useCallback(async () => {
299
+ try { const r = await fetch('/api/kanban'); const j = await r.json(); setCards(Array.isArray(j.cards) ? j.cards : []); }
300
+ catch { setCards([]); }
301
+ }, []);
302
+ useEffect(() => { void load(); }, [load]);
303
+ useEffect(() => { const t = setInterval(() => { if (!collapsed) void load(); }, 15000); return () => clearInterval(t); }, [collapsed, load]);
304
+
305
+ const refresh = async (id: string) => {
306
+ setCards((cs) => cs?.map((c) => c.id === id ? { ...c, status: 'running' } : c) ?? cs);
307
+ await fetch(`/api/kanban/${id}/run`, { method: 'POST' }).catch(() => {});
308
+ void load();
309
+ };
310
+ const del = async (id: string) => {
311
+ if (!confirm('Delete this card?')) return;
312
+ await fetch(`/api/kanban?id=${id}`, { method: 'DELETE' }).catch(() => {});
313
+ if (openId === id) setOpenId(null);
314
+ void load();
315
+ };
316
+ const seed = async () => { await fetch('/api/kanban/seed', { method: 'POST' }).catch(() => {}); void load(); };
317
+ const scrollBy = (dx: number) => scroller.current?.scrollBy({ left: dx, behavior: 'smooth' });
318
+
319
+ if (cards === null) return null;
320
+ const open = openId ? cards.find((c) => c.id === openId) || null : null;
321
+
322
+ return (
323
+ <div className="shrink-0 border-b border-[var(--border)] bg-[var(--bg-primary)]">
324
+ <div className="flex items-center gap-2 px-3 py-1.5">
325
+ <button onClick={toggle} className="text-[11px] font-medium text-[var(--text-primary)] flex items-center gap-1">
326
+ <span className="text-[var(--text-secondary)]">{collapsed ? '▸' : '▾'}</span> Kanban
327
+ </button>
328
+ <span className="text-[10px] text-[var(--text-secondary)]">{cards.length}</span>
329
+ <div className="flex-1" />
330
+ {!collapsed && cards.length > 0 && (
331
+ <>
332
+ <button onClick={() => scrollBy(-CARD_W * 2)} title="Scroll left" className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] text-xs px-1">‹</button>
333
+ <button onClick={() => scrollBy(CARD_W * 2)} title="Scroll right" className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] text-xs px-1">›</button>
334
+ </>
335
+ )}
336
+ <button onClick={seed} title="Add default cards from installed connectors" className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] px-1">+ defaults</button>
337
+ </div>
338
+
339
+ {!collapsed && (
340
+ cards.length === 0 ? (
341
+ <div className="px-3 pb-2 text-[10px] text-[var(--text-secondary)]">
342
+ No cards yet. <button onClick={seed} className="text-[var(--accent)] hover:underline">Seed from connectors</button> to auto-create cards.
343
+ </div>
344
+ ) : (
345
+ <div ref={scroller} className="flex gap-3 px-3 pb-3 overflow-x-auto">
346
+ {cards.map((c) => <Card key={c.id} card={c} onOpen={() => setOpenId(c.id)} onRefresh={() => refresh(c.id)} onDelete={() => del(c.id)} />)}
347
+ </div>
348
+ )
349
+ )}
350
+
351
+ {open && <CardModal card={open} onClose={() => setOpenId(null)} onRefresh={() => refresh(open.id)} onSaved={() => load()} />}
352
+ </div>
353
+ );
354
+ }
@@ -513,7 +513,7 @@ function detectMentionedConnectors(
513
513
  return { strong, medium };
514
514
  }
515
515
 
516
- function buildConnectorTools(): LlmTool[] {
516
+ export function buildConnectorTools(): LlmTool[] {
517
517
  const out: LlmTool[] = [];
518
518
  for (const inst of listInstalledConnectors()) {
519
519
  if (!inst.enabled) continue;
@@ -6,7 +6,10 @@
6
6
  */
7
7
 
8
8
  import { spawn, execSync, type ChildProcess } from 'node:child_process';
9
+ import { existsSync } from 'node:fs';
10
+ import { join } from 'node:path';
9
11
  import { loadSettings } from './settings';
12
+ import { getConfigDir } from './dirs';
10
13
 
11
14
  export interface ClaudeMessage {
12
15
  type: 'system' | 'assistant' | 'result';
@@ -93,6 +96,15 @@ export function sendToClaudeSession(
93
96
  const env = { ...process.env };
94
97
  delete env.CLAUDECODE;
95
98
 
99
+ // Corporate SSL: behind an intercepting proxy the spawned CLI fails with
100
+ // "SSL certificate verification failed" unless it trusts the corp CA. If the
101
+ // server was launched without NODE_EXTRA_CA_CERTS but a CA bundle is dropped
102
+ // at <configDir>/corporate-ca.pem, wire it through so the child inherits it.
103
+ if (!env.NODE_EXTRA_CA_CERTS) {
104
+ const corpCa = join(getConfigDir(), 'corporate-ca.pem');
105
+ if (existsSync(corpCa)) env.NODE_EXTRA_CA_CERTS = corpCa;
106
+ }
107
+
96
108
  // Resolve full path so spawn works without shell for PATH lookup.
97
109
  // Was a lazy require() — fired ReferenceError on Claude task spawn
98
110
  // under ESM concurrent loads. Now top-level imported.
@@ -0,0 +1,59 @@
1
+ # Home Kanban
2
+
3
+ The home page shows a collapsible **Kanban** board of connector-driven cards.
4
+ Each card runs a prompt against one connector on its own schedule and renders the
5
+ result as a compact widget. Nothing is hardcoded per connector — a card is just
6
+ *connector + prompt + period*, and the prompt's output drives what's drawn.
7
+
8
+ ## Cards
9
+
10
+ - **Compact + fixed-size**: cards are small previews so the board never dominates
11
+ the page. Click a card to open a floating modal with the full widget.
12
+ - **Status dot**: grey idle · amber running · green ok · red error.
13
+ - **⟳ refresh** runs the card now. **✕** deletes it. **↗** opens the connector site.
14
+ - **+ defaults** seeds cards from your installed connectors' declared defaults
15
+ (idempotent — re-running won't duplicate).
16
+
17
+ ## Editing a card (no connector republish)
18
+
19
+ A card's prompt and period live in Forge's local database, seeded from the
20
+ connector manifest's `kanban:` block only on first install / `+ defaults`. To
21
+ tune a card, open it and click **Edit prompt / period** — change the prompt text,
22
+ the refresh interval, or the run mode, then **Save & run**. This only changes the
23
+ local card; you never need to edit or republish the connector.
24
+
25
+ ## Run modes
26
+
27
+ - **inline** (default): a fast single LLM turn using your default API profile.
28
+ Best for simple count/list cards.
29
+ - **task**: the card is dispatched as a one-shot CLI task (the same backend as
30
+ normal Forge tasks) that can work over many steps, call the connector via the
31
+ Forge MCP tools, and write the final widget to
32
+ `<dataDir>/kanban/<cardId>/widget.json`. Best for heavy connectors (many tools)
33
+ or richer, multi-step content. The task may also write supplementary files
34
+ (e.g. `report.md`) into the same folder and link to them from the widget via
35
+ `href` `/api/kanban/<cardId>/artifact/<filename>`.
36
+
37
+ Heavy connectors can declare `mode: task` per card in their manifest; you can
38
+ also flip any card's mode in the Edit panel.
39
+
40
+ ## Declaring cards in a connector
41
+
42
+ A connector manifest may carry kanban defaults (read by Forge's seeder, ignored
43
+ by the connector loader — fully additive, old Forge versions skip it):
44
+
45
+ ```yaml
46
+ kanban:
47
+ cards:
48
+ - title: "My widget"
49
+ icon: "📊"
50
+ prompt: |
51
+ What to gather + how to summarize.
52
+ period_sec: 1800 # refresh cadence (default 1800)
53
+ mode: task # optional: 'inline' (default) | 'task'
54
+ ```
55
+
56
+ ## Refresh cadence
57
+
58
+ Cards refresh on their own period via the same scheduler tick as Schedules, plus
59
+ manual ⟳. Snapshots are stored, so a card shows its last result until the next run.
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Per-card artifact directory under <dataDir>/kanban/<cardId>/. A task-mode run
3
+ * writes its final widget to widget.json here, plus optional supplementary
4
+ * files (report.md, data, …) the widget can link to via the artifact route.
5
+ */
6
+
7
+ import { join, normalize } from 'node:path';
8
+ import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
9
+ import { getDataDir } from '../dirs';
10
+ import type { WidgetSpec } from './types';
11
+
12
+ const WIDGET_FILE = 'widget.json';
13
+
14
+ export function cardArtifactDir(cardId: string): string {
15
+ return join(getDataDir(), 'kanban', cardId);
16
+ }
17
+
18
+ export function ensureArtifactDir(cardId: string): string {
19
+ const dir = cardArtifactDir(cardId);
20
+ mkdirSync(dir, { recursive: true });
21
+ return dir;
22
+ }
23
+
24
+ export function widgetFilePath(cardId: string): string {
25
+ return join(cardArtifactDir(cardId), WIDGET_FILE);
26
+ }
27
+
28
+ /** Remove a stale widget.json before a run so we never read a previous result. */
29
+ export function clearWidgetFile(cardId: string): void {
30
+ try { rmSync(widgetFilePath(cardId), { force: true }); } catch { /* ignore */ }
31
+ }
32
+
33
+ /** Read + validate the widget.json a task wrote. Null if missing/invalid. */
34
+ export function readWidgetFile(cardId: string): WidgetSpec | null {
35
+ const p = widgetFilePath(cardId);
36
+ if (!existsSync(p)) return null;
37
+ try {
38
+ const v = JSON.parse(readFileSync(p, 'utf-8'));
39
+ if (v && typeof v === 'object' && Array.isArray((v as any).blocks)) return v as WidgetSpec;
40
+ } catch { /* invalid */ }
41
+ return null;
42
+ }
43
+
44
+ /** Resolve a requested artifact name to an absolute path INSIDE the card dir,
45
+ * or null on traversal / escape. Used by the artifact-serving route. */
46
+ export function safeArtifactPath(cardId: string, name: string): string | null {
47
+ const base = cardArtifactDir(cardId);
48
+ const full = normalize(join(base, name));
49
+ if (full !== base && !full.startsWith(base + '/')) return null; // escaped the dir
50
+ if (!existsSync(full)) return null;
51
+ return full;
52
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Kanban card executor (P2). Runs a card's prompt as a headless,
3
+ * connector-scoped LLM turn: only the target connector's tools are exposed,
4
+ * the system prompt constrains output to a WidgetSpec JSON, and the loop
5
+ * dispatches any tool calls until the model returns a final widget.
6
+ *
7
+ * Reuses the chat primitives — streamLlm (LLM adapter), resolveProvider
8
+ * (default API profile), buildConnectorTools (tool specs), dispatchTool
9
+ * (connector execution) — so it stays consistent with the chat agent loop
10
+ * without dragging in the session machinery.
11
+ */
12
+
13
+ import { randomUUID } from 'node:crypto';
14
+ import { streamLlm } from '../chat/llm';
15
+ import { resolveProvider, buildConnectorTools } from '../chat/agent-loop';
16
+ import { dispatchTool } from '../chat/tool-dispatcher';
17
+ import type { Message, ContentBlock } from '../chat/types';
18
+ import type { WidgetSpec } from './types';
19
+ import { getCard, setCardRunning, setCardResult, setCardError } from './store';
20
+
21
+ const MAX_TOOL_ROUNDS = 6;
22
+ const MAX_TOKENS = 2000;
23
+
24
+ /** The WidgetSpec schema, shared by the inline system prompt and the task-mode
25
+ * contract prompt so both paths produce the same shape. */
26
+ export const WIDGET_SCHEMA_TEXT = [
27
+ 'A widget is a single JSON object matching:',
28
+ '{ "accent"?: Color, "blocks": Block[], "footer"?: string }',
29
+ 'Color = "gray"|"red"|"amber"|"yellow"|"green"|"emerald"|"blue"|"violet"',
30
+ 'Block is one of:',
31
+ ' {"type":"metric_row","items":[{"label":string,"value":string|number,"color"?:Color}]}',
32
+ ' {"type":"list","items":[{"text":string,"badge"?:string,"badgeColor"?:Color,"sub"?:string,"href"?:string,"avatar"?:string}]}',
33
+ ' {"type":"stat","value":string|number,"label":string,"delta"?:string,"deltaColor"?:Color}',
34
+ ' {"type":"progress","label":string,"value":number,"max":number,"color"?:Color}',
35
+ ' {"type":"table","columns":string[],"rows":string[][]}',
36
+ ' {"type":"badges","items":[{"text":string,"color"?:Color}]}',
37
+ ' {"type":"callout","text":string,"color"?:Color,"icon"?:string} // highlighted alert box, e.g. "2 items overdue"',
38
+ ' {"type":"text","markdown":string} // a short **bold** line works as a section header; supports bold/`code`/[links](url)/- bullets',
39
+ '',
40
+ 'Guidance: lead with a metric_row (counts) and/or a callout for anything urgent. Use a progress block for "X of Y done / release readiness" — it renders as a nice bar. For people, set list item "avatar" to their initials (e.g. "YC"). Group long lists under short **bold** text headers.',
41
+ 'BE TERSE: the card is small. List item "text" should be short (aim ≤ 6 words / fits one line — the compact card truncates the rest); put any extra detail in "sub", also short. Prefer keywords over sentences. metric labels ≤ 1-2 words.',
42
+ ].join('\n');
43
+
44
+ function systemPrompt(): string {
45
+ return [
46
+ 'You generate a Forge dashboard "kanban card". Use the available connector tools to gather exactly what the user prompt asks for, then summarize into a compact, glanceable widget.',
47
+ '',
48
+ 'Output ONLY a single JSON object (no prose, no markdown fences) matching:',
49
+ WIDGET_SCHEMA_TEXT.split('\n').slice(1).join('\n'),
50
+ '',
51
+ 'Keep it glanceable: short labels, at most ~6 list items. Choose block types that fit the data (e.g. counts → metric_row, items → list). If a tool errors or returns nothing, still return a valid widget (a text block explaining the state).',
52
+ ].join('\n');
53
+ }
54
+
55
+ function msg(role: 'user' | 'assistant', cardId: string, blocks: ContentBlock[]): Message {
56
+ return { id: randomUUID(), session_id: `kanban:${cardId}`, role, blocks, ts: Date.now() };
57
+ }
58
+
59
+ /** Run one card. Persists the result/error snapshot; never throws. */
60
+ export async function runKanbanCard(cardId: string): Promise<{ ok: boolean; error?: string }> {
61
+ const card = getCard(cardId);
62
+ if (!card) return { ok: false, error: 'card not found' };
63
+
64
+ const provider = resolveProvider(null, null);
65
+ if ('error' in provider) {
66
+ setCardError(cardId, `provider: ${provider.error}`);
67
+ return { ok: false, error: provider.error };
68
+ }
69
+
70
+ const tools = buildConnectorTools().filter((t) => t.name.split('.')[0] === card.connectorId);
71
+ if (tools.length === 0) {
72
+ setCardError(cardId, `connector '${card.connectorId}' has no usable tools (installed + enabled?)`);
73
+ return { ok: false, error: 'no connector tools' };
74
+ }
75
+
76
+ setCardRunning(cardId);
77
+ const history: Message[] = [msg('user', cardId, [{ type: 'text', text: card.prompt }])];
78
+
79
+ try {
80
+ for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
81
+ const res = await streamLlm(
82
+ {
83
+ provider: provider.type,
84
+ apiKey: provider.apiKey,
85
+ baseUrl: provider.baseUrl,
86
+ model: provider.model,
87
+ system: systemPrompt(),
88
+ history,
89
+ tools,
90
+ maxTokens: MAX_TOKENS,
91
+ },
92
+ { onTextDelta: () => {}, onToolUse: () => {} },
93
+ );
94
+ history.push(msg('assistant', cardId, res.content));
95
+
96
+ const toolUses = res.content.filter(
97
+ (b): b is Extract<ContentBlock, { type: 'tool_use' }> => b.type === 'tool_use',
98
+ );
99
+ if (res.stopReason === 'tool_use' && toolUses.length > 0) {
100
+ const results: ContentBlock[] = [];
101
+ for (const tu of toolUses) {
102
+ const r = await dispatchTool({ id: tu.id, name: tu.name, input: tu.input });
103
+ results.push({ type: 'tool_result', tool_use_id: tu.id, content: r.content, is_error: r.is_error });
104
+ }
105
+ history.push(msg('user', cardId, results));
106
+ continue;
107
+ }
108
+
109
+ const text = res.content
110
+ .filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text')
111
+ .map((b) => b.text)
112
+ .join('')
113
+ .trim();
114
+ const spec = parseWidget(text);
115
+ if (!spec) {
116
+ setCardError(cardId, 'LLM did not return a valid widget JSON');
117
+ return { ok: false, error: 'invalid widget' };
118
+ }
119
+ setCardResult(cardId, spec);
120
+ return { ok: true };
121
+ }
122
+ setCardError(cardId, `exceeded ${MAX_TOOL_ROUNDS} tool rounds without a final widget`);
123
+ return { ok: false, error: 'max rounds' };
124
+ } catch (e) {
125
+ setCardError(cardId, (e as Error).message);
126
+ return { ok: false, error: (e as Error).message };
127
+ }
128
+ }
129
+
130
+ /** Tolerant extract: strip optional code fences, take the outermost {...}. */
131
+ export function parseWidget(text: string): WidgetSpec | null {
132
+ const stripped = text.replace(/^```(?:json)?/i, '').replace(/```$/, '').trim();
133
+ const start = stripped.indexOf('{');
134
+ const end = stripped.lastIndexOf('}');
135
+ if (start === -1 || end === -1 || end <= start) return null;
136
+ try {
137
+ const v = JSON.parse(stripped.slice(start, end + 1));
138
+ if (v && typeof v === 'object' && Array.isArray((v as any).blocks)) return v as WidgetSpec;
139
+ } catch { /* not valid JSON */ }
140
+ return null;
141
+ }