@aion0/forge 0.4.16 โ 0.5.1
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/README.md +27 -2
- package/RELEASE_NOTES.md +21 -14
- package/app/api/agents/route.ts +17 -0
- package/app/api/delivery/[id]/route.ts +62 -0
- package/app/api/delivery/route.ts +40 -0
- package/app/api/mobile-chat/route.ts +13 -7
- package/app/api/monitor/route.ts +10 -6
- package/app/api/pipelines/[id]/route.ts +16 -3
- package/app/api/tasks/route.ts +2 -1
- package/app/api/workspace/[id]/agents/route.ts +35 -0
- package/app/api/workspace/[id]/memory/route.ts +23 -0
- package/app/api/workspace/[id]/smith/route.ts +22 -0
- package/app/api/workspace/[id]/stream/route.ts +28 -0
- package/app/api/workspace/route.ts +100 -0
- package/app/global-error.tsx +10 -4
- package/app/icon.ico +0 -0
- package/app/layout.tsx +2 -2
- package/app/login/LoginForm.tsx +96 -0
- package/app/login/page.tsx +7 -98
- package/app/page.tsx +2 -2
- package/bin/forge-server.mjs +13 -1
- package/check-forge-status.sh +9 -0
- package/components/ConversationEditor.tsx +411 -0
- package/components/ConversationGraphView.tsx +347 -0
- package/components/ConversationTerminalView.tsx +303 -0
- package/components/Dashboard.tsx +36 -39
- package/components/DashboardWrapper.tsx +9 -0
- package/components/DeliveryFlowEditor.tsx +491 -0
- package/components/DeliveryList.tsx +230 -0
- package/components/DeliveryWorkspace.tsx +589 -0
- package/components/DocTerminal.tsx +10 -2
- package/components/DocsViewer.tsx +10 -2
- package/components/HelpTerminal.tsx +11 -6
- package/components/InlinePipelineView.tsx +111 -0
- package/components/MobileView.tsx +20 -0
- package/components/MonitorPanel.tsx +9 -4
- package/components/NewTaskModal.tsx +32 -0
- package/components/PipelineEditor.tsx +49 -6
- package/components/PipelineView.tsx +482 -64
- package/components/ProjectDetail.tsx +314 -56
- package/components/ProjectManager.tsx +49 -4
- package/components/SessionView.tsx +27 -13
- package/components/SettingsModal.tsx +790 -124
- package/components/SkillsPanel.tsx +31 -8
- package/components/TaskBoard.tsx +3 -0
- package/components/WebTerminal.tsx +257 -43
- package/components/WorkspaceTree.tsx +221 -0
- package/components/WorkspaceView.tsx +2245 -0
- package/install.sh +2 -2
- package/lib/agents/claude-adapter.ts +104 -0
- package/lib/agents/generic-adapter.ts +64 -0
- package/lib/agents/index.ts +242 -0
- package/lib/agents/types.ts +70 -0
- package/lib/artifacts.ts +106 -0
- package/lib/delivery.ts +787 -0
- package/lib/forge-skills/forge-inbox.md +37 -0
- package/lib/forge-skills/forge-send.md +40 -0
- package/lib/forge-skills/forge-status.md +32 -0
- package/lib/forge-skills/forge-workspace-sync.md +37 -0
- package/lib/help-docs/00-overview.md +7 -1
- package/lib/help-docs/01-settings.md +159 -2
- package/lib/help-docs/05-pipelines.md +89 -0
- package/lib/help-docs/07-projects.md +35 -1
- package/lib/help-docs/11-workspace.md +254 -0
- package/lib/help-docs/CLAUDE.md +7 -2
- package/lib/init.ts +60 -10
- package/lib/pipeline.ts +537 -1
- package/lib/settings.ts +115 -22
- package/lib/skills.ts +249 -372
- package/lib/task-manager.ts +113 -33
- package/lib/telegram-bot.ts +33 -1
- package/lib/workspace/__tests__/state-machine.test.ts +388 -0
- package/lib/workspace/__tests__/workspace.test.ts +311 -0
- package/lib/workspace/agent-bus.ts +416 -0
- package/lib/workspace/agent-worker.ts +667 -0
- package/lib/workspace/backends/api-backend.ts +262 -0
- package/lib/workspace/backends/cli-backend.ts +479 -0
- package/lib/workspace/index.ts +82 -0
- package/lib/workspace/manager.ts +136 -0
- package/lib/workspace/orchestrator.ts +1914 -0
- package/lib/workspace/persistence.ts +310 -0
- package/lib/workspace/presets.ts +170 -0
- package/lib/workspace/skill-installer.ts +188 -0
- package/lib/workspace/smith-memory.ts +498 -0
- package/lib/workspace/types.ts +231 -0
- package/lib/workspace/watch-manager.ts +288 -0
- package/lib/workspace-standalone.ts +814 -0
- package/middleware.ts +1 -0
- package/next-env.d.ts +1 -1
- package/package.json +4 -1
- package/src/config/index.ts +12 -1
- package/src/core/db/database.ts +1 -0
- package/start.sh +7 -0
package/components/Dashboard.tsx
CHANGED
|
@@ -4,11 +4,7 @@ import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react'
|
|
|
4
4
|
import { signOut } from 'next-auth/react';
|
|
5
5
|
import TaskBoard from './TaskBoard';
|
|
6
6
|
import TaskDetail from './TaskDetail';
|
|
7
|
-
import SessionView from './SessionView';
|
|
8
|
-
import NewTaskModal from './NewTaskModal';
|
|
9
|
-
import SettingsModal from './SettingsModal';
|
|
10
7
|
import TunnelToggle from './TunnelToggle';
|
|
11
|
-
import MonitorPanel from './MonitorPanel';
|
|
12
8
|
import type { Task } from '@/src/types';
|
|
13
9
|
import type { WebTerminalHandle } from './WebTerminal';
|
|
14
10
|
|
|
@@ -22,6 +18,12 @@ const HelpDialog = lazy(() => import('./HelpDialog'));
|
|
|
22
18
|
const LogViewer = lazy(() => import('./LogViewer'));
|
|
23
19
|
const SkillsPanel = lazy(() => import('./SkillsPanel'));
|
|
24
20
|
const UsagePanel = lazy(() => import('./UsagePanel'));
|
|
21
|
+
const SessionView = lazy(() => import('./SessionView'));
|
|
22
|
+
const NewTaskModal = lazy(() => import('./NewTaskModal'));
|
|
23
|
+
const SettingsModal = lazy(() => import('./SettingsModal'));
|
|
24
|
+
const MonitorPanel = lazy(() => import('./MonitorPanel'));
|
|
25
|
+
const WorkspaceView = lazy(() => import('./WorkspaceView'));
|
|
26
|
+
// WorkspaceTree moved into ProjectDetail โ no longer needed at Dashboard level
|
|
25
27
|
|
|
26
28
|
interface UsageSummary {
|
|
27
29
|
provider: string;
|
|
@@ -95,7 +97,9 @@ function FloatingBrowser({ onClose }: { onClose: () => void }) {
|
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
export default function Dashboard({ user }: { user: any }) {
|
|
98
|
-
const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'pipelines' | 'skills' | 'logs' | 'usage'>('terminal');
|
|
100
|
+
const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'pipelines' | 'workspace' | 'skills' | 'logs' | 'usage'>('terminal');
|
|
101
|
+
// workspaceProject state kept for forge:open-terminal event compatibility
|
|
102
|
+
const [workspaceProject, setWorkspaceProject] = useState<{ name: string; path: string } | null>(null);
|
|
99
103
|
const [browserMode, setBrowserMode] = useState<'none' | 'float' | 'right' | 'left'>('none');
|
|
100
104
|
const [showBrowserMenu, setShowBrowserMenu] = useState(false);
|
|
101
105
|
const [browserWidth, setBrowserWidth] = useState(600);
|
|
@@ -147,11 +151,10 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
147
151
|
// Listen for open-terminal events from ProjectManager
|
|
148
152
|
useEffect(() => {
|
|
149
153
|
const handler = (e: Event) => {
|
|
150
|
-
const { projectPath, projectName } = (e as CustomEvent).detail;
|
|
154
|
+
const { projectPath, projectName, agentId, resumeMode, sessionId, profileEnv } = (e as CustomEvent).detail;
|
|
151
155
|
setViewMode('terminal');
|
|
152
|
-
// Give terminal time to render, then trigger open
|
|
153
156
|
setTimeout(() => {
|
|
154
|
-
terminalRef.current?.openProjectTerminal?.(projectPath, projectName);
|
|
157
|
+
terminalRef.current?.openProjectTerminal?.(projectPath, projectName, agentId, resumeMode, sessionId, profileEnv);
|
|
155
158
|
}, 300);
|
|
156
159
|
};
|
|
157
160
|
window.addEventListener('forge:open-terminal', handler);
|
|
@@ -291,7 +294,7 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
291
294
|
{/* View mode toggle */}
|
|
292
295
|
<div className="flex items-center bg-[var(--bg-tertiary)] rounded p-0.5">
|
|
293
296
|
{/* Workspace */}
|
|
294
|
-
{(['terminal', 'projects'
|
|
297
|
+
{(['terminal', 'projects'] as const).map(mode => (
|
|
295
298
|
<button
|
|
296
299
|
key={mode}
|
|
297
300
|
onClick={() => setViewMode(mode)}
|
|
@@ -301,7 +304,7 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
301
304
|
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
302
305
|
}`}
|
|
303
306
|
>
|
|
304
|
-
{{ terminal: 'Vibe Coding', projects: 'Projects'
|
|
307
|
+
{{ terminal: 'Vibe Coding', projects: 'Projects' }[mode]}
|
|
305
308
|
</button>
|
|
306
309
|
))}
|
|
307
310
|
<span className="w-[2px] h-4 bg-[var(--text-secondary)]/30 mx-1.5" />
|
|
@@ -332,7 +335,7 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
332
335
|
</button>
|
|
333
336
|
))}
|
|
334
337
|
<span className="w-[2px] h-4 bg-[var(--text-secondary)]/30 mx-1.5" />
|
|
335
|
-
{/*
|
|
338
|
+
{/* Marketplace */}
|
|
336
339
|
<button
|
|
337
340
|
onClick={() => setViewMode('skills')}
|
|
338
341
|
className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
|
|
@@ -341,7 +344,7 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
341
344
|
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
342
345
|
}`}
|
|
343
346
|
>
|
|
344
|
-
|
|
347
|
+
Marketplace
|
|
345
348
|
</button>
|
|
346
349
|
</div>
|
|
347
350
|
|
|
@@ -679,24 +682,14 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
679
682
|
)}
|
|
680
683
|
</aside>
|
|
681
684
|
</>
|
|
682
|
-
) : viewMode === 'sessions' ? (
|
|
683
|
-
<SessionView
|
|
684
|
-
projects={projects}
|
|
685
|
-
onOpenInTerminal={(sessionId, projectPath) => {
|
|
686
|
-
setViewMode('terminal');
|
|
687
|
-
setTimeout(() => {
|
|
688
|
-
terminalRef.current?.openSessionInTerminal(sessionId, projectPath);
|
|
689
|
-
}, 100);
|
|
690
|
-
}}
|
|
691
|
-
/>
|
|
692
685
|
) : null}
|
|
693
686
|
|
|
694
|
-
{/* Projects */}
|
|
695
|
-
{viewMode
|
|
687
|
+
{/* Projects โ keep alive to preserve state across tab switches */}
|
|
688
|
+
<div className={`flex-1 flex flex-col min-h-0 ${viewMode !== 'projects' ? 'hidden' : ''}`}>
|
|
696
689
|
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
|
|
697
690
|
<ProjectManager />
|
|
698
691
|
</Suspense>
|
|
699
|
-
|
|
692
|
+
</div>
|
|
700
693
|
|
|
701
694
|
{/* Pipelines */}
|
|
702
695
|
{viewMode === 'pipelines' && (
|
|
@@ -780,24 +773,28 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
780
773
|
)}
|
|
781
774
|
|
|
782
775
|
{showNewTask && (
|
|
783
|
-
<
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
776
|
+
<Suspense fallback={null}>
|
|
777
|
+
<NewTaskModal
|
|
778
|
+
onClose={() => setShowNewTask(false)}
|
|
779
|
+
onCreate={async (data) => {
|
|
780
|
+
await fetch('/api/tasks', {
|
|
781
|
+
method: 'POST',
|
|
782
|
+
headers: { 'Content-Type': 'application/json' },
|
|
783
|
+
body: JSON.stringify(data),
|
|
784
|
+
});
|
|
785
|
+
setShowNewTask(false);
|
|
786
|
+
fetchData();
|
|
787
|
+
}}
|
|
788
|
+
/>
|
|
789
|
+
</Suspense>
|
|
795
790
|
)}
|
|
796
791
|
|
|
797
|
-
{showMonitor && <MonitorPanel onClose={() => setShowMonitor(false)}
|
|
792
|
+
{showMonitor && <Suspense fallback={null}><MonitorPanel onClose={() => setShowMonitor(false)} /></Suspense>}
|
|
798
793
|
|
|
799
794
|
{showSettings && (
|
|
800
|
-
<
|
|
795
|
+
<Suspense fallback={null}>
|
|
796
|
+
<SettingsModal onClose={() => { setShowSettings(false); fetchData(); refreshDisplayName(); }} />
|
|
797
|
+
</Suspense>
|
|
801
798
|
)}
|
|
802
799
|
{showHelp && (
|
|
803
800
|
<Suspense fallback={null}>
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback, useEffect, useRef } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
ReactFlow,
|
|
6
|
+
Background,
|
|
7
|
+
Controls,
|
|
8
|
+
addEdge,
|
|
9
|
+
useNodesState,
|
|
10
|
+
useEdgesState,
|
|
11
|
+
Handle,
|
|
12
|
+
Position,
|
|
13
|
+
type Node,
|
|
14
|
+
type Edge,
|
|
15
|
+
type Connection,
|
|
16
|
+
type NodeProps,
|
|
17
|
+
MarkerType,
|
|
18
|
+
} from '@xyflow/react';
|
|
19
|
+
import '@xyflow/react/dist/style.css';
|
|
20
|
+
|
|
21
|
+
// โโโ Types โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
22
|
+
|
|
23
|
+
interface RolePreset {
|
|
24
|
+
id: string;
|
|
25
|
+
label: string;
|
|
26
|
+
icon: string;
|
|
27
|
+
role: string;
|
|
28
|
+
inputArtifactTypes: string[];
|
|
29
|
+
outputArtifactName: string;
|
|
30
|
+
outputArtifactType: string;
|
|
31
|
+
waitForHuman?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PhaseOutput {
|
|
35
|
+
name: string;
|
|
36
|
+
label: string;
|
|
37
|
+
icon: string;
|
|
38
|
+
role: string;
|
|
39
|
+
agentId: string;
|
|
40
|
+
inputArtifactTypes: string[];
|
|
41
|
+
outputArtifactName: string;
|
|
42
|
+
outputArtifactType: string;
|
|
43
|
+
waitForHuman: boolean;
|
|
44
|
+
dependsOn: string[];
|
|
45
|
+
requires: string[]; // artifact names needed before starting
|
|
46
|
+
produces: string[]; // artifact names this phase outputs
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// โโโ Colors โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
50
|
+
|
|
51
|
+
const ROLE_COLORS: Record<string, { bg: string; border: string; accent: string }> = {
|
|
52
|
+
pm: { bg: '#1a2a1a', border: '#22c55e', accent: '#4ade80' },
|
|
53
|
+
engineer: { bg: '#1e2a4a', border: '#3b82f6', accent: '#60a5fa' },
|
|
54
|
+
qa: { bg: '#2a1e4a', border: '#8b5cf6', accent: '#a78bfa' },
|
|
55
|
+
reviewer: { bg: '#3a2a1e', border: '#f97316', accent: '#fb923c' },
|
|
56
|
+
devops: { bg: '#1e3a3a', border: '#06b6d4', accent: '#22d3ee' },
|
|
57
|
+
security: { bg: '#3a1e2a', border: '#ec4899', accent: '#f472b6' },
|
|
58
|
+
docs: { bg: '#2a2a1e', border: '#eab308', accent: '#facc15' },
|
|
59
|
+
custom: { bg: '#1a1a2a', border: '#6b7280', accent: '#9ca3af' },
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function getColor(presetId: string) {
|
|
63
|
+
return ROLE_COLORS[presetId] || ROLE_COLORS.custom;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// โโโ Custom Node โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
67
|
+
|
|
68
|
+
interface RoleNodeData {
|
|
69
|
+
label: string;
|
|
70
|
+
icon: string;
|
|
71
|
+
presetId: string;
|
|
72
|
+
agentId: string;
|
|
73
|
+
role: string;
|
|
74
|
+
waitForHuman: boolean;
|
|
75
|
+
outputArtifactName: string;
|
|
76
|
+
onEdit: (id: string) => void;
|
|
77
|
+
onDelete: (id: string) => void;
|
|
78
|
+
[key: string]: unknown;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function RoleNode({ id, data }: NodeProps<Node<RoleNodeData>>) {
|
|
82
|
+
const c = getColor(data.presetId);
|
|
83
|
+
// inputArtifacts comes from edges (resolved in parent), stored in data
|
|
84
|
+
const inputs: string[] = (data as any).inputArtifacts || [];
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div className="rounded-xl shadow-lg" style={{ background: c.bg, border: `2px solid ${c.border}`, minWidth: 220 }}>
|
|
88
|
+
{/* Input handle + input artifacts */}
|
|
89
|
+
<Handle type="target" position={Position.Top} className="!w-3 !h-3" style={{ background: c.accent }} />
|
|
90
|
+
{inputs.length > 0 && (
|
|
91
|
+
<div className="px-3 pt-1 flex flex-wrap gap-1">
|
|
92
|
+
{inputs.map((name, i) => (
|
|
93
|
+
<span key={i} className="text-[7px] px-1 py-0.5 rounded bg-white/5 text-gray-400">โฌ {name}</span>
|
|
94
|
+
))}
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
|
|
98
|
+
{/* Header */}
|
|
99
|
+
<div className="px-3 py-1.5 flex items-center gap-2" style={{ borderBottom: `1px solid ${c.border}40` }}>
|
|
100
|
+
<span className="text-sm">{data.icon}</span>
|
|
101
|
+
<span className="text-[11px] font-bold text-white">{data.label}</span>
|
|
102
|
+
<span className="text-[8px] px-1 py-0.5 rounded" style={{ background: c.accent + '30', color: c.accent }}>{data.agentId}</span>
|
|
103
|
+
{data.waitForHuman && <span className="text-[7px] px-1 py-0.5 rounded bg-yellow-500/20 text-yellow-400">โธ approval</span>}
|
|
104
|
+
<div className="ml-auto flex gap-1">
|
|
105
|
+
<button onClick={() => data.onEdit(id)} className="text-[9px] hover:text-white" style={{ color: c.accent }}>edit</button>
|
|
106
|
+
<button onClick={() => data.onDelete(id)} className="text-[9px] text-red-400 hover:text-red-300">ร</button>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
{/* Role description */}
|
|
111
|
+
<div className="px-3 py-1">
|
|
112
|
+
<div className="text-[8px] text-gray-500 line-clamp-2">{data.role.slice(0, 80)}{data.role.length > 80 ? '...' : ''}</div>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
{/* Output artifact */}
|
|
116
|
+
<div className="px-3 pb-1.5">
|
|
117
|
+
<div className="text-[7px] px-1.5 py-0.5 rounded inline-flex items-center gap-1" style={{ background: c.accent + '15', color: c.accent, border: `1px solid ${c.accent}30` }}>
|
|
118
|
+
โฌ {data.outputArtifactName || 'output.md'}
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<Handle type="source" position={Position.Bottom} className="!w-3 !h-3" style={{ background: c.accent }} />
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const nodeTypes = { role: RoleNode };
|
|
128
|
+
|
|
129
|
+
// โโโ Edit Modal โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
130
|
+
|
|
131
|
+
function EditModal({ node, agents, onSave, onClose }: {
|
|
132
|
+
node: { id: string; label: string; icon: string; presetId: string; agentId: string; role: string; waitForHuman: boolean; outputArtifactName: string };
|
|
133
|
+
agents: { id: string; name: string }[];
|
|
134
|
+
onSave: (data: typeof node) => void;
|
|
135
|
+
onClose: () => void;
|
|
136
|
+
}) {
|
|
137
|
+
const [form, setForm] = useState({ ...node });
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
|
141
|
+
<div className="bg-[#1a1a2a] border border-[#3a3a5a] rounded-xl p-4 w-[420px] space-y-3" onClick={e => e.stopPropagation()}>
|
|
142
|
+
<div className="text-sm font-bold text-white flex items-center gap-2">
|
|
143
|
+
<span>{form.icon}</span> Edit Role
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<div className="grid grid-cols-2 gap-2">
|
|
147
|
+
<div>
|
|
148
|
+
<label className="text-[9px] text-gray-400">Label</label>
|
|
149
|
+
<input value={form.label} onChange={e => setForm(f => ({ ...f, label: e.target.value }))}
|
|
150
|
+
className="w-full text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 py-1.5 text-white" />
|
|
151
|
+
</div>
|
|
152
|
+
<div>
|
|
153
|
+
<label className="text-[9px] text-gray-400">Agent</label>
|
|
154
|
+
<select value={form.agentId} onChange={e => setForm(f => ({ ...f, agentId: e.target.value }))}
|
|
155
|
+
className="w-full text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 py-1.5 text-white">
|
|
156
|
+
{agents.map(a => <option key={a.id} value={a.id}>{a.name || a.id}</option>)}
|
|
157
|
+
</select>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
<div>
|
|
162
|
+
<label className="text-[9px] text-gray-400">Role Description (system prompt)</label>
|
|
163
|
+
<textarea value={form.role} onChange={e => setForm(f => ({ ...f, role: e.target.value }))}
|
|
164
|
+
className="w-full text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 py-1.5 text-gray-300 resize-none"
|
|
165
|
+
rows={4} />
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<div className="grid grid-cols-2 gap-2">
|
|
169
|
+
<div>
|
|
170
|
+
<label className="text-[9px] text-gray-400">Output artifact</label>
|
|
171
|
+
<input value={form.outputArtifactName} onChange={e => setForm(f => ({ ...f, outputArtifactName: e.target.value }))}
|
|
172
|
+
className="w-full text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 py-1.5 text-gray-300" />
|
|
173
|
+
</div>
|
|
174
|
+
<div className="flex items-end pb-1">
|
|
175
|
+
<label className="flex items-center gap-1.5 text-[10px] text-gray-400 cursor-pointer">
|
|
176
|
+
<input type="checkbox" checked={form.waitForHuman} onChange={e => setForm(f => ({ ...f, waitForHuman: e.target.checked }))}
|
|
177
|
+
className="accent-yellow-500" />
|
|
178
|
+
Require approval
|
|
179
|
+
</label>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<div className="flex justify-end gap-2 pt-1">
|
|
184
|
+
<button onClick={onClose} className="text-xs px-3 py-1 text-gray-400 hover:text-white">Cancel</button>
|
|
185
|
+
<button onClick={() => onSave(form)} className="text-xs px-3 py-1 bg-green-600 text-white rounded hover:opacity-90">Save</button>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// โโโ Main Component โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
193
|
+
|
|
194
|
+
export default function DeliveryFlowEditor({ presets, agents, initialPhases, onChange }: {
|
|
195
|
+
presets: RolePreset[];
|
|
196
|
+
agents: { id: string; name: string }[];
|
|
197
|
+
initialPhases?: PhaseOutput[];
|
|
198
|
+
onChange: (phases: PhaseOutput[]) => void;
|
|
199
|
+
}) {
|
|
200
|
+
const [nodes, setNodes, onNodesChange] = useNodesState<Node<RoleNodeData>>([]);
|
|
201
|
+
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
|
202
|
+
const [editing, setEditing] = useState<string | null>(null);
|
|
203
|
+
const nextId = useRef(1);
|
|
204
|
+
|
|
205
|
+
// Initialize with defaults
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
if (nodes.length > 0) return;
|
|
208
|
+
const defaults = initialPhases || presets.filter(p => ['pm', 'engineer', 'qa', 'reviewer'].includes(p.id));
|
|
209
|
+
const initNodes: Node<RoleNodeData>[] = defaults.map((p, i) => {
|
|
210
|
+
const preset = 'presetId' in p ? p : presets.find(pr => pr.id === (p as any).name) || p;
|
|
211
|
+
const presetId = (preset as any).presetId || (preset as any).id || 'custom';
|
|
212
|
+
return {
|
|
213
|
+
id: `role-${i}`,
|
|
214
|
+
type: 'role',
|
|
215
|
+
position: { x: 100 + (i % 2) * 280, y: 50 + Math.floor(i / 2) * 180 },
|
|
216
|
+
data: {
|
|
217
|
+
label: (preset as any).label || (preset as any).name || `Role ${i}`,
|
|
218
|
+
icon: (preset as any).icon || 'โ',
|
|
219
|
+
presetId,
|
|
220
|
+
agentId: (p as any).agentId || 'claude',
|
|
221
|
+
role: (preset as any).role || '',
|
|
222
|
+
waitForHuman: (preset as any).waitForHuman || false,
|
|
223
|
+
outputArtifactName: (preset as any).outputArtifactName || 'output.md',
|
|
224
|
+
onEdit: (id: string) => setEditing(id),
|
|
225
|
+
onDelete: (id: string) => handleDelete(id),
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// Auto-connect sequentially with artifact labels
|
|
231
|
+
const initEdges: Edge[] = [];
|
|
232
|
+
for (let i = 0; i < initNodes.length - 1; i++) {
|
|
233
|
+
const c = getColor(initNodes[i].data.presetId);
|
|
234
|
+
const artifactName = initNodes[i].data.outputArtifactName || 'output';
|
|
235
|
+
initEdges.push({
|
|
236
|
+
id: `${initNodes[i].id}-${initNodes[i + 1].id}`,
|
|
237
|
+
source: initNodes[i].id,
|
|
238
|
+
target: initNodes[i + 1].id,
|
|
239
|
+
markerEnd: { type: MarkerType.ArrowClosed, color: c.accent },
|
|
240
|
+
style: { stroke: c.accent, strokeWidth: 2 },
|
|
241
|
+
animated: true,
|
|
242
|
+
label: `๐ ${artifactName}`,
|
|
243
|
+
labelStyle: { fill: c.accent, fontSize: 9, fontWeight: 500 },
|
|
244
|
+
labelBgStyle: { fill: '#0a0a1a', fillOpacity: 0.8 },
|
|
245
|
+
labelBgPadding: [4, 2] as [number, number],
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
nextId.current = initNodes.length;
|
|
250
|
+
setNodes(initNodes);
|
|
251
|
+
setEdges(initEdges);
|
|
252
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
253
|
+
|
|
254
|
+
// Resolve input artifacts from edges and update node data
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
if (nodes.length === 0) return;
|
|
257
|
+
|
|
258
|
+
// Build input map: nodeId โ [artifact names from source nodes]
|
|
259
|
+
const inputMap = new Map<string, string[]>();
|
|
260
|
+
for (const edge of edges) {
|
|
261
|
+
const sourceNode = nodes.find(n => n.id === edge.source);
|
|
262
|
+
if (!sourceNode) continue;
|
|
263
|
+
const existing = inputMap.get(edge.target) || [];
|
|
264
|
+
existing.push(sourceNode.data.outputArtifactName || 'output.md');
|
|
265
|
+
inputMap.set(edge.target, existing);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Update nodes with resolved inputArtifacts
|
|
269
|
+
let changed = false;
|
|
270
|
+
const updated = nodes.map(n => {
|
|
271
|
+
const inputs = inputMap.get(n.id) || [];
|
|
272
|
+
const current = (n.data as any).inputArtifacts || [];
|
|
273
|
+
if (JSON.stringify(inputs) !== JSON.stringify(current)) {
|
|
274
|
+
changed = true;
|
|
275
|
+
return { ...n, data: { ...n.data, inputArtifacts: inputs } };
|
|
276
|
+
}
|
|
277
|
+
return n;
|
|
278
|
+
});
|
|
279
|
+
if (changed) setNodes(updated);
|
|
280
|
+
|
|
281
|
+
// Emit phases to parent
|
|
282
|
+
const output = buildOutput(nodes, edges);
|
|
283
|
+
onChange(output);
|
|
284
|
+
}, [nodes, edges]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
285
|
+
|
|
286
|
+
const onConnect = useCallback((params: Connection) => {
|
|
287
|
+
const sourceNode = nodes.find(n => n.id === params.source);
|
|
288
|
+
const c = sourceNode ? getColor(sourceNode.data.presetId) : ROLE_COLORS.custom;
|
|
289
|
+
const artifactName = sourceNode?.data.outputArtifactName || 'output';
|
|
290
|
+
setEdges(eds => addEdge({
|
|
291
|
+
...params,
|
|
292
|
+
markerEnd: { type: MarkerType.ArrowClosed, color: c.accent },
|
|
293
|
+
style: { stroke: c.accent, strokeWidth: 2 },
|
|
294
|
+
animated: true,
|
|
295
|
+
label: `๐ ${artifactName}`,
|
|
296
|
+
labelStyle: { fill: c.accent, fontSize: 9, fontWeight: 500 },
|
|
297
|
+
labelBgStyle: { fill: '#0a0a1a', fillOpacity: 0.8 },
|
|
298
|
+
labelBgPadding: [4, 2] as [number, number],
|
|
299
|
+
}, eds));
|
|
300
|
+
}, [nodes, setEdges]);
|
|
301
|
+
|
|
302
|
+
const handleAddPreset = (preset: RolePreset) => {
|
|
303
|
+
const id = `role-${nextId.current++}`;
|
|
304
|
+
const c = getColor(preset.id);
|
|
305
|
+
setNodes(nds => [...nds, {
|
|
306
|
+
id,
|
|
307
|
+
type: 'role',
|
|
308
|
+
position: { x: 100 + (nds.length % 3) * 240, y: 50 + Math.floor(nds.length / 3) * 180 },
|
|
309
|
+
data: {
|
|
310
|
+
label: preset.label,
|
|
311
|
+
icon: preset.icon,
|
|
312
|
+
presetId: preset.id,
|
|
313
|
+
agentId: 'claude',
|
|
314
|
+
role: preset.role,
|
|
315
|
+
waitForHuman: preset.waitForHuman || false,
|
|
316
|
+
outputArtifactName: preset.outputArtifactName,
|
|
317
|
+
onEdit: (nid: string) => setEditing(nid),
|
|
318
|
+
onDelete: (nid: string) => handleDelete(nid),
|
|
319
|
+
},
|
|
320
|
+
}]);
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const handleAddCustom = () => {
|
|
324
|
+
const id = `role-${nextId.current++}`;
|
|
325
|
+
setNodes(nds => [...nds, {
|
|
326
|
+
id,
|
|
327
|
+
type: 'role',
|
|
328
|
+
position: { x: 100 + (nds.length % 3) * 240, y: 50 + Math.floor(nds.length / 3) * 180 },
|
|
329
|
+
data: {
|
|
330
|
+
label: 'Custom Agent',
|
|
331
|
+
icon: 'โ',
|
|
332
|
+
presetId: 'custom',
|
|
333
|
+
agentId: 'claude',
|
|
334
|
+
role: '',
|
|
335
|
+
waitForHuman: false,
|
|
336
|
+
outputArtifactName: 'output.md',
|
|
337
|
+
onEdit: (nid: string) => setEditing(nid),
|
|
338
|
+
onDelete: (nid: string) => handleDelete(nid),
|
|
339
|
+
},
|
|
340
|
+
}]);
|
|
341
|
+
setEditing(id);
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const handleDelete = useCallback((id: string) => {
|
|
345
|
+
setNodes(nds => nds.filter(n => n.id !== id));
|
|
346
|
+
setEdges(eds => eds.filter(e => e.source !== id && e.target !== id));
|
|
347
|
+
}, [setNodes, setEdges]);
|
|
348
|
+
|
|
349
|
+
const handleSaveEdit = (data: { id: string; label: string; icon: string; presetId: string; agentId: string; role: string; waitForHuman: boolean; outputArtifactName: string }) => {
|
|
350
|
+
setNodes(nds => nds.map(n => {
|
|
351
|
+
if (n.id !== data.id) return n;
|
|
352
|
+
return { ...n, data: { ...n.data, ...data } };
|
|
353
|
+
}));
|
|
354
|
+
// Update edge labels if output artifact changed
|
|
355
|
+
setEdges(eds => eds.map(e => {
|
|
356
|
+
if (e.source !== data.id) return e;
|
|
357
|
+
const c = getColor(data.presetId);
|
|
358
|
+
return {
|
|
359
|
+
...e,
|
|
360
|
+
label: `๐ ${data.outputArtifactName || 'output'}`,
|
|
361
|
+
labelStyle: { fill: c.accent, fontSize: 9, fontWeight: 500 },
|
|
362
|
+
};
|
|
363
|
+
}));
|
|
364
|
+
setEditing(null);
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const editingNode = editing ? nodes.find(n => n.id === editing) : null;
|
|
368
|
+
|
|
369
|
+
return (
|
|
370
|
+
<div className="flex flex-col" style={{ height: 350 }}>
|
|
371
|
+
{/* Toolbar: add from presets */}
|
|
372
|
+
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-[#30363d] bg-[#0d1117] shrink-0 flex-wrap">
|
|
373
|
+
<span className="text-[9px] text-gray-500 mr-1">Add:</span>
|
|
374
|
+
{presets.map(p => (
|
|
375
|
+
<button key={p.id} onClick={() => handleAddPreset(p)}
|
|
376
|
+
className="text-[8px] px-1.5 py-0.5 rounded border border-[#30363d] text-gray-400 hover:text-white hover:border-[var(--accent)] flex items-center gap-0.5">
|
|
377
|
+
<span>{p.icon}</span> {p.label}
|
|
378
|
+
</button>
|
|
379
|
+
))}
|
|
380
|
+
<button onClick={handleAddCustom}
|
|
381
|
+
className="text-[8px] px-1.5 py-0.5 rounded border border-dashed border-[#30363d] text-gray-500 hover:text-white hover:border-[var(--accent)]">
|
|
382
|
+
+ Custom
|
|
383
|
+
</button>
|
|
384
|
+
<span className="text-[7px] text-gray-600 ml-auto">Drag to connect ยท Click edit to configure</span>
|
|
385
|
+
</div>
|
|
386
|
+
|
|
387
|
+
{/* ReactFlow canvas */}
|
|
388
|
+
<div className="flex-1">
|
|
389
|
+
<ReactFlow
|
|
390
|
+
nodes={nodes}
|
|
391
|
+
edges={edges}
|
|
392
|
+
onNodesChange={onNodesChange}
|
|
393
|
+
onEdgesChange={onEdgesChange}
|
|
394
|
+
onConnect={onConnect}
|
|
395
|
+
nodeTypes={nodeTypes}
|
|
396
|
+
fitView
|
|
397
|
+
fitViewOptions={{ padding: 0.3 }}
|
|
398
|
+
deleteKeyCode="Delete"
|
|
399
|
+
style={{ background: '#0a0a1a' }}
|
|
400
|
+
minZoom={0.4}
|
|
401
|
+
maxZoom={2}
|
|
402
|
+
>
|
|
403
|
+
<Background color="#1a1a3a" gap={20} />
|
|
404
|
+
<Controls />
|
|
405
|
+
</ReactFlow>
|
|
406
|
+
</div>
|
|
407
|
+
|
|
408
|
+
{/* Edit modal */}
|
|
409
|
+
{editingNode && (
|
|
410
|
+
<EditModal
|
|
411
|
+
node={{
|
|
412
|
+
id: editingNode.id,
|
|
413
|
+
label: editingNode.data.label,
|
|
414
|
+
icon: editingNode.data.icon,
|
|
415
|
+
presetId: editingNode.data.presetId,
|
|
416
|
+
agentId: editingNode.data.agentId,
|
|
417
|
+
role: editingNode.data.role,
|
|
418
|
+
waitForHuman: editingNode.data.waitForHuman,
|
|
419
|
+
outputArtifactName: editingNode.data.outputArtifactName,
|
|
420
|
+
}}
|
|
421
|
+
agents={agents}
|
|
422
|
+
onSave={handleSaveEdit}
|
|
423
|
+
onClose={() => setEditing(null)}
|
|
424
|
+
/>
|
|
425
|
+
)}
|
|
426
|
+
</div>
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// โโโ Build output from graph โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
431
|
+
|
|
432
|
+
function buildOutput(nodes: Node<RoleNodeData>[], edges: Edge[]): PhaseOutput[] {
|
|
433
|
+
// Topological sort by edges
|
|
434
|
+
const adjList = new Map<string, string[]>();
|
|
435
|
+
const inDegree = new Map<string, number>();
|
|
436
|
+
|
|
437
|
+
for (const n of nodes) {
|
|
438
|
+
adjList.set(n.id, []);
|
|
439
|
+
inDegree.set(n.id, 0);
|
|
440
|
+
}
|
|
441
|
+
for (const e of edges) {
|
|
442
|
+
adjList.get(e.source)?.push(e.target);
|
|
443
|
+
inDegree.set(e.target, (inDegree.get(e.target) || 0) + 1);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const sorted: string[] = [];
|
|
447
|
+
const queue = [...nodes.filter(n => (inDegree.get(n.id) || 0) === 0).map(n => n.id)];
|
|
448
|
+
while (queue.length > 0) {
|
|
449
|
+
const id = queue.shift()!;
|
|
450
|
+
sorted.push(id);
|
|
451
|
+
for (const next of adjList.get(id) || []) {
|
|
452
|
+
const deg = (inDegree.get(next) || 1) - 1;
|
|
453
|
+
inDegree.set(next, deg);
|
|
454
|
+
if (deg === 0) queue.push(next);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
// Add any remaining (disconnected)
|
|
458
|
+
for (const n of nodes) {
|
|
459
|
+
if (!sorted.includes(n.id)) sorted.push(n.id);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return sorted.map(id => {
|
|
463
|
+
const node = nodes.find(n => n.id === id)!;
|
|
464
|
+
|
|
465
|
+
// Derive requires: artifact names from source nodes via edges
|
|
466
|
+
const requires = edges
|
|
467
|
+
.filter(e => e.target === id)
|
|
468
|
+
.map(e => {
|
|
469
|
+
const src = nodes.find(n => n.id === e.source);
|
|
470
|
+
return src?.data.outputArtifactName || 'output.md';
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
// Produces: this node's output artifact
|
|
474
|
+
const produces = [node.data.outputArtifactName || 'output.md'];
|
|
475
|
+
|
|
476
|
+
return {
|
|
477
|
+
name: node.data.presetId === 'custom' ? `custom-${id}` : node.data.presetId,
|
|
478
|
+
label: node.data.label,
|
|
479
|
+
icon: node.data.icon,
|
|
480
|
+
role: node.data.role,
|
|
481
|
+
agentId: node.data.agentId,
|
|
482
|
+
inputArtifactTypes: [],
|
|
483
|
+
outputArtifactName: node.data.outputArtifactName,
|
|
484
|
+
outputArtifactType: 'custom',
|
|
485
|
+
waitForHuman: node.data.waitForHuman,
|
|
486
|
+
dependsOn: [],
|
|
487
|
+
requires,
|
|
488
|
+
produces,
|
|
489
|
+
};
|
|
490
|
+
});
|
|
491
|
+
}
|