@aion0/forge 0.10.90 → 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.
package/RELEASE_NOTES.md CHANGED
@@ -1,12 +1,20 @@
1
- # Forge v0.10.90
1
+ # Forge v0.11.0
2
2
 
3
- Released: 2026-06-17
3
+ Released: 2026-06-18
4
4
 
5
- ## Changes since v0.10.89
5
+ ## Changes since v0.10.90
6
6
 
7
7
  ### Other
8
- - fix(build): revert to default Turbopack pin is the real fix, not webpack
9
- - fix(build): pin next to exact 16.2.1 16.2.9 breaks webpack middleware
8
+ - fix(kanban): denser compact cardtighter spacing, thin list rows, hide footer, slim callout, +20px height
9
+ - fix(kanban): smaller tiles, single-line list rows, one-row tiles in modal, terse-text guidance
10
+ - fix(kanban): smaller compact metric tiles + wider default modal (760px)
11
+ - feat(kanban): polished widget renderer — Style A cards + Style B modal
12
+ - fix(server): don't pass --use-system-ca when NODE_EXTRA_CA_CERTS is set
13
+ - fix(task): auto-inject corporate CA into spawned CLI env
14
+ - fix(kanban): task settle by widget-file (survive exit-1) + reconcile + lean prompt
15
+ - feat(kanban): task-backed execution mode (B) + artifacts + help doc
16
+ - feat(kanban): compact cards + expand modal + in-place prompt editing
17
+ - feat(kanban): connector-driven home dashboard (P1–P4)
10
18
 
11
19
 
