● Zion Boggan
repos/Zion Pm/app/page.tsx
zionboggan.com ↗
237 lines · tsx
History for this file →
1
'use client';
2
import { useEffect, useRef, useState, useCallback } from 'react';
3
 
4
type Channel = { id: string; name: string; default_model: string; created_at: number };
5
type Message = { id: string; channel_id: string; role: string; author_model: string | null; body: string; parent_id: string | null; created_at: number };
6
type Task = { id: string; title: string; status: string; assigned_model: string | null; channel_id: string | null };
7
type Outbox = { id: string; action_type: string; payload_json: string; status: string; created_at: number };
8
type FleetRow = { model: string; role: string; label: string; available: number; reason: string; last_checked: number };
9
 
10
const MODELS = ['glm', 'claude', 'codex', 'ollama'] as const;
11
const COLOR: Record<string, string> = {
12
  glm: 'bg-violet-500/20 text-violet-300 border-violet-500/40',
13
  claude: 'bg-orange-500/20 text-orange-300 border-orange-500/40',
14
  codex: 'bg-sky-500/20 text-sky-300 border-sky-500/40',
15
  ollama: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40',
16
  user: 'bg-zinc-600/30 text-zinc-300 border-zinc-500/40',
17
};
18
const DOT: Record<string, string> = { glm: 'bg-violet-400', claude: 'bg-orange-400', codex: 'bg-sky-400', ollama: 'bg-emerald-400' };
19
 
