@aion0/forge 0.10.51 → 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/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 cursor-pointer mb-1 transition-colors ${
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
- <div className="truncate flex-1">
301
- {s.title || (isMain ? 'Main conversation' : `Temp · ${s.id.slice(0, 6)}`)}
302
- </div>
303
- {!isMain && (
304
- <button
305
- onClick={(e) => { e.stopPropagation(); deleteSession(s.id); }}
306
- className="opacity-0 group-hover:opacity-100 text-[var(--text-secondary)] hover:text-red-400 text-base leading-none px-1"
307
- title="Delete session"
308
- >
309
- ×
310
- </button>
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 === 'Enter' && !e.shiftKey) {
439
- e.preventDefault();
440
- send();
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 || streaming}
444
- placeholder={activeId ? 'Message… (Enter to send · Shift+Enter for newline)' : 'Pick or create a session'}
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 || streaming}
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
- {streaming ? '…' : 'Send'}
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={`shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold ${
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
- {isUser ? 'U' : 'AI'}
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)]">{role}</span>
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
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * /scratch/<path...> — in-browser viewer for files under <dataDir>/scratch/.
3
+ *
4
+ * Why this page exists: the raw `/api/scratch/<path>` route returns the
5
+ * file with its proper MIME, which works for images / pdf but is poor UX
6
+ * for markdown — browsers either show raw text or trigger a download
7
+ * (depending on the browser's text/markdown handling).
8
+ *
9
+ * This wrapper renders .md through MarkdownContent (same renderer as
10
+ * chat), and falls back to a plain <pre> for other text formats. It also
11
+ * exposes a one-click download link for any file type.
12
+ */
13
+
14
+ import ScratchViewer from '@/components/ScratchViewer';
15
+
16
+ export default async function ScratchPage({
17
+ params,
18
+ }: {
19
+ params: Promise<{ path: string[] }>;
20
+ }) {
21
+ const { path } = await params;
22
+ const joined = (path || []).map(encodeURIComponent).join('/');
23
+ return <ScratchViewer path={joined} />;
24
+ }
@@ -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 (forge-managed cloned-projects) load + clear helpers. Used by the
187
- // user-menu "Cache" entry to show on-disk size and let the user wipe it
188
- // without dropping to the terminal. Lazy only fetched on menu open.
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
- const clearCache = async (name?: string) => {
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 ok = confirm(name ? `Delete cached repo "${name}"?` : 'Delete ALL cached repos? They will be re-cloned on next pipeline run.');
207
- if (!ok) return;
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 q = name ? `?name=${encodeURIComponent(name)}` : '';
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 — show on-disk size of auto-cloned gitlab repos
787
- under <dataDir>/cloned-projects/, with a click-to-clear
788
- sub-menu listing each entry. Same backend as
789
- `forge clean cache` CLI. */}
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
- {cacheInfo?.entries.map((e) => (
810
- <button
811
- key={e.name}
812
- disabled={cacheBusy}
813
- onClick={() => clearCache(e.name)}
814
- 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"
815
- title={`Click to delete ${e.name}`}
816
- >
817
- <span className="flex-1 truncate">{e.name}</span>
818
- <span className="text-[9px] text-[var(--text-secondary)] flex-shrink-0">{humanBytes(e.bytes)}</span>
819
- </button>
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}