@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 +0 -1
- package/RELEASE_NOTES.md +8 -3
- package/app/api/tasks/[id]/log/entry/route.ts +13 -0
- package/app/api/tasks/[id]/log/route.ts +23 -0
- package/app/api/tasks/route.ts +2 -2
- package/components/ProjectDetail.tsx +1 -16
- package/components/TaskDetail.tsx +201 -51
- package/lib/help-docs/CLAUDE.md +0 -2
- package/lib/task-manager.ts +110 -0
- package/package.json +1 -1
- package/src/types/index.ts +7 -0
- package/app/api/migration/config/route.ts +0 -19
- package/app/api/migration/discover/route.ts +0 -26
- package/app/api/migration/failures/route.ts +0 -35
- package/app/api/migration/fix/route.ts +0 -82
- package/app/api/migration/run/route.ts +0 -22
- package/app/api/migration/run-batch/route.ts +0 -86
- package/components/MigrationCockpit.tsx +0 -541
- package/lib/help-docs/14-migration.md +0 -154
- package/lib/migration/differ.ts +0 -193
- package/lib/migration/discoverer.ts +0 -363
- package/lib/migration/openapi.ts +0 -137
- package/lib/migration/runner.ts +0 -219
- package/lib/migration/store.ts +0 -89
- package/lib/migration/types.ts +0 -115
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.
|
|
1
|
+
# Forge v0.5.49
|
|
2
2
|
|
|
3
3
|
Released: 2026-04-27
|
|
4
4
|
|
|
5
|
-
## Changes since v0.5.
|
|
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
|
-
|
|
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
|
+
}
|
package/app/api/tasks/route.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
|
-
import { createTask,
|
|
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(
|
|
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'
|
|
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
|
-
|
|
63
|
-
|
|
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.
|
|
142
|
-
<
|
|
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
|
-
<
|
|
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={
|
|
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={
|
|
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
|
-
//
|
|
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 {
|
package/lib/help-docs/CLAUDE.md
CHANGED
|
@@ -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`
|
package/lib/task-manager.ts
CHANGED
|
@@ -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
package/src/types/index.ts
CHANGED
|
@@ -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 {
|