anentrypoint-design 0.0.384 → 0.0.385
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/community.css +21 -18
- package/dist/247420.css +107 -49
- package/dist/247420.js +23 -23
- package/package.json +1 -1
- package/scripts/lint-css.mjs +24 -10
- package/src/components/chat/message.js +130 -0
- package/src/components/chat/stats.js +36 -0
- package/src/components/chat/thread-scroll.js +42 -0
- package/src/components/freddie/pages-chat.js +149 -0
- package/src/components/freddie/pages-config.js +203 -0
- package/src/components/freddie/pages-infra.js +120 -0
- package/src/components/freddie/pages-overview.js +107 -0
- package/src/components/freddie/pages-runners.js +102 -0
- package/src/components/freddie/pages-telemetry.js +126 -0
- package/src/components/freddie/pages-workspace.js +176 -0
- package/src/components/freddie/shared.js +32 -0
- package/src/components/freddie/sse.js +85 -0
- package/src/components/freddie.js +21 -1064
- package/src/components/shell/app-shell.js +195 -0
- package/src/components/shell/atoms.js +165 -0
- package/src/components/shell/icons.js +123 -0
- package/src/components/shell/workspace-columns.js +179 -0
- package/src/components/shell/workspace-shell.js +181 -0
- package/src/components/shell.js +15 -805
- package/src/css/app-shell/chat-polish.css +18 -6
- package/src/css/app-shell/files.css +15 -4
- package/src/css/app-shell/hero-content.css +16 -1
- package/src/css/app-shell/kits-appended.css +26 -15
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Freddie infrastructure pages: `gateway` (messaging platform status),
|
|
2
|
+
// `chains` (acptoapi fallback chain CRUD), `machines` (persisted xstate
|
|
3
|
+
// census), and `health` (system + provider checks).
|
|
4
|
+
|
|
5
|
+
import * as webjsx from '../../../vendor/webjsx/index.js';
|
|
6
|
+
import { makePage, api, loadingState, errorState, emptyState, refreshError } from './runtime.js';
|
|
7
|
+
import { Row, Table, PageHeader, TextField } from '../content.js';
|
|
8
|
+
import { Chip, Btn } from '../shell.js';
|
|
9
|
+
import { section, noteAlert, truncJson } from './shared.js';
|
|
10
|
+
|
|
11
|
+
const h = webjsx.createElement;
|
|
12
|
+
|
|
13
|
+
export const gateway = makePage((ctx) => {
|
|
14
|
+
async function load() { try { ctx.set({ loading: false, data: await api('/api/gateway'), error: null }); } catch (e) { ctx.set({ loading: false, error: e }); } }
|
|
15
|
+
load(); ctx.interval(load, 10000);
|
|
16
|
+
return () => {
|
|
17
|
+
const s = ctx.state;
|
|
18
|
+
if (s.loading) return loadingState('loading gateway…');
|
|
19
|
+
if (s.error && !s.data) return errorState(s.error, load);
|
|
20
|
+
const d = s.data || {};
|
|
21
|
+
const platforms = d.platforms || d;
|
|
22
|
+
const rows = Object.entries(platforms).map(([k, v]) => [k, typeof v === 'object' ? (v.running || v.up ? Chip({ tone: 'ok', children: 'up' }) : Chip({ tone: 'miss', children: 'down' })) : String(v)]);
|
|
23
|
+
return [
|
|
24
|
+
PageHeader({ title: 'gateway', lede: 'messaging platform status' }),
|
|
25
|
+
s.error && s.data ? refreshError(s.error) : null,
|
|
26
|
+
section('platforms', rows.length ? Table({ headers: ['platform', 'status'], rows }) : emptyState('no platforms configured')),
|
|
27
|
+
].filter(Boolean);
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export const chains = makePage((ctx) => {
|
|
32
|
+
Object.assign(ctx.state, { name: '', links: '', busy: false, note: null });
|
|
33
|
+
async function load() {
|
|
34
|
+
try {
|
|
35
|
+
const [health, list, cfg] = await Promise.all([
|
|
36
|
+
api('/api/acptoapi/health').catch(() => null),
|
|
37
|
+
api('/api/acptoapi/chains').catch(() => null),
|
|
38
|
+
api('/api/acptoapi/config').catch(() => null),
|
|
39
|
+
]);
|
|
40
|
+
ctx.set({ loading: false, health, list, cfg, error: null });
|
|
41
|
+
} catch (e) { ctx.set({ loading: false, error: e }); }
|
|
42
|
+
}
|
|
43
|
+
async function create() {
|
|
44
|
+
const name = (ctx.state.name || '').trim();
|
|
45
|
+
const links = (ctx.state.links || '').split(',').map(x => x.trim()).filter(Boolean);
|
|
46
|
+
if (!name || !links.length) { ctx.set({ note: { kind: 'warn', msg: 'name and comma-separated links required' } }); return; }
|
|
47
|
+
ctx.set({ busy: true, note: null });
|
|
48
|
+
try { await api('/api/acptoapi/chains', { method: 'POST', body: { name, links } }); ctx.state.name = ''; ctx.state.links = ''; await load(); }
|
|
49
|
+
catch (e) { ctx.set({ note: { kind: 'error', msg: String(e.message || e) } }); }
|
|
50
|
+
ctx.set({ busy: false });
|
|
51
|
+
}
|
|
52
|
+
async function del(name) { ctx.set({ busy: true }); try { await api('/api/acptoapi/chains/' + encodeURIComponent(name), { method: 'DELETE' }); await load(); } catch (e) { ctx.set({ note: { kind: 'error', msg: String(e.message || e) } }); } ctx.set({ busy: false }); }
|
|
53
|
+
load();
|
|
54
|
+
return () => {
|
|
55
|
+
const s = ctx.state;
|
|
56
|
+
if (s.loading) return loadingState('loading chains…');
|
|
57
|
+
if (s.error && !s.cfg && !s.health) return errorState(s.error, load);
|
|
58
|
+
const chainsList = s.list?.chains || s.list || [];
|
|
59
|
+
const up = s.health && (s.health.ok || s.health.status === 'ok' || s.health.healthy);
|
|
60
|
+
return [
|
|
61
|
+
PageHeader({ title: 'chains', lede: 'acptoapi fallback chains', right: up ? Chip({ tone: 'ok', children: 'acptoapi up' }) : Chip({ tone: 'miss', children: 'acptoapi down' }) }),
|
|
62
|
+
noteAlert(s.note),
|
|
63
|
+
section('chains', Array.isArray(chainsList) && chainsList.length ? chainsList.map((c, i) => Row({
|
|
64
|
+
key: i, title: c.name || c, sub: Array.isArray(c.links) ? c.links.join(' -> ') : '',
|
|
65
|
+
trailing: Btn({ variant: 'danger', children: 'delete', onClick: () => del(c.name || c) }),
|
|
66
|
+
})) : emptyState('no chains defined')),
|
|
67
|
+
section('new chain',
|
|
68
|
+
TextField({ label: 'name', value: s.name, onInput: (v) => { s.name = v; } }),
|
|
69
|
+
TextField({ label: 'links (comma-separated models)', value: s.links, onInput: (v) => { s.links = v; }, placeholder: 'mistral/large, openrouter/auto' }),
|
|
70
|
+
Btn({ variant: 'primary', disabled: s.busy, children: s.busy ? 'working…' : 'create chain', onClick: create })),
|
|
71
|
+
s.cfg ? section('config', h('pre', { class: 'fd-pre' }, JSON.stringify(s.cfg, null, 2))) : null,
|
|
72
|
+
].filter(Boolean);
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
export const machines = makePage((ctx) => {
|
|
77
|
+
async function load() { try { ctx.set({ loading: false, data: await api('/api/machines'), error: null }); } catch (e) { ctx.set({ loading: false, error: e }); } }
|
|
78
|
+
load(); ctx.interval(load, 8000);
|
|
79
|
+
return () => {
|
|
80
|
+
const s = ctx.state;
|
|
81
|
+
if (s.loading) return loadingState('loading machines…');
|
|
82
|
+
if (s.error && !s.data) return errorState(s.error, load);
|
|
83
|
+
const d = s.data || {};
|
|
84
|
+
const list = Array.isArray(d) ? d : (d.machines || Object.entries(d).map(([kind, v]) => ({ kind, ...(typeof v === 'object' ? v : { value: v }) })));
|
|
85
|
+
return [
|
|
86
|
+
PageHeader({ title: 'machines', lede: 'persisted xstate machine census' }),
|
|
87
|
+
s.error && s.data ? refreshError(s.error) : null,
|
|
88
|
+
section('machines', list.length ? Table({
|
|
89
|
+
headers: ['kind', 'key', 'state'],
|
|
90
|
+
rows: list.map(m => [m.kind || '—', m.key || m.machine_id || '—', m.state || m.value || truncJson(m)]),
|
|
91
|
+
}) : emptyState('no live machines')),
|
|
92
|
+
].filter(Boolean);
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
export const health = makePage((ctx) => {
|
|
97
|
+
async function load() {
|
|
98
|
+
try {
|
|
99
|
+
const [health, providers] = await Promise.all([
|
|
100
|
+
api('/api/health').catch(() => null),
|
|
101
|
+
api('/api/providers').catch(() => null),
|
|
102
|
+
]);
|
|
103
|
+
ctx.set({ loading: false, health, providers, error: null });
|
|
104
|
+
} catch (e) { ctx.set({ loading: false, error: e }); }
|
|
105
|
+
}
|
|
106
|
+
load(); ctx.interval(load, 15000);
|
|
107
|
+
return () => {
|
|
108
|
+
const s = ctx.state;
|
|
109
|
+
if (s.loading) return loadingState('loading health…');
|
|
110
|
+
if (s.error && !s.health && !s.providers) return errorState(s.error, load);
|
|
111
|
+
const hd = s.health || {};
|
|
112
|
+
const provs = Array.isArray(s.providers) ? s.providers : (s.providers?.providers || []);
|
|
113
|
+
return [
|
|
114
|
+
PageHeader({ title: 'health', lede: 'system & provider health', right: hd.ok ? Chip({ tone: 'ok', children: 'healthy' }) : Chip({ tone: 'miss', children: 'degraded' }) }),
|
|
115
|
+
s.error && (s.health || s.providers) ? refreshError(s.error) : null,
|
|
116
|
+
section('checks', Object.keys(hd).length ? Table({ headers: ['check', 'status'], rows: Object.entries(hd).map(([k, v]) => [k, typeof v === 'object' ? truncJson(v) : (v === true ? Chip({ tone: 'ok', children: 'ok' }) : v === false ? Chip({ tone: 'miss', children: 'no' }) : String(v))]) }) : emptyState('no health data')),
|
|
117
|
+
provs.length ? section('providers', Table({ headers: ['provider', 'status'], rows: provs.map(p => { const n = typeof p === 'string' ? p : p.name || p.id; const ok = typeof p === 'object' ? (p.ok ?? p.available) : null; return [n, ok == null ? '—' : (ok ? Chip({ tone: 'ok', children: 'up' }) : Chip({ tone: 'miss', children: 'down' }))]; }) })) : null,
|
|
118
|
+
].filter(Boolean);
|
|
119
|
+
};
|
|
120
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Freddie overview pages: the dashboard `home` KPI roll-up, `agents` live
|
|
2
|
+
// activity, and `analytics` provider/sampler health. All read-only polling
|
|
3
|
+
// pages over /api/health, /api/agents, /api/sessions and /api/models/*.
|
|
4
|
+
|
|
5
|
+
import { makePage, api, loadingState, errorState, emptyState, refreshError } from './runtime.js';
|
|
6
|
+
import { Table, Kpi, PageHeader } from '../content.js';
|
|
7
|
+
import { fmtAgo } from '../sessions.js';
|
|
8
|
+
import { section, truncSpan, truncJson, TRUNC_TITLE } from './shared.js';
|
|
9
|
+
|
|
10
|
+
export const home = makePage((ctx) => {
|
|
11
|
+
async function load() {
|
|
12
|
+
try {
|
|
13
|
+
// tools/skills counts come from the host when injected; otherwise
|
|
14
|
+
// fall back to the same /api/* endpoints the tools/skills pages use
|
|
15
|
+
// so the home KPIs never render an em-dash placeholder.
|
|
16
|
+
const needTools = ctx.host?.pi?.tools?.size == null;
|
|
17
|
+
const needSkills = ctx.host?.pi?.skills?.size == null;
|
|
18
|
+
const [health, agents, sessions, toolsList, skillsList] = await Promise.all([
|
|
19
|
+
api('/api/health').catch(() => null),
|
|
20
|
+
api('/api/agents').catch(() => null),
|
|
21
|
+
api('/api/sessions').catch((e) => ({ _err: e })),
|
|
22
|
+
needTools ? api('/api/tools').catch(() => null) : Promise.resolve(null),
|
|
23
|
+
needSkills ? api('/api/skills').catch(() => null) : Promise.resolve(null),
|
|
24
|
+
]);
|
|
25
|
+
const toolsCount = needTools ? (Array.isArray(toolsList) ? toolsList.length : (toolsList?.tools?.length ?? null)) : null;
|
|
26
|
+
const skillsCount = needSkills ? (Array.isArray(skillsList) ? skillsList.length : (skillsList?.skills?.length ?? null)) : null;
|
|
27
|
+
const sessFailed = sessions && sessions._err;
|
|
28
|
+
ctx.set({ loading: false, health, agents, sessions: Array.isArray(sessions) ? sessions : [], sessFailed, toolsCount, skillsCount, error: null });
|
|
29
|
+
} catch (e) { ctx.set({ loading: false, error: e }); }
|
|
30
|
+
}
|
|
31
|
+
load();
|
|
32
|
+
ctx.interval(load, 15000);
|
|
33
|
+
return () => {
|
|
34
|
+
const s = ctx.state;
|
|
35
|
+
if (s.loading) return loadingState('loading dashboard…');
|
|
36
|
+
if (s.error) return errorState(s.error, load);
|
|
37
|
+
const sessions = s.sessions || [];
|
|
38
|
+
const agents = s.agents || {};
|
|
39
|
+
const tools = ctx.host?.pi?.tools?.size ?? s.toolsCount ?? '—';
|
|
40
|
+
const skills = ctx.host?.pi?.skills?.size ?? s.skillsCount ?? '—';
|
|
41
|
+
return [
|
|
42
|
+
PageHeader({ title: 'dashboard', lede: 'agent harness · live overview' }),
|
|
43
|
+
Kpi({ items: [
|
|
44
|
+
[tools, 'tools'],
|
|
45
|
+
[skills, 'skills'],
|
|
46
|
+
[sessions.length, 'sessions'],
|
|
47
|
+
[agents.count ?? 0, 'active agents'],
|
|
48
|
+
] }),
|
|
49
|
+
section('recent sessions',
|
|
50
|
+
s.sessFailed
|
|
51
|
+
? errorState(new Error('could not load sessions'))
|
|
52
|
+
: sessions.length
|
|
53
|
+
? Table({
|
|
54
|
+
headers: ['session', 'platform', 'updated'],
|
|
55
|
+
rows: sessions.slice(0, 8).map(x => [truncSpan(x.title || x.id, TRUNC_TITLE), x.platform || '—', fmtAgo(x.updated_at)]),
|
|
56
|
+
})
|
|
57
|
+
: emptyState('no sessions yet')),
|
|
58
|
+
section('health',
|
|
59
|
+
s.health ? Table({ headers: ['check', 'status'], rows: Object.entries(s.health).map(([k, v]) => [k, typeof v === 'object' ? truncJson(v) : String(v)]) })
|
|
60
|
+
: emptyState('health endpoint unavailable')),
|
|
61
|
+
];
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export const agents = makePage((ctx) => {
|
|
66
|
+
async function load() { try { ctx.set({ loading: false, data: await api('/api/agents'), error: null }); } catch (e) { ctx.set({ loading: false, error: e }); } }
|
|
67
|
+
load(); ctx.interval(load, 5000);
|
|
68
|
+
return () => {
|
|
69
|
+
const s = ctx.state;
|
|
70
|
+
if (s.loading) return loadingState('loading agents…');
|
|
71
|
+
if (s.error && !s.data) return errorState(s.error, load);
|
|
72
|
+
const d = s.data || {};
|
|
73
|
+
return [
|
|
74
|
+
PageHeader({ title: 'agents', lede: 'live agent activity' }),
|
|
75
|
+
s.error && s.data ? refreshError(s.error) : null,
|
|
76
|
+
Kpi({ items: [[d.count ?? 0, 'active'], [d.turns ?? 0, 'total turns'], [d.last_activity ? fmtAgo(d.last_activity) : '—', 'last activity']] }),
|
|
77
|
+
section('detail', Table({ headers: ['field', 'value'], rows: Object.entries(d).map(([k, v]) => [k, String(v)]) })),
|
|
78
|
+
].filter(Boolean);
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
export const analytics = makePage((ctx) => {
|
|
83
|
+
async function load() {
|
|
84
|
+
try {
|
|
85
|
+
const [sampler, avail] = await Promise.all([
|
|
86
|
+
api('/api/models/sampler').catch(() => null),
|
|
87
|
+
api('/api/models/availability/summary').catch(() => null),
|
|
88
|
+
]);
|
|
89
|
+
ctx.set({ loading: false, sampler, avail, error: null });
|
|
90
|
+
} catch (e) { ctx.set({ loading: false, error: e }); }
|
|
91
|
+
}
|
|
92
|
+
load(); ctx.interval(load, 15000);
|
|
93
|
+
return () => {
|
|
94
|
+
const s = ctx.state;
|
|
95
|
+
if (s.loading) return loadingState('loading analytics…');
|
|
96
|
+
if (s.error && !s.sampler && !s.avail) return errorState(s.error, load);
|
|
97
|
+
const samp = s.sampler?.status ? Object.values(s.sampler.status) : [];
|
|
98
|
+
const ok = samp.filter(x => x && x.available !== false).length;
|
|
99
|
+
const sum = s.avail?.summary || {};
|
|
100
|
+
return [
|
|
101
|
+
PageHeader({ title: 'analytics', lede: 'provider availability & sampler health' }),
|
|
102
|
+
s.error && (s.sampler || s.avail) ? refreshError(s.error) : null,
|
|
103
|
+
Kpi({ items: [[ok + '/' + samp.length, 'providers up'], [sum.total_models ?? '—', 'models'], [sum.usable_in_any_mode ?? '—', 'usable']] }),
|
|
104
|
+
section('sampler', samp.length ? Table({ headers: ['provider', 'available', 'fails'], rows: Object.entries(s.sampler.status).map(([k, v]) => [k, v.available === false ? 'no' : 'yes', String(v.failCount ?? 0)]) }) : emptyState('no sampler data')),
|
|
105
|
+
].filter(Boolean);
|
|
106
|
+
};
|
|
107
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Freddie execution-surface pages: `cron` (scheduled prompt jobs), `tools`
|
|
2
|
+
// (grouped tool catalogue with schema drill-down), and `batch` (parallel
|
|
3
|
+
// prompt runner + result roll-up).
|
|
4
|
+
|
|
5
|
+
import * as webjsx from '../../../vendor/webjsx/index.js';
|
|
6
|
+
import { makePage, api, loadingState, errorState, emptyState } from './runtime.js';
|
|
7
|
+
import { Row, Table, Kpi, PageHeader, SearchInput, TextField } from '../content.js';
|
|
8
|
+
import { Chip, Btn, Icon } from '../shell.js';
|
|
9
|
+
import { section, noteAlert, trunc, truncSpan, TRUNC_SUB, TRUNC_OUTPUT, TRUNC_DESC, TRUNC_PROMPT } from './shared.js';
|
|
10
|
+
|
|
11
|
+
const h = webjsx.createElement;
|
|
12
|
+
|
|
13
|
+
export const cron = makePage((ctx) => {
|
|
14
|
+
Object.assign(ctx.state, { expr: '', prompt: '', busy: false, note: null });
|
|
15
|
+
async function load() { try { ctx.set({ loading: false, list: await api('/api/cron'), error: null }); } catch (e) { ctx.set({ loading: false, error: e }); } }
|
|
16
|
+
async function add() {
|
|
17
|
+
const expr = (ctx.state.expr || '').trim(); const prompt = (ctx.state.prompt || '').trim();
|
|
18
|
+
if (!expr || !prompt) { ctx.set({ note: { kind: 'warn', msg: 'cron expression and prompt required' } }); return; }
|
|
19
|
+
ctx.set({ busy: true, note: null });
|
|
20
|
+
try { await api('/api/cron', { method: 'POST', body: { cron: expr, prompt } }); ctx.state.expr = ''; ctx.state.prompt = ''; await load(); }
|
|
21
|
+
catch (e) { ctx.set({ note: { kind: 'error', msg: String(e.message || e) } }); }
|
|
22
|
+
ctx.set({ busy: false });
|
|
23
|
+
}
|
|
24
|
+
async function del(id) { ctx.set({ busy: true }); try { await api('/api/cron/' + id, { method: 'DELETE' }); await load(); } catch (e) { ctx.set({ note: { kind: 'error', msg: String(e.message || e) } }); } ctx.set({ busy: false }); }
|
|
25
|
+
load();
|
|
26
|
+
return () => {
|
|
27
|
+
const s = ctx.state;
|
|
28
|
+
if (s.loading) return loadingState('loading cron jobs…');
|
|
29
|
+
if (s.error && !s.list) return errorState(s.error, load);
|
|
30
|
+
const list = Array.isArray(s.list) ? s.list : [];
|
|
31
|
+
return [
|
|
32
|
+
PageHeader({ title: 'cron', lede: list.length + ' scheduled jobs' }),
|
|
33
|
+
noteAlert(s.note),
|
|
34
|
+
section('jobs', list.length ? list.map((j, i) => Row({
|
|
35
|
+
key: i, code: j.enabled ? Icon('play') : Icon('pause'), title: j.cron, sub: trunc(j.prompt, TRUNC_SUB).text,
|
|
36
|
+
trailing: Btn({ variant: 'danger', children: 'delete', onClick: () => del(j.id) }),
|
|
37
|
+
})) : emptyState('no cron jobs')),
|
|
38
|
+
section('new job',
|
|
39
|
+
TextField({ label: 'cron expression', value: s.expr, onInput: (v) => { s.expr = v; }, placeholder: '0 9 * * *' }),
|
|
40
|
+
TextField({ label: 'prompt', value: s.prompt, multiline: true, onInput: (v) => { s.prompt = v; }, placeholder: 'what to run…' }),
|
|
41
|
+
Btn({ variant: 'primary', disabled: s.busy, children: s.busy ? 'working…' : 'add job', onClick: add })),
|
|
42
|
+
];
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export const tools = makePage((ctx) => {
|
|
47
|
+
Object.assign(ctx.state, { open: null, q: '' });
|
|
48
|
+
async function load() { try { ctx.set({ loading: false, list: await api('/api/tools'), error: null }); } catch (e) { ctx.set({ loading: false, error: e }); } }
|
|
49
|
+
load();
|
|
50
|
+
return () => {
|
|
51
|
+
const s = ctx.state;
|
|
52
|
+
if (s.loading) return loadingState('loading tools…');
|
|
53
|
+
if (s.error) return errorState(s.error, load);
|
|
54
|
+
let list = Array.isArray(s.list) ? s.list : (s.list?.tools || []);
|
|
55
|
+
if (s.q) list = list.filter(t => (t.name || '').includes(s.q));
|
|
56
|
+
const groups = {};
|
|
57
|
+
for (const t of list) { const g = t.toolset || 'core'; (groups[g] = groups[g] || []).push(t); }
|
|
58
|
+
return [
|
|
59
|
+
PageHeader({ title: 'tools', lede: list.length + ' tools' }),
|
|
60
|
+
SearchInput({ value: s.q, label: 'filter tools', placeholder: 'filter tools…', onInput: (v) => ctx.set({ q: v }) }),
|
|
61
|
+
...Object.entries(groups).map(([g, ts]) => section(g + ' · ' + ts.length, ts.map((t, i) => h('div', { key: i },
|
|
62
|
+
Row({ title: t.name, sub: trunc(t.schema?.description || t.description, TRUNC_DESC).text, onClick: () => ctx.set({ open: ctx.state.open === t.name ? null : t.name }), active: ctx.state.open === t.name }),
|
|
63
|
+
ctx.state.open === t.name ? h('pre', { class: 'fd-pre' }, JSON.stringify(t.schema || t, null, 2)) : null,
|
|
64
|
+
)))),
|
|
65
|
+
list.length ? null : emptyState('no tools match'),
|
|
66
|
+
].filter(Boolean);
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
export const batch = makePage((ctx) => {
|
|
71
|
+
Object.assign(ctx.state, { loading: false, prompts: '', concurrency: 4, busy: false, result: null, note: null });
|
|
72
|
+
async function run() {
|
|
73
|
+
const prompts = (ctx.state.prompts || '').split('\n').map(x => x.trim()).filter(Boolean);
|
|
74
|
+
if (!prompts.length) { ctx.set({ note: { kind: 'warn', msg: 'enter at least one prompt (one per line)' } }); return; }
|
|
75
|
+
ctx.set({ busy: true, note: null, result: null });
|
|
76
|
+
try { const r = await api('/api/batch', { method: 'POST', body: { prompts, concurrency: Number(ctx.state.concurrency) || 4 } }); ctx.set({ result: r }); }
|
|
77
|
+
catch (e) { ctx.set({ note: { kind: 'error', msg: String(e.message || e) } }); }
|
|
78
|
+
ctx.set({ busy: false });
|
|
79
|
+
}
|
|
80
|
+
return () => {
|
|
81
|
+
const s = ctx.state;
|
|
82
|
+
return [
|
|
83
|
+
PageHeader({ title: 'batch', lede: 'parallel prompt runner' }),
|
|
84
|
+
noteAlert(s.note),
|
|
85
|
+
section('prompts',
|
|
86
|
+
TextField({ label: 'prompts (one per line)', value: s.prompts, multiline: true, rows: 6, onInput: (v) => { s.prompts = v; } }),
|
|
87
|
+
TextField({ label: 'concurrency', type: 'number', min: 1, 'aria-label': 'batch concurrency', value: String(s.concurrency), onInput: (v) => { s.concurrency = v; } }),
|
|
88
|
+
Btn({ variant: 'primary', disabled: s.busy, children: s.busy ? 'running…' : 'run batch', onClick: run })),
|
|
89
|
+
s.result ? section('result', (() => {
|
|
90
|
+
const r = s.result;
|
|
91
|
+
const items = Array.isArray(r.results) ? r.results : (Array.isArray(r) ? r : null);
|
|
92
|
+
if (!items) return h('pre', { class: 'fd-pre' }, JSON.stringify(r, null, 2));
|
|
93
|
+
return [
|
|
94
|
+
Kpi({ items: [[items.length, 'prompts'], [items.filter(x => !x.error).length, 'ok'], [items.filter(x => x.error).length, 'errors']] }),
|
|
95
|
+
Table({ headers: ['#', 'prompt', 'status', 'output'], rows: items.map((x, i) => {
|
|
96
|
+
return [String(i + 1), truncSpan(x.prompt || x.input || '', TRUNC_PROMPT), x.error ? Chip({ tone: 'miss', children: 'error' }) : Chip({ tone: 'ok', children: 'ok' }), truncSpan(x.error || x.result || x.content || x.output || '', TRUNC_OUTPUT)];
|
|
97
|
+
}) }),
|
|
98
|
+
];
|
|
99
|
+
})()) : null,
|
|
100
|
+
].filter(Boolean);
|
|
101
|
+
};
|
|
102
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Freddie telemetry pages: `logs` (live WebSocket JSONL tail with
|
|
2
|
+
// subsystem/severity/message filtering and auto-reconnect) and `debug`
|
|
3
|
+
// (per-subsystem snapshot + log drill-down).
|
|
4
|
+
|
|
5
|
+
import * as webjsx from '../../../vendor/webjsx/index.js';
|
|
6
|
+
import { makePage, api, loadingState, errorState, emptyState, refreshError } from './runtime.js';
|
|
7
|
+
import { Row, Table, PageHeader, SearchInput, Select } from '../content.js';
|
|
8
|
+
import { Chip } from '../shell.js';
|
|
9
|
+
import { formatTime } from '../../locale.js';
|
|
10
|
+
import { register as registerDebug, unregister as unregisterDebug } from '../../debug.js';
|
|
11
|
+
import { section, truncSpan, TRUNC_DESC } from './shared.js';
|
|
12
|
+
|
|
13
|
+
const h = webjsx.createElement;
|
|
14
|
+
|
|
15
|
+
const LOG_SEVERITY_TONE = { error: 'error', warning: 'warning', info: 'accent', debug: 'muted' };
|
|
16
|
+
|
|
17
|
+
export const logs = makePage((ctx) => {
|
|
18
|
+
Object.assign(ctx.state, { lines: [], subsystems: [], activeSubsystem: '', activeSeverity: '', q: '', connected: false, wsError: null });
|
|
19
|
+
const MAX_LINES = 500;
|
|
20
|
+
|
|
21
|
+
async function loadSubsystems() {
|
|
22
|
+
try { ctx.set({ subsystems: await api('/api/logs') }); }
|
|
23
|
+
catch (e) { /* swallow: non-fatal, subsystem list is a filter convenience, not required for the stream */ }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let unmounted = false;
|
|
27
|
+
let reconnectTimer = null;
|
|
28
|
+
function connect() {
|
|
29
|
+
if (unmounted) return;
|
|
30
|
+
if (typeof WebSocket === 'undefined') { ctx.set({ wsError: new Error('WebSocket not available in this environment') }); return }
|
|
31
|
+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
32
|
+
const ws = new WebSocket(proto + '//' + location.host + '/api/logs/stream');
|
|
33
|
+
ws.onopen = () => ctx.set({ connected: true, wsError: null });
|
|
34
|
+
ws.onerror = () => ctx.set({ connected: false, wsError: new Error('log stream connection error') });
|
|
35
|
+
ws.onclose = () => { ctx.set({ connected: false }); if (!unmounted) reconnectTimer = setTimeout(connect, 3000); };
|
|
36
|
+
ws.onmessage = (ev) => {
|
|
37
|
+
let rec; try { rec = JSON.parse(ev.data); } catch { return; }
|
|
38
|
+
const next = [rec, ...ctx.state.lines].slice(0, MAX_LINES);
|
|
39
|
+
ctx.set({ lines: next });
|
|
40
|
+
};
|
|
41
|
+
currentWs = ws;
|
|
42
|
+
}
|
|
43
|
+
let currentWs = null;
|
|
44
|
+
|
|
45
|
+
loadSubsystems();
|
|
46
|
+
connect();
|
|
47
|
+
registerDebug('logs', () => ({ connected: ctx.state.connected, lineCount: ctx.state.lines.length, activeSubsystem: ctx.state.activeSubsystem, activeSeverity: ctx.state.activeSeverity }));
|
|
48
|
+
ctx.onCleanup(() => {
|
|
49
|
+
unmounted = true;
|
|
50
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
51
|
+
try { currentWs?.close(); } catch { /* swallow: teardown-only close, socket may already be closed/closing */ }
|
|
52
|
+
unregisterDebug('logs');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
function filtered() {
|
|
56
|
+
const s = ctx.state;
|
|
57
|
+
return s.lines.filter((l) => {
|
|
58
|
+
if (s.activeSubsystem && l.subsystem !== s.activeSubsystem) return false;
|
|
59
|
+
if (s.activeSeverity && l.severity !== s.activeSeverity) return false;
|
|
60
|
+
if (s.q && !String(l.msg || '').toLowerCase().includes(s.q.toLowerCase())) return false;
|
|
61
|
+
return true;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return () => {
|
|
66
|
+
const s = ctx.state;
|
|
67
|
+
const rows = filtered();
|
|
68
|
+
const severities = ['error', 'warning', 'info', 'debug'];
|
|
69
|
+
return [
|
|
70
|
+
PageHeader({
|
|
71
|
+
title: 'logs', lede: 'live JSONL log tail — /api/logs/stream',
|
|
72
|
+
right: s.connected ? Chip({ tone: 'ok', children: 'live' }) : Chip({ tone: 'miss', children: 'reconnecting…' }),
|
|
73
|
+
}),
|
|
74
|
+
s.wsError ? refreshError(s.wsError) : null,
|
|
75
|
+
h('div', { class: 'ds-toolbar' },
|
|
76
|
+
SearchInput({ value: s.q, placeholder: 'filter by message…', onInput: (v) => ctx.set({ q: v }), resultCount: rows.length }),
|
|
77
|
+
Select({
|
|
78
|
+
label: 'subsystem', value: s.activeSubsystem, placeholder: 'all subsystems',
|
|
79
|
+
options: (s.subsystems || []).map((name) => ({ value: name, label: name })),
|
|
80
|
+
onChange: (v) => ctx.set({ activeSubsystem: v }),
|
|
81
|
+
}),
|
|
82
|
+
Select({
|
|
83
|
+
label: 'severity', value: s.activeSeverity, placeholder: 'all severities',
|
|
84
|
+
options: severities.map((sv) => ({ value: sv, label: sv })),
|
|
85
|
+
onChange: (v) => ctx.set({ activeSeverity: v }),
|
|
86
|
+
}),
|
|
87
|
+
),
|
|
88
|
+
rows.length ? section('lines · ' + rows.length,
|
|
89
|
+
Table({
|
|
90
|
+
headers: ['time', 'subsystem', 'severity', 'message'],
|
|
91
|
+
rows: rows.map((l) => [
|
|
92
|
+
formatTime(l.ts ? Date.parse(l.ts) : Date.now()),
|
|
93
|
+
l.subsystem || '—',
|
|
94
|
+
Chip({ tone: LOG_SEVERITY_TONE[l.severity] || 'dim', children: l.severity || 'info' }),
|
|
95
|
+
truncSpan(l.msg, TRUNC_DESC),
|
|
96
|
+
]),
|
|
97
|
+
}),
|
|
98
|
+
) : emptyState(s.connected ? 'no log lines yet — waiting for activity' : 'connecting to log stream…'),
|
|
99
|
+
].filter(Boolean);
|
|
100
|
+
};
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
export const debug = makePage((ctx) => {
|
|
104
|
+
Object.assign(ctx.state, { sub: null, logs: null });
|
|
105
|
+
async function load() { try { ctx.set({ loading: false, data: await api('/api/debug'), error: null }); } catch (e) { ctx.set({ loading: false, error: e }); } }
|
|
106
|
+
async function loadLogs(name) {
|
|
107
|
+
ctx.set({ sub: name });
|
|
108
|
+
try { ctx.set({ logs: await api('/api/logs/' + encodeURIComponent(name)) }); }
|
|
109
|
+
catch (e) { ctx.set({ logs: { error: String(e.message || e) } }); }
|
|
110
|
+
}
|
|
111
|
+
load();
|
|
112
|
+
return () => {
|
|
113
|
+
const s = ctx.state;
|
|
114
|
+
if (s.loading) return loadingState('loading debug snapshots…');
|
|
115
|
+
if (s.error) return errorState(s.error, load);
|
|
116
|
+
const d = s.data || {};
|
|
117
|
+
const subsystems = d.subsystems || Object.keys(d);
|
|
118
|
+
return [
|
|
119
|
+
PageHeader({ title: 'debug', lede: 'subsystem snapshots & logs' }),
|
|
120
|
+
section('subsystems', subsystems.length ? subsystems.map((name, i) => Row({
|
|
121
|
+
key: i, title: name, onClick: () => loadLogs(name), active: s.sub === name,
|
|
122
|
+
})) : emptyState('no debug subsystems')),
|
|
123
|
+
s.sub ? section('logs · ' + s.sub, h('pre', { class: 'fd-pre' }, JSON.stringify(s.logs, null, 2))) : null,
|
|
124
|
+
].filter(Boolean);
|
|
125
|
+
};
|
|
126
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Freddie workspace pages: `sessions` (searchable transcript browser),
|
|
2
|
+
// `projects` (isolated-workspace CRUD + activation), and `git` (status /
|
|
3
|
+
// diff / log / worktree management for the active project's cwd).
|
|
4
|
+
|
|
5
|
+
import * as webjsx from '../../../vendor/webjsx/index.js';
|
|
6
|
+
import { makePage, api, loadingState, errorState, emptyState, refreshError } from './runtime.js';
|
|
7
|
+
import { Row, Table, PageHeader, SearchInput, TextField } from '../content.js';
|
|
8
|
+
import { Chip, Btn } from '../shell.js';
|
|
9
|
+
import { ChatMessage } from '../chat.js';
|
|
10
|
+
import { fmtTime, fmtAgo } from '../sessions.js';
|
|
11
|
+
import { GitStatusPanel, GitDiffView } from '../git-status.js';
|
|
12
|
+
import { WorktreeSwitcher } from '../worktree-switcher.js';
|
|
13
|
+
import { section, noteAlert, refreshBtn, truncSpan, TRUNC_TITLE, TRUNC_SUB } from './shared.js';
|
|
14
|
+
|
|
15
|
+
const h = webjsx.createElement;
|
|
16
|
+
|
|
17
|
+
export const sessions = makePage((ctx) => {
|
|
18
|
+
Object.assign(ctx.state, { q: '', selected: null, messages: [], msgLoading: false });
|
|
19
|
+
async function load() {
|
|
20
|
+
try { ctx.set({ loading: false, list: await api('/api/sessions'), error: null }); }
|
|
21
|
+
catch (e) { ctx.set({ loading: false, error: e }); }
|
|
22
|
+
}
|
|
23
|
+
async function search(q) {
|
|
24
|
+
if (!q) return load();
|
|
25
|
+
try { ctx.set({ loading: false, list: await api('/api/search?q=' + encodeURIComponent(q)), error: null }); }
|
|
26
|
+
catch (e) { ctx.set({ loading: false, error: e }); }
|
|
27
|
+
}
|
|
28
|
+
async function refresh() { ctx.set({ refreshing: true }); try { ctx.set({ list: await api('/api/sessions'), error: null }); } catch (e) { ctx.set({ error: e }); } ctx.set({ refreshing: false }); }
|
|
29
|
+
async function open(id) {
|
|
30
|
+
ctx.set({ selected: id, msgLoading: true });
|
|
31
|
+
try { ctx.set({ messages: await api('/api/sessions/' + encodeURIComponent(id) + '/messages'), msgLoading: false }); }
|
|
32
|
+
catch (e) { ctx.set({ messages: [], msgLoading: false, error: e }); }
|
|
33
|
+
}
|
|
34
|
+
load();
|
|
35
|
+
return () => {
|
|
36
|
+
const s = ctx.state;
|
|
37
|
+
if (s.loading) return loadingState('loading sessions…');
|
|
38
|
+
if (s.error && !s.list) return errorState(s.error, load);
|
|
39
|
+
const list = Array.isArray(s.list) ? s.list : [];
|
|
40
|
+
return [
|
|
41
|
+
PageHeader({ title: 'sessions', lede: list.length + ' sessions', right: refreshBtn(refresh, s.refreshing) }),
|
|
42
|
+
s.error && s.list ? refreshError(s.error) : null,
|
|
43
|
+
SearchInput({ value: s.q, label: 'search sessions', placeholder: 'search messages…', onInput: (v) => { s.q = v; }, onSubmit: (v) => search(v) }),
|
|
44
|
+
section('sessions',
|
|
45
|
+
list.length
|
|
46
|
+
? Table({ headers: ['session', 'platform', 'updated'], onRowClick: (i) => open(list[i].id),
|
|
47
|
+
rowLabels: list.map(x => x.title || x.id),
|
|
48
|
+
rows: list.map(x => [truncSpan(x.title || x.id, TRUNC_TITLE), x.platform || '—', fmtAgo(x.updated_at)]) })
|
|
49
|
+
: emptyState('no sessions match')),
|
|
50
|
+
s.selected ? section('messages · ' + s.selected,
|
|
51
|
+
s.msgLoading ? loadingState('loading messages…')
|
|
52
|
+
: (s.messages || []).length ? (s.messages).map((m, i) => ChatMessage({ role: m.role, text: m.content || m.text || '', time: m.ts ? fmtTime(m.ts) : '', key: i }))
|
|
53
|
+
: emptyState('no messages')) : null,
|
|
54
|
+
].filter(Boolean);
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export const projects = makePage((ctx) => {
|
|
59
|
+
Object.assign(ctx.state, { newName: '', newPath: '', busy: false, note: null });
|
|
60
|
+
async function load() {
|
|
61
|
+
try { ctx.set({ loading: false, data: await api('/api/projects'), error: null }); }
|
|
62
|
+
catch (e) { ctx.set({ loading: false, error: e }); }
|
|
63
|
+
}
|
|
64
|
+
async function create() {
|
|
65
|
+
const name = (ctx.state.newName || '').trim();
|
|
66
|
+
if (!name) { ctx.set({ note: { kind: 'warn', msg: 'name required' } }); return; }
|
|
67
|
+
ctx.set({ busy: true, note: null });
|
|
68
|
+
try { await api('/api/projects', { method: 'POST', body: { name, path: ctx.state.newPath || undefined } }); ctx.state.newName = ''; ctx.state.newPath = ''; await load(); }
|
|
69
|
+
catch (e) { ctx.set({ note: { kind: 'error', msg: String(e.message || e) } }); }
|
|
70
|
+
ctx.set({ busy: false });
|
|
71
|
+
}
|
|
72
|
+
async function activate(name) { ctx.set({ busy: true }); try { await api('/api/projects/active', { method: 'POST', body: { name } }); await load(); } catch (e) { ctx.set({ note: { kind: 'error', msg: String(e.message || e) } }); } ctx.set({ busy: false }); }
|
|
73
|
+
async function del(name) { ctx.set({ busy: true }); try { await api('/api/projects/' + encodeURIComponent(name), { method: 'DELETE' }); await load(); } catch (e) { ctx.set({ note: { kind: 'error', msg: String(e.message || e) } }); } ctx.set({ busy: false }); }
|
|
74
|
+
load();
|
|
75
|
+
return () => {
|
|
76
|
+
const s = ctx.state;
|
|
77
|
+
if (s.loading) return loadingState('loading projects…');
|
|
78
|
+
if (s.error && !s.data) return errorState(s.error, load);
|
|
79
|
+
const d = s.data || {}; const list = d.projects || [];
|
|
80
|
+
const activeName = (d.active && d.active.name) || d.active || 'default';
|
|
81
|
+
return [
|
|
82
|
+
PageHeader({ title: 'projects', lede: 'isolated workspaces · active: ' + activeName }),
|
|
83
|
+
noteAlert(s.note),
|
|
84
|
+
section('projects',
|
|
85
|
+
list.length ? list.map((p, i) => Row({
|
|
86
|
+
key: i, code: h('span', { class: 'ds-dot ' + (p.name === activeName ? 'ds-dot-on' : 'ds-dot-off'), 'aria-hidden': 'true' }), title: p.name, sub: p.path || '',
|
|
87
|
+
active: p.name === activeName,
|
|
88
|
+
trailing: h('span', { class: 'fd-row-actions' },
|
|
89
|
+
p.name !== activeName ? Btn({ children: 'activate', onClick: () => activate(p.name) }) : Chip({ tone: 'ok', children: 'active' }),
|
|
90
|
+
p.name !== 'default' ? Btn({ variant: 'danger', children: 'delete', onClick: () => del(p.name) }) : null),
|
|
91
|
+
})) : emptyState('no projects')),
|
|
92
|
+
section('new project',
|
|
93
|
+
TextField({ label: 'name', value: s.newName, onInput: (v) => { s.newName = v; }, placeholder: 'my-project' }),
|
|
94
|
+
TextField({ label: 'path (optional)', value: s.newPath, onInput: (v) => { s.newPath = v; }, placeholder: 'C:/path/to/dir' }),
|
|
95
|
+
Btn({ variant: 'primary', disabled: s.busy, children: s.busy ? 'working…' : 'create', onClick: create })),
|
|
96
|
+
].filter(Boolean);
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
export const git = makePage((ctx) => {
|
|
101
|
+
Object.assign(ctx.state, { cwd: null, status: null, log: null, worktrees: null, diff: null, activeFile: null, diffLoading: false, note: null });
|
|
102
|
+
async function load() {
|
|
103
|
+
try {
|
|
104
|
+
const proj = await api('/api/projects').catch(() => null);
|
|
105
|
+
const active = proj && proj.active;
|
|
106
|
+
const cwd = (active && typeof active === 'object' ? active.path : null) || ctx.state.cwd || '';
|
|
107
|
+
const qs = '?cwd=' + encodeURIComponent(cwd);
|
|
108
|
+
const [status, log, worktrees] = await Promise.all([
|
|
109
|
+
api('/api/git/status' + qs).catch((e) => ({ _err: e })),
|
|
110
|
+
api('/api/git/log' + qs + '&limit=20').catch((e) => ({ _err: e })),
|
|
111
|
+
api('/api/worktree' + qs).catch((e) => ({ _err: e })),
|
|
112
|
+
]);
|
|
113
|
+
ctx.set({ loading: false, cwd, status, log, worktrees, error: null });
|
|
114
|
+
} catch (e) { ctx.set({ loading: false, error: e }); }
|
|
115
|
+
}
|
|
116
|
+
async function openDiff(file) {
|
|
117
|
+
ctx.set({ activeFile: file.path, diffLoading: true, diff: null });
|
|
118
|
+
try {
|
|
119
|
+
const qs = '?cwd=' + encodeURIComponent(ctx.state.cwd || '') + '&file=' + encodeURIComponent(file.path);
|
|
120
|
+
const res = await api('/api/git/diff' + qs);
|
|
121
|
+
ctx.set({ diff: res, diffLoading: false });
|
|
122
|
+
} catch (e) { ctx.set({ note: { kind: 'error', msg: String(e.message || e) }, diffLoading: false }); }
|
|
123
|
+
}
|
|
124
|
+
async function createWorktree() {
|
|
125
|
+
const path = (ctx.state.newWtPath || '').trim();
|
|
126
|
+
const branch = (ctx.state.newWtBranch || '').trim();
|
|
127
|
+
if (!path) { ctx.set({ note: { kind: 'warn', msg: 'path required' } }); return; }
|
|
128
|
+
ctx.set({ busy: true, note: null });
|
|
129
|
+
try {
|
|
130
|
+
await api('/api/worktree', { method: 'POST', body: { cwd: ctx.state.cwd || '', path, branch: branch || undefined } });
|
|
131
|
+
ctx.state.newWtPath = ''; ctx.state.newWtBranch = '';
|
|
132
|
+
await load();
|
|
133
|
+
} catch (e) { ctx.set({ note: { kind: 'error', msg: String(e.message || e) } }); }
|
|
134
|
+
ctx.set({ busy: false });
|
|
135
|
+
}
|
|
136
|
+
load();
|
|
137
|
+
return () => {
|
|
138
|
+
const s = ctx.state;
|
|
139
|
+
if (s.loading) return loadingState('loading git status…');
|
|
140
|
+
if (s.error && !s.status) return errorState(s.error, load);
|
|
141
|
+
const statusFailed = s.status && s.status._err;
|
|
142
|
+
const logFailed = s.log && s.log._err;
|
|
143
|
+
const wtFailed = s.worktrees && s.worktrees._err;
|
|
144
|
+
const files = statusFailed ? [] : (s.status && s.status.files) || [];
|
|
145
|
+
const commits = logFailed ? [] : (s.log && s.log.commits) || s.log || [];
|
|
146
|
+
const worktrees = wtFailed ? [] : (s.worktrees && s.worktrees.worktrees) || s.worktrees || [];
|
|
147
|
+
const current = Array.isArray(worktrees) ? (worktrees.find(w => w.path === s.cwd) || {}).path : undefined;
|
|
148
|
+
return [
|
|
149
|
+
PageHeader({ title: 'git', lede: s.cwd || 'active project' }),
|
|
150
|
+
noteAlert(s.note),
|
|
151
|
+
statusFailed ? refreshError(statusFailed) : null,
|
|
152
|
+
section('worktrees',
|
|
153
|
+
WorktreeSwitcher({
|
|
154
|
+
worktrees: Array.isArray(worktrees) ? worktrees : [],
|
|
155
|
+
current,
|
|
156
|
+
onSwitch: () => {},
|
|
157
|
+
onCreate: () => ctx.set({ showWtForm: !s.showWtForm }),
|
|
158
|
+
}),
|
|
159
|
+
s.showWtForm ? h('div', { class: 'fd-row-actions' },
|
|
160
|
+
TextField({ label: 'path', value: s.newWtPath, onInput: (v) => { s.newWtPath = v; }, placeholder: '/path/to/worktree' }),
|
|
161
|
+
TextField({ label: 'branch (optional)', value: s.newWtBranch, onInput: (v) => { s.newWtBranch = v; }, placeholder: 'feature/x' }),
|
|
162
|
+
Btn({ variant: 'primary', disabled: s.busy, children: s.busy ? 'working…' : 'create', onClick: createWorktree })) : null),
|
|
163
|
+
section('changed files',
|
|
164
|
+
statusFailed ? errorState(statusFailed) : GitStatusPanel({ files, onFileClick: openDiff, active: s.activeFile })),
|
|
165
|
+
section('diff' + (s.activeFile ? ' · ' + s.activeFile : ''),
|
|
166
|
+
s.diffLoading ? loadingState('loading diff…')
|
|
167
|
+
: s.diff ? GitDiffView({ diff: s.diff.diff || s.diff, filename: s.activeFile })
|
|
168
|
+
: emptyState('select a file to view its diff')),
|
|
169
|
+
section('log',
|
|
170
|
+
logFailed ? errorState(logFailed)
|
|
171
|
+
: commits.length
|
|
172
|
+
? Table({ headers: ['sha', 'message', 'author', 'date'], rows: commits.slice(0, 20).map(c => [String(c.sha || c.hash || '').slice(0, 8), truncSpan(c.message || c.subject, TRUNC_SUB), c.author || '', c.date ? fmtAgo(c.date) : ''])})
|
|
173
|
+
: emptyState('no commits')),
|
|
174
|
+
].filter(Boolean);
|
|
175
|
+
};
|
|
176
|
+
});
|