20
function Badge({ model }: { model: string }) {
21
  return <span className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide border ${COLOR[model] || COLOR.user}`}>{model}</span>;
22
}
23
 
24
const MENTION_RE = /@(glm|claude|codex|ollama)(?::(\S+))?/gi;
25
function renderBody(body: string) {
26
  const parts: React.ReactNode[] = [];
27
  let last = 0; let m: RegExpExecArray | null; MENTION_RE.lastIndex = 0; let i = 0;
28
  while ((m = MENTION_RE.exec(body)) !== null) {
29
    if (m.index > last) parts.push(<span key={i++}>{body.slice(last, m.index)}</span>);
30
    parts.push(<span key={i++} className={`mx-0.5 rounded px-1 py-0.5 text-xs border ${COLOR[m[1].toLowerCase()]}`}>@{m[1]}{m[2] ? ':' + m[2] : ''}</span>);
31
    last = m.index + m[0].length;
32
  }
33
  parts.push(<span key={i++}>{body.slice(last)}</span>);
34
  return parts;
35
}
36
 
37
export default function Home() {
38
  const [tab, setTab] = useState<'chat' | 'board' | 'fleet'>('chat');
39
  return (
40
    <div className="flex h-screen flex-col bg-zinc-950 text-zinc-100">
41
      <header className="flex items-center gap-4 border-b border-zinc-800 px-4 py-2.5">
42
        <div className="font-bold tracking-tight">⚓ Zion <span className="text-violet-400">PM</span></div>
43
        <nav className="flex gap-1">
44
          {(['chat', 'board', 'fleet'] as const).map((t) => (
45
            <button key={t} onClick={() => setTab(t)} className={`rounded px-3 py-1 text-sm capitalize ${tab === t ? 'bg-zinc-800 text-white' : 'text-zinc-400 hover:text-zinc-200'}`}>{t}</button>
46
          ))}
47
        </nav>
48
        <div className="ml-auto text-xs text-zinc-500">conductor: <span className="text-violet-400">glm</span></div>
49
      </header>
50
      <main className="min-h-0 flex-1">
51
        {tab === 'chat' && <Chat />}
52
        {tab === 'board' && <Board />}
53
        {tab === 'fleet' && <Fleet />}
54
      </main>
55
    </div>
56
  );
57
}
58
 
59
function Chat() {
60
  const [channels, setChannels] = useState<Channel[]>([]);
61
  const [active, setActive] = useState<string>('');
62
  const [messages, setMessages] = useState<Message[]>([]);
63
  const [input, setInput] = useState('');
64
  const [sending, setSending] = useState(false);
65
  const [showModal, setShowModal] = useState(false);
66
  const endRef = useRef<HTMLDivElement>(null);
67
 
68
  const loadChannels = useCallback(async () => {
69
    const r = await fetch('/api/channels').then((x) => x.json());
70
    setChannels(r.channels || []);
71
    if (!active && r.channels?.length) setActive(r.channels.find((c: Channel) => c.name === 'pm')?.id || r.channels[0].id);
72
  }, [active]);
73
 
74
  const loadMessages = useCallback(async (id: string) => {
75
    if (!id) return;
76
    const r = await fetch(`/api/channels/${id}/messages`).then((x) => x.json());
77
    setMessages(r.messages || []);
78
  }, []);
79
 
80
  useEffect(() => { loadChannels(); }, [loadChannels]);
81
  useEffect(() => { if (!active) return; loadMessages(active); const t = setInterval(() => loadMessages(active), 2500); return () => clearInterval(t); }, [active, loadMessages]);
82
  useEffect(() => { endRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]);
83
 
84
  const activeCh = channels.find((c) => c.id === active);
85
 
86
  async function send() {
87
    if (!input.trim() || !active || sending) return;
88
    const text = input; setInput(''); setSending(true);
89
    try { await fetch('/api/messages', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ channelId: active, text }) }); }
90
    finally { setSending(false); loadMessages(active); }
91
  }
92
 
93
  return (
94
    <div className="flex h-full">
95
      <aside className="flex w-56 flex-col border-r border-zinc-800">
96
        <div className="flex items-center justify-between px-3 py-2 text-xs uppercase tracking-wide text-zinc-500">Channels
97
          <button onClick={() => setShowModal(true)} className="rounded bg-zinc-800 px-2 py-0.5 text-zinc-300 hover:bg-zinc-700">+ New</button>
98
        </div>
99
        <div className="flex-1 overflow-y-auto">
100
          {channels.map((c) => (
101
            <button key={c.id} onClick={() => setActive(c.id)} className={`flex w-full items-center justify-between px-3 py-1.5 text-sm ${active === c.id ? 'bg-zinc-800' : 'hover:bg-zinc-900'}`}>
102
              <span className="text-zinc-300">#{c.name}</span><Badge model={c.default_model} />
103
            </button>
104
          ))}
105
        </div>
106
      </aside>
107
      <section className="flex min-w-0 flex-1 flex-col">
108
        <div className="border-b border-zinc-800 px-4 py-2 text-sm text-zinc-300">#{activeCh?.name} {activeCh && <>· default <Badge model={activeCh.default_model} /></>}</div>
109
        <div className="flex-1 space-y-3 overflow-y-auto p-4">
110
          {messages.map((m) => (
111
            <div key={m.id} className="flex flex-col gap-1">
112
              <div className="flex items-center gap-2 text-xs text-zinc-500">{m.role === 'user' ? <span className="font-semibold text-zinc-400">Zion</span> : <Badge model={m.author_model || 'glm'} />}<span>{new Date(m.created_at).toLocaleTimeString()}</span></div>
113
              <div className={`max-w-[80ch] whitespace-pre-wrap rounded-lg border px-3 py-2 text-sm ${m.role === 'user' ? 'border-zinc-700 bg-zinc-900' : 'border-zinc-800 bg-zinc-900/50'}`}>{renderBody(m.body)}</div>
114
            </div>
115
          ))}
116
          {sending && <div className="text-xs text-zinc-500">dispatching…</div>}
117
          <div ref={endRef} />
118
        </div>
119
        <div className="border-t border-zinc-800 p-3">
120
          <div className="mb-1 text-[11px] text-zinc-500">target: {input.match(MENTION_RE)?.map((x) => x).join(' ') || `#${activeCh?.name} default → `}<Badge model={activeCh?.default_model || 'glm'} /> · use @model to override</div>
121
          <div className="flex gap-2">
122
            <textarea value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }} rows={2} placeholder="Message… (@claude, @ollama:mistral-nemo:12b to override)" className="flex-1 resize-none rounded border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm outline-none focus:border-violet-500" />
123
            <button onClick={send} disabled={sending} className="rounded bg-violet-600 px-4 text-sm font-semibold hover:bg-violet-500 disabled:opacity-50">Send</button>
124
          </div>
125
        </div>
126
      </section>
127
      {showModal && <NewChannelModal onClose={() => setShowModal(false)} onCreated={(c) => { setShowModal(false); loadChannels(); setActive(c.id); }} />}
128
    </div>
129
  );
130
}
131
 
