@aion0/forge 0.10.84 → 0.10.85

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/app/chat/page.tsx CHANGED
@@ -1,1239 +1,15 @@
1
- /**
2
- * Simplified web chat for Forge — no extension required.
3
- *
4
- * Talks to the chat-standalone server through /api/chat-proxy. Full
5
- * featureset (per-tab connector hints, browser-side scripts, unread
6
- * badges, fork UI) stays in the browser extension; this page is a
7
- * fallback so users can chat from any device with a browser.
8
- *
9
- * Routes used:
10
- * GET /api/chat-proxy/sessions
11
- * GET /api/chat-proxy/sessions/main
12
- * POST /api/chat-proxy/sessions (new temp session)
13
- * GET /api/chat-proxy/sessions/:id (messages for a session)
14
- * DELETE /api/chat-proxy/sessions/:id/messages (clear)
15
- * POST /api/chat-proxy/sessions/:id/messages (send user turn)
16
- * GET /api/chat-proxy/sessions/:id/events (SSE)
17
- * DELETE /api/chat-proxy/sessions/:id (delete — refused for main)
18
- */
19
-
20
1
  'use client';
21
2
 
22
- import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
23
- import MarkdownContent from '@/components/MarkdownContent';
24
- import WatchesPanel from '@/components/WatchesPanel';
25
- import type { ContentBlock, Message, Session } from '@/lib/chat/types';
26
-
27
- const PROXY = '/api/chat-proxy';
28
-
29
- // The global body font is monospace (see globals.css). The chat page
30
- // is a reading surface — override to a UI-friendly system stack and
31
- // let MarkdownContent's <code> elements switch back to mono inline.
32
- const SANS_FONT =
33
- '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif';
34
-
35
- interface MemoryStatus {
36
- backend?: 'temper' | 'local';
37
- pinnedCount?: number;
38
- blocksCount?: number;
39
- hitsCount?: number;
40
- }
41
-
42
- interface ChatSession extends Session {
43
- meta?: { kind?: 'main' | 'temp'; [k: string]: unknown };
44
- }
45
-
46
- // One configured API profile, as the header's quick-switcher lists them.
47
- // Mirrors settings.apiProfiles entries (apiKey is masked by /api/settings —
48
- // we never read it here, just id/name/provider/model to label the option).
49
- interface ProfileOption {
50
- id: string;
51
- name: string;
52
- provider: string;
53
- model: string;
54
- enabled: boolean;
55
- }
3
+ import WebChatPanel from '@/components/WebChatPanel';
56
4
 
