@aion0/forge 0.10.53 → 0.10.56
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 +14 -3
- package/app/api/activity/summary/route.ts +30 -0
- 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 +82 -26
- package/components/PipelineView.tsx +40 -7
- package/components/ScratchViewer.tsx +14 -3
- package/lib/chat/agent-loop.ts +95 -2
- package/lib/chat/input-queue.ts +159 -0
- package/lib/chat/link-patterns.ts +28 -5
- package/lib/chat/tool-dispatcher.ts +270 -17
- package/lib/chat/turn-control.ts +109 -0
- package/lib/chat-standalone.ts +75 -21
- 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/init.ts +14 -0
- package/lib/pipeline.ts +11 -0
- package/lib/scratch-cleanup.ts +25 -16
- package/lib/task-manager.ts +30 -0
- 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
|
@@ -121,8 +121,12 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
121
121
|
}
|
|
122
122
|
// Optional deep-link to a specific pipeline run — used by the extension
|
|
123
123
|
// Jobs tab when surfacing a dispatch's target pipeline.
|
|
124
|
-
const pid = params.get('pipelineId');
|
|
125
|
-
if (pid) setPendingPipelineId(pid);
|
|
124
|
+
const pid = params.get('pipelineId') || params.get('pipeline');
|
|
125
|
+
if (pid) { setPendingPipelineId(pid); setViewMode('pipelines' as any); }
|
|
126
|
+
// Same shape for tasks — extension ActivityBar deeplinks
|
|
127
|
+
// ?task=<id> to jump straight to that task in the Tasks view.
|
|
128
|
+
const tid = params.get('taskId') || params.get('task');
|
|
129
|
+
if (tid) { setActiveTaskId(tid); setViewMode('tasks' as any); }
|
|
126
130
|
}, []);
|
|
127
131
|
// workspaceProject state kept for forge:open-terminal event compatibility
|
|
128
132
|
const [workspaceProject, setWorkspaceProject] = useState<{ name: string; path: string } | null>(null);
|
|
@@ -176,16 +180,18 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
176
180
|
const [expandedNotif, setExpandedNotif] = useState<string | null>(null);
|
|
177
181
|
const [showUserMenu, setShowUserMenu] = useState(false);
|
|
178
182
|
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
|
|
179
|
-
const [cacheInfo, setCacheInfo] = useState<{ entries: { name: string; bytes: number }[]; total_bytes: number } | null>(null);
|
|
183
|
+
const [cacheInfo, setCacheInfo] = useState<{ entries: { name: string; bytes: number; category?: 'cloned-projects' | 'tmp' | 'scratch' }[]; total_bytes: number } | null>(null);
|
|
180
184
|
const [showCacheMenu, setShowCacheMenu] = useState(false);
|
|
181
185
|
const [cacheBusy, setCacheBusy] = useState(false);
|
|
182
186
|
const [displayName, setDisplayName] = useState(user?.name || 'Forge');
|
|
183
187
|
const [profileDept, setProfileDept] = useState('');
|
|
184
188
|
const terminalRef = useRef<WebTerminalHandle>(null);
|
|
185
189
|
|
|
186
|
-
// Cache
|
|
187
|
-
//
|
|
188
|
-
//
|
|
190
|
+
// Cache load + clear helpers. Two categories live under the same
|
|
191
|
+
// endpoint: `cloned-projects` (pipeline git clones) and `tmp` (chat's
|
|
192
|
+
// save_tmp_file output). Entries arrive flat with a `category` field;
|
|
193
|
+
// the UI groups them visually and offers per-category Clear in
|
|
194
|
+
// addition to the catch-all Clear All.
|
|
189
195
|
const loadCacheInfo = async () => {
|
|
190
196
|
try {
|
|
191
197
|
const r = await fetch('/api/cache');
|
|
@@ -201,13 +207,32 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
201
207
|
if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`;
|
|
202
208
|
return `${(n / 1024 ** 3).toFixed(2)} GB`;
|
|
203
209
|
};
|
|
204
|
-
|
|
210
|
+
// name + category together identify an entry (cloned-projects/<name>
|
|
211
|
+
// and tmp/<name> are distinct files). Omit both → wipe all. Omit name
|
|
212
|
+
// but pass category → wipe that whole category.
|
|
213
|
+
const clearCache = async (name?: string, category?: 'cloned-projects' | 'tmp' | 'scratch') => {
|
|
205
214
|
if (cacheBusy) return;
|
|
206
|
-
const
|
|
207
|
-
|
|
215
|
+
const labelFor = (cat?: string) =>
|
|
216
|
+
cat === 'tmp' ? 'tmp file'
|
|
217
|
+
: cat === 'scratch' ? 'legacy scratch file'
|
|
218
|
+
: cat === 'cloned-projects' ? 'cached repo'
|
|
219
|
+
: 'cache entry';
|
|
220
|
+
const prompt = name
|
|
221
|
+
? `Delete ${labelFor(category)} "${name}"?`
|
|
222
|
+
: category === 'tmp'
|
|
223
|
+
? 'Delete ALL chat-saved tmp/ files? They are ephemeral by design.'
|
|
224
|
+
: category === 'scratch'
|
|
225
|
+
? 'Delete ALL legacy files at scratch/ root? (Task workdirs and CLAUDE.md marker are NOT touched.)'
|
|
226
|
+
: category === 'cloned-projects'
|
|
227
|
+
? 'Delete ALL cached repos? They will be re-cloned on next pipeline run.'
|
|
228
|
+
: 'Delete ALL cached files (cloned repos + tmp/ + scratch/ root)? Cloned repos will be re-cloned; tmp/ and scratch/ files are gone for good.';
|
|
229
|
+
if (!confirm(prompt)) return;
|
|
208
230
|
setCacheBusy(true);
|
|
209
231
|
try {
|
|
210
|
-
const
|
|
232
|
+
const params = new URLSearchParams();
|
|
233
|
+
if (name) params.set('name', name);
|
|
234
|
+
if (category) params.set('category', category);
|
|
235
|
+
const q = params.toString() ? `?${params}` : '';
|
|
211
236
|
const r = await fetch(`/api/cache${q}`, { method: 'DELETE' });
|
|
212
237
|
const data = await r.json();
|
|
213
238
|
if (data?.ok) {
|
|
@@ -783,10 +808,10 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
783
808
|
)}
|
|
784
809
|
</div>
|
|
785
810
|
)}
|
|
786
|
-
{/* Cache —
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
`forge clean cache
|
|
811
|
+
{/* Cache — two on-disk pools: cloned-projects (auto-cloned
|
|
812
|
+
gitlab repos for pipelines) + tmp (chat's save_tmp_file
|
|
813
|
+
output). Groups visually + offers per-category Clear
|
|
814
|
+
plus Clear All. Same backend as `forge clean cache`. */}
|
|
790
815
|
<button
|
|
791
816
|
onClick={() => setShowCacheMenu(v => !v)}
|
|
792
817
|
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 +831,49 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
806
831
|
{cacheInfo && cacheInfo.entries.length === 0 && (
|
|
807
832
|
<div className="text-[10px] text-[var(--text-secondary)] py-1 px-2">Empty.</div>
|
|
808
833
|
)}
|
|
809
|
-
{
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
834
|
+
{/* Render entries grouped by category (cloned-projects
|
|
835
|
+
then tmp) with a category header row + per-category
|
|
836
|
+
"Clear category" link. Entries without an explicit
|
|
837
|
+
category (older API responses) fall under cloned-
|
|
838
|
+
projects for back-compat. */}
|
|
839
|
+
{cacheInfo && cacheInfo.entries.length > 0 && (() => {
|
|
840
|
+
const groups: { key: 'cloned-projects' | 'tmp' | 'scratch'; label: string; entries: typeof cacheInfo.entries }[] = [
|
|
841
|
+
{ key: 'cloned-projects', label: 'Cloned repos', entries: cacheInfo.entries.filter(e => (e.category || 'cloned-projects') === 'cloned-projects') },
|
|
842
|
+
{ key: 'tmp', label: 'Chat tmp/', entries: cacheInfo.entries.filter(e => e.category === 'tmp') },
|
|
843
|
+
{ key: 'scratch', label: 'Legacy scratch/', entries: cacheInfo.entries.filter(e => e.category === 'scratch') },
|
|
844
|
+
];
|
|
845
|
+
return groups.filter(g => g.entries.length > 0).map((g) => {
|
|
846
|
+
const groupBytes = g.entries.reduce((s, e) => s + e.bytes, 0);
|
|
847
|
+
return (
|
|
848
|
+
<div key={g.key} className="mb-1">
|
|
849
|
+
<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)]">
|
|
850
|
+
<span className="flex-1">{g.label}</span>
|
|
851
|
+
<span>{humanBytes(groupBytes)}</span>
|
|
852
|
+
<button
|
|
853
|
+
disabled={cacheBusy}
|
|
854
|
+
onClick={() => clearCache(undefined, g.key)}
|
|
855
|
+
className="text-[9px] text-red-400 hover:underline disabled:opacity-50"
|
|
856
|
+
title={`Clear all ${g.label.toLowerCase()}`}
|
|
857
|
+
>
|
|
858
|
+
clear
|
|
859
|
+
</button>
|
|
860
|
+
</div>
|
|
861
|
+
{g.entries.map((e) => (
|
|
862
|
+
<button
|
|
863
|
+
key={`${g.key}/${e.name}`}
|
|
864
|
+
disabled={cacheBusy}
|
|
865
|
+
onClick={() => clearCache(e.name, g.key)}
|
|
866
|
+
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"
|
|
867
|
+
title={`Click to delete ${g.key}/${e.name}`}
|
|
868
|
+
>
|
|
869
|
+
<span className="flex-1 truncate">{e.name}</span>
|
|
870
|
+
<span className="text-[9px] text-[var(--text-secondary)] flex-shrink-0">{humanBytes(e.bytes)}</span>
|
|
871
|
+
</button>
|
|
872
|
+
))}
|
|
873
|
+
</div>
|
|
874
|
+
);
|
|
875
|
+
});
|
|
876
|
+
})()}
|
|
821
877
|
{cacheInfo && cacheInfo.entries.length > 0 && (
|
|
822
878
|
<button
|
|
823
879
|
disabled={cacheBusy}
|
|
@@ -215,9 +215,15 @@ const STATUS_COLOR: Record<string, string> = {
|
|
|
215
215
|
// this is just a compact status bar to make the loop structure clear.
|
|
216
216
|
// 2. Iterations — each completed iteration as a foldable card; current
|
|
217
217
|
// iter shown as a "running ↓" hint pointing to the DAG nodes below.
|
|
218
|
-
function ForEachStatePanel({ pipeline, onViewTask }: {
|
|
218
|
+
function ForEachStatePanel({ pipeline, onViewTask, onRetry }: {
|
|
219
219
|
pipeline: Pipeline;
|
|
220
220
|
onViewTask?: (taskId: string) => void;
|
|
221
|
+
/** Retry a failed node — wired to /api/pipelines/:id retry-node. Currently
|
|
222
|
+
* the backend resets the node + downstream and (for forEach) rewinds the
|
|
223
|
+
* iteration cursor so the orchestrator picks up from the failed iter and
|
|
224
|
+
* continues through the remaining iterations. Surfaces "only last iter
|
|
225
|
+
* supported" errors from the backend as an alert. */
|
|
226
|
+
onRetry?: (nodeId: string) => void;
|
|
221
227
|
}) {
|
|
222
228
|
const fe = pipeline.forEach!;
|
|
223
229
|
const [openIdx, setOpenIdx] = useState<number | null>(null);
|
|
@@ -325,24 +331,47 @@ function ForEachStatePanel({ pipeline, onViewTask }: {
|
|
|
325
331
|
<div className="px-3 py-2 space-y-1 border-t border-[var(--border)] bg-[var(--bg-tertiary)]/30">
|
|
326
332
|
{Object.entries(iter.nodes).map(([nodeId, n]) => {
|
|
327
333
|
const clickable = !!(n.taskId && onViewTask);
|
|
328
|
-
const
|
|
334
|
+
const retriable = !!onRetry && (n.status === 'failed' || n.status === 'cancelled');
|
|
335
|
+
// Row is a div when there's a retry button — buttons can't
|
|
336
|
+
// nest inside buttons. Status icon / "view task" become
|
|
337
|
+
// their own click targets in that case.
|
|
338
|
+
const rowAsButton = clickable && !retriable;
|
|
339
|
+
const Tag = rowAsButton ? 'button' : 'div';
|
|
329
340
|
return (
|
|
330
341
|
<Tag
|
|
331
342
|
key={nodeId}
|
|
332
|
-
onClick={
|
|
333
|
-
className={`flex items-start gap-2 text-[10px] w-full text-left rounded px-1 -mx-1 py-0.5 ${
|
|
334
|
-
title={
|
|
343
|
+
onClick={rowAsButton ? () => onViewTask!(n.taskId!) : undefined}
|
|
344
|
+
className={`flex items-start gap-2 text-[10px] w-full text-left rounded px-1 -mx-1 py-0.5 ${rowAsButton ? 'hover:bg-[var(--bg-secondary)] cursor-pointer' : ''}`}
|
|
345
|
+
title={rowAsButton ? `View task ${n.taskId}` : undefined}
|
|
335
346
|
>
|
|
336
347
|
<span className={STATUS_COLOR[n.status] ?? 'text-gray-400'}>
|
|
337
348
|
{STATUS_ICON[n.status] ?? '?'}
|
|
338
349
|
</span>
|
|
339
350
|
<span className="font-mono">{nodeId}</span>
|
|
340
|
-
{clickable &&
|
|
351
|
+
{clickable && !rowAsButton && (
|
|
352
|
+
<button
|
|
353
|
+
type="button"
|
|
354
|
+
onClick={(e) => { e.stopPropagation(); onViewTask!(n.taskId!); }}
|
|
355
|
+
className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--accent)] underline"
|
|
356
|
+
title={`View task ${n.taskId}`}
|
|
357
|
+
>↗</button>
|
|
358
|
+
)}
|
|
359
|
+
{clickable && rowAsButton && (
|
|
360
|
+
<span className="text-[9px] text-[var(--text-secondary)]">↗</span>
|
|
361
|
+
)}
|
|
341
362
|
{n.error && (
|
|
342
363
|
<span className="text-red-400 truncate flex-1" title={n.error}>
|
|
343
364
|
— {n.error.slice(0, 80)}
|
|
344
365
|
</span>
|
|
345
366
|
)}
|
|
367
|
+
{retriable && (
|
|
368
|
+
<button
|
|
369
|
+
type="button"
|
|
370
|
+
onClick={(e) => { e.stopPropagation(); onRetry!(nodeId); }}
|
|
371
|
+
className="ml-auto text-[9px] px-1.5 py-0 border border-yellow-500/50 text-yellow-400 hover:bg-yellow-500/10 rounded"
|
|
372
|
+
title={`Retry ${nodeId} — resets this node and any downstream, and (for forEach) resumes from this iteration through the remaining items. Use this instead of retrying the underlying task, which leaves the pipeline stuck.`}
|
|
373
|
+
>↻ retry</button>
|
|
374
|
+
)}
|
|
346
375
|
</Tag>
|
|
347
376
|
);
|
|
348
377
|
})}
|
|
@@ -1554,7 +1583,11 @@ initial_prompt: "{{input.task}}"
|
|
|
1554
1583
|
<div className="overflow-y-auto">
|
|
1555
1584
|
{/* for_each loop: setup + iteration history above the current-iter nodes */}
|
|
1556
1585
|
{selectedPipeline.forEach && (
|
|
1557
|
-
<ForEachStatePanel
|
|
1586
|
+
<ForEachStatePanel
|
|
1587
|
+
pipeline={selectedPipeline}
|
|
1588
|
+
onViewTask={onViewTask}
|
|
1589
|
+
onRetry={(nid) => handleRetryNode(selectedPipeline.id, nid)}
|
|
1590
|
+
/>
|
|
1558
1591
|
)}
|
|
1559
1592
|
<div className="p-4 space-y-2">
|
|
1560
1593
|
{(() => {
|