@aion0/forge 0.10.53 → 0.10.55
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 +3 -3
- package/app/api/cache/route.ts +125 -41
- package/app/api/chat/sessions/[id]/abort/route.ts +14 -0
- package/app/api/chat/sessions/[id]/note/route.ts +16 -0
- package/app/api/files/[...path]/route.ts +94 -0
- package/app/api/scratch/[...path]/route.ts +5 -0
- package/app/chat/page.tsx +237 -36
- package/app/files/[...path]/page.tsx +22 -0
- package/components/Dashboard.tsx +76 -24
- package/components/ScratchViewer.tsx +14 -3
- package/lib/chat/agent-loop.ts +72 -2
- package/lib/chat/link-patterns.ts +28 -5
- package/lib/chat/tool-dispatcher.ts +270 -17
- package/lib/chat/turn-control.ts +81 -0
- package/lib/chat-standalone.ts +51 -0
- package/lib/help-docs/10-troubleshooting.md +16 -0
- package/lib/help-docs/17-connectors.md +19 -0
- package/lib/help-docs/25-chat-tools.md +125 -0
- package/lib/help-docs/CLAUDE.md +2 -0
- package/lib/scratch-cleanup.ts +25 -16
- package/package.json +1 -1
package/app/chat/page.tsx
CHANGED
|
@@ -45,6 +45,10 @@ interface ChatSession extends Session {
|
|
|
45
45
|
|
|
46
46
|
export default function ChatPage() {
|
|
47
47
|
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
|
48
|
+
// Inline rename state — only one row edits at a time. editingId is the
|
|
49
|
+
// session being renamed, editingTitle is the current input value.
|
|
50
|
+
const [editingId, setEditingId] = useState<string | null>(null);
|
|
51
|
+
const [editingTitle, setEditingTitle] = useState('');
|
|
48
52
|
const [activeId, setActiveId] = useState<string>('');
|
|
49
53
|
const [messages, setMessages] = useState<Message[]>([]);
|
|
50
54
|
// Background-watch progress chips (watch_id → {text, ts}). Ambient,
|
|
@@ -52,6 +56,9 @@ export default function ChatPage() {
|
|
|
52
56
|
const [watchChips, setWatchChips] = useState<Record<string, { text: string; ts: number }>>({});
|
|
53
57
|
const [input, setInput] = useState('');
|
|
54
58
|
const [streaming, setStreaming] = useState(false);
|
|
59
|
+
// True from Stop-click → next iteration boundary on the backend. Drives
|
|
60
|
+
// an immediate banner so Stop feels responsive when a tool call is mid-flight.
|
|
61
|
+
const [stopRequested, setStopRequested] = useState(false);
|
|
55
62
|
const [partial, setPartial] = useState('');
|
|
56
63
|
const [memory, setMemory] = useState<MemoryStatus | null>(null);
|
|
57
64
|
const [memoryOpen, setMemoryOpen] = useState(false);
|
|
@@ -130,6 +137,7 @@ export default function ChatPage() {
|
|
|
130
137
|
});
|
|
131
138
|
} else if (type === 'turn_done') {
|
|
132
139
|
setStreaming(false);
|
|
140
|
+
setStopRequested(false);
|
|
133
141
|
setPartial('');
|
|
134
142
|
loadMessages(activeId);
|
|
135
143
|
refreshSessions();
|
|
@@ -147,6 +155,7 @@ export default function ChatPage() {
|
|
|
147
155
|
}
|
|
148
156
|
} else if (type === 'error') {
|
|
149
157
|
setStreaming(false);
|
|
158
|
+
setStopRequested(false);
|
|
150
159
|
setError(String(data.error || 'unknown error'));
|
|
151
160
|
}
|
|
152
161
|
};
|
|
@@ -186,10 +195,10 @@ export default function ChatPage() {
|
|
|
186
195
|
}, [input]);
|
|
187
196
|
|
|
188
197
|
// ─── Actions ──────────────────────────────────────────────
|
|
189
|
-
async function send() {
|
|
190
|
-
const text = input.trim();
|
|
198
|
+
async function send(textArg?: string) {
|
|
199
|
+
const text = (textArg ?? input).trim();
|
|
191
200
|
if (!text || !activeId || streaming) return;
|
|
192
|
-
setInput('');
|
|
201
|
+
if (textArg === undefined) setInput('');
|
|
193
202
|
setStreaming(true);
|
|
194
203
|
setError('');
|
|
195
204
|
setPartial('');
|
|
@@ -209,12 +218,72 @@ export default function ChatPage() {
|
|
|
209
218
|
}
|
|
210
219
|
}
|
|
211
220
|
|
|
221
|
+
// Stop the in-flight tool-call loop. The backend breaks at the next
|
|
222
|
+
// iteration boundary and persists a "⏹ Stopped" message; the turn_done
|
|
223
|
+
// SSE event flips `streaming` off here.
|
|
224
|
+
async function stop() {
|
|
225
|
+
if (!activeId) return;
|
|
226
|
+
// Flip the banner instantly so the user sees feedback. The backend
|
|
227
|
+
// returns immediately but the loop only checks the flag between
|
|
228
|
+
// iterations, so the actual "⏹ Stopped" sentinel can lag a long-
|
|
229
|
+
// running tool call by tens of seconds. turn_done clears this.
|
|
230
|
+
setStopRequested(true);
|
|
231
|
+
try {
|
|
232
|
+
await fetch(`${PROXY}/sessions/${activeId}/abort`, { method: 'POST' });
|
|
233
|
+
} catch (e) {
|
|
234
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Inject supplementary info into the RUNNING turn — the agent picks it
|
|
239
|
+
// up on its next iteration. If the turn just ended (409), fall back to
|
|
240
|
+
// sending it as a normal new message so the text is never lost.
|
|
241
|
+
async function addNote() {
|
|
242
|
+
const text = input.trim();
|
|
243
|
+
if (!text || !activeId) return;
|
|
244
|
+
setInput('');
|
|
245
|
+
// Optimistic display: the server only persists the note on the next
|
|
246
|
+
// iteration (after the current tool call finishes), which can take
|
|
247
|
+
// tens of seconds. Show the user's message in the thread NOW so
|
|
248
|
+
// sending feels responsive — turn_done reloads from DB and the real
|
|
249
|
+
// persisted version replaces this entry.
|
|
250
|
+
const optimisticId = `optimistic-note-${Date.now()}`;
|
|
251
|
+
setMessages((ms) => [...ms, {
|
|
252
|
+
id: optimisticId,
|
|
253
|
+
session_id: activeId,
|
|
254
|
+
role: 'user',
|
|
255
|
+
blocks: [{ type: 'text', text } as ContentBlock],
|
|
256
|
+
ts: Date.now(),
|
|
257
|
+
} as Message]);
|
|
258
|
+
try {
|
|
259
|
+
const r = await fetch(`${PROXY}/sessions/${activeId}/note`, {
|
|
260
|
+
method: 'POST',
|
|
261
|
+
headers: { 'content-type': 'application/json' },
|
|
262
|
+
body: JSON.stringify({ text }),
|
|
263
|
+
});
|
|
264
|
+
if (r.ok) return; // the loop surfaces the note as a user message
|
|
265
|
+
if (r.status === 409) { await send(text); return; } // turn ended → normal send
|
|
266
|
+
const j = await r.json().catch(() => ({}));
|
|
267
|
+
throw new Error(j.error || `HTTP ${r.status}`);
|
|
268
|
+
} catch (e) {
|
|
269
|
+
// Roll back the optimistic on failure
|
|
270
|
+
setMessages((ms) => ms.filter((m) => m.id !== optimisticId));
|
|
271
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
212
275
|
async function newSession() {
|
|
276
|
+
// Optional title via prompt — leave blank for the default
|
|
277
|
+
// "Temp · <id>" label. The user can also rename later by clicking
|
|
278
|
+
// the pencil icon on the sidebar row (or double-clicking the row).
|
|
279
|
+
const raw = window.prompt('Name this conversation (leave blank for default):', '');
|
|
280
|
+
if (raw === null) return; // user hit Cancel
|
|
281
|
+
const title = raw.trim() || undefined;
|
|
213
282
|
try {
|
|
214
283
|
const r = await fetch(`${PROXY}/sessions`, {
|
|
215
284
|
method: 'POST',
|
|
216
285
|
headers: { 'content-type': 'application/json' },
|
|
217
|
-
body: JSON.stringify({ meta: { kind: 'temp' } }),
|
|
286
|
+
body: JSON.stringify({ meta: { kind: 'temp' }, ...(title ? { title } : {}) }),
|
|
218
287
|
});
|
|
219
288
|
const j = (await r.json()) as { session?: ChatSession };
|
|
220
289
|
if (j?.session?.id) {
|
|
@@ -226,6 +295,31 @@ export default function ChatPage() {
|
|
|
226
295
|
}
|
|
227
296
|
}
|
|
228
297
|
|
|
298
|
+
// PATCH /sessions/:id with a new title. Used by both the sidebar's
|
|
299
|
+
// pencil-icon inline editor and the double-click handler. Empty input
|
|
300
|
+
// is a no-op (cancel) — the backend's updateSession uses ?? semantics
|
|
301
|
+
// so passing null doesn't clear, and the "Main conversation" / "Temp
|
|
302
|
+
// · xxx" defaults are computed from null title at render time anyway.
|
|
303
|
+
async function renameSession(id: string, title: string) {
|
|
304
|
+
const clean = title.trim();
|
|
305
|
+
if (!clean) return;
|
|
306
|
+
try {
|
|
307
|
+
const r = await fetch(`${PROXY}/sessions/${id}`, {
|
|
308
|
+
method: 'PATCH',
|
|
309
|
+
headers: { 'content-type': 'application/json' },
|
|
310
|
+
body: JSON.stringify({ title: clean }),
|
|
311
|
+
});
|
|
312
|
+
if (!r.ok) {
|
|
313
|
+
const j = await r.json().catch(() => ({}));
|
|
314
|
+
setError(j.error || `rename failed (HTTP ${r.status})`);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
await refreshSessions();
|
|
318
|
+
} catch (e) {
|
|
319
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
229
323
|
async function clearMessages() {
|
|
230
324
|
if (!activeId) return;
|
|
231
325
|
if (!confirm('Clear all messages in this session?')) return;
|
|
@@ -282,32 +376,76 @@ export default function ChatPage() {
|
|
|
282
376
|
{sessions.map((s) => {
|
|
283
377
|
const isMain = s.meta?.kind === 'main';
|
|
284
378
|
const isActive = s.id === activeId;
|
|
379
|
+
const isEditing = editingId === s.id;
|
|
380
|
+
const displayTitle = s.title || (isMain ? 'Main conversation' : `Temp · ${s.id.slice(0, 6)}`);
|
|
381
|
+
const beginEdit = () => {
|
|
382
|
+
setEditingId(s.id);
|
|
383
|
+
setEditingTitle(s.title || '');
|
|
384
|
+
};
|
|
385
|
+
const commitEdit = async () => {
|
|
386
|
+
const next = editingTitle.trim();
|
|
387
|
+
setEditingId(null);
|
|
388
|
+
if (next && next !== (s.title || '')) {
|
|
389
|
+
await renameSession(s.id, next);
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
const cancelEdit = () => {
|
|
393
|
+
setEditingId(null);
|
|
394
|
+
setEditingTitle('');
|
|
395
|
+
};
|
|
285
396
|
return (
|
|
286
397
|
<div
|
|
287
398
|
key={s.id}
|
|
288
|
-
className={`group flex items-center gap-2 px-3 py-2 rounded-md text-sm
|
|
399
|
+
className={`group flex items-center gap-2 px-3 py-2 rounded-md text-sm mb-1 transition-colors ${
|
|
289
400
|
isActive
|
|
290
401
|
? 'bg-[var(--accent)]/15 text-[var(--text-primary)]'
|
|
291
402
|
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-primary)]'
|
|
292
|
-
}`}
|
|
293
|
-
onClick={() => setActiveId(s.id)}
|
|
403
|
+
} ${isEditing ? '' : 'cursor-pointer'}`}
|
|
404
|
+
onClick={() => { if (!isEditing) setActiveId(s.id); }}
|
|
405
|
+
onDoubleClick={(e) => { e.stopPropagation(); if (!isEditing) beginEdit(); }}
|
|
294
406
|
>
|
|
295
407
|
<span
|
|
296
408
|
className={`inline-block w-2 h-2 rounded-full shrink-0 ${
|
|
297
409
|
isMain ? 'bg-[var(--accent)]' : 'bg-[var(--text-secondary)]/40'
|
|
298
410
|
}`}
|
|
299
411
|
/>
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
412
|
+
{isEditing ? (
|
|
413
|
+
<input
|
|
414
|
+
autoFocus
|
|
415
|
+
type="text"
|
|
416
|
+
value={editingTitle}
|
|
417
|
+
onChange={(e) => setEditingTitle(e.target.value)}
|
|
418
|
+
onClick={(e) => e.stopPropagation()}
|
|
419
|
+
onKeyDown={(e) => {
|
|
420
|
+
if (e.key === 'Enter') { e.preventDefault(); commitEdit(); }
|
|
421
|
+
else if (e.key === 'Escape') { e.preventDefault(); cancelEdit(); }
|
|
422
|
+
}}
|
|
423
|
+
onBlur={commitEdit}
|
|
424
|
+
placeholder={displayTitle}
|
|
425
|
+
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"
|
|
426
|
+
/>
|
|
427
|
+
) : (
|
|
428
|
+
<>
|
|
429
|
+
<div className="truncate flex-1" title="Double-click to rename">
|
|
430
|
+
{displayTitle}
|
|
431
|
+
</div>
|
|
432
|
+
<button
|
|
433
|
+
onClick={(e) => { e.stopPropagation(); beginEdit(); }}
|
|
434
|
+
className="opacity-0 group-hover:opacity-100 text-[var(--text-secondary)] hover:text-[var(--accent)] text-xs leading-none px-1"
|
|
435
|
+
title="Rename"
|
|
436
|
+
>
|
|
437
|
+
✎
|
|
438
|
+
</button>
|
|
439
|
+
{!isMain && (
|
|
440
|
+
<button
|
|
441
|
+
onClick={(e) => { e.stopPropagation(); deleteSession(s.id); }}
|
|
442
|
+
className="opacity-0 group-hover:opacity-100 text-[var(--text-secondary)] hover:text-red-400 text-base leading-none px-1"
|
|
443
|
+
title="Delete session"
|
|
444
|
+
>
|
|
445
|
+
×
|
|
446
|
+
</button>
|
|
447
|
+
)}
|
|
448
|
+
</>
|
|
311
449
|
)}
|
|
312
450
|
</div>
|
|
313
451
|
);
|
|
@@ -420,9 +558,15 @@ export default function ChatPage() {
|
|
|
420
558
|
</div>
|
|
421
559
|
)}
|
|
422
560
|
|
|
561
|
+
{stopRequested && (
|
|
562
|
+
<div className="border-t border-amber-500/40 bg-amber-500/10 text-amber-300 px-6 py-2 text-xs">
|
|
563
|
+
⏳ Stop requested — waiting for the current step to finish, then aborting…
|
|
564
|
+
</div>
|
|
565
|
+
)}
|
|
566
|
+
|
|
423
567
|
<form
|
|
424
568
|
className="border-t border-[var(--border)] px-6 py-4"
|
|
425
|
-
onSubmit={(e) => { e.preventDefault(); send(); }}
|
|
569
|
+
onSubmit={(e) => { e.preventDefault(); if (streaming) addNote(); else send(); }}
|
|
426
570
|
>
|
|
427
571
|
<div className="max-w-3xl mx-auto flex items-end gap-3">
|
|
428
572
|
<textarea
|
|
@@ -435,23 +579,50 @@ export default function ChatPage() {
|
|
|
435
579
|
// isComposing covers modern browsers; keyCode===229 is the
|
|
436
580
|
// legacy fallback some IMEs still emit.
|
|
437
581
|
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
|
|
438
|
-
if (e.key
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
582
|
+
if (e.key !== 'Enter') return;
|
|
583
|
+
// Shift+Enter → newline (let the textarea handle it; never Send/Stop).
|
|
584
|
+
if (e.shiftKey) return;
|
|
585
|
+
// Plain Enter → Send. During streaming this routes the text
|
|
586
|
+
// into the running turn (note). Either way, Enter NEVER stops.
|
|
587
|
+
e.preventDefault();
|
|
588
|
+
if (streaming) addNote(); else send();
|
|
442
589
|
}}
|
|
443
|
-
disabled={!activeId
|
|
444
|
-
placeholder={
|
|
590
|
+
disabled={!activeId}
|
|
591
|
+
placeholder={
|
|
592
|
+
!activeId
|
|
593
|
+
? 'Pick or create a session'
|
|
594
|
+
: streaming
|
|
595
|
+
? 'Message… (lands on the running task’s next step)'
|
|
596
|
+
: 'Message… (Enter to send · Shift+Enter for newline)'
|
|
597
|
+
}
|
|
445
598
|
rows={1}
|
|
446
599
|
style={{ fontFamily: SANS_FONT }}
|
|
447
600
|
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"
|
|
448
601
|
/>
|
|
602
|
+
{/* Stop sits next to Send (mirrors the extension layout).
|
|
603
|
+
While a turn is running, Send routes the text into THAT
|
|
604
|
+
turn (a normal POST would spawn a second concurrent loop
|
|
605
|
+
on the same session). Stop is type="button" so Enter on
|
|
606
|
+
the textarea NEVER triggers it — only an explicit click
|
|
607
|
+
here aborts. */}
|
|
608
|
+
{streaming && (
|
|
609
|
+
<button
|
|
610
|
+
type="button"
|
|
611
|
+
onClick={stop}
|
|
612
|
+
disabled={!activeId || stopRequested}
|
|
613
|
+
title={stopRequested ? 'Stop pending — waiting for current step to finish' : 'Stop the running turn'}
|
|
614
|
+
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"
|
|
615
|
+
>
|
|
616
|
+
■ Stop
|
|
617
|
+
</button>
|
|
618
|
+
)}
|
|
449
619
|
<button
|
|
450
620
|
type="submit"
|
|
451
|
-
disabled={!input.trim() || !activeId
|
|
621
|
+
disabled={!input.trim() || !activeId}
|
|
622
|
+
title={streaming ? 'Send — the running task reads it on its next step' : undefined}
|
|
452
623
|
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"
|
|
453
624
|
>
|
|
454
|
-
|
|
625
|
+
Send
|
|
455
626
|
</button>
|
|
456
627
|
</div>
|
|
457
628
|
</form>
|
|
@@ -472,23 +643,52 @@ function fmtTs(ts?: number): string {
|
|
|
472
643
|
return sameDay ? time : `${d.toLocaleDateString([], { month: 'short', day: 'numeric' })} ${time}`;
|
|
473
644
|
}
|
|
474
645
|
|
|
475
|
-
function RoleBlock({ role, ts, children }: { role: 'user' | 'assistant'; ts?: number; children: React.ReactNode }) {
|
|
646
|
+
function RoleBlock({ role, ts, pending, children }: { role: 'user' | 'assistant'; ts?: number; pending?: boolean; children: React.ReactNode }) {
|
|
476
647
|
const isUser = role === 'user';
|
|
648
|
+
// User → right-aligned with bubble; assistant → left-aligned (avatar +
|
|
649
|
+
// expanded content for markdown / tool cards). Pending optimistic notes
|
|
650
|
+
// get a pink wash that clears the moment the server's persisted version
|
|
651
|
+
// replaces them on the loop's next iteration.
|
|
652
|
+
if (isUser) {
|
|
653
|
+
return (
|
|
654
|
+
<div className="flex justify-end">
|
|
655
|
+
<div className="max-w-[80%]">
|
|
656
|
+
<div className="flex items-baseline gap-2 mb-1 justify-end">
|
|
657
|
+
{pending ? (
|
|
658
|
+
<span className="text-[10px] uppercase tracking-wide" style={{ color: '#f472b6' }}>pending</span>
|
|
659
|
+
) : null}
|
|
660
|
+
{ts ? <span className="text-[10px] text-[var(--text-secondary)] opacity-60">{fmtTs(ts)}</span> : null}
|
|
661
|
+
<span className="text-[11px] uppercase tracking-wide text-[var(--text-secondary)]">you</span>
|
|
662
|
+
</div>
|
|
663
|
+
<div
|
|
664
|
+
className="space-y-2 rounded-lg px-3 py-2 border"
|
|
665
|
+
style={
|
|
666
|
+
// Pending: subtle pink tint over the dark theme bg + a
|
|
667
|
+
// visible pink border. Text stays var(--text-primary) so
|
|
668
|
+
// markdown rendering is readable. The tint vanishes once
|
|
669
|
+
// the server-persisted message replaces the optimistic.
|
|
670
|
+
pending
|
|
671
|
+
? { background: 'rgba(236, 72, 153, 0.10)', borderColor: 'rgba(236, 72, 153, 0.55)' }
|
|
672
|
+
: { background: 'var(--bg-tertiary)', borderColor: 'var(--border)' }
|
|
673
|
+
}
|
|
674
|
+
>
|
|
675
|
+
{children}
|
|
676
|
+
</div>
|
|
677
|
+
</div>
|
|
678
|
+
</div>
|
|
679
|
+
);
|
|
680
|
+
}
|
|
477
681
|
return (
|
|
478
682
|
<div className="flex gap-4">
|
|
479
683
|
<div
|
|
480
|
-
className=
|
|
481
|
-
isUser
|
|
482
|
-
? 'bg-[var(--accent)] text-white'
|
|
483
|
-
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] border border-[var(--border)]'
|
|
484
|
-
}`}
|
|
684
|
+
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)]"
|
|
485
685
|
aria-hidden
|
|
486
686
|
>
|
|
487
|
-
|
|
687
|
+
AI
|
|
488
688
|
</div>
|
|
489
689
|
<div className="flex-1 min-w-0">
|
|
490
690
|
<div className="flex items-baseline gap-2 mb-1">
|
|
491
|
-
<span className="text-[11px] uppercase tracking-wide text-[var(--text-secondary)]">
|
|
691
|
+
<span className="text-[11px] uppercase tracking-wide text-[var(--text-secondary)]">assistant</span>
|
|
492
692
|
{ts ? <span className="text-[10px] text-[var(--text-secondary)] opacity-60">{fmtTs(ts)}</span> : null}
|
|
493
693
|
</div>
|
|
494
694
|
<div className="space-y-2">{children}</div>
|
|
@@ -501,8 +701,9 @@ function RoleBlock({ role, ts, children }: { role: 'user' | 'assistant'; ts?: nu
|
|
|
501
701
|
// memo every prior message re-runs its markdown parse → typing in a long
|
|
502
702
|
// chat became visibly laggy.
|
|
503
703
|
const MessageView = memo(function MessageView({ m }: { m: Message }) {
|
|
704
|
+
const pending = typeof m.id === 'string' && m.id.startsWith('optimistic-note-');
|
|
504
705
|
return (
|
|
505
|
-
<RoleBlock role={m.role} ts={m.ts}>
|
|
706
|
+
<RoleBlock role={m.role} ts={m.ts} pending={pending}>
|
|
506
707
|
{m.blocks.map((b, i) => (
|
|
507
708
|
<BlockView key={i} b={b} role={m.role} />
|
|
508
709
|
))}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /files/<path...> — in-browser viewer for any file under <dataDir>/.
|
|
3
|
+
*
|
|
4
|
+
* Backed by /api/files (dataDir-wide, sensitive blacklist applied).
|
|
5
|
+
* Reuses ScratchViewer with apiBase overridden — same markdown render
|
|
6
|
+
* + image/pdf inline + download button, just rooted at <dataDir>/.
|
|
7
|
+
*
|
|
8
|
+
* Pairs with the chat link-pattern that turns `tmp/foo.md` into
|
|
9
|
+
* `/files/tmp/foo.md` so users click straight through.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import ScratchViewer from '@/components/ScratchViewer';
|
|
13
|
+
|
|
14
|
+
export default async function FilesPage({
|
|
15
|
+
params,
|
|
16
|
+
}: {
|
|
17
|
+
params: Promise<{ path: string[] }>;
|
|
18
|
+
}) {
|
|
19
|
+
const { path } = await params;
|
|
20
|
+
const joined = (path || []).map(encodeURIComponent).join('/');
|
|
21
|
+
return <ScratchViewer path={joined} apiBase="/api/files" topLabel="file" />;
|
|
22
|
+
}
|
package/components/Dashboard.tsx
CHANGED
|
@@ -176,16 +176,18 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
176
176
|
const [expandedNotif, setExpandedNotif] = useState<string | null>(null);
|
|
177
177
|
const [showUserMenu, setShowUserMenu] = useState(false);
|
|
178
178
|
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
|
|
179
|
-
const [cacheInfo, setCacheInfo] = useState<{ entries: { name: string; bytes: number }[]; total_bytes: number } | null>(null);
|
|
179
|
+
const [cacheInfo, setCacheInfo] = useState<{ entries: { name: string; bytes: number; category?: 'cloned-projects' | 'tmp' | 'scratch' }[]; total_bytes: number } | null>(null);
|
|
180
180
|
const [showCacheMenu, setShowCacheMenu] = useState(false);
|
|
181
181
|
const [cacheBusy, setCacheBusy] = useState(false);
|
|
182
182
|
const [displayName, setDisplayName] = useState(user?.name || 'Forge');
|
|
183
183
|
const [profileDept, setProfileDept] = useState('');
|
|
184
184
|
const terminalRef = useRef<WebTerminalHandle>(null);
|
|
185
185
|
|
|
186
|
-
// Cache
|
|
187
|
-
//
|
|
188
|
-
//
|
|
186
|
+
// Cache load + clear helpers. Two categories live under the same
|
|
187
|
+
// endpoint: `cloned-projects` (pipeline git clones) and `tmp` (chat's
|
|
188
|
+
// save_tmp_file output). Entries arrive flat with a `category` field;
|
|
189
|
+
// the UI groups them visually and offers per-category Clear in
|
|
190
|
+
// addition to the catch-all Clear All.
|
|
189
191
|
const loadCacheInfo = async () => {
|
|
190
192
|
try {
|
|
191
193
|
const r = await fetch('/api/cache');
|
|
@@ -201,13 +203,32 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
201
203
|
if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`;
|
|
202
204
|
return `${(n / 1024 ** 3).toFixed(2)} GB`;
|
|
203
205
|
};
|
|
204
|
-
|
|
206
|
+
// name + category together identify an entry (cloned-projects/<name>
|
|
207
|
+
// and tmp/<name> are distinct files). Omit both → wipe all. Omit name
|
|
208
|
+
// but pass category → wipe that whole category.
|
|
209
|
+
const clearCache = async (name?: string, category?: 'cloned-projects' | 'tmp' | 'scratch') => {
|
|
205
210
|
if (cacheBusy) return;
|
|
206
|
-
const
|
|
207
|
-
|
|
211
|
+
const labelFor = (cat?: string) =>
|
|
212
|
+
cat === 'tmp' ? 'tmp file'
|
|
213
|
+
: cat === 'scratch' ? 'legacy scratch file'
|
|
214
|
+
: cat === 'cloned-projects' ? 'cached repo'
|
|
215
|
+
: 'cache entry';
|
|
216
|
+
const prompt = name
|
|
217
|
+
? `Delete ${labelFor(category)} "${name}"?`
|
|
218
|
+
: category === 'tmp'
|
|
219
|
+
? 'Delete ALL chat-saved tmp/ files? They are ephemeral by design.'
|
|
220
|
+
: category === 'scratch'
|
|
221
|
+
? 'Delete ALL legacy files at scratch/ root? (Task workdirs and CLAUDE.md marker are NOT touched.)'
|
|
222
|
+
: category === 'cloned-projects'
|
|
223
|
+
? 'Delete ALL cached repos? They will be re-cloned on next pipeline run.'
|
|
224
|
+
: 'Delete ALL cached files (cloned repos + tmp/ + scratch/ root)? Cloned repos will be re-cloned; tmp/ and scratch/ files are gone for good.';
|
|
225
|
+
if (!confirm(prompt)) return;
|
|
208
226
|
setCacheBusy(true);
|
|
209
227
|
try {
|
|
210
|
-
const
|
|
228
|
+
const params = new URLSearchParams();
|
|
229
|
+
if (name) params.set('name', name);
|
|
230
|
+
if (category) params.set('category', category);
|
|
231
|
+
const q = params.toString() ? `?${params}` : '';
|
|
211
232
|
const r = await fetch(`/api/cache${q}`, { method: 'DELETE' });
|
|
212
233
|
const data = await r.json();
|
|
213
234
|
if (data?.ok) {
|
|
@@ -783,10 +804,10 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
783
804
|
)}
|
|
784
805
|
</div>
|
|
785
806
|
)}
|
|
786
|
-
{/* Cache —
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
`forge clean cache
|
|
807
|
+
{/* Cache — two on-disk pools: cloned-projects (auto-cloned
|
|
808
|
+
gitlab repos for pipelines) + tmp (chat's save_tmp_file
|
|
809
|
+
output). Groups visually + offers per-category Clear
|
|
810
|
+
plus Clear All. Same backend as `forge clean cache`. */}
|
|
790
811
|
<button
|
|
791
812
|
onClick={() => setShowCacheMenu(v => !v)}
|
|
792
813
|
className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] flex items-center gap-2"
|
|
@@ -806,18 +827,49 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
806
827
|
{cacheInfo && cacheInfo.entries.length === 0 && (
|
|
807
828
|
<div className="text-[10px] text-[var(--text-secondary)] py-1 px-2">Empty.</div>
|
|
808
829
|
)}
|
|
809
|
-
{
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
830
|
+
{/* Render entries grouped by category (cloned-projects
|
|
831
|
+
then tmp) with a category header row + per-category
|
|
832
|
+
"Clear category" link. Entries without an explicit
|
|
833
|
+
category (older API responses) fall under cloned-
|
|
834
|
+
projects for back-compat. */}
|
|
835
|
+
{cacheInfo && cacheInfo.entries.length > 0 && (() => {
|
|
836
|
+
const groups: { key: 'cloned-projects' | 'tmp' | 'scratch'; label: string; entries: typeof cacheInfo.entries }[] = [
|
|
837
|
+
{ key: 'cloned-projects', label: 'Cloned repos', entries: cacheInfo.entries.filter(e => (e.category || 'cloned-projects') === 'cloned-projects') },
|
|
838
|
+
{ key: 'tmp', label: 'Chat tmp/', entries: cacheInfo.entries.filter(e => e.category === 'tmp') },
|
|
839
|
+
{ key: 'scratch', label: 'Legacy scratch/', entries: cacheInfo.entries.filter(e => e.category === 'scratch') },
|
|
840
|
+
];
|
|
841
|
+
return groups.filter(g => g.entries.length > 0).map((g) => {
|
|
842
|
+
const groupBytes = g.entries.reduce((s, e) => s + e.bytes, 0);
|
|
843
|
+
return (
|
|
844
|
+
<div key={g.key} className="mb-1">
|
|
845
|
+
<div className="flex items-center gap-2 px-2 py-0.5 text-[9px] uppercase tracking-wide text-[var(--text-secondary)] border-b border-[var(--border)]">
|
|
846
|
+
<span className="flex-1">{g.label}</span>
|
|
847
|
+
<span>{humanBytes(groupBytes)}</span>
|
|
848
|
+
<button
|
|
849
|
+
disabled={cacheBusy}
|
|
850
|
+
onClick={() => clearCache(undefined, g.key)}
|
|
851
|
+
className="text-[9px] text-red-400 hover:underline disabled:opacity-50"
|
|
852
|
+
title={`Clear all ${g.label.toLowerCase()}`}
|
|
853
|
+
>
|
|
854
|
+
clear
|
|
855
|
+
</button>
|
|
856
|
+
</div>
|
|
857
|
+
{g.entries.map((e) => (
|
|
858
|
+
<button
|
|
859
|
+
key={`${g.key}/${e.name}`}
|
|
860
|
+
disabled={cacheBusy}
|
|
861
|
+
onClick={() => clearCache(e.name, g.key)}
|
|
862
|
+
className="w-full text-left px-2 py-1 text-[10px] text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] flex items-center gap-2 disabled:opacity-50"
|
|
863
|
+
title={`Click to delete ${g.key}/${e.name}`}
|
|
864
|
+
>
|
|
865
|
+
<span className="flex-1 truncate">{e.name}</span>
|
|
866
|
+
<span className="text-[9px] text-[var(--text-secondary)] flex-shrink-0">{humanBytes(e.bytes)}</span>
|
|
867
|
+
</button>
|
|
868
|
+
))}
|
|
869
|
+
</div>
|
|
870
|
+
);
|
|
871
|
+
});
|
|
872
|
+
})()}
|
|
821
873
|
{cacheInfo && cacheInfo.entries.length > 0 && (
|
|
822
874
|
<button
|
|
823
875
|
disabled={cacheBusy}
|
|
@@ -16,7 +16,18 @@ function extOf(p: string): string {
|
|
|
16
16
|
return m ? m[1].toLowerCase() : '';
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
export default function ScratchViewer({
|
|
19
|
+
export default function ScratchViewer({
|
|
20
|
+
path,
|
|
21
|
+
apiBase = '/api/scratch',
|
|
22
|
+
topLabel = 'scratch',
|
|
23
|
+
}: {
|
|
24
|
+
path: string;
|
|
25
|
+
/** API route prefix. /api/scratch resolves under <dataDir>/scratch/;
|
|
26
|
+
* /api/files resolves dataDir-wide. Default scratch for back-compat. */
|
|
27
|
+
apiBase?: string;
|
|
28
|
+
/** Tiny header tag shown next to the file path (e.g. "scratch", "file"). */
|
|
29
|
+
topLabel?: string;
|
|
30
|
+
}) {
|
|
20
31
|
const decoded = (() => {
|
|
21
32
|
try {
|
|
22
33
|
return decodeURIComponent(path);
|
|
@@ -25,7 +36,7 @@ export default function ScratchViewer({ path }: { path: string }) {
|
|
|
25
36
|
}
|
|
26
37
|
})();
|
|
27
38
|
const ext = extOf(decoded);
|
|
28
|
-
const rawUrl =
|
|
39
|
+
const rawUrl = `${apiBase}/${path}`;
|
|
29
40
|
const downloadUrl = `${rawUrl}?download=1`;
|
|
30
41
|
|
|
31
42
|
const [text, setText] = useState<string | null>(null);
|
|
@@ -67,7 +78,7 @@ export default function ScratchViewer({ path }: { path: string }) {
|
|
|
67
78
|
<div className="min-h-screen bg-[var(--bg-primary)] text-[var(--text-primary)]">
|
|
68
79
|
<header className="sticky top-0 z-10 flex items-center justify-between gap-2 px-4 py-2 border-b border-[var(--border)] bg-[var(--bg-secondary)]">
|
|
69
80
|
<div className="flex items-center gap-2 min-w-0">
|
|
70
|
-
<span className="text-[10px] uppercase tracking-wide text-[var(--text-secondary)]">
|
|
81
|
+
<span className="text-[10px] uppercase tracking-wide text-[var(--text-secondary)]">{topLabel}</span>
|
|
71
82
|
<span className="text-xs font-mono truncate" title={decoded}>
|
|
72
83
|
{decoded}
|
|
73
84
|
</span>
|