@aion0/forge 0.5.48 → 0.5.49

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/CLAUDE.md CHANGED
@@ -104,7 +104,6 @@ When adding or changing a feature, check if `lib/help-docs/` needs updating. Eac
104
104
  - `10-troubleshooting.md` — common issues
105
105
  - `11-workspace.md` — multi-agent workspace (smiths, daemon, request docs)
106
106
  - `12-usage.md` — token usage analytics and cost tracking
107
- - `14-migration.md` — API migration cockpit (legacy ↔ new parity testing)
108
107
 
109
108
  If a feature change affects user-facing behavior, update the corresponding help doc in the same commit. These docs are also served to the in-app Help AI — `lib/help-docs/CLAUDE.md` is its system prompt.
110
109
 
package/RELEASE_NOTES.md CHANGED
@@ -1,8 +1,13 @@
1
- # Forge v0.5.48
1
+ # Forge v0.5.49
2
2
 
3
3
  Released: 2026-04-27
4
4
 
5
- ## Changes since v0.5.47
5
+ ## Changes since v0.5.48
6
6
 
7
+ ### Other
8
+ - perf(tasks): paginated log endpoint with per-entry truncation
9
+ - perf(tasks): /api/tasks list ships only metadata, TaskDetail lazy-fetches body
10
+ - fix(tasks): TaskDetail freezes browser on large session logs
7
11
 
8
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.47...v0.5.48
12
+
13
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.48...v0.5.49
@@ -0,0 +1,13 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { getTaskLogEntry } from '@/lib/task-manager';
3
+
4
+ // GET /api/tasks/[id]/log/entry?i=N — fetch one full log entry, untruncated.
5
+ export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
6
+ const { id } = await params;
7
+ const url = new URL(req.url);
8
+ const i = Number(url.searchParams.get('i'));
9
+ if (!Number.isFinite(i)) return NextResponse.json({ error: 'i required' }, { status: 400 });
10
+ const entry = getTaskLogEntry(id, i);
11
+ if (!entry) return NextResponse.json({ error: 'not found' }, { status: 404 });
12
+ return NextResponse.json(entry);
13
+ }
@@ -0,0 +1,23 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { getTaskLogSlice, getTaskBody } from '@/lib/task-manager';
3
+
4
+ // GET /api/tasks/[id]/log?offset=N&limit=M&body=1
5
+ // Returns { entries, total, body? } — entries is the requested slice of the log.
6
+ // Default: returns the LAST `limit` entries (offset omitted).
7
+ // `body=1` also returns result_summary / git_diff / error so the client can
8
+ // populate Result / Diff tabs without a second fetch.
9
+ export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
10
+ const { id } = await params;
11
+ const url = new URL(req.url);
12
+ const offsetParam = url.searchParams.get('offset');
13
+ const limitParam = url.searchParams.get('limit');
14
+ const includeBody = url.searchParams.get('body') === '1';
15
+
16
+ const slice = getTaskLogSlice(id, {
17
+ offset: offsetParam !== null ? Number(offsetParam) : undefined,
18
+ limit: limitParam !== null ? Number(limitParam) : undefined,
19
+ });
20
+
21
+ const body = includeBody ? getTaskBody(id) : null;
22
+ return NextResponse.json({ ...slice, body });
23
+ }
@@ -1,5 +1,5 @@
1
1
  import { NextResponse } from 'next/server';
2
- import { createTask, listTasks } from '@/lib/task-manager';
2
+ import { createTask, listTasksLite } from '@/lib/task-manager';
3
3
  import { ensureInitialized } from '@/lib/init';
4
4
  import { getProjectInfo } from '@/lib/projects';
5
5
  import type { TaskStatus } from '@/src/types';
@@ -9,7 +9,7 @@ export async function GET(req: Request) {
9
9
  ensureInitialized();
10
10
  const url = new URL(req.url);
11
11
  const status = url.searchParams.get('status') as TaskStatus | null;
12
- return NextResponse.json(listTasks(status || undefined));
12
+ return NextResponse.json(listTasksLite(status || undefined));
13
13
  }
14
14
 
15
15
  // Create a new task
@@ -7,7 +7,6 @@ import { TerminalSessionPickerLazy, fetchProjectSessions } from './TerminalLaunc
7
7
  const InlinePipelineView = lazy(() => import('./InlinePipelineView'));
8
8
  const WorkspaceViewLazy = lazy(() => import('./WorkspaceView'));
9
9
  const SessionViewLazy = lazy(() => import('./SessionView'));
