@aion0/forge 0.5.48 → 0.5.50
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 +50 -4
- package/app/api/craft-system/build/route.ts +78 -0
- package/app/api/craft-system/delete/route.ts +28 -0
- package/app/api/craft-system/helpers/file/route.ts +20 -0
- package/app/api/craft-system/helpers/openapi/route.ts +27 -0
- package/app/api/craft-system/helpers/shell/route.ts +26 -0
- package/app/api/craft-system/inject/route.ts +41 -0
- package/app/api/craft-system/kill-session/route.ts +19 -0
- package/app/api/craft-system/manifest/route.ts +71 -0
- package/app/api/craft-system/marketplace/install/route.ts +11 -0
- package/app/api/craft-system/marketplace/route.ts +18 -0
- package/app/api/craft-system/marketplace/uninstall/route.ts +11 -0
- package/app/api/craft-system/marketplace/update/route.ts +10 -0
- package/app/api/craft-system/marketplace/updates/route.ts +17 -0
- package/app/api/craft-system/publish/auto/route.ts +173 -0
- package/app/api/craft-system/publish/route.ts +50 -0
- package/app/api/craft-system/registry/route.ts +16 -0
- package/app/api/craft-system/runtime/react/route.ts +26 -0
- package/app/api/craft-system/runtime/react-jsx/route.ts +11 -0
- package/app/api/craft-system/runtime/sdk/route.ts +18 -0
- package/app/api/craft-system/scaffold/route.ts +164 -0
- package/app/api/craft-system/sessions/route.ts +45 -0
- package/app/api/craft-system/storage/route.ts +44 -0
- package/app/api/craft-system/tmux-sessions/route.ts +62 -0
- package/app/api/craft-system/ui/route.ts +30 -0
- package/app/api/crafts/[name]/[...route]/route.ts +48 -0
- package/app/api/crafts/route.ts +29 -0
- 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/CraftBuilder.tsx +241 -0
- package/components/CraftManifestEditor.tsx +258 -0
- package/components/CraftMarketplaceModal.tsx +207 -0
- package/components/CraftPublishModal.tsx +285 -0
- package/components/CraftTabs.tsx +279 -0
- package/components/CraftTerminal.tsx +305 -0
- package/components/CraftTerminalPicker.tsx +179 -0
- package/components/CraftsDropdown.tsx +186 -0
- package/components/CraftsMarketplacePanel.tsx +194 -0
- package/components/ProjectDetail.tsx +102 -13
- package/components/SkillsPanel.tsx +12 -4
- package/components/TaskDetail.tsx +250 -52
- package/lib/craft-sdk/client.tsx +260 -0
- package/lib/craft-sdk/server.ts +14 -0
- package/lib/crafts/loader.ts +117 -0
- package/lib/crafts/registry.ts +272 -0
- package/lib/crafts/runtime.ts +208 -0
- package/lib/crafts/types.ts +92 -0
- package/lib/forge-skills/craft-builder.md +231 -0
- package/lib/help-docs/15-crafts.md +127 -0
- package/lib/help-docs/CLAUDE.md +2 -2
- package/lib/task-manager.ts +110 -0
- package/lib/terminal-standalone.ts +1 -0
- package/next.config.ts +1 -1
- package/package.json +2 -1
- package/src/types/index.ts +7 -0
- package/tsconfig.json +6 -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
|
@@ -7,7 +7,12 @@ 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
|
|
10
|
+
const CraftTabLazy = lazy(() => import('./CraftTabs').then(m => ({ default: m.CraftTab })));
|
|
11
|
+
const CraftBuilderModalLazy = lazy(() => import('./CraftBuilder').then(m => ({ default: m.CraftBuilderModal })));
|
|
12
|
+
const CraftMarketplaceModalLazy = lazy(() => import('./CraftMarketplaceModal'));
|
|
13
|
+
const CraftPublishModalLazy = lazy(() => import('./CraftPublishModal'));
|
|
14
|
+
const CraftManifestEditorLazy = lazy(() => import('./CraftManifestEditor'));
|
|
15
|
+
import CraftsDropdown from './CraftsDropdown';
|
|
11
16
|
|
|
12
17
|
// ─── Syntax highlighting ─────────────────────────────────
|
|
13
18
|
const KEYWORDS = new Set([
|
|
@@ -86,7 +91,27 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
86
91
|
const [diffFile, setDiffFile] = useState<string | null>(null);
|
|
87
92
|
const [projectSkills, setProjectSkills] = useState<{ name: string; displayName: string; type: string; scope: string; version: string; installedVersion: string; hasUpdate: boolean; source: 'registry' | 'local' }[]>([]);
|
|
88
93
|
const [showSkillsDetail, setShowSkillsDetail] = useState(false);
|
|
89
|
-
const [projectTab, setProjectTab] = useState<
|
|
94
|
+
const [projectTab, setProjectTab] = useState<string>('code');
|
|
95
|
+
const [crafts, setCrafts] = useState<Array<{ name: string; displayName: string; icon?: string; description?: string; scope: string; hasUi: boolean; hasServer: boolean }>>([]);
|
|
96
|
+
const [craftBuilder, setCraftBuilder] = useState<{ refineName?: string } | null>(null);
|
|
97
|
+
const [craftMarketplaceOpen, setCraftMarketplaceOpen] = useState(false);
|
|
98
|
+
const [craftPublishName, setCraftPublishName] = useState<string | null>(null);
|
|
99
|
+
const [craftManifestName, setCraftManifestName] = useState<string | null>(null);
|
|
100
|
+
// Once a craft is visited it stays mounted (hidden via CSS) so its terminal + WS persist.
|
|
101
|
+
const [visitedCrafts, setVisitedCrafts] = useState<Set<string>>(() => new Set());
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (projectTab.startsWith('craft:')) {
|
|
104
|
+
const name = projectTab.slice('craft:'.length);
|
|
105
|
+
setVisitedCrafts(prev => prev.has(name) ? prev : new Set(prev).add(name));
|
|
106
|
+
}
|
|
107
|
+
}, [projectTab]);
|
|
108
|
+
const refreshCrafts = useCallback(() => {
|
|
109
|
+
fetch(`/api/crafts?projectPath=${encodeURIComponent(projectPath)}`)
|
|
110
|
+
.then(r => r.ok ? r.json() : { crafts: [] })
|
|
111
|
+
.then(j => setCrafts(j.crafts || []))
|
|
112
|
+
.catch(() => {});
|
|
113
|
+
}, [projectPath]);
|
|
114
|
+
useEffect(() => { refreshCrafts(); }, [refreshCrafts]);
|
|
90
115
|
// Lazy-mount workspace: only mount after first visit, keep mounted to preserve terminal state
|
|
91
116
|
const [wsMounted, setWsMounted] = useState(false);
|
|
92
117
|
useEffect(() => { if (projectTab === 'workspace') setWsMounted(true); }, [projectTab]);
|
|
@@ -618,13 +643,25 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
618
643
|
Pipelines
|
|
619
644
|
{pipelineBindings.length > 0 && <span className="ml-1 text-[9px] opacity-70">({pipelineBindings.length})</span>}
|
|
620
645
|
</button>
|
|
621
|
-
<
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
}`}
|
|
626
|
-
|
|
627
|
-
|
|
646
|
+
<CraftsDropdown
|
|
647
|
+
crafts={crafts}
|
|
648
|
+
activeTab={projectTab}
|
|
649
|
+
projectPath={projectPath}
|
|
650
|
+
onPick={(name) => setProjectTab(`craft:${name}`)}
|
|
651
|
+
onNew={() => setCraftBuilder({})}
|
|
652
|
+
onRefine={(name) => setCraftBuilder({ refineName: name })}
|
|
653
|
+
onPublish={(name) => setCraftPublishName(name)}
|
|
654
|
+
onMarketplace={() => setCraftMarketplaceOpen(true)}
|
|
655
|
+
onEditManifest={(name) => setCraftManifestName(name)}
|
|
656
|
+
onDelete={async (name, displayName) => {
|
|
657
|
+
if (!confirm(`Delete craft "${displayName}"?\n\nThis removes <project>/.forge/crafts/${name}/ permanently.`)) return;
|
|
658
|
+
const r = await fetch(`/api/craft-system/delete?projectPath=${encodeURIComponent(projectPath)}&name=${encodeURIComponent(name)}`, { method: 'DELETE' });
|
|
659
|
+
if (r.ok) {
|
|
660
|
+
setProjectTab('code');
|
|
661
|
+
refreshCrafts();
|
|
662
|
+
}
|
|
663
|
+
}}
|
|
664
|
+
/>
|
|
628
665
|
</div>
|
|
629
666
|
</div>
|
|
630
667
|
{projectTab === 'code' && gitInfo?.lastCommit && (
|
|
@@ -1381,10 +1418,62 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
1381
1418
|
</div>
|
|
1382
1419
|
)}
|
|
1383
1420
|
|
|
1384
|
-
{/*
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1421
|
+
{/* Craft tabs — lazy-mount on first visit, then keep mounted (hidden via CSS)
|
|
1422
|
+
so each craft's terminal panel + WebSocket survive tab switches. */}
|
|
1423
|
+
{crafts.filter(c => visitedCrafts.has(c.name)).map(c => {
|
|
1424
|
+
const isActive = projectTab === `craft:${c.name}`;
|
|
1425
|
+
return (
|
|
1426
|
+
<div key={c.name} className={`flex-1 flex flex-col min-h-0 overflow-hidden ${isActive ? '' : 'hidden'}`}>
|
|
1427
|
+
<Suspense fallback={<div className="p-4 text-xs text-[var(--text-secondary)]">Loading craft {c.displayName}…</div>}>
|
|
1428
|
+
<CraftTabLazy craft={c as any} projectPath={projectPath} projectName={projectName} />
|
|
1429
|
+
</Suspense>
|
|
1430
|
+
</div>
|
|
1431
|
+
);
|
|
1432
|
+
})}
|
|
1433
|
+
|
|
1434
|
+
{/* Craft Marketplace modal */}
|
|
1435
|
+
{craftMarketplaceOpen && (
|
|
1436
|
+
<Suspense fallback={null}>
|
|
1437
|
+
<CraftMarketplaceModalLazy
|
|
1438
|
+
projectPath={projectPath}
|
|
1439
|
+
onClose={() => setCraftMarketplaceOpen(false)}
|
|
1440
|
+
onInstalled={refreshCrafts}
|
|
1441
|
+
/>
|
|
1442
|
+
</Suspense>
|
|
1443
|
+
)}
|
|
1444
|
+
|
|
1445
|
+
{/* Craft Publish modal */}
|
|
1446
|
+
{craftPublishName && (
|
|
1447
|
+
<Suspense fallback={null}>
|
|
1448
|
+
<CraftPublishModalLazy
|
|
1449
|
+
projectPath={projectPath}
|
|
1450
|
+
craftName={craftPublishName}
|
|
1451
|
+
onClose={() => setCraftPublishName(null)}
|
|
1452
|
+
/>
|
|
1453
|
+
</Suspense>
|
|
1454
|
+
)}
|
|
1455
|
+
|
|
1456
|
+
{/* Craft Manifest editor */}
|
|
1457
|
+
{craftManifestName && (
|
|
1458
|
+
<Suspense fallback={null}>
|
|
1459
|
+
<CraftManifestEditorLazy
|
|
1460
|
+
projectPath={projectPath}
|
|
1461
|
+
craftName={craftManifestName}
|
|
1462
|
+
onClose={() => setCraftManifestName(null)}
|
|
1463
|
+
/>
|
|
1464
|
+
</Suspense>
|
|
1465
|
+
)}
|
|
1466
|
+
|
|
1467
|
+
{/* Craft Builder modal */}
|
|
1468
|
+
{craftBuilder && (
|
|
1469
|
+
<Suspense fallback={null}>
|
|
1470
|
+
<CraftBuilderModalLazy
|
|
1471
|
+
projectPath={projectPath}
|
|
1472
|
+
projectName={projectName}
|
|
1473
|
+
refineCraftName={craftBuilder.refineName}
|
|
1474
|
+
onClose={() => setCraftBuilder(null)}
|
|
1475
|
+
onCreated={() => { refreshCrafts(); setCraftBuilder(null); }}
|
|
1476
|
+
/>
|
|
1388
1477
|
</Suspense>
|
|
1389
1478
|
)}
|
|
1390
1479
|
|
|
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, lazy, Suspense } from 'react';
|
|
|
4
4
|
import { useSidebarResize } from '@/hooks/useSidebarResize';
|
|
5
5
|
|
|
6
6
|
const PluginsPanel = lazy(() => import('./PluginsPanel'));
|
|
7
|
+
const CraftsMarketplacePanelLazy = lazy(() => import('./CraftsMarketplacePanel'));
|
|
7
8
|
|
|
8
9
|
type ItemType = 'skill' | 'command';
|
|
9
10
|
|
|
@@ -139,7 +140,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
139
140
|
const [syncing, setSyncing] = useState(false);
|
|
140
141
|
const [loading, setLoading] = useState(true);
|
|
141
142
|
const [installTarget, setInstallTarget] = useState<{ skill: string; show: boolean }>({ skill: '', show: false });
|
|
142
|
-
const [typeFilter, setTypeFilter] = useState<'all' | 'skill' | 'command' | 'local' | 'rules' | 'plugins'>('all');
|
|
143
|
+
const [typeFilter, setTypeFilter] = useState<'all' | 'skill' | 'command' | 'local' | 'rules' | 'plugins' | 'crafts'>('all');
|
|
143
144
|
const [localItems, setLocalItems] = useState<{ name: string; type: string; scope: string; fileCount: number; projectPath?: string }[]>([]);
|
|
144
145
|
// Rules (CLAUDE.md templates)
|
|
145
146
|
const [rulesTemplates, setRulesTemplates] = useState<{ id: string; name: string; description: string; tags: string[]; builtin: boolean; isDefault: boolean; content: string }[]>([]);
|
|
@@ -372,7 +373,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
372
373
|
<div className="flex items-center gap-2">
|
|
373
374
|
<span className="text-xs font-semibold text-[var(--text-primary)]">Marketplace</span>
|
|
374
375
|
<div className="flex items-center bg-[var(--bg-tertiary)] rounded p-0.5">
|
|
375
|
-
{([['all', `All (${skills.length})`], ['skill', `Skills (${skillCount})`], ['command', `Commands (${commandCount})`], ['local', `Local (${localCount})`], ['rules', 'Rules'], ['plugins', 'Plugins']] as const).map(([value, label]) => (
|
|
376
|
+
{([['all', `All (${skills.length})`], ['skill', `Skills (${skillCount})`], ['command', `Commands (${commandCount})`], ['local', `Local (${localCount})`], ['rules', 'Rules'], ['plugins', 'Plugins'], ['crafts', 'Crafts']] as const).map(([value, label]) => (
|
|
376
377
|
<button
|
|
377
378
|
key={value}
|
|
378
379
|
onClick={() => setTypeFilter(value)}
|
|
@@ -397,7 +398,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
397
398
|
</button>
|
|
398
399
|
</div>
|
|
399
400
|
{/* Search — hide on rules tab */}
|
|
400
|
-
{typeFilter !== 'rules' && typeFilter !== 'plugins' && <div className="px-3 py-1.5 border-b border-[var(--border)] shrink-0">
|
|
401
|
+
{typeFilter !== 'rules' && typeFilter !== 'plugins' && typeFilter !== 'crafts' && <div className="px-3 py-1.5 border-b border-[var(--border)] shrink-0">
|
|
401
402
|
<input
|
|
402
403
|
type="text"
|
|
403
404
|
value={searchQuery}
|
|
@@ -407,7 +408,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
407
408
|
/>
|
|
408
409
|
</div>}
|
|
409
410
|
|
|
410
|
-
{typeFilter === 'rules' || typeFilter === 'plugins' ? null : skills.length === 0 ? (
|
|
411
|
+
{typeFilter === 'rules' || typeFilter === 'plugins' || typeFilter === 'crafts' ? null : skills.length === 0 ? (
|
|
411
412
|
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-[var(--text-secondary)]">
|
|
412
413
|
<p className="text-xs">No skills yet</p>
|
|
413
414
|
<button onClick={sync} className="text-xs px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90">
|
|
@@ -984,6 +985,13 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
984
985
|
<PluginsPanel />
|
|
985
986
|
</Suspense>
|
|
986
987
|
)}
|
|
988
|
+
|
|
989
|
+
{/* Crafts — registry browse view */}
|
|
990
|
+
{typeFilter === 'crafts' && (
|
|
991
|
+
<Suspense fallback={<div className="p-4 text-xs text-[var(--text-secondary)]">Loading...</div>}>
|
|
992
|
+
<CraftsMarketplacePanelLazy searchQuery={searchQuery} />
|
|
993
|
+
</Suspense>
|
|
994
|
+
)}
|
|
987
995
|
</div>
|
|
988
996
|
);
|
|
989
997
|
}
|
|
@@ -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">
|
|
@@ -110,7 +184,8 @@ export default function TaskDetail({
|
|
|
110
184
|
</button>
|
|
111
185
|
</div>
|
|
112
186
|
</div>
|
|
113
|
-
<
|
|
187
|
+
<TaskPromptPreview prompt={task.prompt} />
|
|
188
|
+
|
|
114
189
|
<div className="flex items-center gap-3 text-[10px] text-[var(--text-secondary)]">
|
|
115
190
|
<span>Created: {new Date(task.createdAt).toLocaleString()}</span>
|
|
116
191
|
{task.startedAt && <span>Started: {new Date(task.startedAt).toLocaleString()}</span>}
|
|
@@ -129,18 +204,55 @@ export default function TaskDetail({
|
|
|
129
204
|
tab === t ? 'border-[var(--accent)] text-[var(--accent)]' : 'border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
130
205
|
}`}
|
|
131
206
|
>
|
|
132
|
-
{t === 'log' ? `Log (${displayLog.length})` : t === 'diff' ? 'Git Diff' : 'Result'}
|
|
207
|
+
{t === 'log' ? `Log (${logTotal || displayLog.length})` : t === 'diff' ? 'Git Diff' : 'Result'}
|
|
133
208
|
</button>
|
|
134
209
|
))}
|
|
135
210
|
</div>
|
|
136
211
|
|
|
137
212
|
{/* Content */}
|
|
138
|
-
<div className="flex-1 overflow-y-auto p-4 text-sm">
|
|
213
|
+
<div ref={logScrollRef} className="flex-1 overflow-y-auto p-4 text-sm">
|
|
139
214
|
{tab === 'log' && (
|
|
140
215
|
<div className="space-y-2">
|
|
141
|
-
{displayLog.
|
|
142
|
-
<
|
|
143
|
-
|
|
216
|
+
{detailLoading && displayLog.length === 0 && (
|
|
217
|
+
<div className="text-[var(--text-secondary)] text-xs py-2">
|
|
218
|
+
Loading last {LOG_DEFAULT_TAIL} entries{logTotal ? ` of ${logTotal}` : ''}…
|
|
219
|
+
</div>
|
|
220
|
+
)}
|
|
221
|
+
{hiddenTotal > 0 && (
|
|
222
|
+
<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)]">
|
|
223
|
+
<span>
|
|
224
|
+
{hiddenTotal} earlier {hiddenTotal === 1 ? 'entry' : 'entries'} hidden
|
|
225
|
+
{hiddenOnServer > 0 && <span className="opacity-60"> ({hiddenOnServer} not yet fetched)</span>}
|
|
226
|
+
</span>
|
|
227
|
+
<div className="flex gap-1">
|
|
228
|
+
{hiddenInMemory > 0 ? (
|
|
229
|
+
<button
|
|
230
|
+
onClick={() => setVisibleTail(v => v + LOG_LOAD_CHUNK)}
|
|
231
|
+
className="px-2 py-0.5 rounded bg-[var(--accent)]/15 text-[var(--accent)] hover:bg-[var(--accent)]/25"
|
|
232
|
+
>Show {Math.min(LOG_LOAD_CHUNK, hiddenInMemory)} more</button>
|
|
233
|
+
) : (
|
|
234
|
+
<button
|
|
235
|
+
onClick={() => loadEarlier(LOG_LOAD_CHUNK)}
|
|
236
|
+
disabled={loadingMore}
|
|
237
|
+
className="px-2 py-0.5 rounded bg-[var(--accent)]/15 text-[var(--accent)] hover:bg-[var(--accent)]/25 disabled:opacity-50"
|
|
238
|
+
>{loadingMore ? 'Loading…' : `Fetch ${Math.min(LOG_LOAD_CHUNK, hiddenOnServer)} earlier`}</button>
|
|
239
|
+
)}
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
)}
|
|
243
|
+
{visibleLog.map((entry, i) => {
|
|
244
|
+
const absoluteIndex = entry._index ?? (logOffset + inMemoryStart + i);
|
|
245
|
+
return (
|
|
246
|
+
<LogEntry
|
|
247
|
+
key={absoluteIndex}
|
|
248
|
+
entry={entry}
|
|
249
|
+
index={absoluteIndex}
|
|
250
|
+
expanded={expandedTools.has(absoluteIndex)}
|
|
251
|
+
onToggle={toggleTool}
|
|
252
|
+
onLoadFull={loadFullEntry}
|
|
253
|
+
/>
|
|
254
|
+
);
|
|
255
|
+
})}
|
|
144
256
|
{liveStatus === 'running' && (
|
|
145
257
|
<div className="text-[var(--accent)] animate-pulse py-1 text-xs">working...</div>
|
|
146
258
|
)}
|
|
@@ -150,45 +262,22 @@ export default function TaskDetail({
|
|
|
150
262
|
|
|
151
263
|
{tab === 'result' && (
|
|
152
264
|
<div className="prose-container">
|
|
153
|
-
{task.resultSummary ? (
|
|
154
|
-
<MarkdownContent content={task.resultSummary} />
|
|
155
|
-
) : task.error ? (
|
|
265
|
+
{(taskBody?.resultSummary ?? task.resultSummary) ? (
|
|
266
|
+
<MarkdownContent content={(taskBody?.resultSummary ?? task.resultSummary)!} />
|
|
267
|
+
) : (taskBody?.error ?? task.error) ? (
|
|
156
268
|
<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>
|
|
269
|
+
<pre className="whitespace-pre-wrap break-words text-[var(--red)] text-xs font-mono">{taskBody?.error ?? task.error}</pre>
|
|
158
270
|
</div>
|
|
159
271
|
) : (
|
|
160
272
|
<p className="text-[var(--text-secondary)] text-xs">
|
|
161
|
-
{liveStatus === 'running' || liveStatus === 'queued' ? 'Task is still running...' : 'No result'}
|
|
273
|
+
{liveStatus === 'running' || liveStatus === 'queued' ? 'Task is still running...' : detailLoading ? 'Loading…' : 'No result'}
|
|
162
274
|
</p>
|
|
163
275
|
)}
|
|
164
276
|
</div>
|
|
165
277
|
)}
|
|
166
278
|
|
|
167
279
|
{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>
|
|
280
|
+
<DiffView gitDiff={taskBody?.gitDiff ?? task.gitDiff} status={liveStatus} showAll={showFullDiff} onShowAll={() => setShowFullDiff(true)} />
|
|
192
281
|
)}
|
|
193
282
|
</div>
|
|
194
283
|
|
|
@@ -263,12 +352,25 @@ function StatusBadge({ status }: { status: string }) {
|
|
|
263
352
|
);
|
|
264
353
|
}
|
|
265
354
|
|
|
266
|
-
function LogEntry({ entry, index, expanded, onToggle }: {
|
|
355
|
+
const LogEntry = memo(function LogEntry({ entry, index, expanded, onToggle, onLoadFull }: {
|
|
267
356
|
entry: TaskLogEntry;
|
|
268
357
|
index: number;
|
|
269
358
|
expanded: boolean;
|
|
270
|
-
onToggle: () => void;
|
|
359
|
+
onToggle: (index: number) => void;
|
|
360
|
+
onLoadFull?: (index: number) => void;
|
|
271
361
|
}) {
|
|
362
|
+
const handleToggle = () => onToggle(index);
|
|
363
|
+
const handleLoadFull = () => onLoadFull?.(index);
|
|
364
|
+
const truncatedBanner = entry._truncated ? (
|
|
365
|
+
<div className="text-[9px] text-yellow-400/80 italic mt-1">
|
|
366
|
+
Truncated to {(entry.content.length / 1024).toFixed(1)} KB of {(entry._truncated / 1024).toFixed(1)} KB.{' '}
|
|
367
|
+
{onLoadFull && (
|
|
368
|
+
<button onClick={handleLoadFull} className="text-[var(--accent)] hover:underline">
|
|
369
|
+
Show full
|
|
370
|
+
</button>
|
|
371
|
+
)}
|
|
372
|
+
</div>
|
|
373
|
+
) : null;
|
|
272
374
|
// System init
|
|
273
375
|
if (entry.type === 'system' && entry.subtype === 'init') {
|
|
274
376
|
return (
|
|
@@ -283,6 +385,7 @@ function LogEntry({ entry, index, expanded, onToggle }: {
|
|
|
283
385
|
return (
|
|
284
386
|
<div className="p-2 bg-red-900/10 border border-red-800/20 rounded text-xs">
|
|
285
387
|
<pre className="whitespace-pre-wrap break-words text-[var(--red)] font-mono">{entry.content}</pre>
|
|
388
|
+
{truncatedBanner}
|
|
286
389
|
</div>
|
|
287
390
|
);
|
|
288
391
|
}
|
|
@@ -295,7 +398,7 @@ function LogEntry({ entry, index, expanded, onToggle }: {
|
|
|
295
398
|
return (
|
|
296
399
|
<div className="border border-[var(--border)] rounded overflow-hidden">
|
|
297
400
|
<button
|
|
298
|
-
onClick={
|
|
401
|
+
onClick={handleToggle}
|
|
299
402
|
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
403
|
>
|
|
301
404
|
<span className="text-[10px] px-1.5 py-0.5 bg-[var(--accent)]/15 text-[var(--accent)] rounded font-medium font-mono">
|
|
@@ -313,6 +416,7 @@ function LogEntry({ entry, index, expanded, onToggle }: {
|
|
|
313
416
|
{toolContent}
|
|
314
417
|
</pre>
|
|
315
418
|
)}
|
|
419
|
+
{truncatedBanner}
|
|
316
420
|
</div>
|
|
317
421
|
);
|
|
318
422
|
}
|
|
@@ -328,10 +432,11 @@ function LogEntry({ entry, index, expanded, onToggle }: {
|
|
|
328
432
|
{content}
|
|
329
433
|
</pre>
|
|
330
434
|
{isLong && !expanded && (
|
|
331
|
-
<button onClick={
|
|
435
|
+
<button onClick={handleToggle} className="text-[9px] text-[var(--accent)] hover:underline mt-0.5">
|
|
332
436
|
show more
|
|
333
437
|
</button>
|
|
334
438
|
)}
|
|
439
|
+
{truncatedBanner}
|
|
335
440
|
</div>
|
|
336
441
|
);
|
|
337
442
|
}
|
|
@@ -341,6 +446,7 @@ function LogEntry({ entry, index, expanded, onToggle }: {
|
|
|
341
446
|
return (
|
|
342
447
|
<div className="p-3 bg-green-900/5 border border-green-800/15 rounded">
|
|
343
448
|
<MarkdownContent content={entry.content} />
|
|
449
|
+
{truncatedBanner}
|
|
344
450
|
</div>
|
|
345
451
|
);
|
|
346
452
|
}
|
|
@@ -349,11 +455,56 @@ function LogEntry({ entry, index, expanded, onToggle }: {
|
|
|
349
455
|
return (
|
|
350
456
|
<div className="py-1">
|
|
351
457
|
<MarkdownContent content={entry.content} />
|
|
458
|
+
{truncatedBanner}
|
|
352
459
|
</div>
|
|
353
460
|
);
|
|
354
|
-
}
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
// Diff view — caps at DIFF_DEFAULT_LINES until "Show all" clicked.
|
|
464
|
+
function DiffView({ gitDiff, status, showAll, onShowAll }: {
|
|
465
|
+
gitDiff?: string;
|
|
466
|
+
status: string;
|
|
467
|
+
showAll: boolean;
|
|
468
|
+
onShowAll: () => void;
|
|
469
|
+
}) {
|
|
470
|
+
const allLines = useMemo(() => (gitDiff ? gitDiff.split('\n') : []), [gitDiff]);
|
|
471
|
+
if (!gitDiff) {
|
|
472
|
+
return (
|
|
473
|
+
<p className="text-[var(--text-secondary)] text-xs">
|
|
474
|
+
{status === 'running' ? 'Diff will be captured after completion' : 'No changes detected'}
|
|
475
|
+
</p>
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
const lines = showAll ? allLines : allLines.slice(0, DIFF_DEFAULT_LINES);
|
|
479
|
+
const truncated = !showAll && allLines.length > DIFF_DEFAULT_LINES;
|
|
355
480
|
|
|
356
|
-
|
|
481
|
+
return (
|
|
482
|
+
<div className="bg-[var(--bg-tertiary)] rounded border border-[var(--border)] overflow-hidden">
|
|
483
|
+
{truncated && (
|
|
484
|
+
<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)]">
|
|
485
|
+
<span>Showing first {DIFF_DEFAULT_LINES} of {allLines.length} lines</span>
|
|
486
|
+
<button onClick={onShowAll} className="px-2 py-0.5 rounded bg-[var(--accent)]/15 text-[var(--accent)] hover:bg-[var(--accent)]/25">
|
|
487
|
+
Show all
|
|
488
|
+
</button>
|
|
489
|
+
</div>
|
|
490
|
+
)}
|
|
491
|
+
<pre className="p-3 text-xs font-mono overflow-x-auto">
|
|
492
|
+
{lines.map((line, i) => (
|
|
493
|
+
<div key={i} className={`px-2 ${
|
|
494
|
+
line.startsWith('+++') || line.startsWith('---') ? 'text-[var(--text-secondary)] font-semibold' :
|
|
495
|
+
line.startsWith('+') ? 'text-[var(--green)] bg-green-500/5' :
|
|
496
|
+
line.startsWith('-') ? 'text-[var(--red)] bg-red-500/5' :
|
|
497
|
+
line.startsWith('@@') ? 'text-[var(--accent)] bg-[var(--accent)]/5 font-semibold' :
|
|
498
|
+
line.startsWith('diff ') ? 'text-[var(--text-primary)] font-bold border-t border-[var(--border)] pt-2 mt-2' :
|
|
499
|
+
'text-[var(--text-secondary)]'
|
|
500
|
+
}`}>
|
|
501
|
+
{line}
|
|
502
|
+
</div>
|
|
503
|
+
))}
|
|
504
|
+
</pre>
|
|
505
|
+
</div>
|
|
506
|
+
);
|
|
507
|
+
}
|
|
357
508
|
|
|
358
509
|
function formatToolContent(content: string): string {
|
|
359
510
|
try {
|
|
@@ -370,3 +521,50 @@ function formatToolContent(content: string): string {
|
|
|
370
521
|
return content;
|
|
371
522
|
}
|
|
372
523
|
}
|
|
524
|
+
|
|
525
|
+
// ─── Task prompt preview with click-to-expand ──────────────
|
|
526
|
+
const PROMPT_PREVIEW_MAX = 240;
|
|
527
|
+
|
|
528
|
+
function TaskPromptPreview({ prompt }: { prompt: string }) {
|
|
529
|
+
const [open, setOpen] = useState(false);
|
|
530
|
+
const long = prompt.length > PROMPT_PREVIEW_MAX || prompt.includes('\n');
|
|
531
|
+
const preview = long ? prompt.slice(0, PROMPT_PREVIEW_MAX).split('\n')[0] : prompt;
|
|
532
|
+
|
|
533
|
+
return (
|
|
534
|
+
<>
|
|
535
|
+
<div className="flex items-start gap-2 mb-2">
|
|
536
|
+
<p className="text-xs text-[var(--text-secondary)] flex-1 break-words" title={long ? 'Click to view full prompt' : undefined}>
|
|
537
|
+
{preview}
|
|
538
|
+
{long && <span className="text-[var(--text-secondary)] opacity-60">…</span>}
|
|
539
|
+
</p>
|
|
540
|
+
{long && (
|
|
541
|
+
<button
|
|
542
|
+
onClick={() => setOpen(true)}
|
|
543
|
+
className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--accent)]/20 hover:text-[var(--accent)] shrink-0"
|
|
544
|
+
title={`${prompt.length} chars · ${prompt.split('\n').length} lines`}
|
|
545
|
+
>
|
|
546
|
+
View full
|
|
547
|
+
</button>
|
|
548
|
+
)}
|
|
549
|
+
</div>
|
|
550
|
+
|
|
551
|
+
{open && (
|
|
552
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={() => setOpen(false)}>
|
|
553
|
+
<div className="bg-[var(--bg-primary)] border border-[var(--border)] rounded-lg shadow-2xl w-[800px] max-w-[95vw] max-h-[85vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
|
554
|
+
<div className="px-4 py-2 border-b border-[var(--border)] flex items-center gap-2">
|
|
555
|
+
<span className="text-xs font-semibold text-[var(--text-primary)]">Task prompt</span>
|
|
556
|
+
<span className="text-[10px] text-[var(--text-secondary)]">{prompt.length.toLocaleString()} chars · {prompt.split('\n').length} lines</span>
|
|
557
|
+
<div className="flex-1" />
|
|
558
|
+
<button
|
|
559
|
+
onClick={() => { navigator.clipboard.writeText(prompt).catch(() => {}); }}
|
|
560
|
+
className="text-[10px] px-2 py-1 rounded text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]"
|
|
561
|
+
>Copy</button>
|
|
562
|
+
<button onClick={() => setOpen(false)} className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]">✕</button>
|
|
563
|
+
</div>
|
|
564
|
+
<pre className="flex-1 overflow-auto p-4 text-[11px] font-mono whitespace-pre-wrap break-words text-[var(--text-primary)]">{prompt}</pre>
|
|
565
|
+
</div>
|
|
566
|
+
</div>
|
|
567
|
+
)}
|
|
568
|
+
</>
|
|
569
|
+
);
|
|
570
|
+
}
|