5
+ // Thin route wrapper — the actual implementation lives in components/WebChatPanel
6
+ // so it can be embedded in Dashboard's home view alongside session list + pipeline panel.
7
+ // h-screen flex here gives the panel a sized parent (WebChatPanel itself is
8
+ // flex-1 h-full); embedded uses get sizing from Dashboard's flex chain instead.
57
9
  export default function ChatPage() {
58
- const [sessions, setSessions] = useState<ChatSession[]>([]);
59
- // Inline rename state — only one row edits at a time. editingId is the
60
- // session being renamed, editingTitle is the current input value.
61
- const [editingId, setEditingId] = useState<string | null>(null);
62
- const [editingTitle, setEditingTitle] = useState('');
63
- const [activeId, setActiveId] = useState<string>('');
64
- const [messages, setMessages] = useState<Message[]>([]);
65
- // Background-watch progress chips (watch_id → {text, ts}). Ambient,
66
- // updated in place; pruned when stale. Not part of the message thread.
67
- const [watchChips, setWatchChips] = useState<Record<string, { text: string; ts: number }>>({});
68
- // Live pipeline + task runtime (polled from /api/activity/summary). Shows
69
- // what the chat-triggered trigger_pipeline / dispatch_task — or anything
70
- // else — has in flight, without leaving the chat.
71
- const [running, setRunning] = useState<{
72
- pipelines: Array<{ id: string; workflowName: string; currentNode: string | null; progress: { done: number; total: number } }>;
73
- tasks: Array<{ id: string; project: string; prompt_preview: string; status: string }>;
74
- }>({ pipelines: [], tasks: [] });
75
- const [input, setInput] = useState('');
76
- const [streaming, setStreaming] = useState(false);
77
- // True from Stop-click → next iteration boundary on the backend. Drives
78
- // an immediate banner so Stop feels responsive when a tool call is mid-flight.
79
- const [stopRequested, setStopRequested] = useState(false);
80
- const [partial, setPartial] = useState('');
81
- const [memory, setMemory] = useState<MemoryStatus | null>(null);
82
- const [memoryOpen, setMemoryOpen] = useState(false);
83
- const [error, setError] = useState('');
84
- // Configured API profiles + the header quick-switch dropdown's open state.
85
- const [profiles, setProfiles] = useState<ProfileOption[]>([]);
86
- const [switchOpen, setSwitchOpen] = useState(false);
87
- // The model the backend ACTUALLY served on the last turn (from the
88
- // provider response, not the model's self-description). Reset on session
89
- // switch so it never shows a stale value from a different conversation.
90
- const [servedModel, setServedModel] = useState('');
91
-
92
- const eventSrcRef = useRef<EventSource | null>(null);
93
- const scrollRef = useRef<HTMLDivElement>(null);
94
- const composerRef = useRef<HTMLTextAreaElement>(null);
95
-
96
- // ─── Load sessions ────────────────────────────────────────
97
- const refreshSessions = useCallback(async () => {
98
- try {
99
- const [listResp, mainResp] = await Promise.all([
100
- fetch(`${PROXY}/sessions?limit=200`),
101
- fetch(`${PROXY}/sessions/main`),
102
- ]);
103
- const listJson = (await listResp.json()) as { sessions: ChatSession[] };
104
- const list = listJson.sessions || [];
105
- const mainJson = (await mainResp.json()) as { session: ChatSession };
106
- const mainId = mainJson?.session?.id;
107
- const ordered = list.slice().sort((a, b) => {
108
- if (a.id === mainId) return -1;
109
- if (b.id === mainId) return 1;
110
- return (b.updated_at || 0) - (a.updated_at || 0);
111
- });
112
- setSessions(ordered);
113
- if (!activeId && mainId) setActiveId(mainId);
114
- } catch (e) {
115
- setError(e instanceof Error ? e.message : String(e));
116
- }
117
- }, [activeId]);
118
-
119
- useEffect(() => {
120
- refreshSessions();
121
- }, [refreshSessions]);
122
-
123
- // ─── Load messages on session change ──────────────────────
124
- const loadMessages = useCallback(async (id: string) => {
125
- if (!id) return;
126
- try {
127
- const r = await fetch(`${PROXY}/sessions/${id}?limit=1000`);
128
- const j = (await r.json()) as { messages?: Message[] };
129
- setMessages(j.messages || []);
130
- } catch (e) {
131
- setError(e instanceof Error ? e.message : String(e));
132
- }
133
- }, []);
134
-
135
- useEffect(() => {
136
- setServedModel(''); // stale across sessions — cleared until next turn reports
137
- if (activeId) loadMessages(activeId);
138
- }, [activeId, loadMessages]);
139
-
140
- // ─── Load configured API profiles (for the header quick-switcher) ──
141
- // Served by next-server's /api/settings (same origin) with apiKeys masked.
142
- useEffect(() => {
143
- (async () => {
144
- try {
145
- const r = await fetch('/api/settings');
146
- if (!r.ok) return;
147
- const s = (await r.json()) as { apiProfiles?: Record<string, { name?: string; enabled?: boolean; provider?: string; model?: string }> };
148
- const list: ProfileOption[] = Object.entries(s.apiProfiles || {})
149
- .filter(([, p]) => p && p.enabled !== false)
150
- .map(([id, p]) => ({
151
- id,
152
- name: p.name || id,
153
- provider: p.provider || 'anthropic',
154
- model: p.model || 'default',
155
- enabled: p.enabled !== false,
156
- }));
157
- setProfiles(list);
158
- } catch { /* non-fatal — header just falls back to plain text */ }
159
- })();
160
- }, []);
161
-
162
- // ─── SSE subscription ─────────────────────────────────────
163
- useEffect(() => {
164
- if (!activeId) return;
165
- eventSrcRef.current?.close();
166
- const src = new EventSource(`${PROXY}/sessions/${activeId}/events`);
167
- eventSrcRef.current = src;
168
-
169
- src.onmessage = (ev) => {
170
- let payload: { type?: string; data?: any; message_id?: string };
171
- try { payload = JSON.parse(ev.data); } catch { return; }
172
- const type = payload.type;
173
- const data = payload.data || {};
174
- if (type === 'text_delta') {
175
- setPartial((p) => p + (data.delta || ''));
176
- } else if (type === 'message_saved') {
177
- loadMessages(activeId);
178
- setPartial('');
179
- } else if (type === 'memory_status') {
180
- setMemory({
181
- backend: data.backend,
182
- pinnedCount: data.pinnedCount,
183
- blocksCount: data.blocksCount,
184
- hitsCount: data.hitsCount,
185
- });
186
- } else if (type === 'turn_done') {
187
- setStreaming(false);
188
- setStopRequested(false);
189
- setPartial('');
190
- if (data.served_model) setServedModel(String(data.served_model));
191
- loadMessages(activeId);
192
- refreshSessions();
193
- } else if (type === 'watch_status') {
194
- // Ambient background-watch progress — a chip that updates in
195
- // place, NOT a chat message (doesn't touch the thread / LLM).
196
- // A terminal status (done/non-active) drops the chip at once.
197
- const wid = String(data.watch_id || '');
198
- if (wid) {
199
- const terminal = !!data.done || (data.state && data.state !== 'active');
200
- setWatchChips((c) => {
201
- if (terminal) { const n = { ...c }; delete n[wid]; return n; }
202
- return { ...c, [wid]: { text: String(data.text || ''), ts: Date.now() } };
203
- });
204
- }
205
- } else if (type === 'error') {
206
- setStreaming(false);
207
- setStopRequested(false);
208
- setError(String(data.error || 'unknown error'));
209
- }
210
- };
211
- return () => {
212
- src.close();
213
- };
214
- }, [activeId, loadMessages, refreshSessions]);
215
-
216
- // ─── Auto-scroll on new content — only when already at the bottom ──
217
- // stickRef tracks whether the user is parked near the bottom. If they've
218
- // scrolled up to read earlier turns, a refresh / new streamed chunk must
219
- // NOT yank them back down. Updated on every scroll (see onScroll below).
220
- const stickRef = useRef(true);
221
- useEffect(() => {
222
- const el = scrollRef.current;
223
- if (el && stickRef.current) el.scrollTop = el.scrollHeight;
224
- }, [messages, partial]);
225
-
226
- // ─── Jump markers: one tick per user turn along the scroll track ──
227
- const [markers, setMarkers] = useState<{ mid: string; pct: number; offset: number; label: string }[]>([]);
228
- const recomputeMarkers = useCallback(() => {
229
- const el = scrollRef.current;
230
- if (!el) return;
231
- const h = el.scrollHeight || 1;
232
- const nodes = el.querySelectorAll<HTMLElement>('[data-role="user"][data-mid]');
233
- setMarkers([...nodes].map((n) => ({
234
- mid: n.dataset.mid || '',
235
- offset: n.offsetTop,
236
- pct: (n.offsetTop / h) * 100,
237
- label: (n.dataset.label || '').slice(0, 80),
238
- })));
239
- }, []);
240
- useEffect(() => {
241
- const id = requestAnimationFrame(recomputeMarkers);
242
- return () => cancelAnimationFrame(id);
243
- }, [messages, partial, recomputeMarkers]);
244
- useEffect(() => {
245
- const el = scrollRef.current;
246
- if (!el || typeof ResizeObserver === 'undefined') return;
247
- const ro = new ResizeObserver(() => recomputeMarkers());
248
- ro.observe(el);
249
- return () => ro.disconnect();
250
- }, [recomputeMarkers]);
251
-
252
- // ─── Prune stale watch chips (no update in >150s = done/gone) ──
253
- useEffect(() => {
254
- const t = setInterval(() => {
255
- setWatchChips((c) => {
256
- const now = Date.now();
257
- const next: typeof c = {};
258
- let changed = false;
259
- for (const [k, v] of Object.entries(c)) {
260
- if (now - v.ts < 150_000) next[k] = v; else changed = true;
261
- }
262
- return changed ? next : c;
263
- });
264
- }, 30_000);
265
- return () => clearInterval(t);
266
- }, []);
267
-
268
- // ─── Poll live pipeline + task runtime ────────────────────
269
- useEffect(() => {
270
- let alive = true;
271
- const tick = async () => {
272
- try {
273
- const r = await fetch('/api/activity/summary');
274
- if (!r.ok) return;
275
- const j = await r.json();
276
- if (!alive) return;
277
- setRunning({
278
- pipelines: (j.running || []).map((p: any) => ({
279
- id: p.id, workflowName: p.workflowName, currentNode: p.currentNode,
280
- progress: p.progress || { done: 0, total: 0 },
281
- })),
282
- tasks: (j.running_tasks || []).map((t: any) => ({
283
- id: t.id, project: t.project, prompt_preview: t.prompt_preview, status: t.status,
284
- })),
285
- });
286
- } catch { /* keep last */ }
287
- };
288
- tick();
289
- const iv = setInterval(tick, 5000);
290
- return () => { alive = false; clearInterval(iv); };
291
- }, []);
292
-
293
- // ─── Auto-resize composer ─────────────────────────────────
294
- useEffect(() => {
295
- const el = composerRef.current;
296
- if (!el) return;
297
- el.style.height = 'auto';
298
- el.style.height = Math.min(el.scrollHeight, 200) + 'px';
299
- }, [input]);
300
-
301
- // ─── Actions ──────────────────────────────────────────────
302
- async function send(textArg?: string) {
303
- const text = (textArg ?? input).trim();
304
- if (!text || !activeId || streaming) return;
305
- if (textArg === undefined) setInput('');
306
- setStreaming(true);
307
- setError('');
308
- setPartial('');
309
- try {
310
- const r = await fetch(`${PROXY}/sessions/${activeId}/messages`, {
311
- method: 'POST',
312
- headers: { 'content-type': 'application/json' },
313
- body: JSON.stringify({ text }),
314
- });
315
- if (!r.ok) {
316
- const j = await r.json().catch(() => ({}));
317
- throw new Error(j.error || `HTTP ${r.status}`);
318
- }
319
- } catch (e) {
320
- setStreaming(false);
321
- setError(e instanceof Error ? e.message : String(e));
322
- }
323
- }
324
-
325
- // Stop the in-flight tool-call loop. The backend breaks at the next
326
- // iteration boundary and persists a "⏹ Stopped" message; the turn_done
327
- // SSE event flips `streaming` off here.
328
- async function stop() {
329
- if (!activeId) return;
330
- // Flip the banner instantly so the user sees feedback. The backend
331
- // returns immediately but the loop only checks the flag between
332
- // iterations, so the actual "⏹ Stopped" sentinel can lag a long-
333
- // running tool call by tens of seconds. turn_done clears this.
334
- setStopRequested(true);
335
- try {
336
- await fetch(`${PROXY}/sessions/${activeId}/abort`, { method: 'POST' });
337
- } catch (e) {
338
- setError(e instanceof Error ? e.message : String(e));
339
- }
340
- }
341
-
342
- // Inject supplementary info into the RUNNING turn — the agent picks it
343
- // up on its next iteration. If the turn just ended (409), fall back to
344
- // sending it as a normal new message so the text is never lost.
345
- async function addNote() {
346
- const text = input.trim();
347
- if (!text || !activeId) return;
348
- setInput('');
349
- // Optimistic display: the server only persists the note on the next
350
- // iteration (after the current tool call finishes), which can take
351
- // tens of seconds. Show the user's message in the thread NOW so
352
- // sending feels responsive — turn_done reloads from DB and the real
353
- // persisted version replaces this entry.
354
- const optimisticId = `optimistic-note-${Date.now()}`;
355
- setMessages((ms) => [...ms, {
356
- id: optimisticId,
357
- session_id: activeId,
358
- role: 'user',
359
- blocks: [{ type: 'text', text } as ContentBlock],
360
- ts: Date.now(),
361
- } as Message]);
362
- try {
363
- const r = await fetch(`${PROXY}/sessions/${activeId}/note`, {
364
- method: 'POST',
365
- headers: { 'content-type': 'application/json' },
366
- body: JSON.stringify({ text }),
367
- });
368
- if (r.ok) return; // the loop surfaces the note as a user message
369
- if (r.status === 409) { await send(text); return; } // turn ended → normal send
370
- const j = await r.json().catch(() => ({}));
371
- throw new Error(j.error || `HTTP ${r.status}`);
372
- } catch (e) {
373
- // Roll back the optimistic on failure
374
- setMessages((ms) => ms.filter((m) => m.id !== optimisticId));
375
- setError(e instanceof Error ? e.message : String(e));
376
- }
377
- }
378
-
379
- async function newSession() {
380
- // Optional title via prompt — leave blank for the default
381
- // "Temp · <id>" label. The user can also rename later by clicking
382
- // the pencil icon on the sidebar row (or double-clicking the row).
383
- const raw = window.prompt('Name this conversation (leave blank for default):', '');
384
- if (raw === null) return; // user hit Cancel
385
- const title = raw.trim() || undefined;
386
- try {
387
- const r = await fetch(`${PROXY}/sessions`, {
388
- method: 'POST',
389
- headers: { 'content-type': 'application/json' },
390
- body: JSON.stringify({ meta: { kind: 'temp' }, ...(title ? { title } : {}) }),
391
- });
392
- const j = (await r.json()) as { session?: ChatSession };
393
- if (j?.session?.id) {
394
- await refreshSessions();
395
- setActiveId(j.session.id);
396
- }
397
- } catch (e) {
398
- setError(e instanceof Error ? e.message : String(e));
399
- }
400
- }
401
-
402
- // PATCH /sessions/:id with a new title. Used by both the sidebar's
403
- // pencil-icon inline editor and the double-click handler. Empty input
404
- // is a no-op (cancel) — the backend's updateSession uses ?? semantics
405
- // so passing null doesn't clear, and the "Main conversation" / "Temp
406
- // · xxx" defaults are computed from null title at render time anyway.
407
- async function renameSession(id: string, title: string) {
408
- const clean = title.trim();
409
- if (!clean) return;
410
- try {
411
- const r = await fetch(`${PROXY}/sessions/${id}`, {
412
- method: 'PATCH',
413
- headers: { 'content-type': 'application/json' },
414
- body: JSON.stringify({ title: clean }),
415
- });
416
- if (!r.ok) {
417
- const j = await r.json().catch(() => ({}));
418
- setError(j.error || `rename failed (HTTP ${r.status})`);
419
- return;
420
- }
421
- await refreshSessions();
422
- } catch (e) {
423
- setError(e instanceof Error ? e.message : String(e));
424
- }
425
- }
426
-
427
- // Switch the active session's API profile. session.provider holds the
428
- // apiProfile id; session.model the (optional) override. We set both to the
429
- // chosen profile's id + its configured model so the next turn uses it —
430
- // updateSession merges with ?? semantics, so passing the model explicitly
431
- // avoids carrying a stale model from the previous profile.
432
- async function switchProfile(p: ProfileOption) {
433
- setSwitchOpen(false);
434
- if (!activeId) return;
435
- if (activeSession?.provider === p.id) return; // already on it
436
- try {
437
- const r = await fetch(`${PROXY}/sessions/${activeId}`, {
438
- method: 'PATCH',
439
- headers: { 'content-type': 'application/json' },
440
- body: JSON.stringify({ provider: p.id, model: p.model }),
441
- });
442
- if (!r.ok) {
443
- const j = await r.json().catch(() => ({}));
444
- setError(j.error || `switch failed (HTTP ${r.status})`);
445
- return;
446
- }
447
- await refreshSessions();
448
- } catch (e) {
449
- setError(e instanceof Error ? e.message : String(e));
450
- }
451
- }
452
-
453
- async function clearMessages() {
454
- if (!activeId) return;
455
- if (!confirm('Clear all messages in this session?')) return;
456
- await fetch(`${PROXY}/sessions/${activeId}/messages`, { method: 'DELETE' });
457
- setMessages([]);
458
- setPartial('');
459
- }
460
-
461
- async function deleteSession(id: string) {
462
- if (!confirm('Delete this session permanently?')) return;
463
- const r = await fetch(`${PROXY}/sessions/${id}`, { method: 'DELETE' });
464
- if (!r.ok) {
465
- const j = await r.json().catch(() => ({}));
466
- alert(j.error || 'Delete failed (main session cannot be deleted)');
467
- return;
468
- }
469
- if (id === activeId) setActiveId('');
470
- await refreshSessions();
471
- }
472
-
473
- // ─── Render ───────────────────────────────────────────────
474
- const activeSession = useMemo(
475
- () => sessions.find((s) => s.id === activeId),
476
- [sessions, activeId],
477
- );
478
-
479
10
  return (
480
- <div
481
- className="flex h-screen text-[var(--text-primary)] bg-[var(--bg-primary)]"
482
- style={{ fontFamily: SANS_FONT }}
483
- >
484
- {/* ─── Sidebar ─────────────────────────────────────── */}
485
- <aside className="w-64 shrink-0 border-r border-[var(--border)] flex flex-col bg-[var(--bg-secondary)]/40">
486
- <div className="px-4 py-3 border-b border-[var(--border)] flex items-center justify-between">
487
- <a
488
- href="/"
489
- className="text-sm font-semibold text-[var(--text-primary)] hover:text-[var(--accent)] transition-colors"
490
- title="Back to Dashboard"
491
- >
492
- ← Forge
493
- </a>
494
- <button
495
- onClick={newSession}
496
- className="text-xs px-2.5 py-1 bg-[var(--accent)] text-white rounded-md hover:opacity-90 transition-opacity"
497
- >
498
- + New
499
- </button>
500
- </div>
501
-
502
- <div className="flex-1 overflow-y-auto py-2 px-2">
503
- {sessions.length === 0 && (
504
- <div className="text-xs text-[var(--text-secondary)] italic px-2 py-3">No sessions yet.</div>
505
- )}
506
- {sessions.map((s) => {
507
- const isMain = s.meta?.kind === 'main';
508
- const isActive = s.id === activeId;
509
- const isEditing = editingId === s.id;
510
- const displayTitle = s.title || (isMain ? 'Main conversation' : `Temp · ${s.id.slice(0, 6)}`);
511
- const beginEdit = () => {
512
- setEditingId(s.id);
513
- setEditingTitle(s.title || '');
514
- };
515
- const commitEdit = async () => {
516
- const next = editingTitle.trim();
517
- setEditingId(null);
518
- if (next && next !== (s.title || '')) {
519
- await renameSession(s.id, next);
520
- }
521
- };
522
- const cancelEdit = () => {
523
- setEditingId(null);
524
- setEditingTitle('');
525
- };
526
- return (
527
- <div
528
- key={s.id}
529
- className={`group flex items-center gap-2 px-3 py-2 rounded-md text-sm mb-1 transition-colors ${
530
- isActive
531
- ? 'bg-[var(--accent)]/15 text-[var(--text-primary)]'
532
- : 'text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-primary)]'
533
- } ${isEditing ? '' : 'cursor-pointer'}`}
534
- onClick={() => { if (!isEditing) setActiveId(s.id); }}
535
- onDoubleClick={(e) => { e.stopPropagation(); if (!isEditing) beginEdit(); }}
536
- >
537
- <span
538
- className={`inline-block w-2 h-2 rounded-full shrink-0 ${
539
- isMain ? 'bg-[var(--accent)]' : 'bg-[var(--text-secondary)]/40'
540
- }`}
541
- />
542
- {isEditing ? (
543
- <input
544
- autoFocus
545
- type="text"
546
- value={editingTitle}
547
- onChange={(e) => setEditingTitle(e.target.value)}
548
- onClick={(e) => e.stopPropagation()}
549
- onKeyDown={(e) => {
550
- if (e.key === 'Enter') { e.preventDefault(); commitEdit(); }
551
- else if (e.key === 'Escape') { e.preventDefault(); cancelEdit(); }
552
- }}
553
- onBlur={commitEdit}
554
- placeholder={displayTitle}
555
- className="flex-1 min-w-0 bg-[var(--bg-primary)] border border-[var(--accent)] rounded px-1.5 py-0.5 text-sm text-[var(--text-primary)] focus:outline-none"
556
- />
557
- ) : (
558
- <>
559
- <div className="truncate flex-1" title="Double-click to rename">
560
- {displayTitle}
561
- </div>
562
- <button
563
- onClick={(e) => { e.stopPropagation(); beginEdit(); }}
564
- className="opacity-0 group-hover:opacity-100 text-[var(--text-secondary)] hover:text-[var(--accent)] text-xs leading-none px-1"
565
- title="Rename"
566
- >
567
-
568
- </button>
569
- {!isMain && (
570
- <button
571
- onClick={(e) => { e.stopPropagation(); deleteSession(s.id); }}
572
- className="opacity-0 group-hover:opacity-100 text-[var(--text-secondary)] hover:text-red-400 text-base leading-none px-1"
573
- title="Delete session"
574
- >
575
- ×
576
- </button>
577
- )}
578
- </>
579
- )}
580
- </div>
581
- );
582
- })}
583
- </div>
584
-
585
- <WatchesPanel />
586
-
587
- <div className="px-4 py-3 border-t border-[var(--border)] text-xs text-[var(--text-secondary)] space-y-1">
588
- <button
589
- type="button"
590
- onClick={() => setMemoryOpen(true)}
591
- className="flex items-center gap-2 w-full text-left hover:text-[var(--text-primary)] transition-colors"
592
- >
593
- <span>Memory</span>
594
- <span
595
- className={`px-1.5 py-[1px] rounded text-[10px] uppercase tracking-wide border ${
596
- memory?.backend === 'temper'
597
- ? 'border-green-500/60 text-green-400'
598
- : memory?.backend === 'local'
599
- ? 'border-[var(--accent)] text-[var(--accent)]'
600
- : 'border-[var(--border)]'
601
- }`}
602
- >
603
- {memory?.backend ?? '…'}
604
- </span>
605
- <span className="ml-auto text-[10px] opacity-60">view →</span>
606
- </button>
607
- {memory && (
608
- <div className="text-[11px]">
609
- {memory.pinnedCount ?? 0} pinned · {memory.blocksCount ?? 0} blocks
610
- </div>
611
- )}
612
- <div className="text-[10px] italic pt-1 leading-snug">
613
- Simplified web chat — full UX lives in the browser extension.
614
- </div>
615
- </div>
616
- </aside>
617
-
618
- {memoryOpen && <MemoryDrawer onClose={() => setMemoryOpen(false)} />}
619
-
620
- {/* ─── Main pane ───────────────────────────────────── */}
621
- <main className="flex-1 flex flex-col min-w-0">
622
- <header className="border-b border-[var(--border)] px-6 py-3 flex items-center justify-between">
623
- <div className="min-w-0">
624
- <div className="text-sm font-medium truncate">
625
- {activeSession?.title ||
626
- (activeSession?.meta?.kind === 'main' ? 'Main conversation' : activeSession?.id) ||
627
- 'No session'}
628
- </div>
629
- {activeSession && (
630
- <div className="relative inline-block">
631
- <button
632
- type="button"
633
- onClick={() => setSwitchOpen((v) => !v)}
634
- disabled={profiles.length === 0}
635
- title={profiles.length ? 'Switch API profile for this conversation' : 'No API profiles configured'}
636
- className="flex items-center gap-1 text-[11px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:cursor-default disabled:hover:text-[var(--text-secondary)] transition-colors"
637
- >
638
- <span>
639
- {(() => {
640
- const cur = profiles.find((p) => p.id === activeSession.provider);
641
- const label = cur?.name || activeSession.provider || 'auto';
642
- return `${label} · ${activeSession.model || cur?.model || 'default'}`;
643
- })()}
644
- </span>
645
- {profiles.length > 0 && <span className="opacity-50 text-[9px]">▼</span>}
646
- </button>
647
- {switchOpen && profiles.length > 0 && (
648
- <>
649
- {/* click-away backdrop */}
650
- <div className="fixed inset-0 z-10" onClick={() => setSwitchOpen(false)} />
651
- <div className="absolute left-0 top-full mt-1 z-20 min-w-[200px] max-h-[60vh] overflow-y-auto rounded-md border border-[var(--border)] bg-[var(--bg-secondary)] shadow-lg py-1">
652
- {profiles.map((p) => {
653
- const active = p.id === activeSession.provider;
654
- return (
655
- <button
656
- key={p.id}
657
- type="button"
658
- onClick={() => switchProfile(p)}
659
- className={`w-full text-left px-3 py-1.5 text-[11px] hover:bg-[var(--bg-tertiary)] transition-colors ${
660
- active ? 'text-[var(--accent)]' : 'text-[var(--text-primary)]'
661
- }`}
662
- >
663
- <div className="flex items-center gap-1.5">
664
- {active && <span className="text-[9px]">✓</span>}
665
- <span className="font-medium truncate">{p.name}</span>
666
- </div>
667
- <div className="text-[10px] text-[var(--text-secondary)] truncate pl-0.5">
668
- {p.provider} · {p.model}
669
- </div>
670
- </button>
671
- );
672
- })}
673
- </div>
674
- </>
675
- )}
676
- </div>
677
- )}
678
- {servedModel && (() => {
679
- // The honest backend identity. Tint amber when it differs from
680
- // the configured model — that means the proxy (litellm) fell
681
- // back server-side (e.g. qwen → claude), which is the usual
682
- // cause of "why is everything claude".
683
- const cur = profiles.find((p) => p.id === activeSession?.provider);
684
- const requested = activeSession?.model || cur?.model || '';
685
- const mismatch = requested && servedModel && servedModel !== requested;
686
- return (
687
- <div
688
- className="text-[10px] mt-0.5"
689
- style={{ color: mismatch ? '#fbbf24' : 'var(--text-secondary)' }}
690
- title={mismatch
691
- ? `Backend actually served "${servedModel}" — differs from the requested "${requested}". The proxy fell back server-side.`
692
- : `Backend-reported model for the last reply (trustworthy — not the model's self-description)`}
693
- >
694
- served: {servedModel}{mismatch ? ` ⚠ (≠ ${requested})` : ''}
695
- </div>
696
- );
697
- })()}
698
- </div>
699
- <button
700
- onClick={clearMessages}
701
- disabled={!activeId}
702
- className="text-xs px-3 py-1 border border-[var(--border)] text-[var(--text-secondary)] rounded-md hover:border-red-500/60 hover:text-red-400 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
703
- >
704
- Clear
705
- </button>
706
- </header>
707
-
708
- <div className="flex-1 relative min-h-0">
709
- <div
710
- ref={scrollRef}
711
- className="absolute inset-0 overflow-y-auto"
712
- onScroll={(e) => {
713
- const el = e.currentTarget;
714
- stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
715
- }}
716
- >
717
- <div className="max-w-3xl mx-auto px-6 py-6 space-y-6">
718
- {messages.length === 0 && !partial && !streaming && (
719
- <div className="text-center text-sm text-[var(--text-secondary)] mt-12">
720
- <div className="text-base mb-1">Start a conversation</div>
721
- <div className="text-xs">Type a message below. Markdown supported.</div>
722
- </div>
723
- )}
724
- {messages.map((m) => (
725
- <MessageView key={m.id} m={m} />
726
- ))}
727
- {partial && (
728
- <RoleBlock role="assistant">
729
- <MarkdownContent content={partial} linkify />
730
- <span className="inline-block w-2 h-3 ml-0.5 align-text-bottom bg-[var(--accent)] animate-pulse" />
731
- </RoleBlock>
732
- )}
733
- {streaming && !partial && (
734
- <RoleBlock role="assistant">
735
- <div className="text-sm text-[var(--text-secondary)] italic">thinking…</div>
736
- </RoleBlock>
737
- )}
738
- {error && (
739
- <div className="text-sm text-red-400 border border-red-500/30 bg-red-500/5 rounded-md p-3">
740
- {error}
741
- </div>
742
- )}
743
- </div>
744
- </div>
745
- {/* Jump rail — one tick per user turn, mapped to its position in the
746
- scroll content. Click to jump. Hidden until there are ≥2 turns. */}
747
- {markers.length >= 2 && (
748
- <div className="absolute right-0.5 top-2 bottom-2 w-3 z-10 pointer-events-none">
749
- {markers.map((mk) => (
750
- <div
751
- key={mk.mid}
752
- className="group pointer-events-auto absolute right-0 -translate-y-1/2"
753
- style={{ top: `${Math.min(99, Math.max(1, mk.pct))}%` }}
754
- >
755
- <button
756
- onClick={() => {
757
- const el = scrollRef.current;
758
- if (el) el.scrollTo({ top: Math.max(0, mk.offset - 12), behavior: 'smooth' });
759
- }}
760
- className="block h-1.5 w-1.5 rounded-full bg-[var(--text-secondary)] opacity-40 group-hover:opacity-100 group-hover:w-2.5 group-hover:bg-[var(--accent)] transition-all"
761
- />
762
- {/* Instant hover preview (native title lags ~1s). Sits to the
763
- left of the rail; truncates to one ellipsised line. */}
764
- {mk.label && (
765
- <div className="pointer-events-none absolute right-4 top-1/2 -translate-y-1/2 hidden group-hover:block z-20 max-w-[280px] truncate rounded-md border border-[var(--border)] bg-[var(--bg-secondary)] px-2 py-1 text-xs text-[var(--text-primary)] shadow-lg">
766
- {mk.label}
767
- </div>
768
- )}
769
- </div>
770
- ))}
771
- </div>
772
- )}
773
- </div>
774
-
775
- {(Object.keys(watchChips).length > 0 || running.pipelines.length > 0 || running.tasks.length > 0) && (
776
- <div className="px-6 pt-2">
777
- <div className="max-w-3xl mx-auto flex flex-wrap gap-2">
778
- {Object.entries(watchChips).map(([id, w]) => (
779
- <span
780
- key={id}
781
- title={id}
782
- className="inline-flex items-center gap-1.5 text-xs rounded-full border border-[var(--border)] bg-[var(--bg-secondary)] px-3 py-1 text-[var(--text-secondary)]"
783
- >
784
- <span className="inline-block h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" />
785
- {w.text}
786
- </span>
787
- ))}
788
- {running.pipelines.map((p) => (
789
- <span
790
- key={p.id}
791
- title={`pipeline ${p.id}${p.currentNode ? ` — ${p.currentNode}` : ''}`}
792
- className="inline-flex items-center gap-1.5 text-xs rounded-full border border-[var(--border)] bg-[var(--bg-secondary)] px-3 py-1 text-[var(--text-secondary)]"
793
- >
794
- <span className="inline-block h-1.5 w-1.5 rounded-full bg-sky-400 animate-pulse" />
795
- ⛓ {p.workflowName} {p.progress.total > 0 ? `${p.progress.done}/${p.progress.total}` : ''}{p.currentNode ? ` · ${p.currentNode}` : ''}
796
- </span>
797
- ))}
798
- {running.tasks.map((t) => (
799
- <span
800
- key={t.id}
801
- title={`task ${t.id} (${t.project})`}
802
- className="inline-flex items-center gap-1.5 text-xs rounded-full border border-[var(--border)] bg-[var(--bg-secondary)] px-3 py-1 text-[var(--text-secondary)]"
803
- >
804
- <span className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-400 animate-pulse" />
805
- ⚙ {t.project}: {t.prompt_preview.slice(0, 30)}
806
- </span>
807
- ))}
808
- </div>
809
- </div>
810
- )}
811
-
812
- {stopRequested && (
813
- <div className="border-t border-amber-500/40 bg-amber-500/10 text-amber-300 px-6 py-2 text-xs">
814
- ⏳ Stop requested — waiting for the current step to finish, then aborting…
815
- </div>
816
- )}
817
-
818
- <form
819
- className="border-t border-[var(--border)] px-6 py-4"
820
- onSubmit={(e) => { e.preventDefault(); if (streaming) addNote(); else send(); }}
821
- >
822
- <div className="max-w-3xl mx-auto flex items-end gap-3">
823
- <textarea
824
- ref={composerRef}
825
- value={input}
826
- onChange={(e) => setInput(e.target.value)}
827
- onKeyDown={(e) => {
828
- // Skip Enter while an IME composition is active — otherwise
829
- // pinyin/kana commit-with-Enter sends the message by mistake.
830
- // isComposing covers modern browsers; keyCode===229 is the
831
- // legacy fallback some IMEs still emit.
832
- if (e.nativeEvent.isComposing || e.keyCode === 229) return;
833
- if (e.key !== 'Enter') return;
834
- // Shift+Enter → newline (let the textarea handle it; never Send/Stop).
835
- if (e.shiftKey) return;
836
- // Plain Enter → Send. During streaming this routes the text
837
- // into the running turn (note). Either way, Enter NEVER stops.
838
- e.preventDefault();
839
- if (streaming) addNote(); else send();
840
- }}
841
- disabled={!activeId}
842
- placeholder={
843
- !activeId
844
- ? 'Pick or create a session'
845
- : streaming
846
- ? 'Message… (lands on the running task’s next step)'
847
- : 'Message… (Enter to send · Shift+Enter for newline)'
848
- }
849
- rows={1}
850
- style={{ fontFamily: SANS_FONT }}
851
- className="flex-1 resize-none bg-[var(--bg-tertiary)] border border-[var(--border)] rounded-lg px-4 py-3 text-sm text-[var(--text-primary)] leading-relaxed focus:outline-none focus:border-[var(--accent)] focus:ring-1 focus:ring-[var(--accent)]/40 disabled:opacity-50"
852
- />
853
- {/* Stop sits next to Send (mirrors the extension layout).
854
- While a turn is running, Send routes the text into THAT
855
- turn (a normal POST would spawn a second concurrent loop
856
- on the same session). Stop is type="button" so Enter on
857
- the textarea NEVER triggers it — only an explicit click
858
- here aborts. */}
859
- {streaming && (
860
- <button
861
- type="button"
862
- onClick={stop}
863
- disabled={!activeId || stopRequested}
864
- title={stopRequested ? 'Stop pending — waiting for current step to finish' : 'Stop the running turn'}
865
- className="px-4 py-2.5 text-sm font-medium border border-red-500/60 text-red-400 rounded-lg hover:bg-red-500/10 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
866
- >
867
- ■ Stop
868
- </button>
869
- )}
870
- <button
871
- type="submit"
872
- disabled={!input.trim() || !activeId}
873
- title={streaming ? 'Send — the running task reads it on its next step' : undefined}
874
- className="px-4 py-2.5 text-sm font-medium bg-[var(--accent)] text-white rounded-lg hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed transition-opacity"
875
- >
876
- Send
877
- </button>
878
- </div>
879
- </form>
880
- </main>
881
- </div>
882
- );
883
- }
884
-
885
- // ─── Message renderers ────────────────────────────────────
886
-
887
- function fmtTs(ts?: number): string {
888
- if (!ts) return '';
889
- const d = new Date(ts);
890
- if (Number.isNaN(d.getTime())) return '';
891
- const now = new Date();
892
- const sameDay = d.toDateString() === now.toDateString();
893
- const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
894
- return sameDay ? time : `${d.toLocaleDateString([], { month: 'short', day: 'numeric' })} ${time}`;
895
- }
896
-
897
- function RoleBlock({ role, ts, pending, mid, label, children }: { role: 'user' | 'assistant'; ts?: number; pending?: boolean; mid?: string; label?: string; children: React.ReactNode }) {
898
- const isUser = role === 'user';
899
- // User → right-aligned with bubble; assistant → left-aligned (avatar +
900
- // expanded content for markdown / tool cards). Pending optimistic notes
901
- // get a pink wash that clears the moment the server's persisted version
902
- // replaces them on the loop's next iteration.
903
- if (isUser) {
904
- return (
905
- <div className="flex justify-end" data-role="user" data-mid={mid} data-label={label}>
906
- <div className="max-w-[80%]">
907
- <div className="flex items-baseline gap-2 mb-1 justify-end">
908
- {pending ? (
909
- <span className="text-[10px] uppercase tracking-wide" style={{ color: '#f472b6' }}>pending</span>
910
- ) : null}
911
- {ts ? <span className="text-[10px] text-[var(--text-secondary)] opacity-60">{fmtTs(ts)}</span> : null}
912
- <span className="text-[11px] uppercase tracking-wide text-[var(--text-secondary)]">you</span>
913
- </div>
914
- <div
915
- className="space-y-2 rounded-lg px-3 py-2 border"
916
- style={
917
- // Pending: subtle pink tint over the dark theme bg + a
918
- // visible pink border. Text stays var(--text-primary) so
919
- // markdown rendering is readable. The tint vanishes once
920
- // the server-persisted message replaces the optimistic.
921
- pending
922
- ? { background: 'rgba(236, 72, 153, 0.10)', borderColor: 'rgba(236, 72, 153, 0.55)' }
923
- : { background: 'var(--bg-tertiary)', borderColor: 'var(--border)' }
924
- }
925
- >
926
- {children}
927
- </div>
928
- </div>
929
- </div>
930
- );
931
- }
932
- return (
933
- <div className="flex gap-4">
934
- <div
935
- className="shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold bg-[var(--bg-tertiary)] text-[var(--text-secondary)] border border-[var(--border)]"
936
- aria-hidden
937
- >
938
- AI
939
- </div>
940
- <div className="flex-1 min-w-0">
941
- <div className="flex items-baseline gap-2 mb-1">
942
- <span className="text-[11px] uppercase tracking-wide text-[var(--text-secondary)]">assistant</span>
943
- {ts ? <span className="text-[10px] text-[var(--text-secondary)] opacity-60">{fmtTs(ts)}</span> : null}
944
- </div>
945
- <div className="space-y-2">{children}</div>
946
- </div>
947
- </div>
948
- );
949
- }
950
-
951
- // Memoized — keystroke in the composer re-renders the page, and without
952
- // memo every prior message re-runs its markdown parse → typing in a long
953
- // chat became visibly laggy.
954
- const MessageView = memo(function MessageView({ m }: { m: Message }) {
955
- const pending = typeof m.id === 'string' && m.id.startsWith('optimistic-note-');
956
- // Label for the jump rail = first text block of a user turn, trimmed.
957
- const label = m.role === 'user'
958
- ? (m.blocks.find((b) => b.type === 'text')?.text || '').replace(/\s+/g, ' ').trim().slice(0, 80)
959
- : undefined;
960
- return (
961
- <RoleBlock role={m.role} ts={m.ts} pending={pending} mid={String(m.id)} label={label}>
962
- {m.blocks.map((b, i) => (
963
- <BlockView key={i} b={b} role={m.role} />
964
- ))}
965
- {m.error && (
966
- <div className="text-xs text-red-400 border border-red-500/30 bg-red-500/5 rounded-md p-2 mt-1">
967
- {m.error}
968
- </div>
969
- )}
970
- </RoleBlock>
971
- );
972
- });
973
-
974
- const BlockView = memo(function BlockView({ b, role }: { b: ContentBlock; role?: 'user' | 'assistant' }) {
975
- if (b.type === 'text') {
976
- return <MarkdownContent content={b.text} linkify={role === 'assistant'} />;
977
- }
978
- if (b.type === 'tool_use') {
979
- return <ToolUseBlockView name={b.name} input={b.input} />;
980
- }
981
- if (b.type === 'tool_result') {
982
- const txt = typeof b.content === 'string' ? b.content : JSON.stringify(b.content);
983
- return <ToolResultBlockView content={txt} isError={!!b.is_error} />;
984
- }
985
- return null;
986
- });
987
-
988
- function ToolUseBlockView({ name, input }: { name: string; input: unknown }) {
989
- const [open, setOpen] = useState(false);
990
- const preview = JSON.stringify(input);
991
- return (
992
- <div className="rounded-md border border-[var(--border)] bg-[var(--bg-tertiary)]/50 text-xs">
993
- <button
994
- type="button"
995
- onClick={() => setOpen((v) => !v)}
996
- className="w-full px-3 py-1.5 flex items-center gap-2 text-left hover:bg-[var(--bg-tertiary)] transition-colors"
997
- >
998
- <span className="text-[10px]">{open ? '▾' : '▸'}</span>
999
- <span className="text-[var(--accent)] font-mono">→ {name}</span>
1000
- {!open && (
1001
- <span className="text-[var(--text-secondary)] font-mono truncate flex-1">{preview}</span>
1002
- )}
1003
- </button>
1004
- {open && (
1005
- <pre className="px-3 pb-2 pt-1 text-[11px] font-mono text-[var(--text-secondary)] whitespace-pre-wrap break-words border-t border-[var(--border)] bg-[var(--bg-tertiary)]/30">
1006
- {tryPrettyJson(preview)}
1007
- </pre>
1008
- )}
1009
- </div>
1010
- );
1011
- }
1012
-
1013
- function ToolResultBlockView({ content, isError }: { content: string; isError: boolean }) {
1014
- const [open, setOpen] = useState(false);
1015
- const truncated = content.length > 400 ? content.slice(0, 400) + '…' : content;
1016
- return (
1017
- <div
1018
- className={`rounded-md border text-xs ${
1019
- isError
1020
- ? 'border-red-500/40 bg-red-500/5'
1021
- : 'border-[var(--border)] bg-[var(--bg-tertiary)]/30'
1022
- }`}
1023
- >
1024
- <button
1025
- type="button"
1026
- onClick={() => setOpen((v) => !v)}
1027
- className="w-full px-3 py-1.5 flex items-center gap-2 text-left hover:bg-[var(--bg-tertiary)] transition-colors"
1028
- >
1029
- <span className="text-[10px]">{open ? '▾' : '▸'}</span>
1030
- <span className={isError ? 'text-red-400 font-mono' : 'text-[var(--text-secondary)] font-mono'}>
1031
- {isError ? 'error result' : 'tool result'}
1032
- </span>
1033
- {!open && (
1034
- <span className="text-[var(--text-secondary)] font-mono truncate flex-1">
1035
- {truncated.replace(/\s+/g, ' ').slice(0, 100)}
1036
- </span>
1037
- )}
1038
- </button>
1039
- {open && (
1040
- <pre
1041
- className={`px-3 pb-2 pt-1 text-[11px] font-mono whitespace-pre-wrap break-words border-t ${
1042
- isError
1043
- ? 'border-red-500/30 text-red-400'
1044
- : 'border-[var(--border)] text-[var(--text-secondary)]'
1045
- }`}
1046
- >
1047
- {content.length > 4000 ? content.slice(0, 4000) + '\n…(truncated)' : content}
1048
- </pre>
1049
- )}
1050
- </div>
1051
- );
1052
- }
1053
-
1054
- function tryPrettyJson(s: string): string {
1055
- try {
1056
- return JSON.stringify(JSON.parse(s), null, 2);
1057
- } catch {
1058
- return s;
1059
- }
1060
- }
1061
-
1062
- // ─── Memory drawer ───────────────────────────────────────────
1063
- //
1064
- // Inspector for whatever the active memory backend (Temper / Local)
1065
- // has. Read-only: search + list + click-to-expand JSON. The internal
1066
- // summarizer bookkeeping (cursor / health) is hidden by default to
1067
- // reduce noise; toggle reveals it.
1068
-
1069
- const INTERNAL_PREFIXES = ['forge.summarizer.cursor:', 'forge.summarizer.health:'];
1070
-
1071
- interface MemoryBlockRow {
1072
- key: string;
1073
- value: unknown;
1074
- pinned?: boolean;
1075
- description?: string;
1076
- scope?: string;
1077
- }
1078
-
1079
- interface MemoryHitRow {
1080
- id: string;
1081
- kind: string;
1082
- fact?: string;
1083
- score?: number;
1084
- valid_at?: string | null;
1085
- }
1086
-
1087
- interface MemoryBlocksResponse {
1088
- backend: 'temper' | 'local';
1089
- enabled: boolean;
1090
- blocks: MemoryBlockRow[];
1091
- hits: MemoryHitRow[];
1092
- query?: string;
1093
- }
1094
-
1095
- function MemoryDrawer({ onClose }: { onClose: () => void }) {
1096
- const [data, setData] = useState<MemoryBlocksResponse | null>(null);
1097
- const [loading, setLoading] = useState(false);
1098
- const [err, setErr] = useState('');
1099
- const [q, setQ] = useState('');
1100
- const [showInternal, setShowInternal] = useState(false);
1101
- const [expanded, setExpanded] = useState<Record<string, boolean>>({});
1102
-
1103
- const fetchBlocks = useCallback(async (query: string) => {
1104
- setLoading(true);
1105
- setErr('');
1106
- try {
1107
- const url = query
1108
- ? `/api/memory/blocks?q=${encodeURIComponent(query)}&limit=300`
1109
- : `/api/memory/blocks?limit=300`;
1110
- const r = await fetch(url);
1111
- if (!r.ok) throw new Error(`${r.status}`);
1112
- const j = (await r.json()) as MemoryBlocksResponse;
1113
- setData(j);
1114
- } catch (e) {
1115
- setErr(e instanceof Error ? e.message : String(e));
1116
- } finally {
1117
- setLoading(false);
1118
- }
1119
- }, []);
1120
-
1121
- useEffect(() => { fetchBlocks(''); }, [fetchBlocks]);
1122
-
1123
- // Debounce search input → API
1124
- useEffect(() => {
1125
- const t = setTimeout(() => fetchBlocks(q.trim()), 250);
1126
- return () => clearTimeout(t);
1127
- }, [q, fetchBlocks]);
1128
-
1129
- const visibleBlocks = useMemo(() => {
1130
- const all = data?.blocks ?? [];
1131
- if (showInternal) return all;
1132
- return all.filter((b) => !INTERNAL_PREFIXES.some((p) => b.key.startsWith(p)));
1133
- }, [data, showInternal]);
1134
-
1135
- const visibleHits = data?.hits ?? [];
1136
-
1137
- return (
1138
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
1139
- <div
1140
- className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg w-[720px] max-w-[95vw] max-h-[85vh] flex flex-col shadow-xl"
1141
- onClick={(e) => e.stopPropagation()}
1142
- style={{ fontFamily: SANS_FONT }}
1143
- >
1144
- <div className="px-4 py-3 border-b border-[var(--border)] flex items-center gap-3">
1145
- <h2 className="text-sm font-bold text-[var(--text-primary)]">Memory</h2>
1146
- {data && (
1147
- <span
1148
- className={`px-1.5 py-[1px] rounded text-[10px] uppercase tracking-wide border ${
1149
- data.backend === 'temper'
1150
- ? 'border-green-500/60 text-green-400'
1151
- : 'border-[var(--accent)] text-[var(--accent)]'
1152
- }`}
1153
- >
1154
- {data.backend}
1155
- </span>
1156
- )}
1157
- <input
1158
- value={q}
1159
- onChange={(e) => setQ(e.target.value)}
1160
- placeholder="search…"
1161
- className="flex-1 bg-[var(--bg-primary)] border border-[var(--border)] rounded px-2 py-1 text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
1162
- />
1163
- <label className="flex items-center gap-1 text-[10px] text-[var(--text-secondary)]">
1164
- <input
1165
- type="checkbox"
1166
- checked={showInternal}
1167
- onChange={(e) => setShowInternal(e.target.checked)}
1168
- className="accent-[var(--accent)]"
1169
- />
1170
- show internal
1171
- </label>
1172
- <button onClick={onClose} className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]">Close</button>
1173
- </div>
1174
-
1175
- <div className="flex-1 overflow-y-auto">
1176
- {loading && <div className="px-4 py-6 text-xs text-[var(--text-secondary)]">Loading…</div>}
1177
- {err && <div className="px-4 py-6 text-xs text-red-400">Error: {err}</div>}
1178
-
1179
- {visibleHits.length > 0 && (
1180
- <div className="px-4 py-2 border-b border-[var(--border)]">
1181
- <div className="text-[10px] uppercase tracking-wide text-[var(--text-secondary)] mb-1">
1182
- Search hits ({visibleHits.length})
1183
- </div>
1184
- <div className="space-y-1">
1185
- {visibleHits.slice(0, 20).map((h) => (
1186
- <div key={h.id} className="text-[11px] text-[var(--text-primary)]">
1187
- <span className="text-[var(--text-secondary)] font-mono mr-2">{h.id}</span>
1188
- {h.fact || '(no fact)'}
1189
- </div>
1190
- ))}
1191
- </div>
1192
- </div>
1193
- )}
1194
-
1195
- <div className="px-2 py-1 text-[10px] uppercase tracking-wide text-[var(--text-secondary)] sticky top-0 bg-[var(--bg-secondary)]">
1196
- Blocks ({visibleBlocks.length}{data && data.blocks.length !== visibleBlocks.length ? ` of ${data.blocks.length}` : ''})
1197
- </div>
1198
- {visibleBlocks.length === 0 && !loading && (
1199
- <div className="px-4 py-6 text-xs text-[var(--text-secondary)] italic">No blocks{q ? ' match' : ''}.</div>
1200
- )}
1201
- {visibleBlocks.map((b) => {
1202
- const isOpen = !!expanded[b.key];
1203
- const valStr = typeof b.value === 'string' ? b.value : JSON.stringify(b.value);
1204
- const preview = valStr.length > 140 ? valStr.slice(0, 140) + '…' : valStr;
1205
- return (
1206
- <div key={b.key} className="border-b border-[var(--border)]">
1207
- <button
1208
- type="button"
1209
- onClick={() => setExpanded((s) => ({ ...s, [b.key]: !isOpen }))}
1210
- className="w-full text-left px-3 py-2 hover:bg-[var(--bg-primary)] transition-colors"
1211
- >
1212
- <div className="flex items-baseline gap-2">
1213
- <span className="text-[11px] font-mono text-[var(--accent)] truncate flex-1">{b.key}</span>
1214
- {b.pinned && <span className="text-[9px] text-yellow-400">📌</span>}
1215
- </div>
1216
- <div className="text-[11px] text-[var(--text-secondary)] mt-0.5 truncate">
1217
- {preview}
1218
- </div>
1219
- {b.description && (
1220
- <div className="text-[10px] text-gray-500 italic mt-0.5 truncate">{b.description}</div>
1221
- )}
1222
- </button>
1223
- {isOpen && (
1224
- <pre className="px-3 pb-3 text-[10px] font-mono whitespace-pre-wrap break-words text-[var(--text-secondary)]">
1225
- {tryPrettyJson(valStr)}
1226
- </pre>
1227
- )}
1228
- </div>
1229
- );
1230
- })}
1231
- </div>
1232
-
1233
- <div className="px-4 py-2 border-t border-[var(--border)] text-[10px] text-[var(--text-secondary)]">
1234
- Read-only. Edit / delete via Settings → Memory (Temper UI for KG).
1235
- </div>
1236
- </div>
11
+ <div className="h-screen flex">
12
+ <WebChatPanel />
1237
13
  </div>
1238
14
  );
1239
15
  }