@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.
Files changed (93) hide show
  1. package/README.md +27 -2
  2. package/RELEASE_NOTES.md +21 -14
  3. package/app/api/agents/route.ts +17 -0
  4. package/app/api/delivery/[id]/route.ts +62 -0
  5. package/app/api/delivery/route.ts +40 -0
  6. package/app/api/mobile-chat/route.ts +13 -7
  7. package/app/api/monitor/route.ts +10 -6
  8. package/app/api/pipelines/[id]/route.ts +16 -3
  9. package/app/api/tasks/route.ts +2 -1
  10. package/app/api/workspace/[id]/agents/route.ts +35 -0
  11. package/app/api/workspace/[id]/memory/route.ts +23 -0
  12. package/app/api/workspace/[id]/smith/route.ts +22 -0
  13. package/app/api/workspace/[id]/stream/route.ts +28 -0
  14. package/app/api/workspace/route.ts +100 -0
  15. package/app/global-error.tsx +10 -4
  16. package/app/icon.ico +0 -0
  17. package/app/layout.tsx +2 -2
  18. package/app/login/LoginForm.tsx +96 -0
  19. package/app/login/page.tsx +7 -98
  20. package/app/page.tsx +2 -2
  21. package/bin/forge-server.mjs +13 -1
  22. package/check-forge-status.sh +9 -0
  23. package/components/ConversationEditor.tsx +411 -0
  24. package/components/ConversationGraphView.tsx +347 -0
  25. package/components/ConversationTerminalView.tsx +303 -0
  26. package/components/Dashboard.tsx +36 -39
  27. package/components/DashboardWrapper.tsx +9 -0
  28. package/components/DeliveryFlowEditor.tsx +491 -0
  29. package/components/DeliveryList.tsx +230 -0
  30. package/components/DeliveryWorkspace.tsx +589 -0
  31. package/components/DocTerminal.tsx +10 -2
  32. package/components/DocsViewer.tsx +10 -2
  33. package/components/HelpTerminal.tsx +11 -6
  34. package/components/InlinePipelineView.tsx +111 -0
  35. package/components/MobileView.tsx +20 -0
  36. package/components/MonitorPanel.tsx +9 -4
  37. package/components/NewTaskModal.tsx +32 -0
  38. package/components/PipelineEditor.tsx +49 -6
  39. package/components/PipelineView.tsx +482 -64
  40. package/components/ProjectDetail.tsx +314 -56
  41. package/components/ProjectManager.tsx +49 -4
  42. package/components/SessionView.tsx +27 -13
  43. package/components/SettingsModal.tsx +790 -124
  44. package/components/SkillsPanel.tsx +31 -8
  45. package/components/TaskBoard.tsx +3 -0
  46. package/components/WebTerminal.tsx +257 -43
  47. package/components/WorkspaceTree.tsx +221 -0
  48. package/components/WorkspaceView.tsx +2245 -0
  49. package/install.sh +2 -2
  50. package/lib/agents/claude-adapter.ts +104 -0
  51. package/lib/agents/generic-adapter.ts +64 -0
  52. package/lib/agents/index.ts +242 -0
  53. package/lib/agents/types.ts +70 -0
  54. package/lib/artifacts.ts +106 -0
  55. package/lib/delivery.ts +787 -0
  56. package/lib/forge-skills/forge-inbox.md +37 -0
  57. package/lib/forge-skills/forge-send.md +40 -0
  58. package/lib/forge-skills/forge-status.md +32 -0
  59. package/lib/forge-skills/forge-workspace-sync.md +37 -0
  60. package/lib/help-docs/00-overview.md +7 -1
  61. package/lib/help-docs/01-settings.md +159 -2
  62. package/lib/help-docs/05-pipelines.md +89 -0
  63. package/lib/help-docs/07-projects.md +35 -1
  64. package/lib/help-docs/11-workspace.md +254 -0
  65. package/lib/help-docs/CLAUDE.md +7 -2
  66. package/lib/init.ts +60 -10
  67. package/lib/pipeline.ts +537 -1
  68. package/lib/settings.ts +115 -22
  69. package/lib/skills.ts +249 -372
  70. package/lib/task-manager.ts +113 -33
  71. package/lib/telegram-bot.ts +33 -1
  72. package/lib/workspace/__tests__/state-machine.test.ts +388 -0
  73. package/lib/workspace/__tests__/workspace.test.ts +311 -0
  74. package/lib/workspace/agent-bus.ts +416 -0
  75. package/lib/workspace/agent-worker.ts +667 -0
  76. package/lib/workspace/backends/api-backend.ts +262 -0
  77. package/lib/workspace/backends/cli-backend.ts +479 -0
  78. package/lib/workspace/index.ts +82 -0
  79. package/lib/workspace/manager.ts +136 -0
  80. package/lib/workspace/orchestrator.ts +1914 -0
  81. package/lib/workspace/persistence.ts +310 -0
  82. package/lib/workspace/presets.ts +170 -0
  83. package/lib/workspace/skill-installer.ts +188 -0
  84. package/lib/workspace/smith-memory.ts +498 -0
  85. package/lib/workspace/types.ts +231 -0
  86. package/lib/workspace/watch-manager.ts +288 -0
  87. package/lib/workspace-standalone.ts +814 -0
  88. package/middleware.ts +1 -0
  89. package/next-env.d.ts +1 -1
  90. package/package.json +4 -1
  91. package/src/config/index.ts +12 -1
  92. package/src/core/db/database.ts +1 -0
  93. package/start.sh +7 -0
@@ -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', 'sessions'] as const).map(mode => (
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', sessions: 'Sessions' }[mode]}
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
- {/* Skills */}
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
- Skills
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 === 'projects' && (
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
- <NewTaskModal
784
- onClose={() => setShowNewTask(false)}
785
- onCreate={async (data) => {
786
- await fetch('/api/tasks', {
787
- method: 'POST',
788
- headers: { 'Content-Type': 'application/json' },
789
- body: JSON.stringify(data),
790
- });
791
- setShowNewTask(false);
792
- fetchData();
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
- <SettingsModal onClose={() => { setShowSettings(false); fetchData(); refreshDisplayName(); }} />
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,9 @@
1
+ 'use client';
2
+
3
+ import dynamic from 'next/dynamic';
4
+
5
+ const Dashboard = dynamic(() => import('./Dashboard'), { ssr: false });
6
+
7
+ export default function DashboardWrapper({ user }: { user: any }) {
8
+ return <Dashboard user={user} />;
9
+ }
@@ -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
+ }