12
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.89...v0.10.90
20
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.90...v0.11.0
@@ -0,0 +1,33 @@
1
+ /**
2
+ * GET /api/kanban/:id/artifact/:name — serve a file a task-mode card wrote into
3
+ * <dataDir>/kanban/:id/ (report.md, data, …) so widgets can link to richer
4
+ * generated content. Read-only, path-sanitized (no traversal). Auth via proxy.ts.
5
+ */
6
+
7
+ import { NextResponse } from 'next/server';
8
+ import { readFileSync } from 'node:fs';
9
+ import { extname } from 'node:path';
10
+ import { safeArtifactPath } from '@/lib/kanban/artifacts';
11
+
12
+ const TYPES: Record<string, string> = {
13
+ '.md': 'text/markdown; charset=utf-8',
14
+ '.txt': 'text/plain; charset=utf-8',
15
+ '.json': 'application/json; charset=utf-8',
16
+ '.csv': 'text/csv; charset=utf-8',
17
+ '.html': 'text/html; charset=utf-8',
18
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml',
19
+ };
20
+
21
+ export async function GET(_req: Request, { params }: { params: Promise<{ id: string; name: string[] }> }) {
22
+ const { id, name } = await params;
23
+ const rel = (name || []).join('/');
24
+ const full = safeArtifactPath(id, rel);
25
+ if (!full) return NextResponse.json({ error: 'not found' }, { status: 404 });
26
+ try {
27
+ const buf = readFileSync(full);
28
+ const ct = TYPES[extname(full).toLowerCase()] || 'application/octet-stream';
29
+ return new NextResponse(buf, { headers: { 'content-type': ct, 'cache-control': 'no-store' } });
30
+ } catch {
31
+ return NextResponse.json({ error: 'unreadable' }, { status: 404 });
32
+ }
33
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * POST /api/kanban/:id/run — run a card now (manual refresh). Inline cards run
3
+ * synchronously and return the fresh widget; task-mode cards are dispatched and
4
+ * return immediately with status 'running' (the task listener settles them).
5
+ * Auth handled by proxy.ts.
6
+ */
7
+
8
+ import { NextResponse } from 'next/server';
9
+ import { runKanbanCard } from '@/lib/kanban/executor';
10
+ import { runKanbanCardViaTask } from '@/lib/kanban/task-executor';
11
+ import { installKanbanTaskListener } from '@/lib/kanban/task-listener';
12
+ import { getCard } from '@/lib/kanban/store';
13
+
14
+ export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
15
+ const { id } = await params;
16
+ const card = getCard(id);
17
+ if (!card) return NextResponse.json({ error: 'card not found' }, { status: 404 });
18
+
19
+ if (card.execMode === 'task') {
20
+ installKanbanTaskListener(); // ensure settle works even if tick never ran
21
+ const taskId = runKanbanCardViaTask(id);
22
+ return NextResponse.json({ ok: !!taskId, dispatched: true, taskId, card: getCard(id) });
23
+ }
24
+
25
+ const r = await runKanbanCard(id);
26
+ return NextResponse.json({ ok: r.ok, error: r.error, card: getCard(id) });
27
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * GET /api/kanban list cards
3
+ * POST /api/kanban create card { connectorId, title, prompt, icon?, periodSec? }
4
+ * /api/kanban body { reorder: [{id, order}] } → batch reorder
5
+ * PATCH /api/kanban?id= update card
6
+ * DELETE /api/kanban?id= delete card
7
+ *
8
+ * Card execution (run/refresh) lands in P2. Auth is handled by proxy.ts.
9
+ */
10
+
11
+ import { NextResponse } from 'next/server';
12
+ import {
13
+ listCards,
14
+ createCard,
15
+ updateCard,
16
+ deleteCard,
17
+ reorderCards,
18
+ } from '@/lib/kanban/store';
19
+ import { getInstalledConnector } from '@/lib/connectors/registry';
20
+ import { reconcileRunningKanbanTaskCards } from '@/lib/kanban/task-listener';
21
+ import type { KanbanCard, UpdateKanbanCardInput } from '@/lib/kanban/types';
22
+
23
+ /** Attach each card's connector website (base_url) so the UI can link out. */
24
+ function withConnectorUrl(cards: KanbanCard[]): KanbanCard[] {
25
+ const cache = new Map<string, string | null>();
26
+ return cards.map((c) => {
27
+ if (!cache.has(c.connectorId)) {
28
+ const cfg = (getInstalledConnector(c.connectorId)?.config || {}) as Record<string, unknown>;
29
+ const url = (cfg.base_url || cfg.baseUrl || cfg.url || null) as string | null;
30
+ cache.set(c.connectorId, url);
31
+ }
32
+ return { ...c, connectorUrl: cache.get(c.connectorId) ?? null };
33
+ });
34
+ }
35
+
36
+ export async function GET() {
37
+ // Self-heal task-mode cards stuck 'running' whose task already finished
38
+ // (covers missed terminal events) before returning the list.
39
+ try { reconcileRunningKanbanTaskCards(listCards()); } catch { /* best effort */ }
40
+ return NextResponse.json({ cards: withConnectorUrl(listCards()) });
41
+ }
42
+
43
+ export async function POST(req: Request) {
44
+ let body: any = {};
45
+ try { body = await req.json(); } catch {}
46
+
47
+ // Batch reorder shares the POST verb (drag-to-reorder sends the whole order).
48
+ if (Array.isArray(body.reorder)) {
49
+ const rows = body.reorder
50
+ .filter((r: any) => r && typeof r.id === 'string' && typeof r.order === 'number')
51
+ .map((r: any) => ({ id: r.id, order: r.order }));
52
+ reorderCards(rows);
53
+ return NextResponse.json({ ok: true, cards: listCards() });
54
+ }
55
+
56
+ const connectorId = typeof body.connectorId === 'string' ? body.connectorId.trim() : '';
57
+ const title = typeof body.title === 'string' ? body.title.trim() : '';
58
+ const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : '';
59
+ if (!connectorId || !title || !prompt) {
60
+ return NextResponse.json(
61
+ { error: 'connectorId, title and prompt are required' },
62
+ { status: 400 },
63
+ );
64
+ }
65
+ const card = createCard({
66
+ connectorId,
67
+ title,
68
+ prompt,
69
+ icon: typeof body.icon === 'string' ? body.icon : null,
70
+ periodSec: typeof body.periodSec === 'number' && body.periodSec > 0 ? body.periodSec : undefined,
71
+ enabled: body.enabled !== false,
72
+ });
73
+ return NextResponse.json({ ok: true, card });
74
+ }
75
+
76
+ export async function PATCH(req: Request) {
77
+ const id = new URL(req.url).searchParams.get('id');
78
+ if (!id) return NextResponse.json({ error: 'id query param required' }, { status: 400 });
79
+
80
+ let body: any = {};
81
+ try { body = await req.json(); } catch {}
82
+
83
+ const patch: UpdateKanbanCardInput = {};
84
+ if (typeof body.title === 'string') patch.title = body.title;
85
+ if (typeof body.prompt === 'string') patch.prompt = body.prompt;
86
+ if (body.icon === null || typeof body.icon === 'string') patch.icon = body.icon;
87
+ if (typeof body.periodSec === 'number' && body.periodSec > 0) patch.periodSec = body.periodSec;
88
+ if (typeof body.order === 'number') patch.order = body.order;
89
+ if (typeof body.enabled === 'boolean') patch.enabled = body.enabled;
90
+
91
+ const ok = updateCard(id, patch);
92
+ if (!ok) return NextResponse.json({ error: 'card not found or nothing to update' }, { status: 404 });
93
+ return NextResponse.json({ ok: true });
94
+ }
95
+
96
+ export async function DELETE(req: Request) {
97
+ const id = new URL(req.url).searchParams.get('id');
98
+ if (!id) return NextResponse.json({ error: 'id query param required' }, { status: 400 });
99
+ const ok = deleteCard(id);
100
+ if (!ok) return NextResponse.json({ error: 'card not found' }, { status: 404 });
101
+ return NextResponse.json({ ok: true });
102
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * POST /api/kanban/seed — create kanban cards from installed connectors'
3
+ * declared `kanban` defaults (idempotent). Returns the newly-created cards +
4
+ * the full list. Auth handled by proxy.ts.
5
+ */
6
+
7
+ import { NextResponse } from 'next/server';
8
+ import { seedKanbanFromConnectors } from '@/lib/kanban/seed';
9
+ import { listCards } from '@/lib/kanban/store';
10
+
11
+ export async function POST() {
12
+ const { created } = seedKanbanFromConnectors();
13
+ return NextResponse.json({ ok: true, created, cards: listCards() });
14
+ }
@@ -729,7 +729,11 @@ function startBackground() {
729
729
  // inspection, Zscaler, etc.) install a custom root via MDM; Node
730
730
  // doesn't see it without this flag, so marketplace sync fails with
731
731
  // 'self-signed certificate in certificate chain' on api.github.com.
732
- NODE_OPTIONS: [process.env.NODE_OPTIONS, '--use-system-ca'].filter(Boolean).join(' '),
732
+ // BUT it's inherited by spawned `claude` CLIs, where on some macOS
733
+ // setups it breaks TLS to api.anthropic.com ("SSL certificate
734
+ // verification failed"). Skip it when an explicit CA bundle is
735
+ // configured (NODE_EXTRA_CA_CERTS already covers the corp root).
736
+ NODE_OPTIONS: [process.env.NODE_OPTIONS, process.env.NODE_EXTRA_CA_CERTS ? '' : '--use-system-ca'].filter(Boolean).join(' '),
733
737
  },
734
738
  detached: true,
735
739
  });