132
function NewChannelModal({ onClose, onCreated }: { onClose: () => void; onCreated: (c: Channel) => void }) {
133
  const [name, setName] = useState('');
134
  const [model, setModel] = useState<string>('glm');
135
  const [err, setErr] = useState('');
136
  async function create() {
137
    const r = await fetch('/api/channels', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name, defaultModel: model }) });
138
    const j = await r.json();
139
    if (!r.ok) { setErr(j.error || 'failed'); return; }
140
    onCreated(j.channel);
141
  }
142
  return (
143
    <div className="fixed inset-0 z-10 flex items-center justify-center bg-black/60" onClick={onClose}>
144
      <div className="w-80 rounded-lg border border-zinc-700 bg-zinc-900 p-4" onClick={(e) => e.stopPropagation()}>
145
        <div className="mb-3 font-semibold">New channel</div>
146
        <label className="mb-1 block text-xs text-zinc-400">Name</label>
147
        <input autoFocus value={name} onChange={(e) => setName(e.target.value)} placeholder="research" className="mb-3 w-full rounded border border-zinc-700 bg-zinc-950 px-2 py-1.5 text-sm outline-none focus:border-violet-500" />
148
        <label className="mb-1 block text-xs text-zinc-400">Default model</label>
149
        <select value={model} onChange={(e) => setModel(e.target.value)} className="mb-3 w-full rounded border border-zinc-700 bg-zinc-950 px-2 py-1.5 text-sm outline-none">
150
          {MODELS.map((m) => <option key={m} value={m}>{m}</option>)}
151
        </select>
152
        {err && <div className="mb-2 text-xs text-red-400">{err}</div>}
153
        <div className="flex justify-end gap-2"><button onClick={onClose} className="rounded px-3 py-1 text-sm text-zinc-400 hover:text-zinc-200">Cancel</button><button onClick={create} className="rounded bg-violet-600 px-3 py-1 text-sm font-semibold hover:bg-violet-500">Create</button></div>
154
      </div>
155
    </div>
156
  );
157
}
158
 
159
const COLUMNS = ['todo', 'doing', 'done', 'blocked'] as const;
160
function Board() {
161
  const [tasks, setTasks] = useState<Task[]>([]);
162
  const [title, setTitle] = useState('');
163
  const load = useCallback(async () => { const r = await fetch('/api/tasks').then((x) => x.json()); setTasks(r.tasks || []); }, []);
164
  useEffect(() => { load(); const t = setInterval(load, 4000); return () => clearInterval(t); }, [load]);
165
  async function add() { if (!title.trim()) return; await fetch('/api/tasks', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title }) }); setTitle(''); load(); }
166
  async function move(id: string, status: string) { await fetch(`/api/tasks/${id}`, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ status }) }); load(); }
167
  return (
168
    <div className="flex h-full flex-col p-4">
169
      <div className="mb-3 flex gap-2"><input value={title} onChange={(e) => setTitle(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && add()} placeholder="New task…" className="w-72 rounded border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm outline-none focus:border-violet-500" /><button onClick={add} className="rounded bg-violet-600 px-3 text-sm font-semibold hover:bg-violet-500">Add</button></div>
170
      <div className="grid min-h-0 flex-1 grid-cols-4 gap-3">
171
        {COLUMNS.map((col) => (
172
          <div key={col} onDragOver={(e) => e.preventDefault()} onDrop={(e) => { const id = e.dataTransfer.getData('id'); if (id) move(id, col); }} className="flex flex-col rounded-lg border border-zinc-800 bg-zinc-900/30">
173
            <div className="border-b border-zinc-800 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-zinc-400">{col} <span className="text-zinc-600">{tasks.filter((t) => t.status === col).length}</span></div>
174
            <div className="flex-1 space-y-2 overflow-y-auto p-2">
175
              {tasks.filter((t) => t.status === col).map((t) => (
176
                <div key={t.id} draggable onDragStart={(e) => e.dataTransfer.setData('id', t.id)} className="cursor-grab rounded border border-zinc-700 bg-zinc-900 p-2 text-sm">
177
                  <div>{t.title}</div>
178
                  {t.assigned_model && <div className="mt-1"><Badge model={t.assigned_model} /></div>}
179
                </div>
180
              ))}
181
            </div>
182
          </div>
183
        ))}
184
      </div>
185
    </div>
186
  );
187
}
188
 
