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