10
- const MigrationCockpitLazy = lazy(() => import('./MigrationCockpit'));
11
10
 
12
11
  // ─── Syntax highlighting ─────────────────────────────────
13
12
  const KEYWORDS = new Set([
@@ -86,7 +85,7 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
86
85
  const [diffFile, setDiffFile] = useState<string | null>(null);
87
86
  const [projectSkills, setProjectSkills] = useState<{ name: string; displayName: string; type: string; scope: string; version: string; installedVersion: string; hasUpdate: boolean; source: 'registry' | 'local' }[]>([]);
88
87
  const [showSkillsDetail, setShowSkillsDetail] = useState(false);
89
- const [projectTab, setProjectTab] = useState<'workspace' | 'sessions' | 'code' | 'skills' | 'claudemd' | 'pipelines' | 'migration'>('code');
88
+ const [projectTab, setProjectTab] = useState<'workspace' | 'sessions' | 'code' | 'skills' | 'claudemd' | 'pipelines'>('code');
90
89
  // Lazy-mount workspace: only mount after first visit, keep mounted to preserve terminal state
91
90
  const [wsMounted, setWsMounted] = useState(false);
92
91
  useEffect(() => { if (projectTab === 'workspace') setWsMounted(true); }, [projectTab]);
@@ -618,13 +617,6 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
618
617
  Pipelines
619
618
  {pipelineBindings.length > 0 && <span className="ml-1 text-[9px] opacity-70">({pipelineBindings.length})</span>}
620
619
  </button>
621
- <button
622
- onClick={() => setProjectTab('migration')}
623
- className={`text-[11px] font-medium px-2.5 py-1 rounded transition-all ${
624
- projectTab === 'migration' ? 'bg-[var(--accent)]/20 text-[var(--accent)] shadow-sm ring-1 ring-[var(--accent)]/40' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'
625
- }`}
626
- title="API Migration Cockpit — parity testing between legacy and new modules"
627
- >🚚 Migration</button>
628
620
  </div>
629
621
  </div>
630
622
  {projectTab === 'code' && gitInfo?.lastCommit && (
@@ -1381,13 +1373,6 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
1381
1373
  </div>
1382
1374
  )}
1383
1375
 
1384
- {/* Migration tab */}
1385
- {projectTab === 'migration' && (
1386
- <Suspense fallback={<div className="p-4 text-xs text-[var(--text-secondary)]">Loading migration cockpit…</div>}>
1387
- <MigrationCockpitLazy projectPath={projectPath} projectName={projectName} />
1388
- </Suspense>
1389
- )}
1390
-
1391
1376
  {/* Git panel — bottom (code tab only) */}
1392
1377
  {projectTab === 'code' && gitInfo && (
1393
1378
  <div className="border-t border-[var(--border)] shrink-0">
@@ -1,10 +1,17 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useRef } from 'react';
3
+ import { useState, useEffect, useRef, memo, useCallback, useMemo } from 'react';
4
4
  import MarkdownContent from './MarkdownContent';
5
5
  import NewTaskModal from './NewTaskModal';
6
6
  import type { Task, TaskLogEntry } from '@/src/types';
7
7
 
8
+ // Bound the rendered log/diff to keep React from choking on huge sessions.
9
+ // Each LogEntry can include MarkdownContent and tool_use payloads (often
10
+ // kilobytes per entry); rendering even ~200 fat entries can take a beat.
11
+ const LOG_DEFAULT_TAIL = 100; // initial fetch from server
12
+ const LOG_LOAD_CHUNK = 100; // each "load earlier" press
13
+ const DIFF_DEFAULT_LINES = 1000;
14
+
8
15
  export default function TaskDetail({
9
16
  task,
10
17
  onRefresh,
@@ -16,11 +23,66 @@ export default function TaskDetail({
16
23
  }) {
17
24
  const [liveLog, setLiveLog] = useState<TaskLogEntry[]>(task.log);
18
25
  const [liveStatus, setLiveStatus] = useState(task.status);
26
+ const [taskBody, setTaskBody] = useState<{ resultSummary?: string; gitDiff?: string; error?: string } | null>(null);
27
+ const [logTotal, setLogTotal] = useState<number>(task.log?.length ?? 0);
28
+ const [logOffset, setLogOffset] = useState<number>(0); // offset of liveLog[0] in the full log
29
+ const [detailLoading, setDetailLoading] = useState(false);
30
+ const [loadingMore, setLoadingMore] = useState(false);
19
31
  const [tab, setTab] = useState<'log' | 'diff' | 'result'>('log');
20
32
  const [expandedTools, setExpandedTools] = useState<Set<number>>(new Set());
21
33
  const [followUpText, setFollowUpText] = useState('');
22
34
  const [editing, setEditing] = useState(false);
35
+ const [visibleTail, setVisibleTail] = useState(LOG_DEFAULT_TAIL);
36
+ const [showFullDiff, setShowFullDiff] = useState(false);
23
37
  const logEndRef = useRef<HTMLDivElement>(null);
38
+ const logScrollRef = useRef<HTMLDivElement>(null);
39
+
40
+ // Chunked log fetch — list endpoint ships only metadata, so for finished
41
+ // tasks we pull the LAST chunk of log entries via the JSON1-backed slice
42
+ // endpoint. JSON1 lets sqlite slice the array without parsing the whole
43
+ // 1+ MB blob in either node or the browser.
44
+ useEffect(() => {
45
+ let cancelled = false;
46
+ if (task.status === 'running' || task.status === 'queued') return; // SSE handles it
47
+ if (task.log && task.log.length > 0) return; // already populated
48
+ setDetailLoading(true);
49
+ fetch(`/api/tasks/${task.id}/log?limit=${LOG_DEFAULT_TAIL}&body=1`)
50
+ .then(r => r.ok ? r.json() : null)
51
+ .then((data: { entries: TaskLogEntry[]; total: number; body: any } | null) => {
52
+ if (cancelled || !data) return;
53
+ setLiveLog(data.entries);
54
+ setLogTotal(data.total);
55
+ setLogOffset(Math.max(0, data.total - data.entries.length));
56
+ if (data.body) setTaskBody(data.body);
57
+ })
58
+ .finally(() => { if (!cancelled) setDetailLoading(false); });
59
+ return () => { cancelled = true; };
60
+ }, [task.id, task.status, task.log]);
61
+
62
+ const loadFullEntry = useCallback(async (index: number) => {
63
+ const res = await fetch(`/api/tasks/${task.id}/log/entry?i=${index}`);
64
+ if (!res.ok) return;
65
+ const full = await res.json() as TaskLogEntry;
66
+ full._index = index;
67
+ setLiveLog(prev => prev.map(e => e._index === index ? full : e));
68
+ }, [task.id]);
69
+
70
+ const loadEarlier = useCallback(async (chunk: number) => {
71
+ if (loadingMore || logOffset === 0) return;
72
+ setLoadingMore(true);
73
+ const wantOffset = Math.max(0, logOffset - chunk);
74
+ const wantLimit = logOffset - wantOffset;
75
+ try {
76
+ const res = await fetch(`/api/tasks/${task.id}/log?offset=${wantOffset}&limit=${wantLimit}`);
77
+ if (!res.ok) return;
78
+ const data = await res.json() as { entries: TaskLogEntry[]; total: number };
79
+ setLiveLog(prev => [...data.entries, ...prev]);
80
+ setLogOffset(wantOffset);
81
+ setLogTotal(data.total);
82
+ } finally {
83
+ setLoadingMore(false);
84
+ }
85
+ }, [task.id, logOffset, loadingMore]);
24
86
 
25
87
  // SSE stream for running tasks
26
88
  useEffect(() => {
@@ -58,9 +120,22 @@ export default function TaskDetail({
58
120
  return () => es.close();
59
121
  }, [task.id, task.status, onRefresh]);
60
122
 
123
+ // Auto-scroll only when the user is already near the bottom — otherwise we yank
124
+ // them away from a line they're trying to read.
61
125
  useEffect(() => {
62
- logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
63
- }, [liveLog]);
126
+ const el = logScrollRef.current;
127
+ if (!el) return;
128
+ const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 200;
129
+ if (nearBottom) logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
130
+ }, [liveLog.length]);
131
+
132
+ const toggleTool = useCallback((i: number) => {
133
+ setExpandedTools(prev => {
134
+ const next = new Set(prev);
135
+ next.has(i) ? next.delete(i) : next.add(i);
136
+ return next;
137
+ });
138
+ }, []);
64
139
 
65
140
  const handleAction = async (action: string) => {
66
141
  await fetch(`/api/tasks/${task.id}`, {
@@ -71,15 +146,14 @@ export default function TaskDetail({
71
146
  onRefresh();
72
147
  };
73
148
 
74
- const toggleTool = (i: number) => {
75
- setExpandedTools(prev => {
76
- const next = new Set(prev);
77
- next.has(i) ? next.delete(i) : next.add(i);
78
- return next;
79
- });
80
- };
81
-
82
149
  const displayLog = liveLog.length > 0 ? liveLog : task.log;
150
+ // Two layers of "hidden": (a) entries on disk we haven't fetched yet (logOffset > 0),
151
+ // (b) entries we've fetched but are clipped by the in-memory tail cap.
152
+ const inMemoryStart = Math.max(0, displayLog.length - visibleTail);
153
+ const visibleLog = useMemo(() => displayLog.slice(inMemoryStart), [displayLog, inMemoryStart]);
154
+ const hiddenInMemory = inMemoryStart;
155
+ const hiddenOnServer = logOffset;
156
+ const hiddenTotal = hiddenInMemory + hiddenOnServer;
83
157
 
84
158
  return (
85
159
  <div className="flex flex-col h-full">
@@ -129,18 +203,55 @@ export default function TaskDetail({
129
203
  tab === t ? 'border-[var(--accent)] text-[var(--accent)]' : 'border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
130
204
  }`}
131
205
  >
132
- {t === 'log' ? `Log (${displayLog.length})` : t === 'diff' ? 'Git Diff' : 'Result'}
206
+ {t === 'log' ? `Log (${logTotal || displayLog.length})` : t === 'diff' ? 'Git Diff' : 'Result'}
133
207
  </button>
134
208
  ))}
135
209
  </div>
136
210
 
137
211
  {/* Content */}
138
- <div className="flex-1 overflow-y-auto p-4 text-sm">
212
+ <div ref={logScrollRef} className="flex-1 overflow-y-auto p-4 text-sm">
139
213
  {tab === 'log' && (
140
214
  <div className="space-y-2">
141
- {displayLog.map((entry, i) => (
142
- <LogEntry key={i} entry={entry} index={i} expanded={expandedTools.has(i)} onToggle={() => toggleTool(i)} />
143
- ))}
215
+ {detailLoading && displayLog.length === 0 && (
216
+ <div className="text-[var(--text-secondary)] text-xs py-2">
217
+ Loading last {LOG_DEFAULT_TAIL} entries{logTotal ? ` of ${logTotal}` : ''}…
218
+ </div>
219
+ )}
220
+ {hiddenTotal > 0 && (
221
+ <div className="flex items-center justify-between gap-2 py-1.5 px-2 text-[10px] text-[var(--text-secondary)] bg-[var(--bg-tertiary)] rounded border border-[var(--border)]">
222
+ <span>
223
+ {hiddenTotal} earlier {hiddenTotal === 1 ? 'entry' : 'entries'} hidden
224
+ {hiddenOnServer > 0 && <span className="opacity-60"> ({hiddenOnServer} not yet fetched)</span>}
225
+ </span>
226
+ <div className="flex gap-1">
227
+ {hiddenInMemory > 0 ? (
228
+ <button
229
+ onClick={() => setVisibleTail(v => v + LOG_LOAD_CHUNK)}
230
+ className="px-2 py-0.5 rounded bg-[var(--accent)]/15 text-[var(--accent)] hover:bg-[var(--accent)]/25"
231
+ >Show {Math.min(LOG_LOAD_CHUNK, hiddenInMemory)} more</button>
232
+ ) : (
233
+ <button
234
+ onClick={() => loadEarlier(LOG_LOAD_CHUNK)}
235
+ disabled={loadingMore}
236
+ className="px-2 py-0.5 rounded bg-[var(--accent)]/15 text-[var(--accent)] hover:bg-[var(--accent)]/25 disabled:opacity-50"
237
+ >{loadingMore ? 'Loading…' : `Fetch ${Math.min(LOG_LOAD_CHUNK, hiddenOnServer)} earlier`}</button>
238
+ )}
239
+ </div>
240
+ </div>
241
+ )}
242
+ {visibleLog.map((entry, i) => {
243
+ const absoluteIndex = entry._index ?? (logOffset + inMemoryStart + i);
244
+ return (
245
+ <LogEntry
246
+ key={absoluteIndex}
247
+ entry={entry}
248
+ index={absoluteIndex}
249
+ expanded={expandedTools.has(absoluteIndex)}
250
+ onToggle={toggleTool}
251
+ onLoadFull={loadFullEntry}
252
+ />
253
+ );
254
+ })}
144
255
  {liveStatus === 'running' && (
145
256
  <div className="text-[var(--accent)] animate-pulse py-1 text-xs">working...</div>
146
257
  )}
@@ -150,45 +261,22 @@ export default function TaskDetail({
150
261
 
151
262
  {tab === 'result' && (
152
263
  <div className="prose-container">
153
- {task.resultSummary ? (
154
- <MarkdownContent content={task.resultSummary} />
155
- ) : task.error ? (
264
+ {(taskBody?.resultSummary ?? task.resultSummary) ? (
265
+ <MarkdownContent content={(taskBody?.resultSummary ?? task.resultSummary)!} />
266
+ ) : (taskBody?.error ?? task.error) ? (
156
267
  <div className="p-3 bg-red-900/10 border border-red-800/20 rounded">
157
- <pre className="whitespace-pre-wrap break-words text-[var(--red)] text-xs font-mono">{task.error}</pre>
268
+ <pre className="whitespace-pre-wrap break-words text-[var(--red)] text-xs font-mono">{taskBody?.error ?? task.error}</pre>
158
269
  </div>
159
270
  ) : (
160
271
  <p className="text-[var(--text-secondary)] text-xs">
161
- {liveStatus === 'running' || liveStatus === 'queued' ? 'Task is still running...' : 'No result'}
272
+ {liveStatus === 'running' || liveStatus === 'queued' ? 'Task is still running...' : detailLoading ? 'Loading…' : 'No result'}
162
273
  </p>
163
274
  )}
164
275
  </div>
165
276
  )}
166
277
 
167
278
  {tab === 'diff' && (
168
- <div>
169
- {task.gitDiff ? (
170
- <div className="bg-[var(--bg-tertiary)] rounded border border-[var(--border)] overflow-hidden">
171
- <pre className="p-3 text-xs font-mono overflow-x-auto">
172
- {task.gitDiff.split('\n').map((line, i) => (
173
- <div key={i} className={`px-2 ${
174
- line.startsWith('+++') || line.startsWith('---') ? 'text-[var(--text-secondary)] font-semibold' :
175
- line.startsWith('+') ? 'text-[var(--green)] bg-green-500/5' :
176
- line.startsWith('-') ? 'text-[var(--red)] bg-red-500/5' :
177
- line.startsWith('@@') ? 'text-[var(--accent)] bg-[var(--accent)]/5 font-semibold' :
178
- line.startsWith('diff ') ? 'text-[var(--text-primary)] font-bold border-t border-[var(--border)] pt-2 mt-2' :
179
- 'text-[var(--text-secondary)]'
180
- }`}>
181
- {line}
182
- </div>
183
- ))}
184
- </pre>
185
- </div>
186
- ) : (
187
- <p className="text-[var(--text-secondary)] text-xs">
188
- {liveStatus === 'running' ? 'Diff will be captured after completion' : 'No changes detected'}
189
- </p>
190
- )}
191
- </div>
279
+ <DiffView gitDiff={taskBody?.gitDiff ?? task.gitDiff} status={liveStatus} showAll={showFullDiff} onShowAll={() => setShowFullDiff(true)} />
192
280
  )}
193
281
  </div>
194
282
 
@@ -263,12 +351,25 @@ function StatusBadge({ status }: { status: string }) {
263
351
  );
264
352
  }
265
353
 
266
- function LogEntry({ entry, index, expanded, onToggle }: {
354
+ const LogEntry = memo(function LogEntry({ entry, index, expanded, onToggle, onLoadFull }: {
267
355
  entry: TaskLogEntry;
268
356
  index: number;
269
357
  expanded: boolean;
270
- onToggle: () => void;
358
+ onToggle: (index: number) => void;
359
+ onLoadFull?: (index: number) => void;
271
360
  }) {
361
+ const handleToggle = () => onToggle(index);
362
+ const handleLoadFull = () => onLoadFull?.(index);
363
+ const truncatedBanner = entry._truncated ? (
364
+ <div className="text-[9px] text-yellow-400/80 italic mt-1">
365
+ Truncated to {(entry.content.length / 1024).toFixed(1)} KB of {(entry._truncated / 1024).toFixed(1)} KB.{' '}
366
+ {onLoadFull && (
367
+ <button onClick={handleLoadFull} className="text-[var(--accent)] hover:underline">
368
+ Show full
369
+ </button>
370
+ )}
371
+ </div>
372
+ ) : null;
272
373
  // System init
273
374
  if (entry.type === 'system' && entry.subtype === 'init') {
274
375
  return (
@@ -283,6 +384,7 @@ function LogEntry({ entry, index, expanded, onToggle }: {
283
384
  return (
284
385
  <div className="p-2 bg-red-900/10 border border-red-800/20 rounded text-xs">
285
386
  <pre className="whitespace-pre-wrap break-words text-[var(--red)] font-mono">{entry.content}</pre>
387
+ {truncatedBanner}
286
388
  </div>
287
389
  );
288
390
  }
@@ -295,7 +397,7 @@ function LogEntry({ entry, index, expanded, onToggle }: {
295
397
  return (
296
398
  <div className="border border-[var(--border)] rounded overflow-hidden">
297
399
  <button
298
- onClick={onToggle}
400
+ onClick={handleToggle}
299
401
  className="w-full flex items-center gap-2 px-2 py-1.5 bg-[var(--bg-tertiary)] hover:bg-[var(--border)]/30 transition-colors text-left"
300
402
  >
301
403
  <span className="text-[10px] px-1.5 py-0.5 bg-[var(--accent)]/15 text-[var(--accent)] rounded font-medium font-mono">
@@ -313,6 +415,7 @@ function LogEntry({ entry, index, expanded, onToggle }: {
313
415
  {toolContent}
314
416
  </pre>
315
417
  )}
418
+ {truncatedBanner}
316
419
  </div>
317
420
  );
318
421
  }
@@ -328,10 +431,11 @@ function LogEntry({ entry, index, expanded, onToggle }: {
328
431
  {content}
329
432
  </pre>
330
433
  {isLong && !expanded && (
331
- <button onClick={onToggle} className="text-[9px] text-[var(--accent)] hover:underline mt-0.5">
434
+ <button onClick={handleToggle} className="text-[9px] text-[var(--accent)] hover:underline mt-0.5">
332
435
  show more
333
436
  </button>
334
437
  )}
438
+ {truncatedBanner}
335
439
  </div>
336
440
  );
337
441
  }
@@ -341,6 +445,7 @@ function LogEntry({ entry, index, expanded, onToggle }: {
341
445
  return (
342
446
  <div className="p-3 bg-green-900/5 border border-green-800/15 rounded">
343
447
  <MarkdownContent content={entry.content} />
448
+ {truncatedBanner}
344
449
  </div>
345
450
  );
346
451
  }
@@ -349,11 +454,56 @@ function LogEntry({ entry, index, expanded, onToggle }: {
349
454
  return (
350
455
  <div className="py-1">
351
456
  <MarkdownContent content={entry.content} />
457
+ {truncatedBanner}
352
458
  </div>
353
459
  );
354
- }
460
+ });
355
461
 
356
- // MarkdownContent is now imported from ./MarkdownContent
462
+ // Diff view caps at DIFF_DEFAULT_LINES until "Show all" clicked.
463
+ function DiffView({ gitDiff, status, showAll, onShowAll }: {
464
+ gitDiff?: string;
465
+ status: string;
466
+ showAll: boolean;
467
+ onShowAll: () => void;
468
+ }) {
469
+ const allLines = useMemo(() => (gitDiff ? gitDiff.split('\n') : []), [gitDiff]);
470
+ if (!gitDiff) {
471
+ return (
472
+ <p className="text-[var(--text-secondary)] text-xs">
473
+ {status === 'running' ? 'Diff will be captured after completion' : 'No changes detected'}
474
+ </p>
475
+ );
476
+ }
477
+ const lines = showAll ? allLines : allLines.slice(0, DIFF_DEFAULT_LINES);
478
+ const truncated = !showAll && allLines.length > DIFF_DEFAULT_LINES;
479
+
480
+ return (
481
+ <div className="bg-[var(--bg-tertiary)] rounded border border-[var(--border)] overflow-hidden">
482
+ {truncated && (
483
+ <div className="flex items-center justify-between gap-2 py-1.5 px-3 text-[10px] text-[var(--text-secondary)] border-b border-[var(--border)] bg-[var(--bg-secondary)]">
484
+ <span>Showing first {DIFF_DEFAULT_LINES} of {allLines.length} lines</span>
485
+ <button onClick={onShowAll} className="px-2 py-0.5 rounded bg-[var(--accent)]/15 text-[var(--accent)] hover:bg-[var(--accent)]/25">
486
+ Show all
487
+ </button>
488
+ </div>
489
+ )}
490
+ <pre className="p-3 text-xs font-mono overflow-x-auto">
491
+ {lines.map((line, i) => (
492
+ <div key={i} className={`px-2 ${
493
+ line.startsWith('+++') || line.startsWith('---') ? 'text-[var(--text-secondary)] font-semibold' :
494
+ line.startsWith('+') ? 'text-[var(--green)] bg-green-500/5' :
495
+ line.startsWith('-') ? 'text-[var(--red)] bg-red-500/5' :
496
+ line.startsWith('@@') ? 'text-[var(--accent)] bg-[var(--accent)]/5 font-semibold' :
497
+ line.startsWith('diff ') ? 'text-[var(--text-primary)] font-bold border-t border-[var(--border)] pt-2 mt-2' :
498
+ 'text-[var(--text-secondary)]'
499
+ }`}>
500
+ {line}
501
+ </div>
502
+ ))}
503
+ </pre>
504
+ </div>
505
+ );
506
+ }
357
507
 
358
508
  function formatToolContent(content: string): string {
359
509
  try {
@@ -44,7 +44,6 @@ The token is valid for 24 hours. Store it in a variable and reuse for all API ca
44
44
  | `11-workspace.md` | Workspace (Forge Smiths) — multi-agent orchestration, daemon, message bus, profiles |
45
45
  | `12-usage.md` | Token usage analytics — charts, heatmap, cost estimation, by model/project/source |
46
46
  | `13-ide-plugins.md` | VSCode extension + IntelliJ plugin — install, tabs, multi-connection, agent terminal launching |
47
- | `14-migration.md` | API Migration Cockpit — parity testing legacy ↔ new module, doc-driven discovery, batch run, AI fix |
48
47
 
49
48
  ## Matching questions to docs
50
49
 
@@ -68,4 +67,3 @@ The token is valid for 24 hours. Store it in a variable and reuse for all API ca
68
67
  - Sidebar collapse/project tabs/favorites → `07-projects.md`
69
68
  - VSCode/IntelliJ/IDE plugin/extension/marketplace → `13-ide-plugins.md`
70
69
  - vsce/vsix/JetBrains marketplace publish → `13-ide-plugins.md`
71
- - Migration/API parity/legacy vs new/501 stub/parity test/diff endpoints → `14-migration.md`
@@ -119,6 +119,116 @@ export function listTasks(status?: TaskStatus): Task[] {
119
119
  return rows.map(rowToTask);
120
120
  }
121
121
 
122
+ // Slim list — omits the heavy fields (log / git_diff / result_summary) so the
123
+ // task list view doesn't pull megabytes of JSON for tasks with long sessions.
124
+ // Callers that need the full body should fetch /api/tasks/[id] on demand.
125
+ export function listTasksLite(status?: TaskStatus): Task[] {
126
+ const SLIM_COLS = `
127
+ id, project_name, project_path, prompt, mode, status, priority,
128
+ conversation_id, watch_config, git_branch, cost_usd, error, agent,
129
+ created_at, started_at, completed_at, scheduled_at,
130
+ length(log) AS log_size,
131
+ CASE WHEN result_summary IS NULL THEN NULL ELSE substr(result_summary, 1, 1024) END AS result_summary,
132
+ CASE WHEN git_diff IS NULL THEN 0 ELSE 1 END AS has_git_diff
133
+ `;
134
+ let query = `SELECT ${SLIM_COLS} FROM tasks`;
135
+ const params: string[] = [];
136
+ if (status) {
137
+ query += ' WHERE status = ?';
138
+ params.push(status);
139
+ }
140
+ query += ' ORDER BY created_at DESC';
141
+ const rows = db().prepare(query).all(...params) as any[];
142
+ return rows.map(r => rowToLiteTask(r));
143
+ }
144
+
145
+ // Slice the log without parsing the whole JSON in JS — sqlite JSON1's
146
+ // json_each walks the array and we LIMIT/OFFSET in SQL. For a 1.6 MB log
147
+ // with 238 entries this returns just the requested ~100 in milliseconds.
148
+ export function getTaskLogSlice(id: string, opts: { offset?: number; limit?: number; truncate?: number } = {}):
149
+ { entries: (TaskLogEntry & { _truncated?: number; _index?: number })[]; total: number } {
150
+ const totalRow = db().prepare(
151
+ `SELECT COALESCE(json_array_length(log), 0) AS n FROM tasks WHERE id = ?`
152
+ ).get(id) as { n: number } | undefined;
153
+ const total = totalRow?.n ?? 0;
154
+ if (total === 0) return { entries: [], total: 0 };
155
+
156
+ const limit = Math.max(1, Math.min(opts.limit ?? 200, 2000));
157
+ const offset = Math.max(0, opts.offset ?? Math.max(0, total - limit));
158
+ const truncate = opts.truncate ?? 8192; // per-entry content cap; 0 = no cap
159
+ const rows = db().prepare(
160
+ `SELECT json_each.key AS idx, value FROM tasks, json_each(tasks.log)
161
+ WHERE tasks.id = ?
162
+ ORDER BY json_each.key
163
+ LIMIT ? OFFSET ?`
164
+ ).all(id, limit, offset) as { idx: number; value: string }[];
165
+
166
+ const entries = rows.map(r => {
167
+ let entry: TaskLogEntry & { _truncated?: number; _index?: number };
168
+ try { entry = JSON.parse(r.value); }
169
+ catch { entry = { type: 'system', content: '<unparseable entry>' } as any; }
170
+ entry._index = r.idx;
171
+ if (truncate > 0 && typeof entry.content === 'string' && entry.content.length > truncate) {
172
+ const fullLen = entry.content.length;
173
+ entry.content = entry.content.slice(0, truncate);
174
+ entry._truncated = fullLen;
175
+ }
176
+ return entry;
177
+ });
178
+ return { entries, total };
179
+ }
180
+
181
+ // Single entry by index — used to "show full" a previously truncated entry.
182
+ export function getTaskLogEntry(id: string, index: number): TaskLogEntry | null {
183
+ const row = db().prepare(
184
+ `SELECT value FROM tasks, json_each(tasks.log)
185
+ WHERE tasks.id = ? AND json_each.key = ?`
186
+ ).get(id, index) as { value: string } | undefined;
187
+ if (!row) return null;
188
+ try { return JSON.parse(row.value); } catch { return null; }
189
+ }
190
+
191
+ // Fetch only the heavy fields by id (used when the client needs them after
192
+ // having gotten the lite list earlier).
193
+ export function getTaskBody(id: string): { resultSummary?: string; gitDiff?: string; error?: string } | null {
194
+ const row = db().prepare(
195
+ `SELECT result_summary, git_diff, error FROM tasks WHERE id = ?`
196
+ ).get(id) as any;
197
+ if (!row) return null;
198
+ return {
199
+ resultSummary: row.result_summary || undefined,
200
+ gitDiff: row.git_diff || undefined,
201
+ error: row.error || undefined,
202
+ };
203
+ }
204
+
205
+ function rowToLiteTask(row: any): Task {
206
+ return {
207
+ id: row.id,
208
+ projectName: row.project_name,
209
+ projectPath: row.project_path,
210
+ prompt: row.prompt,
211
+ mode: row.mode || 'prompt',
212
+ status: row.status,
213
+ priority: row.priority,
214
+ conversationId: row.conversation_id || undefined,
215
+ watchConfig: row.watch_config ? JSON.parse(row.watch_config) : undefined,
216
+ log: [], // slim — fetch detail separately
217
+ resultSummary: row.result_summary || undefined, // first 1KB only
218
+ gitDiff: undefined, // not loaded
219
+ gitBranch: row.git_branch || undefined,
220
+ costUSD: row.cost_usd || undefined,
221
+ error: row.error || undefined,
222
+ createdAt: toIsoUTC(row.created_at) ?? row.created_at,
223
+ startedAt: toIsoUTC(row.started_at) ?? undefined,
224
+ completedAt: toIsoUTC(row.completed_at) ?? undefined,
225
+ scheduledAt: toIsoUTC(row.scheduled_at) ?? undefined,
226
+ agent: row.agent || undefined,
227
+ logSize: row.log_size || 0,
228
+ hasGitDiff: !!row.has_git_diff,
229
+ } as Task;
230
+ }
231
+
122
232
  export function cancelTask(id: string): boolean {
123
233
  const task = getTask(id);
124
234
  if (!task) return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.5.48",
3
+ "version": "0.5.49",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -109,6 +109,10 @@ export interface Task {
109
109
  startedAt?: string;
110
110
  completedAt?: string;
111
111
  scheduledAt?: string;
112
+ agent?: string;
113
+ // Lite-list metadata: present in /api/tasks responses, undefined in detail
114
+ logSize?: number;
115
+ hasGitDiff?: boolean;
112
116
  }
113
117
 
114
118
  export interface TaskLogEntry {
@@ -117,6 +121,9 @@ export interface TaskLogEntry {
117
121
  content: string;
118
122
  tool?: string;
119
123
  timestamp: string;
124
+ // Slice-API metadata (server adds these when serving a truncated chunk)
125
+ _index?: number;
126
+ _truncated?: number; // original content.length before truncation
120
127
  }
121
128
 
122
129
  export interface AppConfig {