189
function Fleet() {
190
  const [fleet, setFleet] = useState<FleetRow[]>([]);
191
  const [ping, setPing] = useState<Record<string, { ok: boolean; latencyMs: number; detail: string } | 'loading'>>({});
192
  const [outbox, setOutbox] = useState<Outbox[]>([]);
193
  const load = useCallback(async () => {
194
    const r = await fetch('/api/fleet').then((x) => x.json()); setFleet(r.fleet || []);
195
    const o = await fetch('/api/outbox').then((x) => x.json()); setOutbox(o.outbox || []);
196
  }, []);
197
  useEffect(() => { load(); const t = setInterval(load, 5000); return () => clearInterval(t); }, [load]);
198
  async function doPing(model: string) {
199
    setPing((p) => ({ ...p, [model]: 'loading' }));
200
    const r = await fetch('/api/fleet/ping', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ model }) }).then((x) => x.json());
201
    setPing((p) => ({ ...p, [model]: r })); load();
202
  }
203
  async function draftStub() { await fetch('/api/outbox', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ actionType: 'publish', payload: { note: 'stub outward action from Fleet' } }) }); load(); }
204
  async function decide(id: string, action: string) { await fetch(`/api/outbox/${id}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action }) }); load(); }
205
  return (
206
    <div className="grid h-full grid-cols-2 gap-4 p-4">
207
      <div>
208
        <div className="mb-2 text-sm font-semibold text-zinc-300">Fleet</div>
209
        <table className="w-full text-sm">
210
          <thead><tr className="text-left text-xs uppercase text-zinc-500"><th className="py-1">Model</th><th>Role</th><th>Status</th><th>Ping</th></tr></thead>
211
          <tbody>
212
            {fleet.map((f) => { const p = ping[f.model]; return (
213
              <tr key={f.model} className="border-t border-zinc-800">
214
                <td className="py-2"><div className="flex items-center gap-2"><span className={`h-2 w-2 rounded-full ${f.available ? DOT[f.model] : 'bg-red-500'}`} /><Badge model={f.model} /></div><div className="mt-0.5 text-[11px] text-zinc-500">{f.label}</div></td>
215
                <td className="text-zinc-400">{f.role}</td>
216
                <td>{f.available ? <span className="text-emerald-400">●&nbsp;ready</span> : <span className="text-red-400">●&nbsp;down</span>}</td>
217
                <td><button onClick={() => doPing(f.model)} className="rounded bg-zinc-800 px-2 py-0.5 text-xs hover:bg-zinc-700">Ping</button>{p === 'loading' ? <span className="ml-2 text-xs text-zinc-500">…</span> : p ? <span className={`ml-2 text-xs ${p.ok ? 'text-emerald-400' : 'text-red-400'}`}>{p.ok ? `${p.latencyMs}ms` : 'fail'}</span> : null}</td>
218
              </tr>); })}
219
          </tbody>
220
        </table>
221
      </div>
222
      <div>
223
        <div className="mb-2 flex items-center justify-between"><span className="text-sm font-semibold text-zinc-300">Approval outbox</span><button onClick={draftStub} className="rounded bg-zinc-800 px-2 py-0.5 text-xs hover:bg-zinc-700">+ Draft stub</button></div>
224
        <div className="space-y-2">
225
          {outbox.length === 0 && <div className="text-xs text-zinc-600">No drafted actions.</div>}
226
          {outbox.map((o) => (
227
            <div key={o.id} className="rounded border border-zinc-800 bg-zinc-900/40 p-2 text-sm">
228
              <div className="flex items-center justify-between"><span className="font-mono text-xs uppercase text-amber-300">{o.action_type}</span><span className={`text-xs ${o.status === 'draft' ? 'text-amber-400' : o.status === 'approved' ? 'text-emerald-400' : 'text-red-400'}`}>{o.status}</span></div>
229
              <pre className="mt-1 overflow-x-auto text-[11px] text-zinc-400">{o.payload_json}</pre>
230
              {o.status === 'draft' && <div className="mt-2 flex gap-2"><button onClick={() => decide(o.id, 'approve')} className="rounded bg-emerald-600/80 px-2 py-0.5 text-xs hover:bg-emerald-500">Approve</button><button onClick={() => decide(o.id, 'reject')} className="rounded bg-red-600/80 px-2 py-0.5 text-xs hover:bg-red-500">Reject</button></div>}
231
            </div>
232
          ))}
233
        </div>
234
      </div>
235
    </div>
236
  );
237
}