@@ -820,7 +824,11 @@ if (isDev) {
820
824
  // inspection, Zscaler, etc.) install a custom root via MDM; Node
821
825
  // doesn't see it without this flag, so marketplace sync fails with
822
826
  // 'self-signed certificate in certificate chain' on api.github.com.
823
- NODE_OPTIONS: [process.env.NODE_OPTIONS, '--use-system-ca'].filter(Boolean).join(' '),
827
+ // BUT it's inherited by spawned `claude` CLIs, where on some macOS
828
+ // setups it breaks TLS to api.anthropic.com ("SSL certificate
829
+ // verification failed"). Skip it when an explicit CA bundle is
830
+ // configured (NODE_EXTRA_CA_CERTS already covers the corp root).
831
+ NODE_OPTIONS: [process.env.NODE_OPTIONS, process.env.NODE_EXTRA_CA_CERTS ? '' : '--use-system-ca'].filter(Boolean).join(' '),
824
832
  },
825
833
  });
826
834
  child.on('exit', (code) => { stopServices(); process.exit(code || 0); });
@@ -839,7 +847,11 @@ if (isDev) {
839
847
  // inspection, Zscaler, etc.) install a custom root via MDM; Node
840
848
  // doesn't see it without this flag, so marketplace sync fails with
841
849
  // 'self-signed certificate in certificate chain' on api.github.com.
842
- NODE_OPTIONS: [process.env.NODE_OPTIONS, '--use-system-ca'].filter(Boolean).join(' '),
850
+ // BUT it's inherited by spawned `claude` CLIs, where on some macOS
851
+ // setups it breaks TLS to api.anthropic.com ("SSL certificate
852
+ // verification failed"). Skip it when an explicit CA bundle is
853
+ // configured (NODE_EXTRA_CA_CERTS already covers the corp root).
854
+ NODE_OPTIONS: [process.env.NODE_OPTIONS, process.env.NODE_EXTRA_CA_CERTS ? '' : '--use-system-ca'].filter(Boolean).join(' '),
843
855
  },
844
856
  });
845
857
  child.on('exit', (code) => { stopServices(); process.exit(code || 0); });
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react'
4
4
 
5
5
  const WebChatPanel = lazy(() => import('./WebChatPanel'));
6
6
  const PipelineActivityPanel = lazy(() => import('./PipelineActivityPanel'));
7
+ const KanbanBoard = lazy(() => import('./KanbanBoard'));
7
8
 
8
9
  // Home view: 3 columns with draggable splitters.
9
10
  // col 1 — Session list (inside WebChatPanel; width controlled via prop)
@@ -84,7 +85,13 @@ export default function HomeView() {
84
85
  }, [leftWidth, rightWidth]);
85
86
 
86
87
  return (
87
- <div className="flex-1 flex min-h-0 min-w-0">
88
+ <div className="flex-1 flex flex-col min-h-0 min-w-0">
89
+ {/* Top: connector-driven Kanban board (self-renders from prompt output). */}
90
+ <Suspense fallback={null}>
91
+ <KanbanBoard />
92
+ </Suspense>
93
+
94
+ <div className="flex-1 flex min-h-0 min-w-0">
88
95
  {/* Chat (session list + main chat — WebChatPanel handles both internally).
89
96
  The session-list splitter is rendered INSIDE WebChatPanel, between its
90
97
  aside and main, so it sits at the actual sidebar boundary. */}
@@ -137,6 +144,7 @@ export default function HomeView() {
137
144
  </div>
138
145
  </>
139
146
  )}
147
+ </div>
140
148
  </div>
141
149
  );
142
150
  }
@@ -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;